Skip to content

fix: harden sync error handling, unwrap the gateway envelope, and report RLS denials - #64

Open
marcobambini wants to merge 18 commits into
mainfrom
pg-fixes11092026
Open

marcobambini wants to merge 18 commits into
mainfrom
pg-fixes11092026

Conversation

@marcobambini

@marcobambini marcobambini commented Sep 11, 2026

Copy link
Copy Markdown
Member

Release 1.1.4. The substance is @marcobambini's audit hardening (e0ebb77); everything after it is follow-up work from running that hardening against CI and then reviewing it.

Sync error handling (e0ebb77)

The headline fix is a silent data-loss path: cloudsync_payload_apply reassigned rc on every row, so an error on one row was overwritten by any later success. A failed write could report success and advance the receive checkpoint, dropping the changes for good. Errors are now sticky.

Alongside it: little-endian IEEE754 byte order made explicit for PK doubles on every architecture, per-statement SPI tuple-table ownership, block-LWW rollback on write failure, 64-bit clocks above UINT32_MAX, network deadlines, a 256 MiB decompressed-payload cap, and Node ia32 rejection. Details in docs/internal/audit-regressions.md.

Gateway data envelope (c6200a8, a1f3ea3)

Scoping key lookups to a single object was correct, but seven reads take their key from a raw response body, and per API.md the gateway wraps every success payload in {"data": <payload>}. Root-scoped, they stopped resolving — every send failed with missing 'url' in upload response while all local suites stayed green, because no fixture used the real shape.

Affected: the upload URL, the three sync-state fields, and both failure stages on the send path, plus failures.check on the receive path. Each now resolves the payload first via json_response_payload, mirroring the unwrap /check already did. jsmn_find_key is unchanged from e0ebb77 — the hardening is intact, and envelope handling is explicit per call site rather than hidden in a shared helper.

RLS denials: skip, advance, report (f68c523, 067f3fb, 4a1cfd2)

A denial suppressed the receive checkpoint, which gave neither progress nor a signal: the rows are permanently not this site's to hold, so the next check re-delivered them, they were denied again, and one denied row stalled every later change behind it. In a chunked batch it was worse — policy_denied was a local of one apply call, so a denial in a non-final chunk suppressed a checkpoint that was already a no-op and the final chunk advanced past the denied rows anyway.

Denied entries are now counted, skipped, and the cursor advances. The count accumulates across the drain and is reported as receive.denied, so discarding stays visible. receive.rows reports rows actually written, matching what API.md has always documented.

Deliberately not an error even when every row is denied: a single-row payload belonging to another user is denied in full and is a correct outcome, which tests 27 and 29 already assert.

Known gap: this does not extend to a value large enough to be fragmented. Making the v3 path skip a denial leaves PostgreSQL's transaction unusable — the next statement fails with buffer pin is not owned by resource owner — and neither a savepoint around the per-value apply nor dropping the staged-fragment delete recovers it. 067f3fb tried; 4a1cfd2 reverts it. 58_v3_denied_checkpoint.sql builds a real fragmented payload, applies it under a WITH CHECK policy, and pins the current behaviour with a note to flip the assertion once the apply leaves a recoverable state.

Block-column failures (6bf127b)

Reading the row back is part of writing a block column, and a row its own session cannot select could not sync anyway — so the failure stays fatal, but it has to be legible. Five paths returned a bare code with no message, two of them the raw negative from pk_decode_prikey, which is not a DBRES value at all. databasevm_step clears the error text on entry, so PostgreSQL aborted the user's INSERT with an empty errmsg. Each now names the table and column.

Artifact transfer deadlines (7077df7)

CURLOPT_TIMEOUT applied to both pooled handles, but a presigned S3 URL is not an API endpoint, so artifact transfers inherited the 300-second cap: 256 MiB inside 300s demands a sustained ~875 KB/s. API calls keep the elapsed-time cap; artifact transfers now abort after 60 seconds below 1 KB/s — which also catches a real stall five times sooner — with a 1-hour backstop, since nothing can cancel a transfer in flight.

Also

  • merge_flush_pending replaced a failing commit's real error with a generic string (b4a5b3f).
  • Test 39 encoded the old lenient contract and aborted under ON_ERROR_STOP once errors became sticky (9d89fbb).
  • changelog.yml's tag filter is restored: widening it made the workflow fire, but the called workflow derives the version with ${GITHUB_REF#refs/tags/v} and rejects our unprefixed tags. Fixing that needs a change in changelog-action, so the filter goes back and a note records why.
  • API.md documents denied in both receive shapes and all six samples.

