fix: two boot/federation log errors — duplicate-key upserts and peer-pull denial spam - #5827
Merged
Merged
Conversation
A boot-time reconcile was failing with "ON CONFLICT DO UPDATE command cannot affect row a second time", which aborted the whole catalog migration step and left the media asset index permanently stale. Postgres refuses a multi-row INSERT ... ON CONFLICT DO UPDATE whose VALUES list names the same conflict key twice. Two places build exactly that statement from data that makes no uniqueness promise: - mediaAssetIndex reconcile batches every on-disk image and video into chunked upserts, so one repeated gallery filename or video-history id threw and froze the entire index. - memorySync.applyRemoteChanges batches a remote peer's payload inside a transaction, so one repeated id there would roll back the whole apply, every sync cycle, for as long as the peer kept sending it. Both now collapse duplicates first via a shared dedupeByKey helper. The media index keeps the last occurrence (what a sequential upsert loop leaves); memory sync keeps the newest updated_at, so a peer's payload ordering cannot flip a last-writer-wins outcome. Adds DB-backed regression tests for both, each verified to reproduce the original Postgres error without the fix.
A peer that cannot be identified re-polls its sync categories every few seconds, and each denied pull logged a generic "Route error [GET /api/sync/usage/checksum]: peer not authorized for this record" at error level. The module already logs a deliberately throttled line (once per caller per boot) for exactly this, so the route-level line added nothing and buried real errors in the log. Mark the 403 severity: 'warning', the existing errorHandler mechanism for expected, already-classified denials. The response the caller receives is unchanged. Strict mode previously threw with no log at all, which combined with the above would leave a user who enabled federation.strictPullAuthorization with no indication of why a peer stopped syncing. It now logs the same throttled refusal alwaysEnforce does, collapsing the two identical enforce branches into one.
…uplicates The last-writer-wins comparator used a bare Date.parse comparison, so a malformed updated_at compared false against a valid one and won the collapse. That copy then went to a timestamptz column and failed the statement, losing a row that arrived intact in the same payload. Sort NaN explicitly below every real clock, and keep the first copy on a tie to match the SQL's strict `>`.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes two unexplained error lines in the running instance's PM2 logs.
1.
🪄 catalog migrations failed at boot: ON CONFLICT DO UPDATE command cannot affect row a second timePostgres refuses a multi-row
INSERT … ON CONFLICT (key) DO UPDATEwhoseVALUESlist names the same conflict key twice. Two places build exactly that statement from data that makes no uniqueness promise:mediaAssetIndexreconcile batches every on-disk image + video into chunked upserts. One repeated gallery filename or video-history id threw, and because it is the last step of the boot catalog-migration block, the throw aborted the block and left the media asset index permanently stale — it never upserted or pruned again.memorySync.applyRemoteChangesbatches a remote peer's payload inside a transaction, so one repeated id there rolls back the entire apply — not just its batch — every sync cycle, for as long as the peer keeps sending it. This one had no test coverage at all.Both now collapse duplicates first through a shared
dedupeByKeyhelper inserver/lib/arrayUtils.js. Tie-break differs per table because the conflict rules differ: the media index keeps the last occurrence (what a sequentialupsertAssetloop leaves), while memory sync keeps the newestupdated_at, so a peer's payload ordering can't flip a last-writer-wins outcome. An unparseable clock sorts below every real one rather than NaN-comparing false and winning — otherwise a malformed copy displaces a good one and then fails the statement on a row we already had intact.2.
❌ Route error [GET /api/sync/usage/checksum]: peer not authorized for this recordA peer that can't be identified re-polls its sync categories every few seconds, and every denied pull logged this at error level — roughly every 10s per category, forever.
peerPullAuthorizationalready logs a deliberately throttled🔒line (once per caller per boot) for exactly this, so the route-level line added nothing and buried real errors.The 403 is now
severity: 'warning', the existingerrorHandlermechanism for expected, already-classified denials (precedents:mediaJobs.js,imageClean.js,remoteDesktop.js). The response the caller receives is unchanged.Strict mode previously threw with no log at all — combined with the above that would leave a user who enabled
federation.strictPullAuthorizationwith no indication of why a peer stopped syncing. It now logs the same throttled refusalalwaysEnforcedoes, which collapses the two identical enforce branches into one.Test plan
server/services/memorySync.db.test.js— new DB-backed suite forapplyRemoteChanges(the federation write path had none): duplicate id doesn't abort the transaction, tie-break is byupdated_atnot payload order, malformed clock loses, and the existing last-writer-wins behavior still holds.server/services/mediaAssetIndex/db.test.js— reconcile survives repeated refs on disk.ON CONFLICT DO UPDATE command cannot affect row a second time) when the dedup is removed, then pass with it.server/lib/arrayUtils.test.js—dedupeByKeyunit tests incl. the custom-comparator path.server/services/sharing/peerPullAuthorization.test.js— asserts the 403 carriesseverity: 'warning'and that strict mode now logs the throttled🔒line.npm run test:db— 32 files / 279 tests pass (includesdb.guards.test.js, which validates the newDB_TEST_INCLUDEentry).imageTo3d,voice,videoGen) failing onPython was not foundin this environment, unrelated to these files.Notes
dedupeByKeylands in the existingarrayUtils.jsrather than a new module, with the barrel export andserver/lib/README.mdrow the module-maintenance rule requires. Its docblock carries the Postgres invariant so the next batching upsert site finds it instead of re-deriving the workaround.