5092a4dc - Fix double-encoded jsonb writes that trap the nostr publish loop - #103
Draft
davidleomay wants to merge 9 commits into
Draft
5092a4dc - Fix double-encoded jsonb writes that trap the nostr publish loop#103davidleomay wants to merge 9 commits into
davidleomay wants to merge 9 commits into
Conversation
Values bound to a Postgres ::jsonb parameter were passed through JSON.stringify first, so the driver encoded them a second time and the column stored a jsonb string scalar instead of an object. Every SQL path expression on those columns then evaluated to NULL, which made all three listSignedMissing* repair scans match unconditionally and reset published messages forever. Remove the manual serialization at all seven ::jsonb bind sites, backfill the two affected nostr_event columns idempotently on migration, and cap the retry via nostr_attempts, which was declared but never incremented. The same cap is mirrored in InMemoryMessageStore so the test double cannot disagree with the durable store.
The schema arrays were still described as idempotent DDL although each now carries a one-time UPDATE, and the attempt cap added with the loop guard appeared in no TSDoc and no handbook section. Follow the db-change.ts precedent: name the DML in the array TSDoc, describe it in the schema mirror comment, and extend both handbook sections.
The backfill cast could abort the whole migration - and with it the process boot - on a single unparseable row, and it ran before migrateDbChangeSchema attaches trg_db_change, so its writes could land without an audit entry. Replace both statements with a DO block that returns early until the audit trigger is attached, repairs each row inside its own exception handler so one bad value is skipped with a warning instead of failing the boot, and clears nostr_attempts for the rows it repairs - the moment the root cause for that row actually disappears. The counter is deliberately not cleared on a successful publish: in this failure mode publishing always succeeds, so that would reset the cap every cycle and restore the unbounded loop.
…pshot PL/pgSQL fixes the cursor's MVCC snapshot when the loop opens, so the per-row UPDATE was writing a stale unwrapped value keyed only on id. A concurrent writer - an old replica still running resetSignedEvent, which nulls nostr_event - could have its newer state clobbered, and on message the forced nostr_attempts = 0 would re-arm the retry cap this PR installs. Select only the id, recompute the unwrap from the live column, and repeat the type predicate in the UPDATE's own WHERE so a since-changed row matches zero rows instead of being overwritten.
EXCEPTION WHEN others swallowed every error class, so a permission or constraint failure during the repair would have been reduced to a warning and the boot would have continued as if nothing happened. Only the cast failure the handler was written for is tolerated now; anything else propagates. The repair predicate had no index, so every boot paid a sequential scan of message and conversation_message to establish that nothing was left to repair. A partial index over the same predicate is empty once the repair converges. The test counted occurrences of the type predicate, which stayed green even if both sat in the cursor query rather than one in the UPDATE's WHERE. It now anchors the predicate to the UPDATE statement.
The two partial indexes added with the boot repair existed only in the schema arrays, while the array TSDoc and the handbook both still claimed the mirrors matched. The mirror files carry every other index, and their existing disclaimer is scoped to the repair statement alone, so it does not cover DDL. Add both indexes to docs/schema and name them in the two migrate sections.
Narrowing the handler to invalid_text_representation caught SQLSTATE 22P02 only. A nostr_event value carrying a NUL escape or a Unicode escape for a character absent from the database encoding raises 22P05 or 22021 instead - both in the same class, neither caught - so it would escape the block, abort the migration and abort the boot. The values come from arbitrary third-party clients, so those codes are reachable. Catch data_exception, the class category, so any bad value is skipped while permission errors, constraint violations and operational failures still propagate. The test now also asserts the single-code form is absent, so a future edit cannot silently narrow it again.
toContain is a substring match, so EXCEPTION WHEN data_exception OR unique_violation THEN would have satisfied both the positive and the negative assertion and widened the handler undetected. Anchoring on the trailing THEN rejects any appended condition.
The handler wrapped the whole UPDATE, and the UPDATE fires the db_change audit trigger - so it unavoidably covered the trigger too. That is why every attempt at a condition list came out either too broad (WHEN others, swallowing permission and operational failures) or too narrow (a single SQLSTATE, missing sibling codes and aborting the boot). Wrap only the cast. The trigger is no longer inside the handler, so an audit insert that fails on disk_full, insufficient_privilege or a sequence limit now propagates and aborts the boot, which is correct. data_exception on a bare text::jsonb cast is exactly the condition being handled, plus statement_too_complex, which is class 54 and reachable because tags: string[][] is a compile-time type only and inbound third-party events reach this column. The cast now reads the cursor snapshot, so the UPDATE carries a compare-and-swap on nostr_event to keep the guarantee an earlier round established. The warning is also truthful again: previously a trigger failure was reported as a value that could not be unwrapped.
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.
EN:
Values bound to a Postgres
::jsonbparameter were passed throughJSON.stringifyfirst, so the Bun SQL driver encoded them a second time and the column stored a jsonb string scalar instead of an object. Every SQL path expression on those columns therefore evaluated to NULL, which made all threelistSignedMissing*repair scans match unconditionally and reset already-published messages forever, re-signing and re-publishing the same rows in a loop that nothing could break. This removes the manual serialization at all seven::jsonbbind sites, backfills the two affectednostr_eventcolumns in place, and makes the retry self-limiting using thenostr_attemptscolumn, which was declared but never incremented anywhere. Two related follow-ups are deliberately out of scope and named below.DE:
Werte, die an einen Postgres-
::jsonb-Parameter gebunden werden, liefen zuerst durchJSON.stringify; der Bun-SQL-Treiber kodierte sie daraufhin ein zweites Mal, sodass in der Spalte ein jsonb-String-Skalar statt eines Objekts landete. Jeder SQL-Pfadausdruck auf diesen Spalten ergab damit NULL, wodurch alle dreilistSignedMissing*-Reparaturlaeufe bedingungslos zutrafen und bereits veroeffentlichte Nachrichten endlos zuruecksetzten — dieselben Zeilen wurden immer wieder neu signiert und veroeffentlicht, ohne dass irgendetwas die Schleife brechen konnte. Dieser PR entfernt die manuelle Serialisierung an allen sieben::jsonb-Bindestellen, repariert die beiden betroffenennostr_event-Spalten in place und begrenzt die Wiederholung ueber die Spaltenostr_attempts, die zwar deklariert, aber nirgends hochgezaehlt wurde. Zwei zugehoerige Folgearbeiten bleiben bewusst aussen vor und sind unten benannt.Details
Root cause
updateSignedEvent(and six sibling call sites) boundJSON.stringify(value)to a$n::jsonbplaceholder. The driver JSON-encodes a JS string again before sending, sothe stored value is a jsonb string scalar:
Application code never noticed, because
normalizeSignedEventinsrc/lib/nostr/publish.tsparses and then parses again when the result is still astring. SQL has no such fallback, so only the database-side predicates broke.
Why it looped
With
nostr_event->>'content'NULL:listSignedMissingPhoto/listSignedMissingVideo—COALESCE(nostr_event->>'content','') NOT LIKE '%…%'is always truelistSignedMissingHashtags—jsonb_typeof(nostr_event->'content') IS DISTINCT FROM 'string'is always true, and this scan has no media precondition, so it selects everypublished top-level message
resetSignedEventthen clearedevent_id/nostr_eventand set the row back topending; the worker re-signed and re-published it, and the next scan pass selected itagain. Publishing itself always succeeded — the relay acknowledged every attempt — so the
only visible log line was a successful publish, repeating.
resetSignedEventlogsnothing and signing has no success log, which is why the cycle left no trace.
Changes
JSON.stringifyis removed at all seven::jsonbbindsites:
message.nostr_event(insert andupdateSignedEvent),conversation_message.nostr_event(insert andupdateSignedEvent),message_invoice.zap_request,message_invoice.lnurl_response, andnostr_zap_ingest.receipt.UPDATE … WHERE jsonb_typeof(col) = 'string'is appendedto
MESSAGE_SCHEMA_SQLandCONVERSATION_SCHEMA_SQL, repairing existing rows on thenext migration boot. After the first run the predicate matches zero rows.
resetSignedEventnow incrementsnostr_attemptsand stampsnostr_first_attempt_at, and the three repair scans skip rows at or aboveMAX_PUBLISH_ATTEMPTS. The same rule is mirrored inInMemoryMessageStoreso the testdouble cannot show a terminating loop where the durable store would still spin.
push-store.tsis deliberately untouched: itsdelivered_endpointscolumn istext,not
jsonb, soJSON.stringifyis correct there and removing it would corrupt the value.The
db_changeaudit trigger is unchanged, and the backfillUPDATEs are ordinarydurable writes logged by that trigger — no gap is introduced.
Deliberately out of scope
message_invoice.zap_request,message_invoice.lnurl_responseornostr_zap_ingest.receipt. Their write paths are fixed here, so no new corrupt rowsare produced, and all three are read through
parseJsonObject, which accepts a stringor an object — so old and new rows coexist safely.
nostr_zap_ingestis large, andrewriting it would emit one audit row per record; that is a separate, scheduled change.
normalizeSignedEventis kept. It must stay until thebackfill has run everywhere; removing it earlier would turn stale rows into hard
failures. Removing it afterwards is a follow-up, and worth doing so a regression cannot
hide again.
Verification
bun run typecheck,bun run lint,bun run handbook:check,bun run e2e:check,bun run test:coverage,bun run buildandbun run e2e.No exported symbol is added, so no handbook section or e2e entry is required by this
change.