Verification

  • Full CI green on 5171b77: 37 pass, 1 skipped (release, correct on a branch)
  • PostgreSQL 15/17/18 484/484, zero failures
  • SQLite unit suite passes with Memory Leaks Check: OK
  • make postgres-check-migration: 1.1.3 -> 1.1.4, SQL surface identical, no migration script required
  • Each behavioural fix has a negative control — the regression tests were confirmed to fail against the old behaviour before being kept

Open

  • endian-unittest cannot detect anything: pk.c no longer references any endian-conditional symbol, so the forced-big-endian object is byte-identical to the normal one (verified by hash). The target is also unreachable from test: and from CI. Delete it or make it real — @marcobambini's call, since it is his audit scaffolding.
  • The gateway's error envelope ({"errors":[...]}) is never parsed by the client — error bodies are passed through verbatim — so root-scoped lookups cannot affect it. That also means a server error surfaces as a raw JSON blob rather than its detail string. Pre-existing, worth a follow-up.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CCE6B54Qtf3UsaCVAJtVQF

@andinux
andinux changed the base branch from pg-morecols to main September 11, 2026 16:49
marcobambini and others added 4 commits September 11, 2026 12:13
cloudsync_payload_apply now keeps the first error instead of letting a
later successful row overwrite it, so a lock-blocked apply reports the
failure rather than returning quietly. Test 39 encoded the old lenient
behaviour and aborted the script under ON_ERROR_STOP; it now tolerates
the error the way tests 41, 46 and 53 already do, and still asserts the
row kept its old value.

