edef4d7365
* fix(images): backfill orientation for libraries that predate the fix (#1198) #1194 corrected the generators and every ingest path, but did nothing for photos already in the database. Those rows end up worse than untouched ones: before the fix a rotated photo was CONSISTENTLY wrong — a sideways image in a tile shaped to match — and afterwards the regenerated thumbnail is correct while photos.width/height still describe the raw sensor order, so masonry and justified size a portrait photo with a landscape ratio. The dimension repair cannot reach them: it only selects rows with a NULL dimension, and an affected row has both, just transposed. Its own job rather than a mode of that one. They look alike but are not the same operation: the repair FILLS missing values and touches nothing else, while this RECOMPUTES and invalidates the derived data generated against the old orientation. Sharing a lease would also mean one blocks the other. A first attempt at this was reverted from #1194 after review found five problems. All five are addressed here: - Originals are read through resolvePhotoStorageKey + withLocalCopy + withProcessableImage, so the job works on S3 installs and on RAW/DNG. The dimension repair's direct fs read does neither, which stops being an edge case in a job that walks the whole library. - The canonical preview is cleared BEFORE faces are requeued. ensurePreviewImage returns a cached preview whenever it is still a valid image, and a pre-fix unrotated one is perfectly valid — so requeueing alone made the rescan read unrotated pixels and scale those boxes by the corrected dimensions, which is worse than leaving the data alone. - Invalidation keys off the EXIF transform, not a dimension delta. Orientations 2, 3 and 4 move every pixel while leaving width and height unchanged, as does 5-8 on a square image; a delta check skips exactly those rows. - Archived events are excluded — archiving deletes the originals and keeps the rows, so every one of them would fail its read. - The dimension write and the invalidation share a transaction. Split, a failure between them leaves stale face data that no retry can fix, because the retry computes "already correct". Tier deletion stays outside the transaction on purpose: it touches storage, and a failed object delete must not roll back a correct database write. A leftover tier regenerates on next read; a rolled-back write is silent corruption. * fix(images): invalidate every stale rendition, fence the writes, and give the job a button (#1198) Three things from review, one of which mattered a lot. The invalidation was too narrow. Clearing only preview_path fixed the face data and left the gallery worse off: ensureThumbnail and ensureHeroImage return their cached file whenever it is merely VALID, and a pre-fix sideways thumbnail is perfectly valid — so a corrected row rendered the old sideways image inside a newly-corrected portrait tile. All three canonical renditions are cleared now, their stored objects deleted, and both responsive tier sets with them. The responsive tiers also needed handling rather than a hopeful catch. Their helpers swallow delete errors, and ensurePreviewImageAtWidth treats storage.stat(key) as a cache hit — so a tier that survived deletion keeps serving unrotated forever and never regenerates. The keys are re-checked after deletion and survivors are counted into the result, so a run that could not clear them does not report itself as clean. Writes are fenced on the identity that was measured, not just the id. replacePhoto swaps a new file under an existing row and rewrites path/filename, and it IS reachable — from the replace_by_name upload path in adminPhotos.js. A replacement landing while this job read the old original would otherwise have had the previous file's dimensions written over it and its fresh renditions cleared. And the job had no way to start it: the endpoint existed with no caller, so an upgrade would have left every affected library untouched unless an operator found the API themselves. It gets a Status card like its two neighbours, with strings in en/de/fr/sl. No backlog counter, because unlike the other two it cannot know how many rows need it without doing the work. * fix(images): make the backfill idempotent, and stop it lying about what it did (#1198) Six things from review round 2. The job was not idempotent, and the way it failed was expensive. Its trigger is the EXIF tag on the ORIGINAL, which correcting a photo never changes — so every re-run threw away the renditions it had just regenerated and requeued every completed face scan. On a face-enabled install, running it twice meant re-detecting the whole library for nothing. Migration 191 adds photos.orientation_checked_at, written in the same transaction as the work it records, with `force` as the escape hatch for an interrupted run. The candidate query selected preview_path but not thumbnail_path or hero_path, which the deletion loop reads — so those two pointers were cleared in the database while the objects stayed in storage, still reachable through previously issued URLs. watermark_path was missed entirely. gallery.js serves it ahead of the original when branding watermarking is on, which makes it the most visible rendition of the lot. (Its generator needed rotating too — that went into #1185, where the other three live.) storage.stat() RESOLVES with null for a missing key rather than rejecting, so counting "the promise settled" marked every deleted — and every never-created — tier as a survivor. A perfectly clean run told the operator to re-run. Now a null means gone, and a rejection counts as stuck, since a storage error is not proof the object went away. Face data is invalidated whenever the stored dimensions change, not only when the change came from rotation: boxes are scaled by photo.width at read time, so any dimension change strands them. And `corrected` now comes from the affected-row count. If the fence rejected the write because the file was replaced mid-run, the photo was not corrected and the run must not claim it was. * fix(images): stop the backfill doing unnecessary work, and make its retry advice true (#1198) Round 3, four points, all narrower than the last two rounds. It re-processed photos that were already correct. A 5-8 rotation changes the dimensions, so a tagged photo whose stored dimensions are ALREADY oriented must have been ingested after #1185 — its renditions are fine and clearing them deletes valid files and rescans a completed face detection for nothing. Those are now skipped and simply marked. Orientations 2, 3 and 4 (and 5-8 on a square image) leave the dimensions identical either way, so they carry no such evidence and are still done once. The retry advice was impossible to follow. When a responsive tier could not be deleted the row was still marked, so the ordinary re-run the UI recommends found nothing and the stale tier kept serving unrotated forever. The marker is withheld when a tier survives, which is what makes that message honest. Storage cleanup now only runs when a fenced write actually landed. If the file was replaced mid-run every update matched zero rows, but the deletion went ahead anyway and could destroy renditions belonging to the REPLACEMENT — watermarks especially, which are keyed by photo id and alias straight onto the new file. And the full-photo ETag includes the backfill's timestamp. It was built from the ORIGINAL's mtime plus the watermark settings hash, neither of which this job touches — so a guest holding a pre-fix ETag would go on getting 304 and their cached sideways image no matter how many times the backfill succeeded. --------- Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
237 lines
11 KiB
JavaScript
237 lines
11 KiB
JavaScript
/**
|
|
* Shared run state for the maintenance sweeps (#1181).
|
|
*
|
|
* The behaviour that matters here cannot be observed from one process holding
|
|
* a module-level flag, which is exactly why the flag moved into the database.
|
|
* A second replica is simulated the only way that is honest in a single-process
|
|
* test: by asserting on the shared row itself, and by driving claim() twice —
|
|
* a second caller getting null is precisely what a second replica gets.
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const os = require('os');
|
|
const express = require('express');
|
|
const request = require('supertest');
|
|
|
|
describe('maintenance job state (#1181)', () => {
|
|
let tmpDir; let db; let app; let jobs;
|
|
|
|
const dimStatus = () => request(app).get('/api/admin/photos/repair-dimensions/status');
|
|
const capStatus = () => request(app).get('/api/admin/photos/repair-capture-dates/status');
|
|
|
|
beforeAll(async () => {
|
|
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-mjs-'));
|
|
process.env.NODE_ENV = 'test';
|
|
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'db.sqlite');
|
|
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
|
|
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
|
process.env.JWT_SECRET = process.env.JWT_SECRET || 'mjs-secret';
|
|
|
|
jest.resetModules();
|
|
jest.doMock('../../src/middleware/auth', () => ({
|
|
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester', roleName: 'admin' }; next(); },
|
|
}));
|
|
jest.doMock('../../src/middleware/permissions', () => ({
|
|
requirePermission: () => (_req, _res, next) => next(),
|
|
}));
|
|
jest.doMock('../../src/utils/logger', () => ({
|
|
debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(),
|
|
}));
|
|
|
|
({ db } = await require('./helpers/crmDb').bootCrmDb());
|
|
jobs = require('../../src/services/maintenanceJobState');
|
|
|
|
app = express();
|
|
app.use(express.json());
|
|
app.use('/api/admin/photos', require('../../src/routes/adminPhotoDimensions'));
|
|
}, 180000);
|
|
|
|
afterAll(async () => {
|
|
if (db) await db.destroy?.();
|
|
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await db('maintenance_jobs').update({
|
|
is_running: false, started_at: null, heartbeat_at: null, finished_at: null, last_result: null, owner: null, claim_token: null,
|
|
});
|
|
});
|
|
|
|
test('the lease table is kept out of .picpeak archives', () => {
|
|
// It is live state, not data. An archive taken mid-sweep would otherwise
|
|
// carry is_running = true and a claim token owned by a process on the
|
|
// SOURCE install; restored inside the staleness window, the target reports
|
|
// the job as running and refuses new POSTs with no runner to release it.
|
|
// The importer filters on this same set, so archives written before the
|
|
// exclusion are skipped on restore too.
|
|
const { EXCLUDED_TABLES } = require('../../src/services/picpeakExportService');
|
|
expect(EXCLUDED_TABLES.has('maintenance_jobs')).toBe(true);
|
|
});
|
|
|
|
test('the migration seeds a row for each job', async () => {
|
|
const names = await db('maintenance_jobs').pluck('job_name');
|
|
// 190 seeds the orientation backfill alongside 189's two (#1198).
|
|
expect(names.sort()).toEqual([
|
|
'photo_capture_date_backfill', 'photo_dimension_repair', 'photo_orientation_backfill',
|
|
]);
|
|
});
|
|
|
|
test('a second claim is refused while the first is alive', async () => {
|
|
expect(await jobs.claim(jobs.JOB_DIMENSION_REPAIR)).toEqual(expect.any(String));
|
|
// What a second replica's POST does. Nothing about the first claim lives in
|
|
// this process, so this is the same question the other replica asks.
|
|
expect(await jobs.claim(jobs.JOB_DIMENSION_REPAIR)).toBeNull();
|
|
});
|
|
|
|
test('the two jobs claim independently', async () => {
|
|
expect(await jobs.claim(jobs.JOB_DIMENSION_REPAIR)).toEqual(expect.any(String));
|
|
expect(await jobs.claim(jobs.JOB_CAPTURE_DATE_BACKFILL)).toEqual(expect.any(String));
|
|
});
|
|
|
|
test('each claim gets a distinct token', async () => {
|
|
const first = await jobs.claim(jobs.JOB_DIMENSION_REPAIR);
|
|
await jobs.release(jobs.JOB_DIMENSION_REPAIR, first);
|
|
const second = await jobs.claim(jobs.JOB_DIMENSION_REPAIR);
|
|
// Same process, same pid — so an owner string would have collided here and
|
|
// the fencing below would be worthless.
|
|
expect(second).not.toBe(first);
|
|
});
|
|
|
|
test('a claim whose heartbeat has gone quiet can be taken over', async () => {
|
|
expect(await jobs.claim(jobs.JOB_DIMENSION_REPAIR)).toEqual(expect.any(String));
|
|
expect(await jobs.claim(jobs.JOB_DIMENSION_REPAIR)).toBeNull();
|
|
|
|
// The replica holding it was killed: no release, no further heartbeats.
|
|
const longAgo = new Date(Date.now() - jobs.DEFAULT_STALE_MS - 60000).toISOString();
|
|
await db('maintenance_jobs').where({ job_name: jobs.JOB_DIMENSION_REPAIR }).update({ heartbeat_at: longAgo });
|
|
|
|
expect(await jobs.claim(jobs.JOB_DIMENSION_REPAIR)).toEqual(expect.any(String));
|
|
});
|
|
|
|
test('a superseded runner cannot renew its lease', async () => {
|
|
const oldToken = await jobs.claim(jobs.JOB_DIMENSION_REPAIR);
|
|
const longAgo = new Date(Date.now() - jobs.DEFAULT_STALE_MS - 60000).toISOString();
|
|
await db('maintenance_jobs').where({ job_name: jobs.JOB_DIMENSION_REPAIR }).update({ heartbeat_at: longAgo });
|
|
const newToken = await jobs.claim(jobs.JOB_DIMENSION_REPAIR);
|
|
expect(newToken).toEqual(expect.any(String));
|
|
|
|
// The old runner is still alive and mid-loop. Its renewal must tell it so,
|
|
// which is what makes the route loop stop instead of running alongside the
|
|
// new owner.
|
|
expect(await jobs.heartbeat(jobs.JOB_DIMENSION_REPAIR, oldToken)).toBe(false);
|
|
expect(await jobs.heartbeat(jobs.JOB_DIMENSION_REPAIR, newToken)).toBe(true);
|
|
});
|
|
|
|
test('a superseded runner cannot release the new owner\'s claim', async () => {
|
|
const oldToken = await jobs.claim(jobs.JOB_CAPTURE_DATE_BACKFILL);
|
|
const longAgo = new Date(Date.now() - jobs.DEFAULT_STALE_MS - 60000).toISOString();
|
|
await db('maintenance_jobs').where({ job_name: jobs.JOB_CAPTURE_DATE_BACKFILL }).update({ heartbeat_at: longAgo });
|
|
const newToken = await jobs.claim(jobs.JOB_CAPTURE_DATE_BACKFILL);
|
|
|
|
// The old runner finishes late and tries to write its result. Unfenced,
|
|
// this cleared is_running under the new owner and let a THIRD sweep start.
|
|
expect(await jobs.release(jobs.JOB_CAPTURE_DATE_BACKFILL, oldToken, { success: 999, noExif: 0, failed: 0 })).toBe(false);
|
|
|
|
const state = await jobs.read(jobs.JOB_CAPTURE_DATE_BACKFILL);
|
|
expect(state.isRunning).toBe(true);
|
|
expect(state.lastResult).toBeNull();
|
|
// And the row is still the new owner's to release.
|
|
expect(await jobs.release(jobs.JOB_CAPTURE_DATE_BACKFILL, newToken, { success: 1, noExif: 0, failed: 0 })).toBe(true);
|
|
});
|
|
|
|
test('a stale run reads as not running, so the button comes back', async () => {
|
|
await jobs.claim(jobs.JOB_DIMENSION_REPAIR);
|
|
expect((await jobs.read(jobs.JOB_DIMENSION_REPAIR)).isRunning).toBe(true);
|
|
|
|
const longAgo = new Date(Date.now() - jobs.DEFAULT_STALE_MS - 60000).toISOString();
|
|
await db('maintenance_jobs').where({ job_name: jobs.JOB_DIMENSION_REPAIR }).update({ heartbeat_at: longAgo });
|
|
|
|
// is_running is still true in the row — nothing released it — but a status
|
|
// poll must not leave the operator staring at a job that cannot finish.
|
|
expect((await db('maintenance_jobs').where({ job_name: jobs.JOB_DIMENSION_REPAIR }).first()).is_running).toBeTruthy();
|
|
expect((await jobs.read(jobs.JOB_DIMENSION_REPAIR)).isRunning).toBe(false);
|
|
});
|
|
|
|
test('a heartbeat keeps a long run claimed', async () => {
|
|
const token = await jobs.claim(jobs.JOB_DIMENSION_REPAIR);
|
|
const longAgo = new Date(Date.now() - jobs.DEFAULT_STALE_MS - 60000).toISOString();
|
|
await db('maintenance_jobs').where({ job_name: jobs.JOB_DIMENSION_REPAIR }).update({ heartbeat_at: longAgo });
|
|
|
|
expect(await jobs.heartbeat(jobs.JOB_DIMENSION_REPAIR, token)).toBe(true);
|
|
|
|
expect(await jobs.claim(jobs.JOB_DIMENSION_REPAIR)).toBeNull();
|
|
expect((await jobs.read(jobs.JOB_DIMENSION_REPAIR)).isRunning).toBe(true);
|
|
});
|
|
|
|
test('release stores the result and read gives it back parsed', async () => {
|
|
const token = await jobs.claim(jobs.JOB_CAPTURE_DATE_BACKFILL);
|
|
await jobs.release(jobs.JOB_CAPTURE_DATE_BACKFILL, token, { success: 3, noExif: 2, failed: 1 });
|
|
|
|
const state = await jobs.read(jobs.JOB_CAPTURE_DATE_BACKFILL);
|
|
expect(state.isRunning).toBe(false);
|
|
expect(state.lastResult).toEqual({ success: 3, noExif: 2, failed: 1 });
|
|
});
|
|
|
|
test('releasing without a result keeps the previous run visible', async () => {
|
|
const first = await jobs.claim(jobs.JOB_CAPTURE_DATE_BACKFILL);
|
|
await jobs.release(jobs.JOB_CAPTURE_DATE_BACKFILL, first, { success: 7, noExif: 0, failed: 0 });
|
|
|
|
// The "nothing to do" path: claimed, found no candidates, released. It must
|
|
// not blank the numbers the last real run reported.
|
|
const second = await jobs.claim(jobs.JOB_CAPTURE_DATE_BACKFILL);
|
|
await jobs.release(jobs.JOB_CAPTURE_DATE_BACKFILL, second);
|
|
|
|
expect((await jobs.read(jobs.JOB_CAPTURE_DATE_BACKFILL)).lastResult).toEqual({ success: 7, noExif: 0, failed: 0 });
|
|
});
|
|
|
|
test('a malformed result does not take the status endpoint down', async () => {
|
|
await db('maintenance_jobs').where({ job_name: jobs.JOB_DIMENSION_REPAIR }).update({ last_result: 'not json' });
|
|
const state = await jobs.read(jobs.JOB_DIMENSION_REPAIR);
|
|
expect(state.lastResult).toBeNull();
|
|
expect(state.isRunning).toBe(false);
|
|
});
|
|
|
|
test('both status endpoints report the shared row, not process memory', async () => {
|
|
await jobs.claim(jobs.JOB_DIMENSION_REPAIR);
|
|
const capToken = await jobs.claim(jobs.JOB_CAPTURE_DATE_BACKFILL);
|
|
await jobs.release(jobs.JOB_CAPTURE_DATE_BACKFILL, capToken, { success: 1, noExif: 0, failed: 0 });
|
|
|
|
// Written straight to the row, exactly as another replica would have.
|
|
const dim = await dimStatus();
|
|
expect(dim.status).toBe(200);
|
|
expect(dim.body.isRunning).toBe(true);
|
|
|
|
const cap = await capStatus();
|
|
expect(cap.status).toBe(200);
|
|
expect(cap.body.isRunning).toBe(false);
|
|
expect(cap.body.lastResult).toEqual({ success: 1, noExif: 0, failed: 0 });
|
|
});
|
|
|
|
test('a POST is refused while another replica holds the claim', async () => {
|
|
// The claim was taken by "another replica" — this process knows nothing
|
|
// about it beyond the row.
|
|
await jobs.claim(jobs.JOB_CAPTURE_DATE_BACKFILL);
|
|
|
|
const res = await request(app).post('/api/admin/photos/repair-capture-dates');
|
|
expect(res.status).toBe(409);
|
|
|
|
const dimRes = await request(app).post('/api/admin/photos/repair-dimensions');
|
|
// The other job is untouched by that claim, so it is free to start.
|
|
expect(dimRes.status).toBe(200);
|
|
});
|
|
|
|
test('the no-op path releases the claim it took', async () => {
|
|
// No photos at all, so both endpoints take their "nothing to do" exit.
|
|
await db('photos').del();
|
|
|
|
const res = await request(app).post('/api/admin/photos/repair-capture-dates');
|
|
expect(res.body.count).toBe(0);
|
|
|
|
const row = await db('maintenance_jobs').where({ job_name: jobs.JOB_CAPTURE_DATE_BACKFILL }).first();
|
|
expect(row.is_running).toBeFalsy();
|
|
// ...and a second POST is therefore accepted rather than 409ing forever.
|
|
expect((await request(app).post('/api/admin/photos/repair-capture-dates')).status).toBe(200);
|
|
});
|
|
});
|