Skip to content

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

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

marcobambini wants to merge 28 commits into
mainfrom
pg-fixes11092026

Conversation

@marcobambini

@marcobambini marcobambini commented Sep 11, 2026

Copy link
Copy Markdown
Member

Release 1.1.4 hardens sync error handling and receive checkpoints, preserves error origin, fixes gateway response parsing, and bounds network and fragment-maintenance work.

Apply errors and receive checkpoints

An apply now stops at the first failed write, including constraints, raising triggers, type errors, RLS denials, missing privileges and transient failures. Later changes are not processed and the receive checkpoint stays put. PostgreSQL preserves the database SQLSTATE and rolls back the failing SQL statement. SQLite may retain earlier successful groups, which re-merge on redelivery once the cause is fixed. Network results retain the applied prefix in receive.rows and report receive.error.

There is no internal denial retry loop and no receive.denied or receive.failed counter. A permanent denial remains an error until its cause is resolved. This applies to ordinary columns, block/GOS paths and fragmented values.

Pending ordinary columns and their metadata share a group savepoint; rejected flushes roll back sentinel/reset/winner clocks. Block materialization flushes ordinary columns first, so this is not a promise of whole-payload or whole-row atomicity on SQLite.

Receive streams hold one durable checkpoint until the final watermark, reject incomplete fragmented values and restart paging after apply failure. Direct v3 fragment calls do not advance the receive checkpoint, and a fragmented final chunk without a watermark fails explicitly.

Fragment concurrency and cleanup

Staging, reconstruction, application and removal of each completed value happen under a savepoint. READ COMMITTED fragment applies serialize per value; SERIALIZABLE can require a retry; REPEATABLE READ fragment applies are refused.

Cleanup only removes incomplete groups whose newest piece is over 24 hours old, and skips values being applied by another transaction. Each PostgreSQL cleanup now materializes at most 64 candidate groups before acquiring advisory locks. The previous query could acquire a lock for every stale group, exhaust shared lock memory and roll back all maintenance. A 30,000-group backlog reproduced this indefinitely. Subsequent bounded passes now make progress; the existing once-per-minute, per-connection throttle remains.

Test 61 covers that backlog, a concurrently locked group, bounded lock retention, progress on the next pass and completion of a genuine fragmented value. It fails on the previous query and passes with the fix (9d0abb3).

Other changes

  • Gateway success envelopes are explicitly unwrapped before scoped lookups for URLs, sync state and failures.
  • PostgreSQL owns SPI tuple tables per statement and restores savepoint resource owners/memory contexts; errors retain SQLSTATE.
  • Block failures report their stage, table, column and underlying cause. Existing block/GOS rows use UPDATE with upsert fallback when no row changes.
  • PK doubles preserve deployed little-endian IEEE754 bytes on every architecture; clocks above UINT32_MAX are retained.
  • Decompression is bounded by LZ4's maximum expansion ratio and INT_MAX, not a fixed 256 MiB cap.
  • Curl uses asynchronous DNS, API deadlines and progress-based artifact deadlines. Settings can tune deadlines at runtime, and SQLite 3.41+ can cancel an in-flight call with sqlite3_interrupt.
  • Node rejects ia32. make unittest-s390x replaces the ineffective forced-endian test.

Verification

Local verification of the cleanup fix:

  • PostgreSQL 15.19 / 17.11 / 18.6: 521 checks per version, zero failures, with ON_ERROR_STOP enabled and expected errors handled by the suite.
  • Negative control: test 61 fails with out of shared memory and no cleanup progress on the previous query; all five assertions pass with the bounded query.
  • Migration check: 1.1.3 → 1.1.4, unchanged SQL surface, no migration required.
  • Git whitespace check passed.

Independent review of preceding commit 1820a15 also passed 150 SQLite checks plus focused regressions, extension-core ASan/UBSan builds, 30,000 randomized writes across three replicas (10,800 payload deliveries and 600 convergence comparisons), 100 randomized fragment/retry trials, 14 Linux network groups, 340 fractional-indexing tests, and 14 Node tests/typecheck/build.