Also restores the changelog workflow's v-prefixed tag filter. Widening
it made the workflow fire but it then failed: the called workflow
derives the version with ${GITHUB_REF#refs/tags/v} and rejects our
unprefixed tags. Fixing that needs a change in changelog-action, so the
filter goes back and the note records why, keeping the manual run.

Adds the 1.1.4 changelog entry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCE6B54Qtf3UsaCVAJtVQF
Key lookups are now scoped to one object, but six reads on the send and
status path take their key from a raw response body, where the gateway
wraps every success payload in {"data": ...}: the upload URL, the three
sync-state fields, and both failure stages. Root-scoped, they stopped
resolving, so every send failed with "missing 'url' in upload response"
while the local suites stayed green.

Those readers now resolve the payload first, the way the /check path
already does, keeping lookups scoped to a single object. Chunk objects
sliced out of chunks[] and legacy unwrapped bodies fall through
unchanged.

The new test covers the documented shapes in both directions: an
enveloped status payload with gaps and failures, a legacy unwrapped
body, an enveloped url staying invisible to a root-scoped read, and a
sliced chunk object resolving directly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCE6B54Qtf3UsaCVAJtVQF
The opportunistic failures.check read takes its key from the raw /check
response body, which the gateway wraps in {"data": ...}. Root-scoped it
returned NULL, so a server-reported check failure was never surfaced —
silently, since the field is optional.

This was the one site missed by the previous commit; every remaining
lookup now reads either an unwrapped payload, a chunk object sliced out
of chunks[], or an already-extracted sub-object.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCE6B54Qtf3UsaCVAJtVQF
@andinux andinux changed the title fix: harden sync error handling and release 1.1.4 fix: harden sync error handling and unwrap the gateway data envelope Sep 11, 2026
andinux and others added 7 commits September 11, 2026 14:45
…ursor

A denial suppressed the receive checkpoint, which gave neither progress
nor a signal. The rows are permanently not this site's to hold, so the
next check re-delivered them, they were denied again, and nothing ever
surfaced to break the cycle. One denied row also stalled every later
change behind it.

Worse in a chunked batch: policy_denied was a local of one apply call,
but each chunk is a separate call. A denial in a non-final chunk
suppressed a checkpoint that was already a no-op (non-final chunks pass
CHECKPOINT_NONE), the flag died with the call, and the final chunk
advanced the cursor past the denied rows — dropping them silently, the
shape this was meant to prevent.

Denied entries are now counted, skipped, and the cursor advances. The
count accumulates across the drain on the context and is reported as
receive.denied, so discarding stays visible: a non-zero denied with zero
rows is the shape of an apply connection with no session identity.

Deliberately not an error, even when every row is denied: a single-row
payload belonging to another user is denied in full and is a correct
outcome, which tests 27 and 29 already assert.

Test 27 now checks the cursor moves past a denied apply. Verified it
fails ("left the checkpoint at 4, expected > 4") with the old
suppression restored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCE6B54Qtf3UsaCVAJtVQF
Reading the row back is part of writing a block column: without its text
there is nothing to split, and a row its own session cannot select could
not sync anyway. So the failure stays fatal — but it has to be legible.

Five paths across local_block_insert and block_migrate_existing_rows
returned a bare code with no message. databasevm_step clears the error
text on entry, so PG's cloudsync_insert raised the user's INSERT with an
empty errmsg — the "not an error" confusion the comment in
cloudsync_payload_apply already warns about. Two of them returned the
raw negative from pk_decode_prikey, which is not a DBRES value at all
(-1 is neither OK nor any known error), so callers testing for a known
code fell through.

Each now names the table and the column, and the unreadable-row case
points at the SELECT policy. Aborting the migration stays recoverable:
its Phase 1 scan skips already-migrated rows, so a re-run resumes.

Test 57 covers the unreadable case end to end. Verified it fails
("reported a blank error") with the message removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCE6B54Qtf3UsaCVAJtVQF
CURLOPT_TIMEOUT was applied to both pooled handles, but they carry very
different traffic. An S3 presigned URL is not one of the API endpoints,
so artifact GETs and PUTs used the artifact handle and inherited the
300-second cap: 256 MiB (the new decompressed limit) inside 300s demands
a sustained ~875 KB/s, so a healthy transfer on a slow link was killed
mid-flight and reported as a timeout, indistinguishable from a dead
server, on every retry.

API calls keep the elapsed-time cap, which is the right shape for small
JSON. Artifact transfers now abort after 60 seconds below 1 KB/s, which
also catches a real stall five times sooner than the 300s cap did, and
keeps a 1-hour backstop because there is no progress callback to cancel
a transfer that trickles just fast enough to stay alive.

The stalled-server test covered the artifact handle only (every endpoint
in its stub context is NULL). It now runs both policies. Verified the
artifact case fails when the low-speed options are removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCE6B54Qtf3UsaCVAJtVQF
error_message was snapshotted only on the way into cleanup, so arriving
with rc OK left it empty. A database_commit_savepoint that then failed
set rc but its message — a deadlock or serialization failure, say — was
replaced by the generic "Unable to flush pending changes".

Snapshot after the commit attempt as well, before the rollback, which
touches the error state itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCE6B54Qtf3UsaCVAJtVQF
…rows written

Three consequences of the denial work, which only covered the row path.

The v3 fragment path had no denial branch, so a denied oversize value
still returned POLICY_DENIED up through network_apply_payload_buffer,
became a receive error, and aborted the whole drain — skipping the rest
of the payload, stalling the cursor, and leaving the staged fragments
undeleted to churn until stale cleanup. It gets the same treatment as
the row path: count, skip, checkpoint. A denied value's fragments are as
finished as an applied one's, so they are dropped too; any other failure
still keeps them for the retry.

receive.rows counted denied entries, because it came from the payload's
entry count. An all-denied receive reported {"rows":N,"denied":N} while
tables was correctly empty, and the diagnostic the CHANGELOG describes —
a non-zero denied with a zero rows — could never occur. API.md has
always documented the field as rows "received and applied", so the
number now matches its own contract.

Fixed in the drain rather than in the apply return value: that return
counts payload entries including denied ones, which tests 27 and 29
asserts as part of the SQL surface. Both paths accumulate an accurate
applied count on the context instead, next to the denied one.

API.md documents denied in both receive shapes and all six samples.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCE6B54Qtf3UsaCVAJtVQF
…sable

067f3fb made the v3 fragment path skip a denied value and checkpoint past
it, matching the row path. That is wrong on PostgreSQL: a denial leaves
the transaction unusable, so the next statement — the checkpoint write —
fails with "buffer pin is not owned by resource owner TopTransaction".
The symptom is worse than the behaviour it replaced, which at least
reported the denial cleanly.

Neither a savepoint around the per-value apply nor dropping the
staged-fragment delete recovers the state; both were tried and both
still fail. The row path is safe only because merge_flush_pending rolls
back its own savepoint around the write.

So the v3 path goes back to failing on a denial, and the comment records
why. The gap the revert leaves open is real and now covered: the cursor
does not advance, so a denied oversize value is re-delivered on every
drain. 58_v3_denied_checkpoint.sql builds a genuine fragmented payload,
applies it under a WITH CHECK policy, and pins that behaviour, with a
note to flip the assertion when the apply leaves a recoverable state.

Closing it properly needs the fragment apply to roll back to a savepoint
the way merge_flush_pending does, which is more than a follow-up to the
reporting work.

The CHANGELOG now scopes the skip-and-advance claim to the row path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCE6B54Qtf3UsaCVAJtVQF
The denial path no longer reaches it, so "already applied or permanently
denied" describes a state that cannot occur.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCE6B54Qtf3UsaCVAJtVQF
@andinux andinux changed the title fix: harden sync error handling and unwrap the gateway data envelope fix: harden sync error handling, unwrap the gateway envelope, and report RLS denials Sep 11, 2026
marcobambini and others added 7 commits September 17, 2026 13:13
…reserve error origin

Review follow-ups on the payload apply path (review points 1, 3, 4, 5, 7, 8, 9).

- Failed writes: a change that fails on its data (constraint, raising trigger, type
  error) is skipped, logged as a warning and reported as receive.failed /
  receive.failedError, and the cursor advances. Transient failures (busy/locked,
  deadlock, serialization failure, cancel, out of memory/disk, I/O) and configuration
  failures (missing privilege, read-only database) fail the apply and keep the cursor.
- PostgreSQL savepoints restore the caller's resource owner and memory context:
  SELECT cloudsync_payload_apply(data) FROM some_table no longer fails with a foreign
  buffer pin. Only the outermost savepoint swaps the active snapshot, so a rollback
  inside the caller's subtransaction no longer trips EnsurePortalSnapshotExists.
- RLS denials: receive.denied is removed (denials never occur on the SQLite client);
  PostgreSQL raises one summary WARNING. Every payload row runs in its own savepoint
  and the cloudsync_changes trigger re-raises denials as 42501, so block-column and GOS
  denials are skipped instead of failing the apply. Denied rows are retried once the
  rest of the payload is in, so policies depending on later rows (memberships) apply.
- Block columns and GOS tables write existing rows with an UPDATE (fallback to the
  upsert when no row changes), and a row's pending columns are flushed before its
  blocks, so they work under INSERT policies and NOT NULL columns.
- Each PK group is applied under one savepoint covering the metadata its rows write, so
  a failed flush leaves nothing behind (a resurrected row is created when re-delivered)
  and skipped changes are counted per payload row, sentinel included.
- Errors keep their origin: the SQLSTATE of the database error survives to the
  ereport (40001, 23505, ... instead of XX000); SQLite triggers report cloudsync's
  message and the real result code; block failures name the stage, column and table
  and are never blank.

Tests: review_regressions (skip/transient/WAL busy, resurrected groups, block errors,
allocation sweep), network_unit (receive JSON), PostgreSQL 39, 57 and new 59.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…columns

Review point 2. The migration scans the metadata table, so it can return a pk whose
base row no longer exists (deleted while sync was disabled) or is hidden by a SELECT
policy. Failing on it made cloudsync_set_column(..., 'algo', 'block') impossible for
that table, and on SQLite left algo=block persisted with a half-done migration. Such
rows are skipped again, as before the refactor; the row's next local write creates
its blocks.

Tests: review_regressions and PostgreSQL 57 convert a column with orphaned metadata.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…fixed 256 MiB cap

Review point 6. The fixed cap rejected payloads the library itself produces
(cloudsync_payload_encode / cloudsync_payload_save have no such limit), so a large
export could no longer be loaded, and it only applied to compressed payloads. The
real risk is a few forged header bytes claiming a huge allocation: a compressed
payload cannot expand beyond LZ4's 255:1 ratio, so a declared size above
compressed_len * 255 + 64 (or above INT_MAX, where LZ4 gets a negative capacity) is
rejected up front, and any genuine payload stays loadable.

Tests: forged 4 GB and 268 MB headers, the exact 255:1 boundary, and a genuine
payload compressing to 254:1. A 300 MiB payload was also verified manually.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s390x)

