fix: harden sync error handling, unwrap the gateway envelope, and report RLS denials - #64
marcobambini wants to merge 28 commits into
Conversation
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
90565fc to
a1f3ea3
Compare
…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
…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>
Review follow-upsIssues 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); Apply path — 9cc60d2
Other fixes
New/updated tests: 🤖 Generated with Claude Code |
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>
New commits: fail fast on apply errors, one durable receive window per streamThese five commits change how failures in received changes are handled: WhySkipping 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:
One durable receive window until final success.
Also included
No SQL signatures, payload formats or table definitions change ( Tested: SQLite unit and review regressions, network unit tests (including new receive-stream scenarios run against a test 🤖 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>
…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>
Network deadlines: how the libcurl timeouts work nowThree more commits complete the request deadlines that this PR introduces: Two policies, by kind of request
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 (
|
| 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_setis only stored oncecloudsync_init/cloudsync_network_inithas run. Before that it is silently ignored. - Hosts that configure through environment variables: read them at startup and call
cloudsync_setafter init, withNULLwhen 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_timeouttakes effect; - a zero value keeps the default;
sqlite3_interrupt()from another thread ends a call with a 30 s deadline well before it, withSQLITE_INTERRUPT.
- SQLite unit, review and network suites pass locally.
check-postgres-migration.shreports 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
|
Ho completato la revisione approfondita e pubblicato la correzione nel commit Regressione trovataLa 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: L'errore annullava tutta la pulizia. Una nuova sessione riproduceva lo stesso risultato, impedendo al backlog di ridursi. La query precedente su CorrezioneLa 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
Controllo negativo: con la query precedente il test riproduce ValidazioneDopo la correzione, suite completa con
Superati anche il controllo whitespace e la verifica migrazione: La revisione del commit precedente Documentazione e cloud realeHo aggiornato la descrizione della PR, La nuova guida 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. |
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.rowsand reportreceive.error.There is no internal denial retry loop and no
receive.deniedorreceive.failedcounter. 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
sqlite3_interrupt.make unittest-s390xreplaces the ineffective forced-endian test.Verification
Local verification of the cleanup fix:
out of shared memoryand no cleanup progress on the previous query; all five assertions pass with the bounded query.Independent review of preceding commit
1820a15also 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
1820a15reports 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.