Real-cloud verification and limits

The repository already has the seven integration secrets. Linux CI on 1820a15 reports OK, not SKIPPED, for Init+Sync, Token Auth, all seven chunked cases, Offline Error and Failure Path. That overall workflow failed in Android x86_64; do not interpret it as fully green.

The new push starts CI for the cleanup-fix commit. Building the client does not deploy the PostgreSQL extension to remote nodes: validating the cleanup fix on a real server requires a dedicated staging node running this build. Cloud test guide documents the existing CI route, tenant fixtures and server-side staging procedure. Audit details describe the current contract and coverage.

Known pre-existing limits from the independent review: SQLite deferred-constraint commit failures can leave a transaction open; very deep PostgreSQL savepoints exceed the fixed owner-storage depth; allocation-fault cleanup still has gaps. The local review did not run Windows/Android/WASM/iOS or s390x and did not complete PostgreSQL ASan runtime testing. These are not claimed as validated by the local test results above.

@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

andinux and others added 5 commits September 18, 2026 19:54
Skipping a failed or denied write and advancing the receive checkpoint could
acknowledge changes that were never stored. cloudsync_payload_apply now stops
at the first change it cannot write and returns that error, with its SQLSTATE
on PostgreSQL. The failed PK group is rolled back, no later change is applied,
and the checkpoint does not move, so redelivering after the cause is fixed
applies the payload.

- Remove the skip policy: transient/permanent classification, the in-payload
  RLS retry passes, skip warnings and the apply_failed/apply_failure counters.
- Keep earlier PK groups where the enclosing transaction allows it (SQLite);
  a PostgreSQL statement failure still rolls the statement back.
- Count the rows applied before a failure, so receive.rows and receive.tables
  report them together with receive.error; drop receive.failed/failedError.
- A batched multi-column UPDATE that changes no row (row gone or hidden by a
  USING policy) falls back to the upsert instead of recording winner clocks.

Update API.md, the RLS reference, the changelog and the SQLite, network and
PostgreSQL regressions (27, 29, 57, 58, 59) to the fail-fast behavior.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The receive checkpoint also identifies the server's prepared pages, so it
stays fixed for the whole stream and moves once, after the final chunk.

- A fragmented value the stream delivered must be complete when its final
  chunk applies; otherwise the call fails and the checkpoint stays. Only values
  this stream staged are tracked, so staging left by other streams or direct
  calls never blocks progress. Later pieces of a value the stream already
  applied are ignored instead of being staged again as an incomplete group.
- Any failed chunk makes the next call replay the window from page 0 with
  fresh observations; calls capped by max_chunks keep both.
- Checkpoint write failures are returned instead of ignored, and a direct v3
  fragment call no longer moves the checkpoint.

Add a test-only /check responder and network tests for capped paging,
failure replay, checkpoint write errors, incomplete/out-of-order/duplicate
fragments, abandoned staging and completion by another caller.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Stale cleanup removes a group only when all its pieces are older than the
  retention window, and runs after the incoming piece is staged, so a value
  resumed after a long pause keeps its earlier pieces. Retention and throttle
  are unchanged.
- The cleanup runs in its own savepoint: a failure is rolled back and logged
  instead of failing the piece being applied.
- Staging a piece, reassembling, applying the value and removing its pieces
  run under one savepoint; a failure to remove the pieces is now returned
  instead of ignored, and pieces staged by earlier calls stay for a retry.
- A failed staging count query is reported instead of read as "incomplete".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The server applies each uploaded chunk as its own job, so pieces of one value
can reach concurrent transactions. Under READ COMMITTED each could stage its
piece, see only its own, and commit without ever applying the value, and the
stale cleanup could remove the old pieces of a value another transaction was
resuming (both reproduced by the new test before the fix).

- READ COMMITTED: a transaction-level advisory lock per value_id makes the
  pieces wait for each other; after the wait the next statement sees the
  committed pieces. The stale cleanup try-locks the same key and skips a
  value being applied.
- SERIALIZABLE takes no lock: the lost-value outcome matches no serial order,
  so one transaction fails with a retryable serialization error.
- REPEATABLE READ is refused for fragments (0A000): a waiter would keep the
  snapshot taken before the wait and miss the other piece.
- SQLite already serializes writers; its lock is a no-op.

Add 60_fragment_concurrency.sql (dblink) for the three isolation levels and
the cleanup race.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…mark

Without a watermark the network fallback checkpointed through LAST_APPLIED,
the mode direct SQL calls use, which no longer moves the cursor for a v3
fragment. A stream ending in a fragment then applied the value but never
advanced check_dbversion, and every call replayed the same window while
reporting complete. The current server always sends a watermark on chunked
responses, so this needs an older or non-conforming server.

The fallback now has its own mode, CLOUDSYNC_CHECKPOINT_STREAM_LEGACY: a
monolithic final chunk keeps the last-applied checkpoint plus the stream's
completeness check, while a v3 payload fails before staging. A fragment's
final chunk may apply nothing new (pieces of a value the stream already
applied are skipped), which leaves no position to checkpoint and would
bring the silent replay back. Direct SQL calls are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@andinux

andinux commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator

New commits: fail fast on apply errors, one durable receive window per stream

These five commits change how failures in received changes are handled:

5c980a1 fix(apply): stop at the first failed write instead of skipping it
05d42de fix(receive): keep one checkpoint per stream and check its fragments
684aae3 fix(fragments): keep resumed groups and make each fragment call one unit
ce0557f fix(postgres): serialize concurrent applies of one fragmented value
8ebc951 fix(receive): fail a fragmented stream whose final chunk has no watermark

Why

Skipping errors can permanently acknowledge missing data. Until now, a write that failed on its data (a constraint, a trigger, a type error) or was denied by an RLS policy was skipped, and the receive checkpoint still moved past it. Once the checkpoint has passed a change, nothing delivers that change again. Fixing the data or the policy afterwards doesn't bring it back, so the row is lost for good, reported only as a warning. Retrying denied rows later in the same payload makes this rarer but doesn't remove it.

These commits deliberately choose explicit failure and retry instead:

  • cloudsync_payload_apply stops at the first error.
    • It returns the original error, with its SQLSTATE on PostgreSQL.
    • The failed primary-key group is rolled back, data and sync metadata alike, and no later change in the payload is applied.
    • On SQLite, the changes applied before the failure are kept where the enclosing transaction allows it. On PostgreSQL the failed statement rolls back as usual.
    • Once the cause is fixed (data, policy or grant), delivering the same payload again applies it. The changes already applied merge again as no-ops.
    • A policy that depends on rows later in the same payload is not reordered or retried inside the apply: the apply fails until those rows are in.
  • The receive response reports the successful prefix. receive.rows and receive.tables count the changes applied before the failure, and receive.error gives the first error with receive.complete: false. receive.failed / receive.failedError are removed.
  • Updates that write nothing are not recorded as applied. A batched multi-column update that changes no row (the row is gone, or hidden by an UPDATE policy) now falls back to the upsert. Before, it recorded the change as applied without storing it.

One durable receive window until final success. check_dbversion/check_seq also identify the server's prepared pages. So they stay fixed for the whole chunk stream: across non-final chunks, calls capped by max_chunks, and failures. They move once, to the final watermark, only after the final chunk has applied. Moving them between pages would reset the server's paging and replay the backlog. Specifically:

  • A failed chunk makes the next call replay the window from page 0. Changes already applied re-merge as no-ops.
  • A checkpoint write that fails is reported instead of ignored.
  • A fragmented value that the stream delivered must be complete before the stream checkpoints; otherwise the call fails.
    • Only values delivered by the current stream are tracked, so pieces left behind by other streams, interrupted attempts or direct SQL calls never block progress.
    • Pieces of a value the stream already applied are ignored, so a replay doesn't leave them behind again.
  • A direct SQL call applying a fragment never moves the receive checkpoint.
  • A stream from a server that sends no watermark and ends in a fragment now fails loudly instead of replaying the same window forever. The current server always sends a watermark, so this is purely defensive.

Also included

  • Fragment staging.
    • The age cleanup removes a group only when all its pieces are old, and it runs after the new piece is staged. Before, it could delete the old pieces of a value whose delivery had just resumed.
    • A failing cleanup no longer fails the apply.
    • Applying a completed value and removing its pieces now succeed or fail together.
  • PostgreSQL concurrency. The server applies each uploaded chunk as its own job, so pieces of one value can be applied by concurrent transactions. Under READ COMMITTED, two transactions could each see only their own piece, both succeed, and the value was never applied (reproduced by the new 60_fragment_concurrency.sql before the fix).
    • A per-value advisory lock serializes those transactions.
    • SERIALIZABLE relies on PostgreSQL's serialization failure instead, which the client can retry.
    • REPEATABLE READ is refused for fragments.

No SQL signatures, payload formats or table definitions change (check-postgres-migration.sh: SQL surface identical). API.md, the RLS reference and the unreleased 1.1.4 CHANGELOG entries describe the new behaviour.

Tested: SQLite unit and review regressions, network unit tests (including new receive-stream scenarios run against a test /check responder), PostgreSQL 17 (516 PASS) and Supabase (514 PASS).

🤖 Generated with Claude Code

CI runs the suite with psql -v ON_ERROR_STOP=on, so an SQL error that a test
expects must be wrapped in \set ON_ERROR_STOP off/on, as tests 58 and 59 do.
The denied applies rewritten in 27 and 29 raised unwrapped and stopped the
whole run at 27_rls_batch_merge.sql:284 on PostgreSQL 15, 17 and 18.

Test 39's lock attempt, which is expected to fail on Supabase and then skip
the lock-contention case, is wrapped the same way: once an earlier test turns
ON_ERROR_STOP back on, that failure would stop a Supabase run too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
andinux and others added 3 commits September 18, 2026 23:18
…lines

libcurl was built with --disable-threaded-resolver. Its synchronous resolver
can only time out a lookup with SIGALRM, which CURLOPT_NOSIGNAL disables, so a
hung DNS server blocked a request past every connect and request deadline.
Build with --enable-threaded-resolver instead (c-ares stays disabled). The
--disable-pthreads flag is dropped: curl 8.12 does not recognize it, and
pthreads are detected and used by the threaded resolver on POSIX builds.

A network unit test asserts that the linked libcurl resolves names
asynchronously (CURL_VERSION_ASYNCHDNS); it fails against the previous build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nothing could stop a network call before its deadline. Each libcurl handle now
gets an XFERINFO callback that aborts the transfer as soon as the connection
that started it is interrupted (sqlite3_is_interrupted, SQLite 3.41+; on an
older host library the call still ends on its deadline). The connection is
recorded on network_data when it is created; a network_data without one, as in
the curl timeout test helper, is never interrupted.

A cancelled call fails with SQLITE_INTERRUPT instead of SQLITE_ERROR, from the
two places that report network errors, so a caller can tell a deliberate stop
from a failure worth retrying. The logout, init and cleanup paths already pass
the database's own result code through.

Adds a test that a transfer to a stalled server on an interrupted connection
aborts at once with CURLE_ABORTED_BY_CALLBACK.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The deadlines were compile-time macros only, so a too-tight value on a slow
link needed a rebuild. They can now be set per database through the existing
settings mechanism, which works on every host (mobile included, where process
environment variables are impractical):

  network_connect_timeout, network_request_timeout, network_artifact_timeout
  (seconds), network_artifact_low_speed_limit (bytes/s),
  network_artifact_low_speed_time (seconds)

They are read from cloudsync_settings for every request, so a change applies to
the next one. A missing or non-positive value keeps the compiled default; a
value may raise or lower it, clamped to 24 h (1 GiB/s for the speed limit),
and the connect timeout never exceeds the request's total. No SQL function or
signature changes: these are cloudsync_set keys, like payload_max_chunk_size.

Tests against a stalled local server: a raised request deadline takes effect,
a non-positive value keeps the default, and sqlite3_interrupt() from another
thread cancels a call with SQLITE_INTERRUPT well before its deadline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@andinux

andinux commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator

Network deadlines: how the libcurl timeouts work now

Three more commits complete the request deadlines that this PR introduces:

4e2d36e fix(network): resolve names on a thread so DNS lookups honor the deadlines
443eae7 feat(network): cancel a transfer in flight with sqlite3_interrupt()
1820a15 feat(network): tune the deadlines at runtime with cloudsync_set

Two policies, by kind of request

API requests (check, upload URL, apply, status) Payload transfers (upload / download of a payload)
Connect, name resolution included 30 s 30 s
Bound whole request: 300 s lack of progress: aborted when slower than 1024 B/s for 60 s, with a 3600 s absolute limit as a backstop

API calls carry small JSON, so an elapsed-time cap fits them. Payload transfers are bulk: an elapsed-time cap would kill a healthy large transfer on a slow link, so they are bounded by lack of progress instead.

DNS is bounded too (4e2d36e)

libcurl was built with --disable-threaded-resolver. Its synchronous resolver can only time out a lookup with SIGALRM, and CURLOPT_NOSIGNAL turns that off, so a hung DNS server blocked a request past every deadline. libcurl is now built with --enable-threaded-resolver (c-ares stays disabled). --disable-pthreads was dropped: curl 8.12 doesn't recognize it, so it did nothing. A unit test asserts that the linked libcurl resolves names asynchronously (CURL_VERSION_ASYNCHDNS).

Cancellation (443eae7)

  • sqlite3_interrupt() on the connection stops a network call in flight. libcurl checks the connection on its progress callback, about once a second even while idle, and aborts the transfer.
  • The call then fails with SQLITE_INTERRUPT instead of a generic error, so the caller can tell a deliberate stop from a failure worth retrying.
  • The check needs SQLite 3.41 or later (sqlite3_is_interrupted). With an older host library a call still ends on its deadline.

Runtime tuning with cloudsync_set (1820a15)

The compiled defaults (the CLOUDSYNC_*_TIMEOUT_SECONDS / CLOUDSYNC_ARTIFACT_LOW_SPEED_* macros) can now be overridden per database. These are cloudsync_set keys stored in cloudsync_settings, like payload_max_chunk_size; no SQL function or signature changes.

Key Unit Default Max
network_connect_timeout seconds 30 86400
network_request_timeout seconds 300 86400
network_artifact_timeout seconds 3600 86400
network_artifact_low_speed_limit bytes/s 1024 1073741824
network_artifact_low_speed_time seconds 60 86400
  • When it applies: values are read for every request, so a change applies to the next request.
  • Invalid or missing values: a missing, zero or negative value keeps the default. A value can raise or lower the default, and anything above the maximum is clamped.
  • Connect vs total: the connect timeout never exceeds the request's total.
  • Removing an override: settings persist in the database; cloudsync_set(key, NULL) removes one.
  • Call it after init: cloudsync_set is only stored once cloudsync_init / cloudsync_network_init has run. Before that it is silently ignored.
  • Hosts that configure through environment variables: read them at startup and call cloudsync_set after init, with NULL when a variable is unset so a stale value doesn't linger. Values are whole seconds, so round milliseconds up.

Tests

  • Unit tests against a local server that never answers check that:
    • the API elapsed cap and the payload stall limit each abort, on pooled and non-pooled handles;
    • a transfer on an interrupted connection aborts at once;
    • a raised network_request_timeout takes effect;
    • a zero value keeps the default;
    • sqlite3_interrupt() from another thread ends a call with a 30 s deadline well before it, with SQLITE_INTERRUPT.
  • SQLite unit, review and network suites pass locally. check-postgres-migration.sh reports the SQL surface unchanged.

Not covered

The Mac Catalyst build uses NSURLSession (network.m) instead of libcurl. That path still runs on the system default timeouts (60 s idle, 7 days per resource) and has no cancellation; it's left for a follow-up.

🤖 Generated with Claude Code

@marcobambini

Copy link
Copy Markdown
Member Author

Ho completato la revisione approfondita e pubblicato la correzione nel commit 9d0abb306e44d9a92dbe2a12c735b93d1bf0ee41.

Regressione trovata

La pulizia PostgreSQL dei frammenti scaduti acquisiva un advisory lock transazionale per ogni gruppo candidato, senza limitarne il numero. Con 30.000 gruppi incompleti vecchi di due giorni, la query esauriva la memoria condivisa dei lock:

WARNING: cloudsync: stale fragment cleanup failed: out of shared memory
stale_before = 30000
stale_after  = 30000

L'errore annullava tutta la pulizia. Una nuova sessione riproduceva lo stesso risultato, impedendo al backlog di ridursi. La query precedente su main, sugli stessi dati, eliminava invece tutti i 30.000 gruppi. Il payload di prova poteva comunque completarsi: il difetto osservato riguardava la mancata pulizia, non la perdita di quel valore.

Correzione

La query ora materializza al massimo 64 gruppi candidati prima di acquisire gli advisory lock. Ogni passaggio mantiene quindi limitato il lavoro sui lock; le chiamate successive proseguono sul backlog. Restano invariati la protezione dei gruppi usati da altre transazioni, il criterio di scadenza basato sul frammento più recente e il throttling di un minuto per connessione.

Non viene eseguito un ciclo di batch nella stessa transazione: rilasciare un savepoint non libera i lock transazionali già acquisiti. Transazioni applicative molto lunghe possono comunque accumulare lock da più apply.

Il nuovo 61_fragment_cleanup_backlog.sql verifica:

  • progresso su 30.000 gruppi scaduti, con un candidato bloccato da un'altra transazione;
  • numero limitato di lock trattenuti;
  • conservazione del gruppo bloccato e dei frammenti appena ricevuti;
  • ulteriore progresso nella chiamata successiva, dopo il rilascio del lock;
  • completamento di un vero valore suddiviso in due frammenti.

Controllo negativo: con la query precedente il test riproduce out of shared memory e fallisce le verifiche di progresso; con la correzione passano tutte e cinque le verifiche.

Validazione

Dopo la correzione, suite completa con ON_ERROR_STOP=1:

PostgreSQL Risultato
15.19 521 controlli superati, zero fallimenti
17.11 521 controlli superati, zero fallimenti
18.6 521 controlli superati, zero fallimenti

Superati anche il controllo whitespace e la verifica migrazione: 1.1.3 → 1.1.4, superficie SQL invariata, nessuna migrazione richiesta.

La revisione del commit precedente 1820a15 aveva inoltre superato gli stress test indipendenti: 30.000 scritture casuali fra tre repliche SQLite, 10.800 applicazioni di payload e 600 confronti senza divergenze, più 100 prove sui frammenti con ordine casuale, duplicati, errore forzato, rollback e retry. Questi risultati sono distinti dalla verifica della nuova correzione.

Documentazione e cloud reale

Ho aggiornato la descrizione della PR, docs/internal/audit-regressions.md e il changelog. Le descrizioni precedenti parlavano ancora di skip dei dinieghi RLS e contatori rimossi: il codice attuale interrompe l'apply al primo errore e mantiene fermo il checkpoint.

La nuova guida docs/internal/cloud-e2e.md documenta il percorso di test cloud già disponibile. Nei log Linux del commit 1820a15 risultano effettivamente OK, non SKIPPED, autenticazione, tutti i sette casi chunked e i casi offline/failure. Il push della correzione ha avviato una nuova CI; questo commento non ne dichiara l'esito finale.

La correzione del garbage collector non è ancora stata verificata su un server cloud aggiornato: la CI del client non installa l'estensione PostgreSQL sui nodi remoti. Per quella verifica serve un nodo staging con questa build e il test del backlog attivato da un upload reale.

I limiti preesistenti emersi durante la revisione — transazione SQLite lasciata aperta dopo alcuni commit falliti, savepoint PostgreSQL molto profondi e cleanup incompleto in alcuni percorsi OOM — sono documentati separatamente e non sono presentati come risolti da questo commit.

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