Review point 10. Primary-key and payload encodings are byte-order sensitive, but every
suite ran on little-endian hosts only, where the pre-1.1.4 host-dependent double
encoding is indistinguishable from the fixed one. The new target builds and runs
dist/unit and dist/review_regressions in a linux/s390x Alpine container under QEMU,
in separate build directories, and aborts unless the host really is big-endian.

With the pre-1.1.4 double encoding restored it fails on s390x on the golden bytes
while still passing on macOS. No migration is added for big-endian data written by
earlier versions: no such deployment exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review point 11. Centralizing block writes in local_block_insert/local_block_update
left SQL_BLOCKS_INSERT_IGNORE, SQL_META_INSERT_BLOCK_IGNORE, block_initial_positions()
and table_block_list_stmt() without callers. Their names suggested the migration was
idempotent through INSERT OR IGNORE, which is no longer how it works.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review point 12. Once pk.c stopped using host-order conversions, compiling it with a
forced __BYTE_ORDER__ produced a byte-identical object, so the target could not fail;
it was also not run by make test or CI. make unittest-s390x now covers big-endian
hosts. cloudsync_endian.h keeps only bswap64_u64, so a host-dependent encoding cannot
be reintroduced by accident; the pk.c comments state the format is the same on every
host.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ay what is left

Review point 14. remove_test_directory() deleted only files starting with
cloudsync-test-, so a test creating any other file made the cleanup fail silently and
left the directory in TMPDIR, although the directory is private to the run (mkdtemp).
It now deletes every entry, names any it cannot delete, and prints the directory when
it is kept (cleanup disabled or failed). The Windows length check gets the size_t
cast the POSIX branch already had. review_regressions' on-disk BUSY test uses its own
private directory instead of a file directly in TMPDIR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@marcobambini

Copy link
Copy Markdown
Member Author

Review follow-ups

Issues found while reviewing this PR, all fixed in the 7 commits just pushed. Each commit builds and passes SQLite unit/regression, network and PostgreSQL 17 suites (502 PASS, 0 FAIL); make unittest-s390x also passes on big-endian.

Apply path9cc60d2

  • Sticky errors stalled sync forever: a row failing on its data (constraint, raising trigger) blocked the cursor with no escape; on PG the whole payload was rejected. Such writes are now skipped, logged and reported (receive.failed / failedError); transient and configuration failures (lock, deadlock, serialization, cancel, OOM, I/O, missing privilege, read-only) still fail the apply and keep the cursor.
  • receive.denied was always 0: denials only happen on PG, which has no receive drain. Field removed; PG emits one summary WARNING.
  • RLS denials outside the batched path (block columns, GOS tables) failed the whole apply. Each row now runs in its own savepoint and the trigger re-raises denials as 42501.
  • Order-dependent denials lost data (e.g. a membership row later in the payload). Denied rows are retried once the rest of the payload is applied.
  • Block columns and GOS tables never wrote under RLS / NOT NULL: the per-column upsert proposed a partial row. Existing rows are now updated in place (falling back to the upsert when no row changes), and pending columns are flushed before blocks.
  • A failed flush left partial metadata (sentinel, zeroed clocks), so a resurrected row was silently never created on re-delivery; skipped counts also missed the sentinel. Each PK group now runs under one savepoint and counts payload rows.
  • PostgreSQL crashes/errors from savepoints (pre-existing): SELECT cloudsync_payload_apply(data) FROM t failed with a foreign buffer pin, and nested savepoints could trip EnsurePortalSnapshotExists. Resource owner and memory context are restored; only the outermost savepoint swaps the snapshot.
  • Error origin lost: trigger and apply errors surfaced as XX000 (breaking 40001/40P01 retry logic), SQLite triggers reported SQLITE_ERROR without context, and some block failures were blank. SQLSTATE and result codes are preserved, and messages name stage, column and table.

Other fixes

  • af8e5b0 — block migration failed on metadata whose base row is gone, making set_column(..., 'block') impossible; those rows are skipped again.
  • 268011b — the fixed 256 MiB cap rejected payloads the library itself produces; the declared size is now bounded by LZ4's 255:1 ratio, and INT_MAX is still enforced.
  • 1ade3e3 — new make unittest-s390x, running the SQLite suites on a real big-endian host (QEMU); it catches the pre-1.1.4 double encoding.
  • c4a2b07 — removed code left unused by the block refactor.
  • 31c5d06 — removed make endian-unittest, which compiled to an identical object and could not fail, plus the unused host-order helpers.
  • 3e8d95d — test temp-directory cleanup no longer depends on file names and reports what is left.
  • Submodule: ddaf414 was only on a side branch of fractional-indexing; it has been fast-forwarded to its main (same SHA) and the branch deleted.

New/updated tests: review_regressions.c, network_unit.c, PostgreSQL 39, 57 and new 59. Each fix was checked against a negative control.

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants