diff --git a/Cargo.lock b/Cargo.lock index 4562be0684..5bbc8dcb09 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1318,7 +1318,7 @@ checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" [[package]] name = "datacenter" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "futures-util", @@ -1377,7 +1377,7 @@ dependencies = [ [[package]] name = "depot" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "async-channel", @@ -1420,7 +1420,7 @@ dependencies = [ [[package]] name = "depot-client-embedded" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "async-trait", @@ -1696,7 +1696,7 @@ checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" [[package]] name = "epoxy" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "axum 0.8.4", @@ -1741,7 +1741,7 @@ dependencies = [ [[package]] name = "epoxy-protocol" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "rivet-vbare-compiler", @@ -1835,6 +1835,30 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "example-chat-room-rust" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "rivetkit", + "serde", + "serde_json", + "tokio", +] + +[[package]] +name = "example-hello-world-rust" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "rivetkit", + "serde", + "serde_json", + "tokio", +] + [[package]] name = "fallible-iterator" version = "0.2.0" @@ -2068,7 +2092,7 @@ dependencies = [ [[package]] name = "gasoline" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "async-stream", @@ -2119,7 +2143,7 @@ dependencies = [ [[package]] name = "gasoline-macros" -version = "2.4.0" +version = "2.3.14" dependencies = [ "proc-macro2", "quote", @@ -2128,7 +2152,7 @@ dependencies = [ [[package]] name = "gasoline-runtime" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "epoxy", @@ -3351,7 +3375,7 @@ dependencies = [ [[package]] name = "namespace" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "epoxy", @@ -3920,7 +3944,7 @@ dependencies = [ [[package]] name = "pegboard" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "base64 0.22.1", @@ -3978,7 +4002,7 @@ dependencies = [ [[package]] name = "pegboard-envoy" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "async-trait", @@ -4027,7 +4051,7 @@ dependencies = [ [[package]] name = "pegboard-gateway" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "async-trait", @@ -4061,7 +4085,7 @@ dependencies = [ [[package]] name = "pegboard-gateway2" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "async-trait", @@ -4096,7 +4120,7 @@ dependencies = [ [[package]] name = "pegboard-gateway3" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "async-trait", @@ -4131,7 +4155,7 @@ dependencies = [ [[package]] name = "pegboard-outbound" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "epoxy", @@ -4158,7 +4182,7 @@ dependencies = [ [[package]] name = "pegboard-runner" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "async-trait", @@ -5030,7 +5054,7 @@ dependencies = [ [[package]] name = "rivet-actor-runtime-socket-protocol" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "rivet-vbare-compiler", @@ -5041,7 +5065,7 @@ dependencies = [ [[package]] name = "rivet-api-builder" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "axum 0.8.4", @@ -5084,7 +5108,7 @@ dependencies = [ [[package]] name = "rivet-api-peer" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "axum 0.8.4", @@ -5120,7 +5144,7 @@ dependencies = [ [[package]] name = "rivet-api-public" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "axum 0.8.4", @@ -5158,7 +5182,7 @@ dependencies = [ [[package]] name = "rivet-api-public-openapi-gen" -version = "2.4.0" +version = "2.3.14" dependencies = [ "rivet-api-public", "serde_json", @@ -5167,7 +5191,7 @@ dependencies = [ [[package]] name = "rivet-api-types" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "gasoline", @@ -5182,7 +5206,7 @@ dependencies = [ [[package]] name = "rivet-api-util" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "axum 0.8.4", @@ -5240,7 +5264,7 @@ dependencies = [ [[package]] name = "rivet-bootstrap" -version = "2.4.0" +version = "2.3.14" dependencies = [ "datacenter", "depot", @@ -5262,7 +5286,7 @@ dependencies = [ [[package]] name = "rivet-build-meta" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "epoxy-protocol", @@ -5276,7 +5300,7 @@ dependencies = [ [[package]] name = "rivet-cache" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "futures-util", @@ -5302,7 +5326,7 @@ dependencies = [ [[package]] name = "rivet-cache-purge" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "futures-util", @@ -5319,14 +5343,14 @@ dependencies = [ [[package]] name = "rivet-cache-result" -version = "2.4.0" +version = "2.3.14" dependencies = [ "rivet-util", ] [[package]] name = "rivet-cli" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anstyle", "anyhow", @@ -5347,7 +5371,7 @@ dependencies = [ [[package]] name = "rivet-config" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "chrono", @@ -5369,16 +5393,38 @@ dependencies = [ [[package]] name = "rivet-config-schema-gen" -version = "2.4.0" +version = "2.3.14" dependencies = [ "rivet-config", "schemars 0.8.22", "serde_json", ] +[[package]] +name = "rivet-container-runner" +version = "2.3.14" +dependencies = [ + "anyhow", + "async-trait", + "ciborium", + "clap", + "futures-util", + "nix 0.30.1", + "reqwest 0.12.22", + "rivetkit", + "scc", + "serde", + "serde_json", + "tokio", + "tokio-tungstenite", + "tokio-util", + "tracing", + "tracing-subscriber", +] + [[package]] name = "rivet-data" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "gasoline", @@ -5391,7 +5437,7 @@ dependencies = [ [[package]] name = "rivet-depot-client" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "async-trait", @@ -5412,6 +5458,7 @@ dependencies = [ "scc", "sha2", "tempfile", + "thiserror 1.0.69", "tokio", "tracing", "universaldb", @@ -5420,11 +5467,11 @@ dependencies = [ [[package]] name = "rivet-depot-client-types" -version = "2.4.0" +version = "2.3.14" [[package]] name = "rivet-depot-protocol" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "rivet-vbare-compiler", @@ -5435,7 +5482,7 @@ dependencies = [ [[package]] name = "rivet-dynamic-config" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "futures-util", @@ -5453,7 +5500,7 @@ dependencies = [ [[package]] name = "rivet-engine" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "async-trait", @@ -5536,7 +5583,7 @@ dependencies = [ [[package]] name = "rivet-env" -version = "2.4.0" +version = "2.3.14" dependencies = [ "lazy_static", "uuid", @@ -5544,7 +5591,7 @@ dependencies = [ [[package]] name = "rivet-envoy-client" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "futures-util", @@ -5576,7 +5623,7 @@ dependencies = [ [[package]] name = "rivet-envoy-protocol" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "hex", @@ -5591,7 +5638,7 @@ dependencies = [ [[package]] name = "rivet-error" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "indoc", @@ -5603,7 +5650,7 @@ dependencies = [ [[package]] name = "rivet-error-macros" -version = "2.4.0" +version = "2.3.14" dependencies = [ "indoc", "proc-macro2", @@ -5614,7 +5661,7 @@ dependencies = [ [[package]] name = "rivet-guard" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "async-trait", @@ -5677,7 +5724,7 @@ dependencies = [ [[package]] name = "rivet-guard-core" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "async-trait", @@ -5727,7 +5774,7 @@ dependencies = [ [[package]] name = "rivet-logs" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "chrono", @@ -5741,7 +5788,7 @@ dependencies = [ [[package]] name = "rivet-metrics" -version = "2.4.0" +version = "2.3.14" dependencies = [ "lazy_static", "prometheus", @@ -5749,7 +5796,7 @@ dependencies = [ [[package]] name = "rivet-metrics-server" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "console-subscriber", @@ -5767,7 +5814,7 @@ dependencies = [ [[package]] name = "rivet-outbound-guard" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "ipnet", @@ -5781,7 +5828,7 @@ dependencies = [ [[package]] name = "rivet-perf" -version = "2.4.0" +version = "2.3.14" dependencies = [ "prometheus", "tokio", @@ -5791,7 +5838,7 @@ dependencies = [ [[package]] name = "rivet-pools" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "clickhouse", @@ -5824,7 +5871,7 @@ dependencies = [ [[package]] name = "rivet-postgres-util" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "rustls", @@ -5835,7 +5882,7 @@ dependencies = [ [[package]] name = "rivet-profiling" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "gasoline", @@ -5850,7 +5897,7 @@ dependencies = [ [[package]] name = "rivet-runner-protocol" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "gasoline", @@ -5867,7 +5914,7 @@ dependencies = [ [[package]] name = "rivet-runtime" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "console-subscriber", @@ -5896,7 +5943,7 @@ dependencies = [ [[package]] name = "rivet-service-manager" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "chrono", @@ -5913,7 +5960,7 @@ dependencies = [ [[package]] name = "rivet-telemetry" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "rivet-config", @@ -5924,7 +5971,7 @@ dependencies = [ [[package]] name = "rivet-term" -version = "2.4.0" +version = "2.3.14" dependencies = [ "console", "derive_builder 0.12.0", @@ -5936,7 +5983,7 @@ dependencies = [ [[package]] name = "rivet-test-deps" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "futures-util", @@ -5954,7 +6001,7 @@ dependencies = [ [[package]] name = "rivet-test-deps-docker" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "portpicker", @@ -5971,7 +6018,7 @@ dependencies = [ [[package]] name = "rivet-test-envoy" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "async-stream", @@ -5987,7 +6034,7 @@ dependencies = [ [[package]] name = "rivet-tracing-utils" -version = "2.4.0" +version = "2.3.14" dependencies = [ "futures-util", "lazy_static", @@ -5997,7 +6044,7 @@ dependencies = [ [[package]] name = "rivet-types" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "gasoline", @@ -6014,7 +6061,7 @@ dependencies = [ [[package]] name = "rivet-universaldb-commit" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "rivet-vbare-compiler", @@ -6025,7 +6072,7 @@ dependencies = [ [[package]] name = "rivet-ups-broadcast" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "futures-util", @@ -6044,7 +6091,7 @@ dependencies = [ [[package]] name = "rivet-ups-protocol" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "base64 0.22.1", @@ -6056,7 +6103,7 @@ dependencies = [ [[package]] name = "rivet-util" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "async-trait", @@ -6084,7 +6131,7 @@ dependencies = [ [[package]] name = "rivet-util-id" -version = "2.4.0" +version = "2.3.14" dependencies = [ "serde", "thiserror 1.0.69", @@ -6095,7 +6142,7 @@ dependencies = [ [[package]] name = "rivet-util-serde" -version = "2.4.0" +version = "2.3.14" dependencies = [ "indexmap 2.14.0", "serde", @@ -6132,7 +6179,7 @@ dependencies = [ [[package]] name = "rivet-version-management" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "futures-util", @@ -6152,7 +6199,7 @@ dependencies = [ [[package]] name = "rivet-workflow-worker" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "datacenter", @@ -6168,7 +6215,7 @@ dependencies = [ [[package]] name = "rivetkit" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "async-trait", @@ -6195,7 +6242,7 @@ dependencies = [ [[package]] name = "rivetkit-actor-persist" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "rivet-vbare-compiler", @@ -6206,7 +6253,7 @@ dependencies = [ [[package]] name = "rivetkit-client" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "axum 0.8.4", @@ -6236,7 +6283,7 @@ dependencies = [ [[package]] name = "rivetkit-client-protocol" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "rivet-vbare-compiler", @@ -6247,7 +6294,7 @@ dependencies = [ [[package]] name = "rivetkit-core" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "async-trait", @@ -6306,7 +6353,7 @@ dependencies = [ [[package]] name = "rivetkit-engine-process" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "libc", @@ -6323,7 +6370,7 @@ dependencies = [ [[package]] name = "rivetkit-inspector-protocol" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "rivet-vbare-compiler", @@ -6334,7 +6381,7 @@ dependencies = [ [[package]] name = "rivetkit-napi" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "async-trait", @@ -6360,7 +6407,7 @@ dependencies = [ [[package]] name = "rivetkit-shared-types" -version = "2.4.0" +version = "2.3.14" dependencies = [ "serde", "serde_json", @@ -6368,7 +6415,7 @@ dependencies = [ [[package]] name = "rivetkit-wasm" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "console_error_panic_hook", @@ -7552,7 +7599,7 @@ dependencies = [ [[package]] name = "test-snapshot-gen" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "async-trait", @@ -8335,7 +8382,7 @@ checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] name = "universaldb" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "async-trait", @@ -8373,7 +8420,7 @@ dependencies = [ [[package]] name = "universalpubsub" -version = "2.4.0" +version = "2.3.14" dependencies = [ "anyhow", "async-trait", diff --git a/docs-internal/engine/sqlite-vfs.md b/docs-internal/engine/sqlite-vfs.md index 4d5e6c3d22..971444b6c2 100644 --- a/docs-internal/engine/sqlite-vfs.md +++ b/docs-internal/engine/sqlite-vfs.md @@ -15,6 +15,14 @@ Rules for the SQLite VFS implementation. - SQLite VFS v2 storage keys use literal ASCII path segments under the `0x02` subspace prefix with big-endian numeric suffixes so `scan_prefix` and `BTreeMap` ordering stay numerically correct. - SQLite v2 slow-path staging writes encoded LTX bytes directly under DELTA chunk keys. Do not expect `/STAGE` keys or a fixed one-chunk-per-page mapping in tests or recovery code. +## Deferred commits + +- Deferred mode stages locally committed pages in the VFS overlay and exposes them to reads until the single background flusher receives a durable acknowledgement. +- Every flush batch carries the durable head fence captured when the batch forms. Retries reuse the same bytes and fence. A lost acknowledgement or divergent head breaks the database because durability is indeterminate. +- Flush waiters check the terminal error before sequence progress. Once broken, no sequence wait may report success and the core failure monitor stops the actor generation once. +- Close first stops new work, rolls back an open lease, drains the overlay within the retry deadline, then shuts down the flusher before releasing the VFS. +- Keep the named structures, algorithms, and invariants synchronized with the [deferred commits specification](sqlite/deferred-commits/SPEC.md). + ## Read-mode/write-mode connection manager - The native connection manager is the SQLite read/write routing policy boundary. TypeScript and NAPI wrappers forward calls to native execution and must not decide routing from SQL text. diff --git a/docs-internal/engine/sqlite/deferred-commits/SPEC.md b/docs-internal/engine/sqlite/deferred-commits/SPEC.md new file mode 100644 index 0000000000..892d30849c --- /dev/null +++ b/docs-internal/engine/sqlite/deferred-commits/SPEC.md @@ -0,0 +1,1129 @@ +# SQLite Deferred Commits -- Specification + +## 1. Overview + +Deferred commits let a SQLite transaction return to the caller as soon as it +has committed to the actor-local page state, before the engine has made it +durable. A background flusher ships committed pages to the engine in order and +publishes a monotonic **flush sequence** that callers can wait on. This is the +storage-layer half of Cloudflare Durable Object output gates: the runtime layer +decides *when* to wait; the storage layer guarantees *what* a wait means. + +Today every SQLite commit in the native VFS blocks the SQLite worker thread +until the engine acknowledges the depot commit +(`SQLITE_FCNTL_COMMIT_ATOMIC_WRITE` -> `commit_atomic_write` -> +`block_on_buffered_commit`). With the synchronous SQLite API that turns every +`INSERT` into one engine round trip on the JavaScript thread. Deferred commits +remove the round trip from the commit path and move it to a single flusher. + +Deferred mode is **opt-in per actor**. The default stays `awaited`, which is +byte-for-byte today's behavior. + +### 1.1 Goals + +- `executeSync`/`transactionSync` writes return without an engine round trip. +- Read-your-own-writes is preserved across every connection to the same + database, whether or not the writes are durable yet. +- A caller can wait until a specific point in commit history is durable, with + snapshot semantics: later commits never extend an earlier wait. +- Commits reach the engine in order and each engine commit is atomic. +- A flush that cannot complete permanently breaks the database; every pending + and future wait rejects, and the actor generation stops. Nothing that waited + can observe a lost write as durable. +- The API surface is sufficient to implement Durable Object storage semantics + (sync SQL, sync and async KV, implicit and explicit transactions, `sync()`, + `allowUnconfirmed`) on top of it without further storage changes. + +### 1.2 Non-goals + +- Input gates, output gates, `blockConcurrencyWhile`, and actor reset live in + the runtime that consumes this API. This spec does not implement them. +- Pipelining more than one engine commit at a time. The flusher keeps exactly + one commit in flight. The sequence-number API does not change if pipelining + is added later. +- Protocol or engine changes. The design uses the existing `sqlite_commit` and + `sqlite_get_pages` requests and the existing head-txid and generation fences. + There is no commit identity, so an indeterminate commit whose resend hits the + head fence cannot be proven ours and breaks the database (section 3.5). A + commit nonce in the protocol is the follow-up that removes that restart. +- WebAssembly and remote-SQLite backends. They reject `deferred` at actor + context construction. +- An SQLite authorizer that rejects transaction-control statements in user SQL + (workerd does this). Section 6.6 records the deviation. + +## 2. Terminology + +| Term | Meaning | +| --- | --- | +| **Local commit** | The VFS finished a SQLite transaction that changed at least one page or the database size. In deferred mode the change is in the overlay, not yet at the engine. Read-only transactions and no-op syncs are not local commits. | +| **Commit sequence** (`commit_seq`) | Monotonic `u64`, incremented once per local commit. Continues from the previous open of the same actor within the process (section 3.1). | +| **Flushed sequence** (`flushed_seq`) | The highest commit sequence whose changes the engine has acknowledged. Always `<= commit_seq`. In awaited mode it equals `commit_seq` at all times. | +| **Overlay** | Pinned, never-evicted map of page versions belonging to local commits that are not yet acknowledged. | +| **Batch** | The overlay snapshot sent to the engine in one `sqlite_commit` request. A batch covers every local commit with sequence `<= batch.seq`. | +| **Durable head** (`durable_head_txid`) | The engine txid the flusher has proven durable. A plain `u64` in deferred mode: the open path always yields one (`fetch_initial_pages` synthesizes `0` for a database that does not exist yet), and deferred mode is rejected at open if the peer omits it. Owned exclusively by the flusher and the open path. Never assigned from a read response. | +| **Broken** | Terminal state after a flush failure that exhausted retries or was unrecoverable. | + +## 3. Semantics + +### 3.1 Sequences and waits + +- Every local commit, in either mode, increments `commit_seq` under the VFS + state write lock before the VFS callback returns to SQLite. A commit that + dirtied no page and did not change the database size does not increment it. + In awaited mode the existing blocking commit path increments `commit_seq` + and publishes the same value as `flushed_seq` after the engine acknowledges, + so the sequence API is uniform across modes. +- On every open both counters start at `initial_commit_seq`; the seed never + creates deferred work. +- `commit_seq` continues across close and reopen of the same actor within one + process: `SqliteDb` remembers the last value and seeds the next open with it. + A sequence captured before a sleep is therefore still meaningful after wake, + and because close drains (section 3.6) it is always already durable. + A failed drain still seeds a later open from the final local sequence; the + lost generation cannot survive sleep with a waiter holding that sequence, so + preserving monotonicity is preferable to reusing it. +- `wait_for_flush(seq)` resolves once `flushed_seq >= seq`, or rejects with the + flush error once the database is broken. The error check happens before the + sequence check, so a wait for an already-flushed sequence still rejects after + a break. +- The output-gate primitive is `wait_for_flush` of a sequence captured + synchronously at the call site. The TypeScript wrapper reads `commitSeq()` + synchronously and passes an explicit sequence to native code; native code + never defaults the target itself, because an async call may start polling + after later commits. +- Waiting for `seq > commit_seq` rejects immediately with an invalid-argument + error. Waiting for `0` resolves immediately. +- In awaited mode `wait_for_flush(seq)` resolves immediately for any valid + `seq`. + +### 3.2 Read-your-own-writes + +All connections in the SQLite worker share one `VfsContext`. Page resolution +consults, in order: + +1. `write_buffer.dirty` -- pages of the transaction SQLite is executing now. +2. The overlay -- pages of local commits not yet acknowledged. +3. `committed_page_cache` / `page_cache` -- evictable caches of durable pages. +4. The engine via `get_pages`. + +A page any unacknowledged commit touched is always served from step 2, so +engine reads are only consulted for pages the in-flight batch did not touch. +Those pages are identical before and after the engine applies the batch, so +reads during an in-flight commit are consistent. The overlay is authoritative +in every read path: `has_readable_page`, the prefetch predictor's `to_fetch` +selection, and response insertion. Fetched pages, including prefetch and +overflow-expanded pages the engine adds to a response, are inserted into +caches only if, at insertion time under the state lock, the page number is +neither dirty nor in the overlay. The "synthesize an empty page 1 when the +database does not exist yet" path keeps its original awaited-mode gate, +`commit_total == 0`. Deferred mode uses `commit_seq == 0` instead, because a +superseded generation with a local commit must never be handed an empty +database before that commit is acknowledged. + +### 3.3 Ordering and batching + +- The flusher sends at most one `sqlite_commit` at a time. +- While a batch is in flight, further local commits merge into the overlay. + When the acknowledgement arrives the flusher snapshots everything currently + in the overlay into the next batch. Later bytes for the same page win. The + engine therefore sees one atomic commit per batch, txids increase by exactly + one per batch, and `flushed_seq` jumps to the highest local commit the batch + covered. +- Ordering invariant: for local commits `a < b`, `b` is never durable unless + `a` is durable. This follows from "one batch in flight, batches are prefixes + of commit history". + +### 3.4 Truncation + +`xTruncate` is a commit boundary of its own: SQLite may shrink the file after +`COMMIT_ATOMIC_WRITE` without another sync. A truncate that changes the size +outside an atomic write produces a local commit with an empty page set and the +new size. A shrink removes overlay pages above the new size. Pages above the +new size that are already in the in-flight batch are harmless: the engine +stores them, and the next batch's smaller `db_size_pages` truncates them +(`depot/src/conveyer/commit/apply.rs`, `collect_truncate_cleanup`). +Acknowledgement never moves a page above the current size into the committed +cache. + +### 3.5 Failure handling + +Each attempt to ship a batch ends in one of three classes: + +| Class | Examples | Action | +| --- | --- | --- | +| **Indeterminate** | transport error, timeout, connection lost, any engine error response that is not a head fence mismatch | Resend the identical request (same pages, same size, same `expected_head_txid`) after backoff, until the retry deadline. | +| **Applied** | commit-ok response | Treat the batch as acknowledged. | +| **Fatal** | head fence mismatch; commit-ok with a head that is not `expected + 1`; retry deadline exceeded; flusher panic or cancellation; VFS already dead | Break the database. | + +The head fence is the idempotency check. A resend after an indeterminate +attempt succeeds exactly when the earlier attempt never applied, because the +engine still sits at `expected`. If the earlier attempt did apply, or another +writer advanced the head, the resend fails the fence. Those two cases cannot +be told apart without a commit identity in the protocol: a foreign writer can +produce identical bytes on the batch's pages and also touch pages outside it, +and a size-only batch has no pages to compare at all. So the fence mismatch is +fatal, exactly as it is in awaited mode today. The cost is an actor restart on +a lost acknowledgement, which is rare (the engine applied the commit and the +reply was lost); the data is in fact durable and the next generation reopens +onto it. Every waiter rejects, which is the conservative and correct signal. + +Breaking the database, in this order: + +- The VFS is marked dead first (`VfsState.dead`, `fatal_error`). The worker + handle's `check_fatal_error` runs before every enqueue and after every + reply, so no statement can succeed at the `NativeDatabaseHandle` boundary + after this point. (SQLite serves reads from its own page cache under + `locking_mode = EXCLUSIVE`, so `VfsState.dead` alone would not fail them.) +- The terminal error is then stored under the progress lock and published; + every waiter rejects with it. Because the fatal flag is already set, no + waiter can observe the break while a new statement can still pass the + pre-check. +- The database failure channel (section 6.1) resolves with the structured + error. rivetkit-core's failure monitor reports it once through + `report_sqlite_worker_fatal`, which calls `stop_actor`. That is the + Cloudflare "shut down and restart the object" step. +- Overlay pages are discarded with the generation. The next generation reopens + from the engine's durable head. + +All of this is one idempotent operation, `break_database(err)`, used by every +fatal source: the flusher, an out-of-window read response (section 5.6), and +close. The acknowledgement path rechecks the terminal state under the lock +before publishing `flushed_seq`, so a break from another thread is never +overtaken by a late acknowledgement. + +The retry deadline is enforced with `tokio::time::timeout_at` on every await +inside the flusher: the commit request, each staged-commit segment, and the +backoff sleep. A hung request cannot outlive the deadline. + +### 3.6 Close + +Closing a deferred-mode database follows one lifecycle owned by +`NativeDatabaseHandle::close`: + +1. Stop admitting SQL (existing worker close request). +2. `NativeDatabaseHandle::close` sets the VFS `closing` flag first, which + releases byte backpressure waits (section 3.7). The hard page-count bound + still drains before merge while closing. The worker rejects queued SQL + and drops `NativeDatabase`. In deferred mode `Drop` never promotes + `write_buffer` contents into the overlay: pages SQLite spilled for a + transaction it has not committed must not become a local commit. + `sqlite3_close_v2` rolls such a transaction back. Size-only truncates were + already staged at their commit boundary (section 3.4), so nothing + legitimate is left to stage. `Drop` discards the write buffer, notifies the + flusher, and never blocks on the engine, which keeps it inside the worker's + five-second close budget. +3. Request flusher drain (`flush_shutdown`), then `wait_for_flush(commit_seq)` + bounded by `retry_deadline + retry_backoff_max`. +4. Join the flusher task, then unregister the VFS. + +If the worker itself does not close within its five-second budget, close marks +the database broken with `Aborted("worker close timeout")`, aborts the flusher, +and returns the worker timeout without starting a drain. The still-running +worker could otherwise stage work after the drain target was captured. + +Close returns the flush error if the database broke, and +`FlushError::Aborted("close deadline")` if the drain did not finish. The +flusher is terminated and joined before `close` returns; it exits through its +shutdown path after a successful drain and is aborted on failure or timeout. +No commit, probe, or verification request is issued by this `VfsContext` after +`close` returns, success or failure, and an attempt cut off by an abort is +logged as indeterminate. `SqliteDb::close` awaits the result; a drain timeout +or flush error during a sleep or stop is logged at error level as lost +unflushed data and completes the shutdown, it does not call `stop_actor` +again. The worker thread's own five-second close budget is untouched because +the drain runs after the worker has closed. + +### 3.7 Backpressure + +The overlay is bounded by `max_unflushed_bytes`. A local commit that pushes it +over the bound first publishes the commit and notifies the flusher, then blocks +the worker thread until the overlay shrinks below the bound or the database +breaks. This degrades to awaited behavior under sustained engine slowness +instead of growing memory without limit. The in-flight batch shares one `Arc` +page vector across retry attempts and makes only the protocol request's owned +copy, so transient memory is bounded by the overlay plus one shippable batch +and one request serialization copy. + +Every formable batch must be shippable. `commit_buffered_pages` refuses more +than `MAX_COMMIT_DIRTY_PAGES` pages in one request, and the flusher cannot +split a coalesced batch at commit boundaries. Two rules keep the bound: +a transaction with more than `MAX_COMMIT_DIRTY_PAGES` dirty pages is rejected +with the same `SQLITE_IOERR` behavior as awaited mode **before** anything is +merged, so SQLite can roll it back. Otherwise, when the existing overlay plus +the transaction would exceed the hard page cap, staging arms the progress +receiver and drains before merging until the batch is formable or the database +breaks. Closing does not bypass this page-count drain. After the merge, staging +returns `SQLITE_OK` unconditionally unless the database is already dead; a +failed `COMMIT_ATOMIC_WRITE` would make SQLite discard pages the overlay +already holds. Byte backpressure waits only on flush progress, a break, or the +`closing` flag, never on a timer. Once `closing` is set that byte wait returns +immediately because the hard page cap is already guaranteed and the close +drain provides durability. No half-cap relationship between the byte and page +limits is required. + +### 3.8 Mode interaction with existing features + +- Explicit transactions (`db.transaction`, `transactionSync`) and actor state + transactions (`JsActorStateTransaction`) commit through the same VFS and get + the same deferral. `await c.saveState()` resolves at local commit in deferred + mode; runtimes that need durability wait on `wait_for_flush`. Actor state + persistence, connection state, queue, and alarm writes queue behind an open + synchronous transaction handle (section 6.4) for at most one turn. +- The commit counters that feed transaction round-trip metrics + (`commit_total`, `record_commit`, `commit_atomic_count`) count engine + acknowledgements, not local commits. +- Staged commits for oversized batches are unchanged, except that a retry of a + staged batch re-begins the stage from scratch. +- Profiling, prefetch, page caches, and startup preload are unchanged. + +## 4. Data structures + +All new types live in `engine/packages/depot-client/src/vfs.rs` unless noted. +Names are normative; field layout is illustrative. `VfsState` derives +`Clone, Debug`, so everything stored in it must too. + +```rust +/// Selected once per database open. Immutable afterwards. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum CommitMode { + #[default] + Awaited, + Deferred, +} + +#[derive(Clone, Debug)] +pub struct DeferredCommitConfig { + /// Break the database after this long without acknowledging the current + /// batch. Default 30 s. Bounds every await inside the flusher. + pub retry_deadline: Duration, + /// Exponential backoff bounds between attempts. Default 50 ms .. 2 s. + pub retry_backoff_min: Duration, + pub retry_backoff_max: Duration, + /// Block local commits once the overlay exceeds this. Default 64 MiB. + pub max_unflushed_bytes: usize, +} + +// VfsConfig gains: +pub struct VfsConfig { + // ...existing fields... + pub commit_mode: CommitMode, + pub deferred_commit: DeferredCommitConfig, + /// Seeds commit_seq so sequences continue across reopen. + pub initial_commit_seq: u64, +} +``` + +```rust +/// One page version in the overlay. `seq` is the local commit that wrote it. +#[derive(Clone, Debug)] +struct OverlayPage { + bytes: Vec, + seq: u64, +} + +/// Committed-but-unacknowledged state. Lives inside `VfsState` so it shares +/// the existing state lock with `write_buffer`, `db_size_pages`, and +/// `committed_db_size_pages`. +#[derive(Clone, Debug, Default)] +struct Overlay { + pages: BTreeMap, + bytes: usize, + /// Highest local commit sequence. + commit_seq: u64, + /// `db_size_pages` as of `commit_seq`. + db_size_pages: u32, + /// The batch currently at the engine, if any. + in_flight: Option, +} + +#[derive(Clone, Debug)] +struct InFlightBatch { + /// Covers every local commit with sequence <= seq. + seq: u64, + /// `durable_head_txid` when the batch was formed; sent as the head fence + /// and reused verbatim by retries. + expected_head_txid: u64, + db_size_pages: u32, + /// Pages included, with the bytes that were sent, so acknowledgement can + /// move exactly these versions into the committed cache and a resend is + /// byte-identical. + pages: Arc>, + started_at: tokio::time::Instant, + attempts: u32, +} +``` + +`VfsState` gains `durable_head_txid: u64`, set at open from the initial +pages (`fetch_initial_pages` synthesizes `0` for a database that does not exist +yet). Deferred mode is rejected at open if the peer reports no head, so an +unfenced commit is never sent: the engine skips the fence entirely when it is +`None`, and a lost acknowledgement plus resend would then apply a batch twice. +In deferred mode the existing `head_txid` field is written only at open and by +the flusher's acknowledgement path; `get_pages` responses validate against it +but never assign it. In awaited mode behavior is unchanged. + +```rust +/// Progress state, guarded by its own mutex, with a watch channel used only +/// as a change notification. Storing the value under the lock rather than in +/// the channel means a waiter that subscribes late still sees the latest +/// value, and the terminal error is checked before the sequence. +#[derive(Clone, Debug, Default)] +pub struct FlushProgress { + pub flushed_seq: u64, + pub error: Option, +} + +#[derive(Clone, Debug, thiserror::Error)] +pub enum FlushError { + #[error("sqlite flush retry deadline exceeded after {attempts} attempts: {last_error}")] + RetryDeadlineExceeded { attempts: u32, last_error: String }, + #[error("sqlite durable head diverged: expected {expected}, engine has {actual:?}")] + HeadDiverged { expected: u64, actual: Option }, + #[error("sqlite flusher aborted: {0}")] + Aborted(String), + #[error("sqlite flush sequence {requested} is ahead of commit sequence {current}")] + InvalidSequence { requested: u64, current: u64 }, +} + +/// Owned by `VfsContext`. Acyclic: the flusher task holds an +/// `Arc`; the context holds only the task's `JoinHandle`, which +/// does not keep the task alive. `flush_shutdown` plus the supervision guard +/// guarantee the task exits, so the context is always released. +struct FlushController { + progress: Mutex, + progress_changed: watch::Sender, // bumped on every publish + _progress_rx: watch::Receiver, // permanent receiver so sends never fail + wake: Notify, + shutdown: AtomicBool, + /// Set by `NativeDatabaseHandle::close` before the worker is closed. + closing: AtomicBool, + task: Mutex>>, +} +``` + +`SqliteVfs.ctx` becomes `Arc`; SQLite's `pAppData` and +`VfsFile.ctx` hold `Arc::as_ptr` raw pointers exactly as they hold the `Box` +pointer today. The flusher is spawned in +`register_with_transport_and_initial_pages` when `commit_mode == Deferred`. + +### 4.1 Result metadata + +`depot_client_types::ExecuteResult` gains: + +- `readonly: Option` from `sqlite3_stmt_readonly`, read after prepare and + before finalize. `Some` for the local backend, `None` for the remote backend + whose protocol carries no such bit. `sqlite3_stmt_readonly` reports `true` + for `BEGIN`, `COMMIT`, `SAVEPOINT`, `RELEASE`, and `ROLLBACK`; the empty + statement is `Some(true)`. +- `commit_seq: Option`: the sequence of the local commit this statement + produced, if it produced one (the worker compares `commit_seq` before and + after the statement on its single connection). + +`depot_client_types::QueryResult` (multi-statement `exec`) gains +`readonly: Option`, the conjunction over every prepared statement. + +## 5. Algorithms + +### 5.1 Local commit + +The two commit entry points keep their existing guards and share one staging +function: + +- `SQLITE_FCNTL_COMMIT_ATOMIC_WRITE` stages only when `in_atomic_write` is set + and the dirty set or size changed. +- `xSync` stages only outside atomic mode, when the dirty set or size changed. + It remains a no-op during an atomic write. +- `xTruncate` outside atomic mode with a size change stages a size-only commit + **only when the write buffer is empty** (the existing guard). With dirty + pages present the truncate is part of an in-progress transaction: it only + updates `db_size_pages`, and the shrink's overlay eviction happens when that + transaction reaches its commit boundary. Evicting earlier would lose a + committed page if the transaction rolled back. +- `SQLITE_FCNTL_ROLLBACK_ATOMIC_WRITE` is unchanged: it discards + `write_buffer.dirty` and restores the saved size. It never touches the + overlay. + +``` +fn stage_local_commit(ctx) -> Result<(), CommitBufferError>: + if ctx.commit_mode == Awaited: + return existing blocking path (unchanged) + seq = with state.write(): + if state.dead: return Err(dead) + if write_buffer.dirty.is_empty() && state.db_size_pages == overlay.db_size_pages: + write_buffer.in_atomic_write = false // no-op: no sequence, but leave atomic mode + return Ok(()) + if write_buffer.dirty.len() > MAX_COMMIT_DIRTY_PAGES: + return Err(too large) // SQLite rolls back; overlay untouched + while overlay.pages.len() + write_buffer.dirty.len() > MAX_COMMIT_DIRTY_PAGES: + drop(state); wait_for_flush_progress_or_break() // also while closing; receiver armed first + reacquire state + seq = overlay.commit_seq + 1 + for (pgno, bytes) in write_buffer.dirty.drain(): + overlay.replace(pgno, OverlayPage { bytes, seq }) // updates overlay.bytes + if state.db_size_pages < overlay.db_size_pages: + overlay.remove_pages_above(state.db_size_pages) // shrink + overlay.db_size_pages = state.db_size_pages + overlay.commit_seq = seq + state.committed_db_size_pages = state.db_size_pages // locally committed + write_buffer.in_atomic_write = false + seq + ctx.flush.wake.notify_one() + apply_backpressure(ctx, seq) // 5.7 + Ok(()) +``` + +`committed_db_size_pages` keeps its meaning of "size SQLite believes is +committed" for `io_close`, `Drop`, and `truncate_main_file`; the durable size +is tracked by the flusher through `InFlightBatch.db_size_pages`. + +### 5.2 Page resolution and cache insertion + +`resolve_pages` inserts one lookup between the dirty check and the cache +check: `overlay.pages.get(&pgno)`. When a `get_pages` response is applied, +every returned page (requested, prefetched, or overflow-expanded) is inserted +only if, under the state write lock, `pgno` is neither in `write_buffer.dirty` +nor in `overlay.pages`. Overlay pages are inserted into the committed cache only +by the acknowledgement path. + +### 5.3 Flusher loop + +``` +async fn flusher_loop(ctx: Arc): + guard = on drop (panic, cancel, unexpected return): if not terminal, break(Aborted) + loop: + batch = with state.write(): + if overlay.commit_seq == flushed_seq: + if ctx.flush.shutdown: return + None + else: + b = InFlightBatch { seq: overlay.commit_seq, + expected_head_txid: state.durable_head_txid, // u64 + db_size_pages: overlay.db_size_pages, + pages: snapshot of overlay.pages, started_at: now, attempts: 0 } + overlay.in_flight = Some(b.clone()); Some(b) + if batch is None: ctx.flush.wake.notified().await; continue + match ship(ctx, batch).await: + Acked(head) => + with state.write(): + if terminal error already set (another thread broke the database): return + state.durable_head_txid = head + state.head_txid = Some(head) + for page in batch.pages: + if let Some(op) = overlay.pages.get(page.pgno) && op.seq <= batch.seq: + overlay.pages.remove(page.pgno) // updates overlay.bytes + if page.pgno <= state.db_size_pages: + state.cache_committed_page(page.pgno, page.bytes) + // cache_committed_page is a no-op in some cache modes; a stale + // engine version must not remain readable, so also evict the + // page from page_cache / protected_page_cache in that case + overlay.in_flight = None + commit_total += 1; metrics.record_commit() + publish(flushed_seq = batch.seq) + Broken(err) => + break_database(err); return + +fn break_database(err): // idempotent; every fatal source calls this + ctx.mark_fatal(err.to_string()) // VFS dead + fatal_error FIRST (section 3.5) + first = with ctx.flush.progress.lock(): if error.is_none() { error = Some(err); true } else { false } + ctx.flush.progress_changed.send_replace(next) + if first: ctx.failure_tx.send(DatabaseFailure::Flush(err)) // failure channel, 6.1, once +``` + +Pages with `seq > batch.seq` were rewritten while the batch was in flight and +stay in the overlay for the next batch. That is the whole coalescing rule. + +### 5.4 Ship with retries + +``` +async fn ship(ctx, batch) -> Acked(u64) | Broken(FlushError): + deadline = batch.started_at + retry_deadline + backoff = retry_backoff_min + last_error = None + target = batch.expected_head_txid + 1 + loop: + batch.attempts += 1 + request = SqliteCommitRequest { dirty_pages: batch.pages, db_size_pages: batch.db_size_pages, + expected_head_txid: Some(batch.expected_head_txid), .. } + result = timeout_at(deadline, commit_buffered_pages(transport, request)).await + match result: + Ok(Ok(SqliteCommitOk { head_txid })) => + match head_txid: + None => return Acked(target) // ok is definitive for this request + Some(h) if h == target => return Acked(h) + Some(h) => return Broken(HeadDiverged { expected: target, actual: Some(h) }) + Ok(Err(e)) if is_head_fence_mismatch(e) => + return Broken(HeadDiverged { expected: target, actual: e.actual_head }) + Ok(Err(e)) => last_error = e // structured {group, code, message} kept + Err(elapsed) => return Broken(RetryDeadlineExceeded { attempts, last_error }) + if now >= deadline: return Broken(RetryDeadlineExceeded { .. }) + timeout_at(deadline, sleep(backoff)).await or return Broken(RetryDeadlineExceeded { .. }) + backoff = min(backoff * 2, retry_backoff_max) +``` + +`CommitBufferError` keeps the engine's `group` and `code` alongside the +message so the classification above and the final error carry structure. A +retry of a staged (oversized) batch re-begins the stage from scratch. The +envoy reports a stale generation as an unstructured internal error with the +message `actor does not exist`, the same message a never-created database +returns; it is Indeterminate here and the deadline breaks the database. + +### 5.5 Idempotency of resends + +A resend is byte-identical to the first attempt and carries the same fence. +The engine applies it only if its head still equals `expected`, which is true +exactly when no earlier attempt applied. There is therefore no double apply +and no need to probe. The single unrecoverable case, an applied commit whose +reply was lost, surfaces as a fence mismatch on the resend and is fatal by +section 3.5. + +### 5.6 Reads while deferred work exists + +`resolve_pages` builds `get_pages` requests with +`expected_head_txid: state.head_txid` today. In deferred mode, whenever +`overlay.commit_seq > flushed_seq` (pending or in flight), the request sends +`expected_head_txid: None` and records `durable_at_request = +durable_head_txid` under the state lock. When the response is handled, again +under the state lock, the returned `head_txid` (when present) must satisfy + +``` +durable_at_request <= head <= durable_at_response + (in_flight.is_some() ? 1 : 0) +``` + +A read served before an acknowledgement but processed after it, or served +after a later batch applied, both fall inside this window; anything outside +it is a foreign writer and calls `break_database`. The response never assigns +`head_txid` or `durable_head_txid` in deferred mode. When no deferred work +exists at request time the existing fence is sent unchanged, and a mismatch +is fatal as today. + +### 5.7 Backpressure + +``` +fn apply_backpressure(ctx, seq): + rx = ctx.flush.progress_changed.subscribe() // arm BEFORE the first check + loop: + if ctx.flush.closing.load(): return Ok(()) // staged already; drain covers it + (bytes, error) = with state.read(): (overlay.bytes, ctx.flush.progress.lock().error.clone()) + if error.is_some(): return Ok(()) // already dead; SQLite sees it on the next call + if bytes <= max_unflushed_bytes: return Ok(()) + ctx.runtime.block_on(rx.changed()) // wakes on any publish, break, or closing +``` + +Arming the receiver before the first check closes the lost-notification +window: a publish that lands between the check and the wait is still observed. +`close` and `break_database` both bump `progress_changed`. The flusher was +already notified in `stage_local_commit` before this loop, so it is never +asleep with work pending while a commit waits here. The commit callback has +already merged the pages, so this function never returns an error (section +3.7). + +### 5.8 Wait for flush + +``` +async fn wait_for_flush(ctx, seq) -> Result<(), FlushError>: + if seq > commit_seq(): return Err(InvalidSequence { requested: seq, current: commit_seq() }) + loop: + rx = ctx.flush.progress_changed.subscribe() // arm before checking + p = ctx.flush.progress.lock().clone() + if let Some(e) = p.error: return Err(e) + if p.flushed_seq >= seq: return Ok(()) + rx.changed().await +``` + +## 6. API surface + +### 6.1 Rust: `depot-client` + +```rust +// vfs.rs +pub enum CommitMode { Awaited, Deferred } +pub struct DeferredCommitConfig { .. } +pub struct FlushProgress { pub flushed_seq: u64, pub error: Option } +pub enum FlushError { .. } + +/// Why the database can no longer serve SQL. Delivered once on the failure channel. +pub enum DatabaseFailure { + Closed, + WorkerStopped, + Flush(FlushError), +} + +impl SqliteVfs { // NativeVfsHandle = Arc + pub fn commit_mode(&self) -> CommitMode; + pub fn commit_seq(&self) -> u64; + pub fn flushed_seq(&self) -> u64; + pub fn flush_error(&self) -> Option; + pub async fn wait_for_flush(&self, seq: u64) -> Result<(), FlushError>; + pub async fn drain_and_shutdown_flusher(&self, timeout: Duration) -> Result<(), FlushError>; +} + +// database.rs +pub async fn open_database_from_transport( + transport, actor_id, generation, rt_handle, metrics, + commit_mode: CommitMode, // new + initial_commit_seq: u64, // new +) -> Result; + +impl NativeDatabaseHandle { + pub fn commit_seq(&self) -> u64; + pub fn flushed_seq(&self) -> u64; + pub fn flush_error(&self) -> Option; + pub async fn wait_for_flush(&self, seq: u64) -> Result<(), FlushError>; + /// Resolves once with the structured reason: worker thread death or a + /// broken flusher, whichever comes first. Replaces the bool-returning + /// `wait_for_worker_failure`. + pub async fn wait_for_failure(&self) -> DatabaseFailure; // Closed is clean and is not reported + /// Section 3.6 lifecycle. + pub async fn close(&self) -> Result<()>; +} + +// depot-client-types +pub struct ExecuteResult { .., pub readonly: Option, pub commit_seq: Option } +pub struct QueryResult { .., pub readonly: Option } + +// worker.rs: every command reply carries the connection's autocommit state +// after the command, on success and on error, so the coordinator can detect +// an SQLite-initiated rollback (section 6.2). +pub struct SqliteWorkerReply { pub result: Result, pub post_autocommit: bool } +``` + +Callers of `open_database_from_transport` outside rivetkit-core +(`depot-client-embedded`, rivetkit-core `tests/metrics.rs`) pass +`CommitMode::Awaited` and `0`. + +`SqliteVfsMetrics` gains default no-op hooks: `set_overlay_pages(u64)`, +`record_flush_batch(pages, bytes)`, `observe_flush_latency(ns)`, +`record_flush_retry(class: &'static str)`, `record_flush_broken()`. +`SqliteVfsMetricsSnapshot` is unchanged; sequence state is read through the +accessors above. + +`depot-client` adds `thiserror` as a dependency. `tokio` already provides +`watch`, `Notify`, and `time`. + +### 6.2 Rust: `rivetkit-core` + +```rust +// actor/config.rs +pub enum SqliteCommitMode { Awaited, Deferred } // maps 1:1 to depot_client::CommitMode +pub struct ActorConfig { .., pub sqlite_commit_mode: SqliteCommitMode } +pub struct ActorConfigInput { .., pub sqlite_commit_mode: Option } + +// actor/sqlite/mod.rs +impl SqliteDb { + pub fn new_with_remote_sqlite(handle, actor_id, actor_key, generation, enabled, remote_sqlite, + commit_mode: SqliteCommitMode) -> Result; + pub fn commit_mode(&self) -> SqliteCommitMode; + pub fn commit_seq(&self) -> u64; // 0 before first open; remembered across reopen + pub fn flushed_seq(&self) -> u64; + pub fn flush_error(&self) -> Option; + pub async fn wait_for_flush(&self, seq: u64) -> Result<()>; +} +``` + +- `select_sqlite_backend` rejects `Deferred` with `RemoteEnvoy` or a build + without `sqlite-local` using `SqliteRuntimeError::DeferredCommitsUnsupported` + (code `deferred_commits_unsupported`). This runs at `ActorContext::build`, + before any lazy open. +- `SqliteDb` stores the mode and the last `commit_seq` independently of the + native handle. `open()` passes both to `open_database_from_transport`. + `close_backend` records the handle's final `commit_seq` after close returns, + on both success and failure. +- `start_worker_failure_monitor` awaits `wait_for_failure()` and reports the + structured reason (`sqlite worker thread stopped unexpectedly` or the flush + error's message) through `report_sqlite_worker_fatal` exactly once. +- New error codes in `error.rs`: `flush_failed`, `invalid_argument`, + `deferred_commits_unsupported`, `transaction_active`. Invalid or ahead-of- + current flush sequences use `invalid_argument`; terminal durability failures + use `flush_failed`. + +Transaction coordinator (`actor/sqlite/tx.rs`): + +- Every core entry point that can wait for the coordinator gate takes a + `CallMode { Async, SyncBlocking }`. NAPI's `*_sync` methods pass + `SyncBlocking`; everything else passes `Async`. Core cannot infer this from + the call, because today both NAPI paths call the same async core methods. +- Every transaction records a `TransactionOrigin`: `Bridge` for leases begun + from NAPI (sync or async, including state transactions) or `Internal` for + Rust-owned leases (state persistence). NAPI passes the origin. The origin is + recorded as *pending* under the coordinator mutex **before** the gate is + acquired and `BEGIN` runs, and moves to *active* afterwards, so there is no + window in which a Bridge lease holds or is about to hold the gate without + being visible. +- A `SyncBlocking` operation that would wait for the gate while a `Bridge` + lease is pending or active fails immediately with `transaction_active`. The + JavaScript thread owns such a lease and would block itself. Waiting for an + `Internal` lease is unchanged, and `Async` callers always wait. +- Every worker reply carries `post_autocommit`. After any statement runs + through a lease, on success or error, the coordinator reads it. If SQLite + rolled the transaction back on its own (`SQLITE_FULL`, `SQLITE_IOERR`, + `ON CONFLICT ROLLBACK`, a user `COMMIT` or `ROLLBACK` in SQL), the lease + becomes terminal `RolledBack`: further statements and `commit` fail with + `transaction_closed`, and `rollback` is a no-op. Later statements can no + longer autocommit individually behind the caller's back. For a + transaction-scoped multi-statement `exec`, the worker checks autocommit + after each statement and stops executing the rest as soon as it turns on, + returning `transaction_closed` for the remainder. +- `finish_transaction(commit = true)` returns `Option`: the local commit + sequence the `COMMIT` produced, or `None` when nothing was written. + +### 6.3 NAPI: `@rivetkit/rivetkit-napi` + +```ts +export declare class JsNativeDatabase { + // existing members unchanged + commitSeq(): number + flushedSeq(): number + waitForFlush(seq: number): Promise // seq is required; TS supplies the default + flushError(): string | null + supportsSyncMetadata(): boolean +} +export declare class JsSqliteTransaction { + // existing members, plus: + commit(): Promise // was Promise + commitSync(): number | null // was void +} +export interface NativeExecuteResult { columns; rows; changes; lastInsertRowId; readonly?: boolean; commitSeq?: number } +export interface QueryResult { columns; rows; readonly?: boolean } +export interface JsActorConfig { ..; sqliteCommitMode?: string } // validated to "awaited" | "deferred" in From +``` + +Sequences are `u64` in Rust and `number` in JS, converted with `as f64`; the +JS side validates a non-negative safe integer before calling native. +`index.d.ts` is regenerated by `napi build`, never edited by hand. + +### 6.4 TypeScript: `rivetkit` + +```ts +// common/database/config.ts +export type SqliteCommitMode = "awaited" | "deferred"; + +export interface DatabaseFactoryConfig { + // existing: onMigrate, warnOnManualTransactions, profiling + commitMode?: SqliteCommitMode; // default "awaited" +} + +export interface DatabaseProvider { + // existing: sqliteProfiling, createClient, ... + sqliteCommitMode?: SqliteCommitMode; // read by the native runtime when building actor config +} + +export interface SqliteExecuteResult { + columns; rows; changes; lastInsertRowId?; + readonly?: boolean; // present on the native backend + commitSeq?: number; // present when the statement produced a local commit +} + +export interface SqliteDatabase { + // existing members unchanged + commitSeq?(): number; + flushedSeq?(): number; + waitForFlush?(seq: number): Promise; + flushError?(): string | null; + supportsSyncMetadata?(): boolean; +} +export interface SqliteTransactionDatabase { + execSync(sql: string, callback?): { readonly?: boolean }; + commit(): Promise; + .. +} +export type SynchronousSqliteTransactionDatabase = SqliteTransactionDatabase & { commitSync(): number | null; .. }; + +// RawAccess: optional, like executeSync? and nativeMetrics? +type RawAccess = { + // existing members unchanged + commitSeq?(): number; + flushedSeq?(): number; + waitForFlush?(seq?: number): Promise; + flushError?(): string | null; +}; + +// SynchronousRawAccess (Node.js native): required +interface SynchronousRawAccess extends RawAccess { + // existing: executeSync, transactionSync + commitSeq(): number; + flushedSeq(): number; + /** Resolves when `seq` (default: commitSeq() read synchronously now) is durable. Rejects once the database is broken. */ + waitForFlush(seq?: number): Promise; + flushError(): string | null; + /** Like executeSync but returns metadata: readonly (required here), changes, lastInsertRowId, commitSeq. */ + executeSyncRaw(query: string, ...args: unknown[]): SqliteExecuteResult & { readonly: boolean }; + /** Handle-form synchronous transaction that stays open across event-loop turns. */ + beginTransactionSync(options?: Omit): SynchronousTransactionHandle; +} + +interface SynchronousTransactionHandle { + executeSync(query: string, ...args: unknown[]): TRow[]; + executeSyncRaw(query: string, ...args: unknown[]): SqliteExecuteResult & { readonly: boolean }; + execSync(sql: string, callback?): { readonly?: boolean }; // multi-statement + commitSeq(): number; + flushedSeq(): number; + waitForFlush(seq?: number): Promise; + flushError(): string | null; + commitSync(): number | null; // local commit sequence, null when nothing was written + rollbackSync(): void; + readonly isOpen: boolean; +} +``` + +Rules enforced by the `db()` client (and mirrored in `db/drizzle.ts`, which +keeps its own copy of the guard): + +- While a `beginTransactionSync` handle is open, the **synchronous** members + of the base client throw: `executeSync`, `executeSyncRaw`, `execSync`, + `transactionSync`, `beginTransactionSync`. The JavaScript thread would + otherwise block on the Rust lease it holds (the Rust side now also fails + fast, section 6.2, so this is belt and braces). Asynchronous members + (`execute`, `exec`, `transaction`) are allowed and queue behind the lease. + `commitSeq`, `flushedSeq`, `waitForFlush`, and `flushError` are always allowed. +- Transaction-scoped clients (the `tx` passed to `transaction()` and + `transactionSync()`, including its synchronous callback access) delegate + `commitSeq`/`flushedSeq`/`waitForFlush`/`flushError` to the base client. +- `commitSync`/`rollbackSync` close the handle. `isOpen` turns false on either, + and on `transaction_closed`/`transaction_expired` errors surfaced by a + statement. +- `executeSyncRaw` checks `supportsSyncMetadata()` before executing and rejects + remote or WebAssembly backends without running the statement. On runtimes + without native SQLite, `commitSeq()` returns `0`, `flushedSeq()` returns `0`, + `waitForFlush()` resolves immediately, `flushError()` returns `null`, and + `executeSyncRaw`/`beginTransactionSync` throw the existing "only available in + the Node.js native runtime" error. +- `db({ commitMode: "deferred" })` on a WebAssembly or remote-SQLite actor + fails at actor start with `deferred_commits_unsupported`. The WebAssembly + runtime mirrors the config field (`rivetkit-wasm/src/lib.rs`) so it rejects + rather than silently running awaited. + +Runtime interface (`registry/runtime.ts`, `napi-runtime.ts`, `wasm-runtime.ts`) +gains `actorSqlCommitSeq`, `actorSqlFlushedSeq`, `actorSqlWaitForFlush`, +`actorSqlFlushError`, `actorSqlSupportsSyncMetadata`, +`RuntimeSqlExecuteResult.readonly?`/`commitSeq?`, `RuntimeSqlExecResult.readonly?`, +transaction `commit` returning the sequence, and `sqliteCommitMode` on the actor +config handed to the native factory, mirroring how `remoteSqlite` flows today. +The `lastInsertRowIdColumnName` shortcut in `native-database.ts` synthesizes +`readonly: true`. + +### 6.5 Sufficiency for Durable Object storage + +| Durable Object capability | Provided by | +| --- | --- | +| `sql.exec` synchronous cursor, multi-statement | `executeSyncRaw` / `execSync` on the open handle; `readonly` on both results | +| `sql.databaseSize` | `PRAGMA page_count` and `page_size` via `executeSync` | +| Sync KV `get/put/delete/list` | `executeSync` over a KV table | +| Async KV, `deleteAll`, `list` options | JS wrappers over the same, resolving after local commit | +| Write coalescing between awaits | The consumer opens a `beginTransactionSync` handle before the **first statement of a turn**, routes every statement through it, and commits it on the next event-loop turn. `commitSync()` returns the sequence to gate on, or `null` for a read-only turn. `readonly` decides whether the turn holds a confirmed write (`allowUnconfirmed` bookkeeping), not whether to open the handle: by the time `readonly` is known the statement has run, so a first write outside a handle would already be its own local commit. | +| Explicit `transactionSync()` | Nested inside the open handle with `SAVEPOINT`/`RELEASE`/`ROLLBACK TO` statements issued through the handle (workerd does the same). | +| Explicit async `transaction()` | Not nested: the consumer first awaits its pending implicit commit, then opens a new handle at the root and holds it across the closure's awaits (its input-gate critical section blocks other events), with nested transactions as savepoints. The consumer sets a long `timeout`; Cloudflare has none. | +| Output gate before an outbound send | `waitForFlush(seq)` where `seq` comes from the pending implicit commit's `commitSync()` (committed first if still open), or `commitSeq()` when no handle is open | +| `sync()` | `waitForFlush()` after committing any open implicit transaction | +| `allowUnconfirmed` | Do not chain that commit's sequence into the gate; the local commit still happens and ordering still covers it | +| Gate broken -> discard outputs, reset object | `waitForFlush` rejects and `flushError()` is set; the actor generation stops via the failure channel. Commit-time errors (`transaction_closed`, constraint failures at `COMMIT`) leave storage healthy; the consumer breaks its own gate and resets the object itself. | +| Streaming bodies / queued sends | `commitSeq()` now, `waitForFlush(seq)` later | +| Shutdown | The runtime commits or rolls back its open implicit handle before `close()`; a handle still open at close is rolled back by the lease and its writes are lost, which the never-sent response makes unobservable | + +### 6.6 Known deviations from Cloudflare + +- No SQLite authorizer. A user `COMMIT`, `ROLLBACK`, `SAVEPOINT`, `ATTACH`, or + restricted `PRAGMA` in SQL text is not rejected. The autocommit check in + section 6.2 bounds the damage of a user `COMMIT`/`ROLLBACK` to "the rest of + the turn fails", and the consumer may screen statements with its own + matcher. A real authorizer is a follow-up. +- `rowsRead`/`rowsWritten` cursor counters are not provided; rows are fully + materialized. + +## 7. Configuration plumbing + +``` +db({ commitMode: "deferred" }) + -> DatabaseProvider.sqliteCommitMode + -> native runtime actor config (registry/native.ts, next to remoteSqlite) + -> JsActorConfig.sqliteCommitMode (NAPI actor_factory.rs; wasm mirror in rivetkit-wasm/src/lib.rs) + -> ActorConfigInput.sqlite_commit_mode -> ActorConfig.sqlite_commit_mode + -> SqliteDb::new_with_remote_sqlite(.., commit_mode) // registry/mod.rs, rejects unsupported backends + -> open_database_from_transport(.., commit_mode, initial_commit_seq) + -> VfsConfig.commit_mode / initial_commit_seq +``` + +Result metadata flows back the other way: + +``` +sqlite3_stmt_readonly / commit_seq delta (query.rs, worker.rs) + -> depot_client_types::ExecuteResult / QueryResult + -> rivetkit-core (remote conversion sets None) + -> NAPI NativeExecuteResult.readonly? / commitSeq?, QueryResult.readonly? + -> RuntimeSqlExecuteResult / RuntimeSqlExecResult + -> SqliteExecuteResult (native-database.ts) -> executeSyncRaw +``` + +`DeferredCommitConfig` values come from `SqliteOptimizationFlags` +(`RIVETKIT_SQLITE_OPT_FLUSH_RETRY_DEADLINE_MS`, +`RIVETKIT_SQLITE_OPT_FLUSH_RETRY_BACKOFF_MIN_MS`, +`RIVETKIT_SQLITE_OPT_FLUSH_RETRY_BACKOFF_MAX_MS`, +`RIVETKIT_SQLITE_OPT_MAX_UNFLUSHED_BYTES`) parsed with the existing +`from_env_reader` helpers and mapped in `VfsConfig::from_optimization_flags`. +They are runner-wide tuning, not per-actor API. Tests construct +`DeferredCommitConfig` directly with small real durations; nothing uses +`tokio::time::pause`. + +## 8. Invariants + +1. `flushed_seq <= commit_seq` always; both are monotonic per process for a + given actor. +2. In awaited mode `flushed_seq == commit_seq` at every observable point. +3. A page in the overlay is never evicted and is always the newest local + version of that page outside `write_buffer.dirty`. No read response ever + overwrites it. +4. At most one `InFlightBatch` exists, and its `seq` equals `commit_seq` at + the moment it was formed. +5. `durable_head_txid` is assigned only at open and by the acknowledgement + path, every acknowledgement advances it by exactly one, and every commit + request carries `Some(durable_head_txid)` as its fence. +6. If `wait_for_flush(s)` resolved `Ok`, the engine's durable state equals the + local commit history up to at least `s`, and reopening the database from + the engine observes all of it. +7. After a break, no later `wait_for_flush` resolves `Ok` (including for + already-flushed sequences), no later SQL statement succeeds, and the actor + generation is stopped exactly once. +8. Engine contents after any sequence of operations, including a break, equal + the local commit history up to some sequence `f` with + `flushed_seq <= f <= commit_seq`, never a mix. +9. Every await inside the flusher is bounded by the retry deadline, and the + flusher exiting for any reason other than a clean drain breaks the + database. +10. Every batch the flusher can form is within `MAX_COMMIT_DIRTY_PAGES`. +11. No engine request is issued by a `VfsContext` after its `close` returned. + +## 9. Testing + +### 9.1 Deterministic VFS tests (`engine/packages/depot-client/tests/inline/vfs.rs`) + +The harness already provides `DirectEngineHarness`, `DirectTransportHooks` +(`fail_next_commit`, `fail_next_commit_after_apply` for a lost ack, +`hang_next_commit`, `pause_next_commit`/`DirectCommitPause` for hold/release, +`commit_requests()` capture), `DirectStorage::read_branch_head` for the engine +txid, and `open_db_on_engine` for reopening. Add a repeat count to the fail +hooks ("fail the next N attempts") and a hook that returns a response with a +wrong or missing head. Tests, each ending with the durability oracle in 9.2: + +- `deferred_commit_returns_before_engine_ack` +- `read_only_transaction_does_not_advance_commit_seq` +- `wait_for_flush_snapshot_ignores_later_commits` +- `wait_for_flush_rejects_future_sequence` and `wait_for_flush_zero_is_immediate` +- `wait_for_flush_rejects_after_break_even_for_flushed_sequence` +- `read_your_writes_while_batch_in_flight` (also invalidates the page cache + mid-flight and asserts overlay bytes win) +- `overflow_expanded_read_does_not_overwrite_overlay` +- `read_between_staging_and_in_flight_omits_head_fence` +- `stale_unfenced_read_does_not_regress_durable_head` (read delayed across an + acknowledgement; next batch still expects the right head) +- `read_window_accepts_response_served_before_ack_processed_after` and + `read_window_rejects_foreign_head` +- `prefetch_and_has_readable_page_skip_overlay_pages` +- `empty_page_synthesis_disabled_after_local_commit` +- `oversized_transaction_is_rejected_before_merge` and + `closing_drains_page_cap_before_merging_another_commit`; + `oversized_sql_transaction_rolls_back_and_connection_remains_usable`; + `awaited_mode_ignores_deferred_byte_limit_with_small_pages` +- `no_requests_after_close_returns` (abort an in-flight attempt at close and + assert the transport sees nothing afterwards) +- `worker_timeout_aborts_flusher_before_paused_io_resumes` +- `commits_during_in_flight_batch_coalesce_into_one_request` (request count, + newest bytes, txid chain) +- `lost_ack_resend_hits_fence_and_breaks_database` (engine applied, reply + dropped; the oracle shows the data durable while every waiter rejected) +- `transient_error_resend_is_byte_identical_and_applies_once` (request capture + proves one applied commit and the same fence on both attempts) +- `foreign_writer_at_expected_plus_one_breaks_database` +- `commit_ok_with_wrong_head_breaks_database`; `commit_ok_without_head_is_accepted` +- `transient_errors_retry_then_succeed` +- `retry_deadline_breaks_database_and_rejects_waiters` +- `hung_commit_is_bounded_by_deadline` +- `late_ack_after_break_does_not_publish_progress` +- `truncate_with_dirty_pages_defers_staging_to_commit_boundary` +- `drop_with_open_transaction_stages_nothing` +- `backpressure_returns_when_closing` +- `awaited_mode_advances_sequences_on_ack` +- `flusher_panic_breaks_database` (test-only hook) +- `size_only_truncate_is_a_local_commit`; `shrink_evicts_overlay_pages_above_size`; + `shrink_while_expansion_in_flight` +- `xsync_during_atomic_write_does_not_stage`; `rollback_atomic_write_leaves_overlay` +- `backpressure_blocks_commit_until_flush_progress` (flusher asleep, no batch + in flight when the bound is hit) +- `close_drains_pending_flushes`; `close_returns_flush_error_when_broken`; + `drop_without_close_stages_only_and_is_short` +- `commit_seq_continues_across_reopen` +- `awaited_mode_is_unchanged` (sequence equality, request count parity with + the existing tests) +- `execute_result_reports_readonly_and_commit_seq` + +### 9.2 Durability oracle + +A helper reopens a fresh `NativeDatabase` against the same in-process engine +and compares table contents with the expected prefix of local commits. +Invariants 6 and 8 are asserted by every deferred-mode test, including the +lost-acknowledgement path (data durable, waiters rejected). + +### 9.3 Seeded randomized interleaving + +One test drives a seeded RNG over: write transaction, read, size-only +truncate, `wait_for_flush`, hold/release/fail/lose-ack/wrong-head on the +transport, page-cache invalidation, and close/reopen, against an in-memory +model of `{ local: Vec, acked_prefix }`. Small real durations; the +seed is printed on failure; runs a fixed iteration count in CI +(`cargo test -p rivet-depot-client deferred_random`). + +### 9.4 rivetkit-core + +- `SqliteDb` in deferred mode: `wait_for_flush` maps errors, unsupported + backends reject at context construction, a broken flusher calls `stop_actor` + exactly once with the flush error message, `commit_seq` survives close and + reopen. +- Coordinator: a `SyncBlocking` operation against a pending or active + `Bridge` lease fails fast with `transaction_active` (including the window + between gate acquisition and `BEGIN`); an `Internal` lease still waits; a + statement that triggers SQLite auto-rollback makes the lease terminal on + both the success and error paths; `exec("COMMIT; INSERT ...")` inside a + lease stops before the `INSERT`; `commit` returns the sequence or `None`. + +### 9.5 TypeScript driver tests + +Add a `dbActorDeferred` fixture (`db({ commitMode: "deferred" })`) registered +in `fixtures/driver-test-suite/registry-static.ts`, with actions for +`executeSync` writes, `waitForFlush`, `commitSeq`/`flushedSeq`, an implicit +transaction via `beginTransactionSync` committed on `setImmediate`, and +`executeSyncRaw().readonly`. A separate sleep fixture with a short +`sleepTimeout` follows `sleep-db.ts`. Tests in +`tests/driver/actor-db-deferred.test.ts` over the SQLite matrix: + +- writes are visible to a following read before `waitForFlush` +- `waitForFlush` resolves and a fresh actor instance sees the rows +- `commitSeq` advances per write, not per read, and `flushedSeq` catches up +- `waitForFlush()` captures the sequence synchronously (a write issued right + after the call is not waited for) +- `readonly` is `true` for `SELECT`, `false` for `INSERT` and DDL +- handle-form transaction: `commitSync()` returns the sequence, a read-only + turn returns `null`, synchronous base-client calls throw while it is open, + asynchronous ones queue, and the gate-then-send ordering holds +- native/remote and wasm/remote matrix cells assert + `deferred_commits_unsupported` at actor start +- sleep variant: write, `waitForFlush`, trigger sleep, wake, read back; + `commitSeq` after wake is greater than before sleep + +### 9.6 Docs + +- `docs/content/docs/sqlite.mdx`: a "Deferred commits" section under the + synchronous-operations section explaining `commitMode`, `waitForFlush`, + `beginTransactionSync`, and the durability contract in user terms. +- `docs-internal/engine/sqlite-vfs.md`: a "Deferred commits" rules block + linking here. + +## 10. Follow-ups (out of scope) + +- Pipelined batches with predicted head txids once the engine orders + per-actor commits or accepts a client commit id. +- A client commit nonce recorded by the engine and echoed on `get_pages`, so + a lost acknowledgement can be recovered by a probe instead of a restart. +- A structured generation-fence error code at the envoy boundary, so a stale + generation breaks immediately instead of at the retry deadline. +- An SQLite authorizer for transaction-control and restricted statements. +- Worker-thread isolation of synchronous SQLite. +- Runtime-level input and output gates, `blockConcurrencyWhile`, and actor + reset semantics in the Durable Object compatibility layer. diff --git a/docs/content/docs/sqlite.mdx b/docs/content/docs/sqlite.mdx index 5797930d5a..a77b1d3fab 100644 --- a/docs/content/docs/sqlite.mdx +++ b/docs/content/docs/sqlite.mdx @@ -111,7 +111,35 @@ const todoId = c.db.transactionSync((tx) => { }); ``` -The callback must be synchronous and must use its `tx` value, which exposes only `executeSync(...)`. It must not return a promise. `{ name, timeout }` options are supported, matching `transaction(...)`. Synchronous operations are unavailable in WebAssembly runtimes. +The callback must be synchronous and must use its `tx` value. The transaction client exposes `executeSync(...)` plus `commitSeq()`, `flushedSeq()`, `waitForFlush(...)`, and `flushError()`, delegating sequence and durability state to the base database. It must not return a promise. `{ name, timeout }` options are supported, matching `transaction(...)`. Synchronous operations are unavailable in WebAssembly runtimes. + +### Deferred commits + +Native SQLite normally waits for durable storage before a write returns. Integrations that need synchronous, turn-based storage can opt into deferred commits: + +```ts @nocheck +db: db({ + commitMode: "deferred", + onMigrate: async (db) => { + await db.execute("CREATE TABLE IF NOT EXISTS todos (id INTEGER PRIMARY KEY, title TEXT)"); + }, +}) +``` + +In deferred mode, a successful write is immediately visible to later queries in the same actor, but it may not be durable yet. Call `await c.db.waitForFlush()` before sending an output that depends on those writes. The call captures the current commit sequence synchronously, so writes started afterward are not part of that wait. You can also capture `c.db.commitSeq()` and pass it explicitly to `waitForFlush(sequence)`. + +`c.db.flushError()` returns the terminal durability error after the database has broken, or `null` while it is healthy. Transaction-scoped clients and synchronous transaction handles expose the same sequence, wait, and flush-error methods by delegating them to the base database. `execSync()` returns `{ readonly }`, matching the metadata available from `executeSyncRaw()`. + +`beginTransactionSync()` opens a synchronous handle that can stay open until a later event-loop turn. Run all synchronous SQL through the handle, then call `commitSync()` to receive its commit sequence, or `rollbackSync()` to discard it. Base-client synchronous calls throw while the handle is open; asynchronous calls queue behind it. + +```ts @nocheck +const tx = c.db.beginTransactionSync({ name: "todo-turn" }); +tx.executeSync("INSERT INTO todos (title) VALUES (?)", title); +const sequence = tx.commitSync(); +if (sequence !== null) await c.db.waitForFlush(sequence); +``` + +Deferred commits require local SQLite in the Node.js native runtime. Metadata-returning synchronous execution checks that capability before running SQL, so an unsupported remote statement is not executed and then rejected afterward. Actor shutdown drains staged commits. If durability becomes indeterminate, `waitForFlush()` rejects, `flushError()` reports the reason, and the actor generation stops rather than continuing with uncertain storage. ### Transactions diff --git a/engine/packages/depot-client-embedded/src/lib.rs b/engine/packages/depot-client-embedded/src/lib.rs index c624d7c546..bae307920f 100644 --- a/engine/packages/depot-client-embedded/src/lib.rs +++ b/engine/packages/depot-client-embedded/src/lib.rs @@ -11,7 +11,7 @@ use async_trait::async_trait; use depot::error::SqliteStorageError; use depot_client::{ database::{NativeDatabaseHandle, open_database_from_transport}, - vfs::{SqliteTransport, SqliteVfsMetrics}, + vfs::{CommitMode, SqliteTransport, SqliteVfsMetrics}, }; use rivet_envoy_protocol as protocol; use tokio::runtime::Handle; @@ -39,6 +39,8 @@ pub async fn open_database_from_embedded_depot( generation, rt_handle, metrics, + CommitMode::Awaited, + 0, ) .await } diff --git a/engine/packages/depot-client-types/src/lib.rs b/engine/packages/depot-client-types/src/lib.rs index c1b08549e8..069b302baa 100644 --- a/engine/packages/depot-client-types/src/lib.rs +++ b/engine/packages/depot-client-types/src/lib.rs @@ -82,6 +82,7 @@ pub struct ExecResult { pub struct QueryResult { pub columns: Vec, pub rows: Vec>, + pub readonly: Option, } #[derive(Clone, Debug, PartialEq)] @@ -90,6 +91,8 @@ pub struct ExecuteResult { pub rows: Vec>, pub changes: i64, pub last_insert_row_id: Option, + pub readonly: Option, + pub commit_seq: Option, } impl ExecuteResult { @@ -97,6 +100,7 @@ impl ExecuteResult { QueryResult { columns: self.columns, rows: self.rows, + readonly: self.readonly, } } @@ -130,6 +134,8 @@ mod tests { ]], changes: 3, last_insert_row_id: Some(42), + readonly: Some(false), + commit_seq: Some(7), }; assert_eq!(result.columns, vec!["id", "name"]); @@ -151,6 +157,8 @@ mod tests { rows: vec![vec![ColumnValue::Integer(9)]], changes: 2, last_insert_row_id: Some(10), + readonly: Some(false), + commit_seq: Some(8), }; let query_result = result.clone().into_query_result(); diff --git a/engine/packages/depot-client/Cargo.toml b/engine/packages/depot-client/Cargo.toml index 3f13ca8f9a..99d05df88f 100644 --- a/engine/packages/depot-client/Cargo.toml +++ b/engine/packages/depot-client/Cargo.toml @@ -26,6 +26,7 @@ depot-client-types.workspace = true moka = { version = "0.12", default-features = false, features = ["sync"] } parking_lot.workspace = true scc.workspace = true +thiserror.workspace = true [dev-dependencies] depot = { workspace = true, features = ["test-faults"] } diff --git a/engine/packages/depot-client/src/database.rs b/engine/packages/depot-client/src/database.rs index 86ee623c6b..b31b99e81b 100644 --- a/engine/packages/depot-client/src/database.rs +++ b/engine/packages/depot-client/src/database.rs @@ -9,11 +9,14 @@ use tokio::runtime::Handle; use crate::{ query::{BindParam, ExecResult, ExecuteResult, QueryResult}, vfs::{ - NativeVfsHandle, SqliteOpenPhase, SqliteTransportHandle, SqliteVfs, SqliteVfsMetrics, - SqliteVfsMetricsSnapshot, VfsConfig, VfsPreloadHintSnapshot, - fetch_initial_pages_for_registration, + CommitMode, DatabaseFailure, FlushError, NativeVfsHandle, SqliteOpenPhase, + SqliteTransportHandle, SqliteVfs, SqliteVfsMetrics, SqliteVfsMetricsSnapshot, VfsConfig, + VfsPreloadHintSnapshot, fetch_initial_pages_for_registration, + }, + worker::{ + SqliteWorkerCloseTimeoutError, SqliteWorkerFatalError, SqliteWorkerHandle, + SqliteWorkerResult, }, - worker::{SqliteWorkerFatalError, SqliteWorkerHandle, SqliteWorkerResult}, }; #[derive(Clone)] @@ -80,10 +83,14 @@ pub async fn open_database_from_transport( generation: u64, rt_handle: Handle, metrics: Option>, + commit_mode: CommitMode, + initial_commit_seq: u64, ) -> Result { let open_timer = SqliteOpenTimer::new(&metrics); let vfs_name = vfs_name_for_actor_database(&actor_id, generation); - let config = VfsConfig::default(); + let mut config = VfsConfig::default(); + config.commit_mode = commit_mode; + config.initial_commit_seq = initial_commit_seq; let transport: SqliteTransportHandle = Arc::new(GenerationFencedTransport { inner: transport, generation, @@ -253,6 +260,7 @@ impl NativeDatabaseHandle { self.execute(sql, params).await.map(|result| QueryResult { columns: result.columns, rows: result.rows, + readonly: result.readonly, }) } @@ -281,14 +289,79 @@ impl NativeDatabaseHandle { } pub async fn close(&self) -> Result<()> { - match self.worker.close().await { - Ok(()) => Ok(()), - Err(error) => Err(self.fatal_error().unwrap_or(error)), + self.close_with_timeouts(None, self.vfs.close_flush_timeout()) + .await + } + + async fn close_with_timeouts( + &self, + worker_timeout: Option, + flush_timeout: std::time::Duration, + ) -> Result<()> { + self.vfs.begin_close(); + #[cfg(test)] + let worker_result = match worker_timeout { + Some(timeout) => self.worker.close_with_timeout_for_test(timeout).await, + None => self.worker.close().await, + }; + #[cfg(not(test))] + let worker_result = { + let _ = worker_timeout; + self.worker.close().await + }; + if worker_result.as_ref().err().is_some_and(|error| { + error + .downcast_ref::() + .is_some() + }) { + self.vfs.abort_flusher_for_worker_timeout().await; + return worker_result; + } + let flush_result = self.vfs.drain_and_shutdown_flusher(flush_timeout).await; + match (worker_result, flush_result) { + (Ok(()), Ok(())) => Ok(()), + (_, Err(error)) => Err(anyhow!(error)), + (Err(error), Ok(())) => Err(self.fatal_error().unwrap_or(error)), + } + } + + #[cfg(test)] + pub(crate) async fn close_with_timeouts_for_test( + &self, + worker_timeout: std::time::Duration, + flush_timeout: std::time::Duration, + ) -> Result<()> { + self.close_with_timeouts(Some(worker_timeout), flush_timeout) + .await + } + + pub async fn wait_for_failure(&self) -> DatabaseFailure { + tokio::select! { + reason = self.vfs.wait_for_failure() => reason, + failed = self.worker.wait_for_failure() => { + if failed { + DatabaseFailure::WorkerStopped + } else { + DatabaseFailure::Closed + } + } } } - pub async fn wait_for_worker_failure(&self) -> bool { - self.worker.wait_for_failure().await + pub fn commit_seq(&self) -> u64 { + self.vfs.commit_seq() + } + + pub fn flushed_seq(&self) -> u64 { + self.vfs.flushed_seq() + } + + pub fn flush_error(&self) -> Option { + self.vfs.flush_error() + } + + pub async fn wait_for_flush(&self, seq: u64) -> std::result::Result<(), FlushError> { + self.vfs.wait_for_flush(seq).await } pub fn take_last_kv_error(&self) -> Option { diff --git a/engine/packages/depot-client/src/optimization_flags.rs b/engine/packages/depot-client/src/optimization_flags.rs index e8b2bc35af..f7aaa2d701 100644 --- a/engine/packages/depot-client/src/optimization_flags.rs +++ b/engine/packages/depot-client/src/optimization_flags.rs @@ -24,6 +24,10 @@ pub const VFS_PAGE_CACHE_CAPACITY_PAGES_ENV: &str = pub const VFS_PROTECTED_CACHE_PAGES_ENV: &str = "RIVETKIT_SQLITE_OPT_VFS_PROTECTED_CACHE_PAGES"; pub const VFS_STAGING_CACHE_TTL_MS_ENV: &str = "RIVETKIT_SQLITE_OPT_VFS_STAGING_CACHE_TTL_MS"; pub const PAGER_CACHE_SIZE_KIB_ENV: &str = "RIVETKIT_SQLITE_OPT_PAGER_CACHE_SIZE_KIB"; +pub const FLUSH_RETRY_DEADLINE_MS_ENV: &str = "RIVETKIT_SQLITE_OPT_FLUSH_RETRY_DEADLINE_MS"; +pub const FLUSH_RETRY_BACKOFF_MIN_MS_ENV: &str = "RIVETKIT_SQLITE_OPT_FLUSH_RETRY_BACKOFF_MIN_MS"; +pub const FLUSH_RETRY_BACKOFF_MAX_MS_ENV: &str = "RIVETKIT_SQLITE_OPT_FLUSH_RETRY_BACKOFF_MAX_MS"; +pub const MAX_UNFLUSHED_BYTES_ENV: &str = "RIVETKIT_SQLITE_OPT_MAX_UNFLUSHED_BYTES"; pub const DEFAULT_STARTUP_PRELOAD_MAX_BYTES: usize = 2 * 1024 * 1024; pub const MAX_STARTUP_PRELOAD_MAX_BYTES: usize = 64 * 1024 * 1024; @@ -37,6 +41,10 @@ pub const DEFAULT_VFS_STAGING_CACHE_TTL_MS: u64 = 30_000; pub const MAX_VFS_STAGING_CACHE_TTL_MS: u64 = 300_000; pub const DEFAULT_PAGER_CACHE_SIZE_KIB: u64 = 8 * 1024; pub const MAX_PAGER_CACHE_SIZE_KIB: u64 = 256 * 1024; +pub const DEFAULT_FLUSH_RETRY_DEADLINE_MS: u64 = 30_000; +pub const DEFAULT_FLUSH_RETRY_BACKOFF_MIN_MS: u64 = 50; +pub const DEFAULT_FLUSH_RETRY_BACKOFF_MAX_MS: u64 = 2_000; +pub const DEFAULT_MAX_UNFLUSHED_BYTES: usize = 64 * 1024 * 1024; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SqliteReadAheadMode { @@ -110,6 +118,10 @@ pub struct SqliteOptimizationFlags { pub vfs_protected_cache_pages: usize, pub vfs_staging_cache_ttl_ms: u64, pub pager_cache_size_kib: u64, + pub flush_retry_deadline_ms: u64, + pub flush_retry_backoff_min_ms: u64, + pub flush_retry_backoff_max_ms: u64, + pub max_unflushed_bytes: usize, } impl Default for SqliteOptimizationFlags { @@ -138,6 +150,10 @@ impl Default for SqliteOptimizationFlags { vfs_protected_cache_pages: DEFAULT_VFS_PROTECTED_CACHE_PAGES, vfs_staging_cache_ttl_ms: DEFAULT_VFS_STAGING_CACHE_TTL_MS, pager_cache_size_kib: DEFAULT_PAGER_CACHE_SIZE_KIB, + flush_retry_deadline_ms: DEFAULT_FLUSH_RETRY_DEADLINE_MS, + flush_retry_backoff_min_ms: DEFAULT_FLUSH_RETRY_BACKOFF_MIN_MS, + flush_retry_backoff_max_ms: DEFAULT_FLUSH_RETRY_BACKOFF_MAX_MS, + max_unflushed_bytes: DEFAULT_MAX_UNFLUSHED_BYTES, } } } @@ -216,6 +232,22 @@ impl SqliteOptimizationFlags { DEFAULT_PAGER_CACHE_SIZE_KIB, MAX_PAGER_CACHE_SIZE_KIB, ), + flush_retry_deadline_ms: u64_by_default( + read_env(FLUSH_RETRY_DEADLINE_MS_ENV).as_deref(), + DEFAULT_FLUSH_RETRY_DEADLINE_MS, + ), + flush_retry_backoff_min_ms: u64_by_default( + read_env(FLUSH_RETRY_BACKOFF_MIN_MS_ENV).as_deref(), + DEFAULT_FLUSH_RETRY_BACKOFF_MIN_MS, + ), + flush_retry_backoff_max_ms: u64_by_default( + read_env(FLUSH_RETRY_BACKOFF_MAX_MS_ENV).as_deref(), + DEFAULT_FLUSH_RETRY_BACKOFF_MAX_MS, + ), + max_unflushed_bytes: usize_by_default( + read_env(MAX_UNFLUSHED_BYTES_ENV).as_deref(), + DEFAULT_MAX_UNFLUSHED_BYTES, + ), } } } @@ -246,6 +278,18 @@ fn usize_bounded_by_default(value: Option<&str>, default: usize, max: usize) -> .min(max) } +fn usize_by_default(value: Option<&str>, default: usize) -> usize { + value + .and_then(|value| value.trim().parse::().ok()) + .unwrap_or(default) +} + +fn u64_by_default(value: Option<&str>, default: u64) -> u64 { + value + .and_then(|value| value.trim().parse::().ok()) + .unwrap_or(default) +} + fn u64_bounded_by_default(value: Option<&str>, default: u64, max: u64) -> u64 { value .and_then(|value| value.trim().parse::().ok()) diff --git a/engine/packages/depot-client/src/query.rs b/engine/packages/depot-client/src/query.rs index ef69f6b3ed..aafe7c56e6 100644 --- a/engine/packages/depot-client/src/query.rs +++ b/engine/packages/depot-client/src/query.rs @@ -12,8 +12,8 @@ use libsqlite3_sys::{ sqlite3_bind_int64, sqlite3_bind_null, sqlite3_bind_text, sqlite3_changes, sqlite3_column_blob, sqlite3_column_bytes, sqlite3_column_count, sqlite3_column_double, sqlite3_column_int64, sqlite3_column_name, sqlite3_column_text, sqlite3_column_type, sqlite3_errmsg, - sqlite3_extended_errcode, sqlite3_finalize, sqlite3_last_insert_rowid, sqlite3_prepare_v2, - sqlite3_step, + sqlite3_extended_errcode, sqlite3_finalize, sqlite3_get_autocommit, sqlite3_last_insert_rowid, + sqlite3_prepare_v2, sqlite3_step, sqlite3_stmt_readonly, }; #[derive(Clone, Copy, Debug, Default)] @@ -40,6 +40,17 @@ impl fmt::Display for SqliteStatementError { impl Error for SqliteStatementError {} +#[derive(Debug)] +pub struct SqliteTransactionClosedError; + +impl fmt::Display for SqliteTransactionClosedError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("sqlite transaction closed before the remaining exec statements") + } +} + +impl Error for SqliteTransactionClosedError {} + pub fn execute_statement( db: *mut sqlite3, sql: &str, @@ -97,8 +108,10 @@ pub fn query_statement( return Ok(QueryResult { columns: Vec::new(), rows: Vec::new(), + readonly: Some(true), }); } + let readonly = unsafe { sqlite3_stmt_readonly(stmt) != 0 }; let result = (|| { if let Some(params) = params { @@ -124,7 +137,11 @@ pub fn query_statement( rows.push(row); } - Ok(QueryResult { columns, rows }) + Ok(QueryResult { + columns, + rows, + readonly: Some(readonly), + }) })(); unsafe { @@ -150,7 +167,7 @@ pub fn execute_single_statement( "failed to prepare sqlite execute statement", )); } - if has_non_whitespace_tail(tail) { + if tail_has_statement(db, tail, 1)? { if !stmt.is_null() { unsafe { sqlite3_finalize(stmt); @@ -169,8 +186,11 @@ pub fn execute_single_statement( rows: Vec::new(), changes: 0, last_insert_row_id: None, + readonly: Some(true), + commit_seq: None, }); } + let readonly = unsafe { sqlite3_stmt_readonly(stmt) != 0 }; let result = (|| { if let Some(params) = params { @@ -205,6 +225,8 @@ pub fn execute_single_statement( rows, changes, last_insert_row_id: (changes > 0).then(|| unsafe { sqlite3_last_insert_rowid(db) }), + readonly: Some(readonly), + commit_seq: None, }) })(); @@ -231,7 +253,7 @@ pub fn execute_single_statement_profiled( "failed to prepare sqlite execute statement", )); } - if has_non_whitespace_tail(tail) { + if tail_has_statement(db, tail, 1)? { if !stmt.is_null() { unsafe { sqlite3_finalize(stmt); @@ -251,10 +273,13 @@ pub fn execute_single_statement_profiled( rows: Vec::new(), changes: 0, last_insert_row_id: None, + readonly: Some(true), + commit_seq: None, }, SqliteQueryProfile::default(), )); } + let readonly = unsafe { sqlite3_stmt_readonly(stmt) != 0 }; let result = (|| { let mut profile = SqliteQueryProfile::default(); @@ -293,6 +318,8 @@ pub fn execute_single_statement_profiled( rows, changes, last_insert_row_id: (changes > 0).then(|| unsafe { sqlite3_last_insert_rowid(db) }), + readonly: Some(readonly), + commit_seq: None, }, profile, )) @@ -306,13 +333,27 @@ pub fn execute_single_statement_profiled( } pub fn exec_statements(db: *mut sqlite3, sql: &str) -> Result { + exec_statements_inner(db, sql, false) +} + +pub fn exec_statements_in_transaction(db: *mut sqlite3, sql: &str) -> Result { + exec_statements_inner(db, sql, true) +} + +fn exec_statements_inner( + db: *mut sqlite3, + sql: &str, + stop_when_autocommit_resumes: bool, +) -> Result { let c_sql = CString::new(sql).map_err(|err| anyhow!(err.to_string()))?; let mut remaining = c_sql.as_ptr(); let mut statement_index = 0_u32; let mut final_result = QueryResult { columns: Vec::new(), rows: Vec::new(), + readonly: Some(true), }; + let mut all_readonly = true; while unsafe { *remaining } != 0 { let mut stmt = ptr::null_mut(); @@ -333,6 +374,7 @@ pub fn exec_statements(db: *mut sqlite3, sql: &str) -> Result { remaining = tail; continue; } + all_readonly &= unsafe { sqlite3_stmt_readonly(stmt) != 0 }; let result = (|| { let columns = collect_columns(stmt); @@ -366,7 +408,17 @@ pub fn exec_statements(db: *mut sqlite3, sql: &str) -> Result { let (columns, rows) = result?; if !columns.is_empty() || !rows.is_empty() { - final_result = QueryResult { columns, rows }; + final_result = QueryResult { + columns, + rows, + readonly: Some(all_readonly), + }; + } + if stop_when_autocommit_resumes + && unsafe { sqlite3_get_autocommit(db) } != 0 + && tail_has_statement(db, tail, statement_index.saturating_add(1))? + { + return Err(SqliteTransactionClosedError.into()); } if tail == remaining { @@ -376,12 +428,28 @@ pub fn exec_statements(db: *mut sqlite3, sql: &str) -> Result { statement_index = statement_index.saturating_add(1); } + final_result.readonly = Some(all_readonly); Ok(final_result) } pub fn exec_statements_profiled( db: *mut sqlite3, sql: &str, +) -> Result<(QueryResult, SqliteQueryProfile)> { + exec_statements_profiled_inner(db, sql, false) +} + +pub fn exec_statements_profiled_in_transaction( + db: *mut sqlite3, + sql: &str, +) -> Result<(QueryResult, SqliteQueryProfile)> { + exec_statements_profiled_inner(db, sql, true) +} + +fn exec_statements_profiled_inner( + db: *mut sqlite3, + sql: &str, + stop_when_autocommit_resumes: bool, ) -> Result<(QueryResult, SqliteQueryProfile)> { let c_sql = CString::new(sql).map_err(|err| anyhow!(err.to_string()))?; let mut remaining = c_sql.as_ptr(); @@ -389,8 +457,10 @@ pub fn exec_statements_profiled( let mut final_result = QueryResult { columns: Vec::new(), rows: Vec::new(), + readonly: Some(true), }; let mut final_profile = SqliteQueryProfile::default(); + let mut all_readonly = true; while unsafe { *remaining } != 0 { let mut stmt = ptr::null_mut(); @@ -411,6 +481,7 @@ pub fn exec_statements_profiled( remaining = tail; continue; } + all_readonly &= unsafe { sqlite3_stmt_readonly(stmt) != 0 }; let result = (|| { let columns = collect_columns(stmt); @@ -449,9 +520,19 @@ pub fn exec_statements_profiled( let (columns, rows, profile) = result?; if !columns.is_empty() || !rows.is_empty() { - final_result = QueryResult { columns, rows }; + final_result = QueryResult { + columns, + rows, + readonly: Some(all_readonly), + }; final_profile = profile; } + if stop_when_autocommit_resumes + && unsafe { sqlite3_get_autocommit(db) } != 0 + && tail_has_statement(db, tail, statement_index.saturating_add(1))? + { + return Err(SqliteTransactionClosedError.into()); + } if tail == remaining { break; @@ -460,6 +541,7 @@ pub fn exec_statements_profiled( statement_index = statement_index.saturating_add(1); } + final_result.readonly = Some(all_readonly); Ok((final_result, final_profile)) } @@ -631,13 +713,32 @@ fn logical_bind_bytes(param: &BindParam) -> u64 { } } -fn has_non_whitespace_tail(tail: *const c_char) -> bool { - if tail.is_null() { - return false; +fn tail_has_statement( + db: *mut sqlite3, + mut remaining: *const c_char, + statement_index: u32, +) -> Result { + while !remaining.is_null() && unsafe { *remaining } != 0 { + let mut stmt = ptr::null_mut(); + let mut tail = ptr::null(); + let rc = unsafe { sqlite3_prepare_v2(db, remaining, -1, &mut stmt, &mut tail) }; + if rc != SQLITE_OK { + return Err(sqlite_error( + db, + statement_index, + "failed to prepare sqlite trailing statement", + )); + } + if !stmt.is_null() { + unsafe { sqlite3_finalize(stmt) }; + return Ok(true); + } + if tail == remaining { + break; + } + remaining = tail; } - - let bytes = unsafe { CStr::from_ptr(tail).to_bytes() }; - bytes.iter().any(|byte| !byte.is_ascii_whitespace()) + Ok(false) } fn sqlite_error(db: *mut sqlite3, statement_index: u32, context: &str) -> anyhow::Error { @@ -742,6 +843,25 @@ mod tests { assert_eq!(result.rows, vec![vec![ColumnValue::Integer(2)]]); } + #[test] + fn exec_keeps_only_the_last_compatible_result_set() { + let db = MemoryDb::open(); + let result = exec_statements(db.as_ptr(), "SELECT 1 AS value; SELECT 2 AS value;") + .expect("both selects should execute"); + + assert_eq!(result.columns, vec!["value"]); + assert_eq!(result.rows, vec![vec![ColumnValue::Integer(2)]]); + } + + #[test] + fn transaction_exec_allows_comments_after_commit() { + let db = MemoryDb::open(); + exec_statements(db.as_ptr(), "BEGIN").unwrap(); + let result = exec_statements_in_transaction(db.as_ptr(), "COMMIT; -- complete") + .expect("a trailing comment is not another transaction statement"); + assert_eq!(result.readonly, Some(true)); + } + #[test] fn exec_reports_the_actual_failing_statement_index() { let db = MemoryDb::open(); diff --git a/engine/packages/depot-client/src/vfs.rs b/engine/packages/depot-client/src/vfs.rs index 348472977b..23fa69ebbd 100644 --- a/engine/packages/depot-client/src/vfs.rs +++ b/engine/packages/depot-client/src/vfs.rs @@ -6,8 +6,8 @@ use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; use std::ffi::{CStr, CString, c_char, c_int, c_void}; use std::ptr; use std::slice; -use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Weak}; use std::time::{Duration, Instant}; use anyhow::Result; @@ -19,6 +19,8 @@ use parking_lot::{Mutex, RwLock}; use rivet_envoy_protocol as protocol; use scc::HashMap as SccHashMap; use tokio::runtime::Handle; +use tokio::sync::{Notify, watch}; +use tokio::task::JoinHandle; use crate::optimization_flags::{ SqliteOptimizationFlags, SqliteVfsPageCacheMode, sqlite_optimization_flags, @@ -153,6 +155,58 @@ fn sqlite_now_ms() -> Result { .try_into()?) } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum CommitMode { + #[default] + Awaited, + Deferred, +} + +#[derive(Clone, Debug, thiserror::Error)] +pub enum FlushError { + #[error("sqlite flush retry deadline exceeded after {attempts} attempts: {last_error}")] + RetryDeadlineExceeded { attempts: u32, last_error: String }, + #[error("sqlite durable head diverged: expected {expected}, engine has {actual:?}")] + HeadDiverged { expected: u64, actual: Option }, + #[error("sqlite flusher aborted: {0}")] + Aborted(String), + #[error("sqlite flush sequence {requested} is ahead of commit sequence {current}")] + InvalidSequence { requested: u64, current: u64 }, +} + +#[derive(Clone, Debug)] +pub enum DatabaseFailure { + Closed, + WorkerStopped, + Flush(FlushError), +} + +#[derive(Clone, Debug, Default)] +pub struct FlushProgress { + pub flushed_seq: u64, + pub error: Option, +} + +#[derive(Clone, Debug)] +pub struct DeferredCommitConfig { + pub retry_deadline: Duration, + pub retry_backoff_min: Duration, + pub retry_backoff_max: Duration, + pub max_unflushed_bytes: usize, +} + +impl Default for DeferredCommitConfig { + fn default() -> Self { + let flags = sqlite_optimization_flags(); + Self { + retry_deadline: Duration::from_millis(flags.flush_retry_deadline_ms), + retry_backoff_min: Duration::from_millis(flags.flush_retry_backoff_min_ms), + retry_backoff_max: Duration::from_millis(flags.flush_retry_backoff_max_ms), + max_unflushed_bytes: flags.max_unflushed_bytes, + } + } +} + #[derive(Debug, Clone)] pub struct VfsConfig { pub cache_capacity_pages: u64, @@ -175,6 +229,11 @@ pub struct VfsConfig { pub recent_page_hints: bool, pub adaptive_read_ahead: bool, pub retain_read_cache: bool, + pub commit_mode: CommitMode, + pub deferred_commit: DeferredCommitConfig, + pub initial_commit_seq: u64, + #[cfg(test)] + pub max_commit_dirty_pages: usize, #[cfg(test)] pub assert_batch_atomic: bool, #[cfg(test)] @@ -231,6 +290,16 @@ impl VfsConfig { recent_page_hints: flags.recent_page_hints, adaptive_read_ahead: flags.adaptive_read_ahead, retain_read_cache: flags.vfs_page_cache_mode.caches_any_pages(), + commit_mode: CommitMode::Awaited, + deferred_commit: DeferredCommitConfig { + retry_deadline: Duration::from_millis(flags.flush_retry_deadline_ms), + retry_backoff_min: Duration::from_millis(flags.flush_retry_backoff_min_ms), + retry_backoff_max: Duration::from_millis(flags.flush_retry_backoff_max_ms), + max_unflushed_bytes: flags.max_unflushed_bytes, + }, + initial_commit_seq: 0, + #[cfg(test)] + max_commit_dirty_pages: depot_client_types::MAX_COMMIT_DIRTY_PAGES, #[cfg(test)] assert_batch_atomic: true, #[cfg(test)] @@ -282,7 +351,7 @@ pub enum CommitPath { pub struct BufferedCommitRequest { pub actor_id: String, pub new_db_size_pages: u32, - pub dirty_pages: Vec, + pub dirty_pages: Arc>, pub expected_head_txid: Option, } @@ -297,6 +366,22 @@ pub struct BufferedCommitOutcome { pub enum CommitBufferError { FenceMismatch(String), Other(String), + Response { + group: String, + code: String, + message: String, + }, +} + +impl CommitBufferError { + fn message(&self) -> &str { + match self { + CommitBufferError::FenceMismatch(message) | CommitBufferError::Other(message) => { + message + } + CommitBufferError::Response { message, .. } => message, + } + } } #[derive(Debug, Clone, Copy, Default)] @@ -549,6 +634,16 @@ pub trait SqliteVfsMetrics: Send + Sync { fn record_commit(&self); + fn set_overlay_pages(&self, _pages: u64) {} + + fn record_flush_batch(&self, _pages: u64, _bytes: u64) {} + + fn observe_flush_latency(&self, _duration_ns: u64) {} + + fn record_flush_retry(&self, _class: &'static str) {} + + fn record_flush_broken(&self) {} + fn observe_commit_phases( &self, request_build_ns: u64, @@ -611,11 +706,24 @@ pub struct VfsContext { last_error: Mutex>, transient_commit_error: Mutex>, fatal_error: RwLock>, + flush: FlushController, + failure_tx: watch::Sender>, + _failure_rx: watch::Receiver>, #[cfg(test)] fail_next_aux_open: Mutex>, #[cfg(test)] fail_next_aux_delete: Mutex>, + #[cfg(test)] + break_after_fatal_marker: Mutex>, commit_atomic_count: AtomicU64, + #[cfg(test)] + commit_atomic_attempt_count: AtomicU64, + #[cfg(test)] + rollback_atomic_count: AtomicU64, + #[cfg(test)] + aux_write_count: AtomicU64, + #[cfg(test)] + main_sync_count: AtomicU64, io_methods: Box, // Performance counters pub resolve_pages_total: AtomicU64, @@ -646,6 +754,7 @@ struct VfsState { /// through `xTruncate` alone. committed_db_size_pages: u32, head_txid: Option, + durable_head_txid: u64, page_size: usize, page_cache: Cache>, committed_page_cache: Cache>, @@ -654,12 +763,109 @@ struct VfsState { prefetched_pages: Arc>, prefetch_unused_total: Arc, write_buffer: WriteBuffer, + overlay: Overlay, predictor: ClassifiedPredictor, read_ahead: ClassifiedReadAhead, recent_pages: RecentPageTracker, dead: bool, } +#[derive(Clone, Debug)] +struct OverlayPage { + bytes: Vec, + seq: u64, +} + +#[derive(Clone, Debug, Default)] +struct Overlay { + pages: BTreeMap, + bytes: usize, + commit_seq: u64, + db_size_pages: u32, + in_flight: Option, +} + +#[derive(Clone, Debug)] +struct InFlightBatch { + seq: u64, + expected_head_txid: u64, + db_size_pages: u32, + pages: Arc>, + started_at: tokio::time::Instant, + attempts: u32, +} + +struct FlushController { + progress: Mutex, + progress_changed: watch::Sender, + _progress_rx: watch::Receiver, + wake: Arc, + shutdown: AtomicBool, + closing: AtomicBool, + #[cfg(test)] + panic_requested: AtomicBool, + task: Mutex>>, +} + +#[cfg(test)] +struct BreakPublicationGate { + reached: std::sync::mpsc::Sender<()>, + resume: std::sync::mpsc::Receiver<()>, +} + +#[cfg(test)] +struct BreakPublicationPause { + reached: std::sync::mpsc::Receiver<()>, + resume: std::sync::mpsc::Sender<()>, +} + +#[cfg(test)] +impl BreakPublicationPause { + fn wait_until_reached(&self) { + self.reached + .recv_timeout(Duration::from_secs(1)) + .expect("database break should pause after marking the state dead"); + } + + fn resume(self) { + self.resume + .send(()) + .expect("database break should resume terminal-error publication"); + } +} + +impl FlushController { + fn new(initial_seq: u64) -> Self { + let (progress_changed, progress_rx) = watch::channel(0); + Self { + progress: Mutex::new(FlushProgress { + flushed_seq: initial_seq, + error: None, + }), + progress_changed, + _progress_rx: progress_rx, + wake: Arc::new(Notify::new()), + shutdown: AtomicBool::new(false), + closing: AtomicBool::new(false), + #[cfg(test)] + panic_requested: AtomicBool::new(false), + task: Mutex::new(None), + } + } + + fn publish_change(&self) { + let next = self.progress_changed.borrow().wrapping_add(1); + self.progress_changed.send_replace(next); + } +} + +impl Drop for FlushController { + fn drop(&mut self) { + self.shutdown.store(true, Ordering::Release); + self.wake.notify_one(); + } +} + #[derive(Debug, Clone, Default)] struct WriteBuffer { in_atomic_write: bool, @@ -813,7 +1019,7 @@ unsafe impl Sync for VfsContext {} pub struct SqliteVfs { _registration: SqliteVfsRegistration, _name: CString, - ctx: Box, + ctx: Arc, } unsafe impl Send for SqliteVfs {} @@ -1166,6 +1372,7 @@ impl VfsState { db_size_pages: 1, committed_db_size_pages: 1, head_txid: None, + durable_head_txid: 0, page_size: DEFAULT_PAGE_SIZE, page_cache, committed_page_cache, @@ -1174,6 +1381,10 @@ impl VfsState { prefetched_pages: Arc::new(scc::HashSet::new()), prefetch_unused_total: Arc::new(AtomicU64::new(0)), write_buffer: WriteBuffer::default(), + overlay: Overlay { + commit_seq: config.initial_commit_seq, + ..Overlay::default() + }, predictor: ClassifiedPredictor::default(), read_ahead: ClassifiedReadAhead::default(), recent_pages: RecentPageTracker::new( @@ -1292,6 +1503,9 @@ impl VfsState { if self.write_buffer.dirty.contains_key(&pgno) { return true; } + if self.overlay.pages.contains_key(&pgno) { + return true; + } if !can_read_cached_page(config, pgno) { return false; } @@ -1413,6 +1627,104 @@ fn can_read_cached_page(config: &VfsConfig, pgno: u32) -> bool { } impl VfsContext { + fn stage_deferred_local_commit( + &self, + require_atomic: bool, + ) -> std::result::Result { + let mut changed = self.flush.progress_changed.subscribe(); + let seq = loop { + let mut state = self.state.write(); + if state.dead { + return Err(CommitBufferError::Other( + "sqlite actor lost its fence".to_string(), + )); + } + if require_atomic && !state.write_buffer.in_atomic_write { + return Ok(false); + } + if !require_atomic && state.write_buffer.in_atomic_write { + return Ok(false); + } + if state.write_buffer.dirty.is_empty() + && state.db_size_pages == state.overlay.db_size_pages + { + if require_atomic { + state.write_buffer.in_atomic_write = false; + } + return Ok(false); + } + + let dirty_len = state.write_buffer.dirty.len(); + let page_limit = self.max_commit_dirty_pages(); + if dirty_len > page_limit { + return Err(CommitBufferError::Other(format!( + "commit of {dirty_len} dirty pages exceeds the {} page maximum", + page_limit, + ))); + } + if state.overlay.pages.len().saturating_add(dirty_len) > page_limit { + drop(state); + if let Some(error) = self.flush.progress.lock().error.clone() { + return Err(CommitBufferError::Other(error.to_string())); + } + self.runtime.block_on(changed.changed()).map_err(|_| { + CommitBufferError::Other("sqlite flush progress channel closed".to_string()) + })?; + continue; + } + + let seq = state.overlay.commit_seq.saturating_add(1); + let dirty = std::mem::take(&mut state.write_buffer.dirty); + for (pgno, bytes) in dirty { + if let Some(previous) = state.overlay.pages.insert(pgno, OverlayPage { bytes, seq }) + { + state.overlay.bytes = state.overlay.bytes.saturating_sub(previous.bytes.len()); + } + state.overlay.bytes = state + .overlay + .bytes + .saturating_add(state.overlay.pages[&pgno].bytes.len()); + } + if state.db_size_pages < state.overlay.db_size_pages { + let first_removed = state.db_size_pages.saturating_add(1); + let removed = state.overlay.pages.split_off(&first_removed); + for page in removed.into_values() { + state.overlay.bytes = state.overlay.bytes.saturating_sub(page.bytes.len()); + } + } + state.overlay.db_size_pages = state.db_size_pages; + state.overlay.commit_seq = seq; + state.committed_db_size_pages = state.db_size_pages; + state.write_buffer.in_atomic_write = false; + if let Some(metrics) = &self.metrics { + metrics.set_overlay_pages(state.overlay.pages.len() as u64); + } + break seq; + }; + + self.flush.wake.notify_one(); + self.apply_deferred_backpressure()?; + Ok(seq > 0) + } + + fn apply_deferred_backpressure(&self) -> std::result::Result<(), CommitBufferError> { + let mut changed = self.flush.progress_changed.subscribe(); + loop { + if self.flush.closing.load(Ordering::Acquire) { + return Ok(()); + } + let bytes = self.state.read().overlay.bytes; + if self.flush.progress.lock().error.is_some() + || bytes <= self.config.deferred_commit.max_unflushed_bytes + { + return Ok(()); + } + if self.runtime.block_on(changed.changed()).is_err() { + return Ok(()); + } + } + } + fn new( actor_id: String, generation: Option, @@ -1428,10 +1740,18 @@ impl VfsContext { .is_some_and(|metrics| metrics.profiling_enabled()); let mut state = VfsState::new(&config, profiling_enabled); let initial_pages = initial_pages.into(); + if config.commit_mode == CommitMode::Deferred && initial_pages.head_txid.is_none() { + return Err( + "deferred sqlite commits require a durable head transaction id".to_string(), + ); + } state.head_txid = initial_pages.head_txid; + state.durable_head_txid = initial_pages.head_txid.unwrap_or_default(); for (pgno, page) in initial_pages.pages { state.seed_page(&config, PageCacheInsertKind::Startup, pgno, page); } + state.overlay.db_size_pages = state.db_size_pages; + let (failure_tx, failure_rx) = watch::channel(None); Ok(Self { actor_id, @@ -1444,11 +1764,24 @@ impl VfsContext { last_error: Mutex::new(None), transient_commit_error: Mutex::new(None), fatal_error: RwLock::new(None), + flush: FlushController::new(config.initial_commit_seq), + failure_tx, + _failure_rx: failure_rx, #[cfg(test)] fail_next_aux_open: Mutex::new(None), #[cfg(test)] fail_next_aux_delete: Mutex::new(None), + #[cfg(test)] + break_after_fatal_marker: Mutex::new(None), commit_atomic_count: AtomicU64::new(0), + #[cfg(test)] + commit_atomic_attempt_count: AtomicU64::new(0), + #[cfg(test)] + rollback_atomic_count: AtomicU64::new(0), + #[cfg(test)] + aux_write_count: AtomicU64::new(0), + #[cfg(test)] + main_sync_count: AtomicU64::new(0), io_methods: Box::new(io_methods), resolve_pages_total: AtomicU64::new(0), resolve_pages_cache_hits: AtomicU64::new(0), @@ -1681,6 +2014,31 @@ impl VfsContext { self.fail_next_aux_delete.lock().take() } + #[cfg(test)] + fn pause_next_break_after_fatal_marker(&self) -> BreakPublicationPause { + let (reached_tx, reached_rx) = std::sync::mpsc::channel(); + let (resume_tx, resume_rx) = std::sync::mpsc::channel(); + *self.break_after_fatal_marker.lock() = Some(BreakPublicationGate { + reached: reached_tx, + resume: resume_rx, + }); + BreakPublicationPause { + reached: reached_rx, + resume: resume_tx, + } + } + + fn max_commit_dirty_pages(&self) -> usize { + #[cfg(test)] + { + return self.config.max_commit_dirty_pages; + } + #[cfg(not(test))] + { + depot_client_types::MAX_COMMIT_DIRTY_PAGES + } + } + fn is_dead(&self) -> bool { self.state.read().dead } @@ -1694,6 +2052,99 @@ impl VfsContext { } } + fn break_database(&self, error: FlushError) { + tracing::error!( + actor_id = %self.actor_id, + error = %error, + "sqlite database broken" + ); + self.mark_fatal(error.to_string()); + #[cfg(test)] + if let Some(gate) = self.break_after_fatal_marker.lock().take() { + let _ = gate.reached.send(()); + let _ = gate.resume.recv(); + } + let first = { + let mut progress = self.flush.progress.lock(); + if progress.error.is_some() { + false + } else { + progress.error = Some(error.clone()); + true + } + }; + self.flush.publish_change(); + if first { + if let Some(metrics) = &self.metrics { + metrics.record_flush_broken(); + } + if !self.flush.closing.load(Ordering::Acquire) { + self.failure_tx + .send_replace(Some(DatabaseFailure::Flush(error))); + } + } + } + + fn handle_read_fatal(&self, message: String) { + match self.config.commit_mode { + CommitMode::Awaited => self.mark_fatal(message), + CommitMode::Deferred => self.break_database(FlushError::Aborted(message)), + } + } + + fn commit_seq(&self) -> u64 { + self.state.read().overlay.commit_seq + } + + fn flushed_seq(&self) -> u64 { + self.flush.progress.lock().flushed_seq + } + + fn flush_error(&self) -> Option { + self.flush.progress.lock().error.clone() + } + + async fn wait_for_flush(&self, seq: u64) -> std::result::Result<(), FlushError> { + let current = self.commit_seq(); + if seq > current { + return Err(FlushError::InvalidSequence { + requested: seq, + current, + }); + } + let mut changed = self.flush.progress_changed.subscribe(); + loop { + let progress = self.flush.progress.lock().clone(); + if let Some(error) = progress.error { + return Err(error); + } + if progress.flushed_seq >= seq { + return Ok(()); + } + changed + .changed() + .await + .map_err(|_| FlushError::Aborted("flush progress channel closed".to_string()))?; + } + } + + async fn wait_for_failure(&self) -> DatabaseFailure { + let mut failure = self.failure_tx.subscribe(); + loop { + if let Some(reason) = failure.borrow().clone() { + return reason; + } + if failure.changed().await.is_err() { + return DatabaseFailure::WorkerStopped; + } + } + } + + fn begin_close(&self) { + self.flush.closing.store(true, Ordering::Release); + self.flush.publish_change(); + } + pub(crate) fn snapshot_preload_hints(&self) -> VfsPreloadHintSnapshot { if !self.config.recent_page_hints { return VfsPreloadHintSnapshot::default(); @@ -1755,6 +2206,10 @@ impl VfsContext { resolved.insert(pgno, Some(bytes.clone())); continue; } + if let Some(page) = state.overlay.pages.get(&pgno) { + resolved.insert(pgno, Some(page.bytes.clone())); + continue; + } if let Some((bytes, was_prefetched)) = state.cached_page(&self.config, pgno) { prefetch_consumed = prefetch_consumed.saturating_add(u64::from(was_prefetched)); resolved.insert(pgno, Some(bytes)); @@ -1823,6 +2278,8 @@ impl VfsContext { skipped_cached_predicted_pages, db_size_pages, expected_head_txid, + durable_at_request, + deferred_read, ) = { let mut state = self.state.write(); let ClassifiedReadAheadPlan { @@ -1861,6 +2318,10 @@ impl VfsContext { to_fetch.push(predicted); } } + let flushed_seq = self.flush.progress.lock().flushed_seq; + let deferred_read = self.config.commit_mode == CommitMode::Deferred + && state.overlay.commit_seq > flushed_seq; + let expected_head_txid = if deferred_read { None } else { state.head_txid }; ( to_fetch, state.page_size.max(1), @@ -1872,7 +2333,9 @@ impl VfsContext { predicted_pgnos, skipped_cached_predicted_pages, state.db_size_pages, - state.head_txid, + expected_head_txid, + state.durable_head_txid, + deferred_read, ) }; @@ -1955,6 +2418,10 @@ impl VfsContext { return Err(GetPagesError::Other(err.to_string())); } }; + let synthesize_empty_page = match self.config.commit_mode { + CommitMode::Awaited => self.commit_total.load(Relaxed) == 0, + CommitMode::Deferred => self.commit_seq() == 0, + }; match response { protocol::SqliteGetPagesResponse::SqliteGetPagesOk(ok) => { @@ -2000,19 +2467,42 @@ impl VfsContext { }); } let response_head_txid = ok.head_txid; - if let Some(head_txid) = response_head_txid { - self.state.write().head_txid = Some(head_txid); + { + let mut state = self.state.write(); + if self.config.commit_mode == CommitMode::Deferred { + if let Some(head) = response_head_txid { + let upper = state + .durable_head_txid + .saturating_add(u64::from(state.overlay.in_flight.is_some())); + let invalid = if deferred_read { + head < durable_at_request || head > upper + } else { + head != durable_at_request + }; + if invalid { + let expected = if head < durable_at_request { + durable_at_request + } else { + upper + }; + // Mark the database dead while the validation guard is still + // held, so a concurrent acknowledgement cannot advance durable + // state before `break_database` publishes the terminal error. + state.dead = true; + drop(state); + let error = FlushError::HeadDiverged { + expected, + actual: Some(head), + }; + self.break_database(error.clone()); + return Err(GetPagesError::FenceMismatch(error.to_string())); + } + } + } else if let Some(head_txid) = response_head_txid { + state.head_txid = Some(head_txid); + } } let missing_pages = missing.iter().copied().collect::>(); - let (page_cache, protected_page_cache, prefetched_pages, prefetch_unused_total) = { - let state = self.state.read(); - ( - state.page_cache.clone(), - state.protected_page_cache.clone(), - state.prefetched_pages.clone(), - state.prefetch_unused_total.clone(), - ) - }; #[cfg(debug_assertions)] let mut returned_pgnos = HashSet::new(); #[cfg(debug_assertions)] @@ -2031,11 +2521,13 @@ impl VfsContext { } } let bytes = if fetched.bytes.is_none() - && self.commit_total.load(Relaxed) == 0 + && synthesize_empty_page && missing_pages.contains(&fetched.pgno) && fetched.pgno == 1 { - self.state.write().head_txid = Some(0); + if self.config.commit_mode == CommitMode::Awaited { + self.state.write().head_txid = Some(0); + } Some(empty_db_page()) } else { fetched.bytes @@ -2046,19 +2538,14 @@ impl VfsContext { } else { PageCacheInsertKind::Prefetch }; - cache_page( - &self.config, - &page_cache, - &protected_page_cache, - &prefetched_pages, - &prefetch_unused_total, - profile_active, - kind, - fetched.pgno, - bytes.clone(), - ); + let mut state = self.state.write(); + if !state.write_buffer.dirty.contains_key(&fetched.pgno) + && !state.overlay.pages.contains_key(&fetched.pgno) + { + state.cache_page(&self.config, kind, fetched.pgno, bytes.clone()); + } } - resolved.insert(fetched.pgno, bytes); + resolved.entry(fetched.pgno).or_insert(bytes); } #[cfg(debug_assertions)] { @@ -2107,7 +2594,7 @@ impl VfsContext { self.profile_request_limit(), ); }); - if self.commit_total.load(Relaxed) == 0 + if synthesize_empty_page && missing.contains(&1) && is_initial_main_page_missing(&error.message) { @@ -2144,6 +2631,10 @@ impl VfsContext { &self, timeout: Option, ) -> std::result::Result>, CommitBufferError> { + if self.config.commit_mode == CommitMode::Deferred { + self.stage_deferred_local_commit(false)?; + return Ok(CommitWait::Completed(None)); + } let total_start = Instant::now(); let request_build_start = Instant::now(); let request = { @@ -2164,15 +2655,17 @@ impl VfsContext { actor_id: self.actor_id.clone(), new_db_size_pages: state.db_size_pages, expected_head_txid: state.head_txid, - dirty_pages: state - .write_buffer - .dirty - .iter() - .map(|(pgno, bytes)| protocol::SqliteDirtyPage { - pgno: *pgno, - bytes: bytes.clone(), - }) - .collect(), + dirty_pages: Arc::new( + state + .write_buffer + .dirty + .iter() + .map(|(pgno, bytes)| protocol::SqliteDirtyPage { + pgno: *pgno, + bytes: bytes.clone(), + }) + .collect(), + ), } }; let request_build_ns = request_build_start.elapsed().as_nanos() as u64; @@ -2243,11 +2736,17 @@ impl VfsContext { state.head_txid = outcome .head_txid .or_else(|| state.head_txid.map(|head_txid| head_txid.saturating_add(1))); - for dirty_page in &request.dirty_pages { + for dirty_page in request.dirty_pages.iter() { state.cache_committed_page(&self.config, dirty_page.pgno, dirty_page.bytes.clone()); } state.write_buffer.dirty.clear(); + let seq = state.overlay.commit_seq.saturating_add(1); + state.overlay.commit_seq = seq; + state.overlay.db_size_pages = state.db_size_pages; + self.flush.progress.lock().flushed_seq = seq; let state_update_ns = state_update_start.elapsed().as_nanos() as u64; + drop(state); + self.flush.publish_change(); self.add_commit_phase_metrics( request_build_ns, transport_metrics, @@ -2270,6 +2769,10 @@ impl VfsContext { &self, timeout: Option, ) -> std::result::Result, CommitBufferError> { + if self.config.commit_mode == CommitMode::Deferred { + self.stage_deferred_local_commit(true)?; + return Ok(CommitWait::Completed(())); + } let total_start = Instant::now(); let request_build_start = Instant::now(); let request = { @@ -2293,15 +2796,17 @@ impl VfsContext { actor_id: self.actor_id.clone(), new_db_size_pages: state.db_size_pages, expected_head_txid: state.head_txid, - dirty_pages: state - .write_buffer - .dirty - .iter() - .map(|(pgno, bytes)| protocol::SqliteDirtyPage { - pgno: *pgno, - bytes: bytes.clone(), - }) - .collect(), + dirty_pages: Arc::new( + state + .write_buffer + .dirty + .iter() + .map(|(pgno, bytes)| protocol::SqliteDirtyPage { + pgno: *pgno, + bytes: bytes.clone(), + }) + .collect(), + ), } }; let request_build_ns = request_build_start.elapsed().as_nanos() as u64; @@ -2370,12 +2875,18 @@ impl VfsContext { state.head_txid = outcome .head_txid .or_else(|| state.head_txid.map(|head_txid| head_txid.saturating_add(1))); - for dirty_page in &request.dirty_pages { + for dirty_page in request.dirty_pages.iter() { state.cache_committed_page(&self.config, dirty_page.pgno, dirty_page.bytes.clone()); } state.write_buffer.dirty.clear(); state.write_buffer.in_atomic_write = false; + let seq = state.overlay.commit_seq.saturating_add(1); + state.overlay.commit_seq = seq; + state.overlay.db_size_pages = state.db_size_pages; + self.flush.progress.lock().flushed_seq = seq; let state_update_ns = state_update_start.elapsed().as_nanos() as u64; + drop(state); + self.flush.publish_change(); self.add_commit_phase_metrics( request_build_ns, transport_metrics, @@ -2385,6 +2896,13 @@ impl VfsContext { Ok(CommitWait::Completed(())) } + fn rollback_atomic_write(&self) { + let mut state = self.state.write(); + state.write_buffer.dirty.clear(); + state.write_buffer.in_atomic_write = false; + state.db_size_pages = state.write_buffer.saved_db_size; + } + /// Returns true when the truncate left behind a size change that only a commit can carry to /// depot. /// @@ -2440,6 +2958,241 @@ impl Drop for VfsContext { } } +struct FlusherExitGuard { + ctx: Weak, + armed: bool, +} + +impl Drop for FlusherExitGuard { + fn drop(&mut self) { + if self.armed + && let Some(ctx) = self.ctx.upgrade() + { + ctx.break_database(FlushError::Aborted( + "background flusher exited unexpectedly".to_string(), + )); + } + } +} + +async fn flusher_task(weak_ctx: Weak) { + let mut guard = FlusherExitGuard { + ctx: weak_ctx.clone(), + armed: true, + }; + loop { + let Some(ctx) = weak_ctx.upgrade() else { + guard.armed = false; + return; + }; + #[cfg(test)] + if ctx.flush.panic_requested.swap(false, Ordering::AcqRel) { + panic!("test deferred sqlite flusher panic"); + } + let wake = ctx.flush.wake.clone(); + let batch = { + let flushed_seq = ctx.flush.progress.lock().flushed_seq; + let mut state = ctx.state.write(); + if state.overlay.commit_seq == flushed_seq { + None + } else { + let pages = Arc::new( + state + .overlay + .pages + .iter() + .map(|(pgno, page)| protocol::SqliteDirtyPage { + pgno: *pgno, + bytes: page.bytes.clone(), + }) + .collect::>(), + ); + let batch = InFlightBatch { + seq: state.overlay.commit_seq, + expected_head_txid: state.durable_head_txid, + db_size_pages: state.overlay.db_size_pages, + pages, + started_at: tokio::time::Instant::now(), + attempts: 0, + }; + state.overlay.in_flight = Some(batch.clone()); + Some(batch) + } + }; + + let Some(batch) = batch else { + if ctx.flush.shutdown.load(Ordering::Acquire) { + guard.armed = false; + return; + } + drop(ctx); + wake.notified().await; + continue; + }; + + match ship_deferred_batch(&ctx, batch.clone()).await { + Ok((head, transport_metrics)) => { + let state_update_start = Instant::now(); + let mut state = ctx.state.write(); + if state.dead { + guard.armed = false; + return; + } + // Durable state and progress are one publication. A fatal marker or + // terminal error that wins first prevents every acknowledgement-side + // mutation, including the `flushed_seq` advance. + let mut progress = ctx.flush.progress.lock(); + if progress.error.is_some() { + guard.armed = false; + return; + } + state.durable_head_txid = head; + state.head_txid = Some(head); + for page in batch.pages.iter() { + let should_remove = state + .overlay + .pages + .get(&page.pgno) + .is_some_and(|overlay| overlay.seq <= batch.seq); + if should_remove && let Some(overlay) = state.overlay.pages.remove(&page.pgno) { + state.overlay.bytes = + state.overlay.bytes.saturating_sub(overlay.bytes.len()); + state.page_cache.invalidate(&page.pgno); + state.protected_page_cache.remove_sync(&page.pgno); + if page.pgno <= state.db_size_pages { + state.cache_committed_page(&ctx.config, page.pgno, page.bytes.clone()); + } + } + } + state.overlay.in_flight = None; + if let Some(metrics) = &ctx.metrics { + metrics.set_overlay_pages(state.overlay.pages.len() as u64); + metrics.record_flush_batch( + batch.pages.len() as u64, + batch.pages.iter().map(|page| page.bytes.len() as u64).sum(), + ); + metrics.observe_flush_latency(batch.started_at.elapsed().as_nanos() as u64); + metrics.record_commit(); + } + ctx.commit_total.fetch_add(1, Ordering::Relaxed); + let state_update_ns = state_update_start.elapsed().as_nanos() as u64; + progress.flushed_seq = batch.seq; + drop(progress); + drop(state); + ctx.add_commit_phase_metrics( + 0, + transport_metrics, + state_update_ns, + batch.started_at.elapsed().as_nanos() as u64, + ); + ctx.flush.publish_change(); + } + Err(error) => { + ctx.break_database(error); + return; + } + } + } +} + +async fn ship_deferred_batch( + ctx: &VfsContext, + mut batch: InFlightBatch, +) -> std::result::Result<(u64, CommitTransportMetrics), FlushError> { + let deadline = batch.started_at + ctx.config.deferred_commit.retry_deadline; + let mut backoff = ctx.config.deferred_commit.retry_backoff_min; + let target_head = batch.expected_head_txid.saturating_add(1); + let mut last_error = "commit did not complete".to_string(); + loop { + batch.attempts = batch.attempts.saturating_add(1); + let request = BufferedCommitRequest { + actor_id: ctx.actor_id.clone(), + new_db_size_pages: batch.db_size_pages, + dirty_pages: Arc::clone(&batch.pages), + expected_head_txid: Some(batch.expected_head_txid), + }; + match tokio::time::timeout_at(deadline, commit_buffered_pages(&*ctx.transport, request)) + .await + { + Ok(Ok((outcome, metrics))) => { + return match outcome.head_txid { + None => Ok((target_head, metrics)), + Some(head) if head == target_head => Ok((head, metrics)), + Some(head) => Err(FlushError::HeadDiverged { + expected: target_head, + actual: Some(head), + }), + }; + } + Ok(Err(CommitBufferError::FenceMismatch(message))) => { + return Err(FlushError::HeadDiverged { + expected: target_head, + actual: parse_actual_head_txid(&message), + }); + } + Ok(Err(error)) => { + last_error = error.message().to_string(); + #[allow(unreachable_patterns)] + let retry_class = match &error { + CommitBufferError::Other(_) => "transport", + CommitBufferError::Response { .. } => "engine", + _ => "unknown", + }; + if let Some(metrics) = &ctx.metrics { + metrics.record_flush_retry(retry_class); + } + tracing::warn!( + actor_id = %ctx.actor_id, + class = retry_class, + attempt = batch.attempts, + last_error = %last_error, + elapsed_ms = batch.started_at.elapsed().as_millis(), + "retrying deferred sqlite flush" + ); + } + Err(_) => { + return Err(FlushError::RetryDeadlineExceeded { + attempts: batch.attempts, + last_error, + }); + } + } + + if tokio::time::Instant::now() >= deadline { + return Err(FlushError::RetryDeadlineExceeded { + attempts: batch.attempts, + last_error, + }); + } + if tokio::time::timeout_at(deadline, tokio::time::sleep(backoff)) + .await + .is_err() + { + return Err(FlushError::RetryDeadlineExceeded { + attempts: batch.attempts, + last_error, + }); + } + backoff = backoff + .saturating_mul(2) + .min(ctx.config.deferred_commit.retry_backoff_max); + } +} + +fn parse_actual_head_txid(message: &str) -> Option { + ["current head txid ", "actual head txid ", "engine has "] + .into_iter() + .find_map(|prefix| { + let value = message.split(prefix).nth(1)?; + let digits = value + .trim_start() + .chars() + .take_while(char::is_ascii_digit) + .collect::(); + (!digits.is_empty()).then(|| digits.parse().ok()).flatten() + }) +} + fn cleanup_batch_atomic_probe(db: *mut sqlite3) { if let Err(err) = sqlite_exec(db, "DROP TABLE IF EXISTS __rivet_batch_probe;") { tracing::warn!(%err, "failed to clean up sqlite batch atomic probe table"); @@ -2495,7 +3248,9 @@ fn assert_batch_atomic_probe(db: *mut sqlite3, vfs: &SqliteVfs) -> std::result:: fn handle_non_finalize_commit_error(ctx: &VfsContext, err: &CommitBufferError) { match err { CommitBufferError::FenceMismatch(message) => ctx.mark_fatal(message.clone()), - CommitBufferError::Other(message) => ctx.set_last_error(message.clone()), + CommitBufferError::Other(message) | CommitBufferError::Response { message, .. } => { + ctx.set_last_error(message.clone()) + } } } @@ -2652,7 +3407,7 @@ async fn commit_buffered_pages( let serialize_start = Instant::now(); let commit_request = protocol::SqliteCommitRequest { actor_id: request.actor_id.clone(), - dirty_pages: request.dirty_pages.clone(), + dirty_pages: request.dirty_pages.as_ref().clone(), db_size_pages: request.new_db_size_pages, now_ms: sqlite_now_ms().map_err(|err| CommitBufferError::Other(err.to_string()))?, expected_generation: None, @@ -2680,7 +3435,11 @@ async fn commit_buffered_pages( if is_head_fence_mismatch_response(&error) { Err(CommitBufferError::FenceMismatch(error.message)) } else { - Err(CommitBufferError::Other(error.message)) + Err(CommitBufferError::Response { + group: error.group, + code: error.code, + message: error.message, + }) } } } @@ -2709,7 +3468,8 @@ async fn commit_staged_pages( let mut metrics = CommitTransportMetrics::default(); let serialize_start = Instant::now(); - let mut dirty_pages = request.dirty_pages; + let mut dirty_pages = + Arc::try_unwrap(request.dirty_pages).unwrap_or_else(|pages| pages.as_ref().clone()); // Cutting segments needs ascending pages, and sorting once here keeps the per-segment work to a // slice. dirty_pages.sort_by_key(|page| page.pgno); @@ -2788,7 +3548,11 @@ fn staged_commit_error(error: protocol::SqliteErrorResponse) -> CommitBufferErro if is_head_fence_mismatch_response(&error) { CommitBufferError::FenceMismatch(error.message) } else { - CommitBufferError::Other(error.message) + CommitBufferError::Response { + group: error.group, + code: error.code, + message: error.message, + } } } @@ -3039,6 +3803,15 @@ unsafe extern "C" fn io_close(p_file: *mut sqlite3_file) -> c_int { Ok(()) } else { let ctx = &*file.ctx; + if ctx.config.commit_mode == CommitMode::Deferred { + let mut state = ctx.state.write(); + state.write_buffer.dirty.clear(); + state.write_buffer.in_atomic_write = false; + drop(state); + ctx.flush.wake.notify_one(); + file.base.pMethods = ptr::null(); + return SQLITE_OK; + } let should_flush = { let state = ctx.state.read(); state.write_buffer.in_atomic_write @@ -3082,6 +3855,8 @@ unsafe extern "C" fn io_read( let file = get_file(p_file); if let Some(aux) = get_aux_state(file) { + #[cfg(test)] + (&*file.ctx).aux_write_count.fetch_add(1, Ordering::Relaxed); if i_offset < 0 { return SQLITE_IOERR_READ; } @@ -3133,7 +3908,7 @@ unsafe extern "C" fn io_read( error = %message, "sqlite xRead hit fatal sqlite error" ); - ctx.mark_fatal(message); + ctx.handle_read_fatal(message); return SQLITE_IOERR_READ; } Err(GetPagesError::Other(message)) => { @@ -3273,7 +4048,7 @@ unsafe extern "C" fn io_write( match ctx.resolve_pages(&pages_to_resolve, false) { Ok(pages) => pages, Err(GetPagesError::FenceMismatch(message)) => { - ctx.mark_fatal(message); + ctx.handle_read_fatal(message); return SQLITE_IOERR_WRITE; } Err(GetPagesError::Other(message)) => { @@ -3399,6 +4174,8 @@ unsafe extern "C" fn io_sync(p_file: *mut sqlite3_file, _flags: c_int) -> c_int return SQLITE_OK; } let ctx = &*file.ctx; + #[cfg(test)] + ctx.main_sync_count.fetch_add(1, Ordering::Relaxed); if let Some(message) = ctx.take_transient_commit_error() { ctx.set_last_error(message); return SQLITE_IOERR_FSYNC; @@ -3483,30 +4260,36 @@ unsafe extern "C" fn io_file_control( state.write_buffer.dirty.clear(); SQLITE_OK } - SQLITE_FCNTL_COMMIT_ATOMIC_WRITE => match ctx.commit_atomic_write() { - Ok(()) => { - ctx.commit_atomic_count.fetch_add(1, Ordering::Relaxed); - SQLITE_OK - } - Err(err) => { - tracing::error!( - actor_id = %ctx.actor_id, - last_error = ?ctx.clone_last_error(), - ?err, - "sqlite atomic write file control failed" - ); - if let CommitBufferError::Other(message) = &err { - ctx.defer_transient_commit_error(message.clone()); + SQLITE_FCNTL_COMMIT_ATOMIC_WRITE => { + #[cfg(test)] + ctx.commit_atomic_attempt_count + .fetch_add(1, Ordering::Relaxed); + match ctx.commit_atomic_write() { + Ok(()) => { + ctx.commit_atomic_count.fetch_add(1, Ordering::Relaxed); + SQLITE_OK + } + Err(err) => { + tracing::error!( + actor_id = %ctx.actor_id, + last_error = ?ctx.clone_last_error(), + ?err, + "sqlite atomic write file control failed" + ); + if let CommitBufferError::Other(message) + | CommitBufferError::Response { message, .. } = &err + { + ctx.defer_transient_commit_error(message.clone()); + } + handle_finalize_fence_error(ctx, &err); + SQLITE_IOERR } - handle_finalize_fence_error(ctx, &err); - SQLITE_IOERR } - }, + } SQLITE_FCNTL_ROLLBACK_ATOMIC_WRITE => { - let mut state = ctx.state.write(); - state.write_buffer.dirty.clear(); - state.write_buffer.in_atomic_write = false; - state.db_size_pages = state.write_buffer.saved_db_size; + #[cfg(test)] + ctx.rollback_atomic_count.fetch_add(1, Ordering::Relaxed); + ctx.rollback_atomic_write(); SQLITE_OK } _ => SQLITE_NOTFOUND, @@ -3753,6 +4536,119 @@ unsafe extern "C" fn vfs_get_last_error( } impl SqliteVfs { + pub fn commit_mode(&self) -> CommitMode { + self.ctx.config.commit_mode + } + + pub fn commit_seq(&self) -> u64 { + self.ctx.commit_seq() + } + + pub fn flushed_seq(&self) -> u64 { + self.ctx.flushed_seq() + } + + pub fn flush_error(&self) -> Option { + self.ctx.flush_error() + } + + pub async fn wait_for_flush(&self, seq: u64) -> std::result::Result<(), FlushError> { + self.ctx.wait_for_flush(seq).await + } + + pub async fn wait_for_failure(&self) -> DatabaseFailure { + self.ctx.wait_for_failure().await + } + + pub fn begin_close(&self) { + self.ctx.begin_close(); + } + + pub fn close_flush_timeout(&self) -> Duration { + self.ctx + .config + .deferred_commit + .retry_deadline + .saturating_add(self.ctx.config.deferred_commit.retry_backoff_max) + } + + pub async fn drain_and_shutdown_flusher( + &self, + timeout: Duration, + ) -> std::result::Result<(), FlushError> { + if self.commit_mode() == CommitMode::Awaited { + return match self.flush_error() { + Some(error) => Err(error), + None => Ok(()), + }; + } + + self.ctx.flush.shutdown.store(true, Ordering::Release); + self.ctx.flush.wake.notify_one(); + let target = self.commit_seq(); + let wait_result = tokio::time::timeout(timeout, self.wait_for_flush(target)).await; + let mut result = match wait_result { + Ok(result) => result, + Err(_) => { + let error = FlushError::Aborted("close deadline".to_string()); + self.ctx.break_database(error.clone()); + Err(error) + } + }; + + let task = self.ctx.flush.task.lock().take(); + if let Some(mut task) = task { + if result.is_err() { + tracing::error!( + actor_id = %self.ctx.actor_id, + "aborting deferred sqlite flusher; the cut-off commit attempt is indeterminate" + ); + task.abort(); + } + match tokio::time::timeout(timeout, &mut task).await { + Ok(Ok(())) => {} + Ok(Err(join_error)) if join_error.is_cancelled() && result.is_err() => {} + Ok(Err(join_error)) => { + let error = self.flush_error().unwrap_or_else(|| { + FlushError::Aborted(format!("flusher task failed: {join_error}")) + }); + result = Err(error); + } + Err(_) => { + tracing::error!( + actor_id = %self.ctx.actor_id, + "aborting hung deferred sqlite flusher; the cut-off commit attempt is indeterminate" + ); + task.abort(); + let _ = task.await; + let error = FlushError::Aborted("close deadline".to_string()); + self.ctx.break_database(error.clone()); + result = Err(error); + } + } + } + result + } + + pub async fn abort_flusher_for_worker_timeout(&self) { + if self.commit_mode() != CommitMode::Deferred { + return; + } + self.ctx.flush.shutdown.store(true, Ordering::Release); + self.ctx.flush.wake.notify_one(); + self.ctx + .break_database(FlushError::Aborted("worker close timeout".to_string())); + let task = self.ctx.flush.task.lock().take(); + if let Some(task) = task { + tracing::error!( + actor_id = %self.ctx.actor_id, + "aborting deferred sqlite flusher after worker close timeout; the cut-off commit attempt is indeterminate" + ); + task.abort(); + let _ = task.await; + } + } + pub(crate) fn take_last_error(&self) -> Option { self.ctx.take_last_error() } @@ -3860,7 +4756,7 @@ impl SqliteVfs { let generation = name .rsplit_once("-g") .and_then(|(_, generation)| generation.parse::().ok()); - let mut ctx = Box::new(VfsContext::new( + let ctx = Arc::new(VfsContext::new( actor_id, generation, runtime, @@ -3870,7 +4766,7 @@ impl SqliteVfs { initial_pages, metrics, )?); - let ctx_ptr = (&mut *ctx) as *mut VfsContext; + let ctx_ptr = Arc::as_ptr(&ctx) as *mut VfsContext; let name_cstring = CString::new(name).map_err(|err| err.to_string())?; let mut vfs: sqlite3_vfs = unsafe { std::mem::zeroed() }; @@ -3889,6 +4785,10 @@ impl SqliteVfs { vfs.xGetLastError = Some(vfs_get_last_error); let registration = SqliteVfsRegistration::register(vfs)?; + if ctx.config.commit_mode == CommitMode::Deferred { + let task = ctx.runtime.spawn(flusher_task(Arc::downgrade(&ctx))); + *ctx.flush.task.lock() = Some(task); + } Ok(Self { _registration: registration, @@ -3956,6 +4856,10 @@ impl NativeDatabase { self._vfs.ctx.round_trip_counts() } + pub fn commit_seq(&self) -> u64 { + self._vfs.commit_seq() + } + pub(crate) fn begin_operation_profile(&self) -> SqliteOperationProfileGuard<'_> { self._vfs.ctx.begin_operation_profile(); SqliteOperationProfileGuard { @@ -3973,6 +4877,22 @@ impl Drop for NativeDatabase { fn drop(&mut self) { if !self.db.is_null() { let ctx = self._vfs.ctx(); + // Deferred commits are promoted into the overlay only at an SQLite commit + // boundary. In particular, do not stage dirty pages from an open + // transaction while closing: sqlite3_close_v2 rolls that transaction back + // and io_close discards the write buffer. + if ctx.config.commit_mode == CommitMode::Deferred { + let rc = unsafe { sqlite3_close_v2(self.db) }; + if rc != SQLITE_OK { + tracing::warn!( + rc, + error = sqlite_error_message(self.db), + "failed to close deferred sqlite database" + ); + } + self.db = ptr::null_mut(); + return; + } let should_flush = { let state = ctx.state.read(); state.write_buffer.in_atomic_write diff --git a/engine/packages/depot-client/src/worker.rs b/engine/packages/depot-client/src/worker.rs index 53f6e02c6d..51c4a94137 100644 --- a/engine/packages/depot-client/src/worker.rs +++ b/engine/packages/depot-client/src/worker.rs @@ -17,7 +17,8 @@ use tokio::sync::{Notify, oneshot}; use crate::{ query::{ - BindParam, ExecuteResult, QueryResult, exec_statements, exec_statements_profiled, + BindParam, ExecuteResult, QueryResult, exec_statements, exec_statements_in_transaction, + exec_statements_profiled, exec_statements_profiled_in_transaction, execute_single_statement, execute_single_statement_profiled, }, vfs::{ @@ -78,11 +79,14 @@ enum SqliteCommand { } #[derive(Debug)] -pub struct SqliteWorkerResult { +pub struct SqliteWorkerReply { pub result: Result, pub profile: SqliteOperationProfile, + pub post_autocommit: bool, } +pub type SqliteWorkerResult = SqliteWorkerReply; + struct CloseRequest; struct WorkerInflightGuard<'a>(Option<&'a dyn SqliteVfsMetrics>); @@ -241,6 +245,10 @@ impl SqliteWorkerHandle { } pub async fn close(&self) -> Result<()> { + self.close_with_timeout(SQLITE_WORKER_CLOSE_TIMEOUT).await + } + + async fn close_with_timeout(&self, timeout: Duration) -> Result<()> { let start = Instant::now(); if self.inner.mark_closing() { // Close is a control path, not SQL work, so it must bypass the bounded @@ -260,7 +268,7 @@ impl SqliteWorkerHandle { } }; - match tokio::time::timeout(SQLITE_WORKER_CLOSE_TIMEOUT, wait_closed).await { + match tokio::time::timeout(timeout, wait_closed).await { Ok(result) => result?, Err(_) => { if let Some(metrics) = &self.inner.metrics { @@ -280,6 +288,11 @@ impl SqliteWorkerHandle { self.join_worker().await } + #[cfg(test)] + pub(crate) async fn close_with_timeout_for_test(&self, timeout: Duration) -> Result<()> { + self.close_with_timeout(timeout).await + } + pub async fn wait_for_failure(&self) -> bool { loop { let closed = self.inner.closed.notified(); @@ -527,12 +540,13 @@ fn run_command( return; } begin_transaction_if_needed(db, transaction); + let commit_seq_before = db.commit_seq(); // Read the transaction state before running so the label reflects the // transaction the statement executed against, not the state it leaves // behind (a BEGIN flips autocommit off, a COMMIT flips it back on). let in_tx = command_in_tx(db); let stmt_kind = classify_statement(&sql); - let worker_result = if let Some(enqueued_at) = enqueued_at { + let mut worker_result = if let Some(enqueued_at) = enqueued_at { let worker_wait_ns = enqueued_at.elapsed().as_nanos() as u64; let operation_profile = db.begin_operation_profile(); let execution_start = Instant::now(); @@ -560,6 +574,7 @@ fn run_command( SqliteWorkerResult { result: result.map(|(value, _)| value), profile, + post_autocommit: false, } } else { let result = execute_single_statement(db.as_ptr(), &sql, params.as_deref()); @@ -574,8 +589,16 @@ fn run_command( SqliteWorkerResult { result, profile: SqliteOperationProfile::default(), + post_autocommit: false, } }; + let commit_seq_after = db.commit_seq(); + if commit_seq_after > commit_seq_before + && let Ok(result) = &mut worker_result.result + { + result.commit_seq = Some(commit_seq_after); + } + worker_result.post_autocommit = !command_in_tx(db); finalize_transaction_if_complete(db, metrics, file_name, transaction); let _ = reply.send(Ok(worker_result)); } @@ -594,7 +617,11 @@ fn run_command( let worker_wait_ns = enqueued_at.elapsed().as_nanos() as u64; let operation_profile = db.begin_operation_profile(); let execution_start = Instant::now(); - let result = exec_statements_profiled(db.as_ptr(), &sql); + let result = if in_tx { + exec_statements_profiled_in_transaction(db.as_ptr(), &sql) + } else { + exec_statements_profiled(db.as_ptr(), &sql) + }; let execution_ns = execution_start.elapsed().as_nanos() as u64; let mut profile = operation_profile.finish(); profile.worker_wait_ns = worker_wait_ns; @@ -610,13 +637,19 @@ fn run_command( SqliteWorkerResult { result: result.map(|(value, _)| value), profile, + post_autocommit: !command_in_tx(db), } } else { - let result = exec_statements(db.as_ptr(), &sql); + let result = if in_tx { + exec_statements_in_transaction(db.as_ptr(), &sql) + } else { + exec_statements(db.as_ptr(), &sql) + }; record_command_metrics(metrics, "exec", in_tx, stmt_kind, &result, start.elapsed()); SqliteWorkerResult { result, profile: SqliteOperationProfile::default(), + post_autocommit: !command_in_tx(db), } }; finalize_transaction_if_complete(db, metrics, file_name, transaction); diff --git a/engine/packages/depot-client/tests/inline/vfs.rs b/engine/packages/depot-client/tests/inline/vfs.rs index 74719b8918..2ab4055eda 100644 --- a/engine/packages/depot-client/tests/inline/vfs.rs +++ b/engine/packages/depot-client/tests/inline/vfs.rs @@ -10,7 +10,7 @@ use std::mem::ManuallyDrop; use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use std::sync::{Arc, Barrier, mpsc}; use std::thread; -use std::time::Duration; +use std::time::{Duration, Instant}; use async_trait::async_trait; use parking_lot::Mutex as SyncMutex; @@ -59,6 +59,10 @@ fn vfs_config_wires_optimization_flags() { vfs_protected_cache_pages: DEFAULT_VFS_PROTECTED_CACHE_PAGES / 2, vfs_staging_cache_ttl_ms: DEFAULT_VFS_STAGING_CACHE_TTL_MS / 2, pager_cache_size_kib: DEFAULT_PAGER_CACHE_SIZE_KIB, + flush_retry_deadline_ms: 30_000, + flush_retry_backoff_min_ms: 50, + flush_retry_backoff_max_ms: 2_000, + max_unflushed_bytes: 64 * 1024 * 1024, }; let config = VfsConfig::from_optimization_flags(flags); @@ -147,6 +151,101 @@ impl SqliteTransport for RecordingInitialPagesTransport { } } +struct PausableDirectTransport { + inner: DirectDepotTransport, + next_read: SyncMutex>, +} + +impl PausableDirectTransport { + fn new(engine: Arc) -> Self { + Self { + inner: DirectDepotTransport::new(engine), + next_read: SyncMutex::new(None), + } + } + + fn pause_next_read(&self) -> DirectReadPause { + let (reached_tx, reached_rx) = mpsc::channel(); + let (resume_tx, resume_rx) = mpsc::channel(); + *self.next_read.lock() = Some(DirectReadGate { + reached: reached_tx, + resume: resume_rx, + }); + DirectReadPause { + reached: reached_rx, + resume: resume_tx, + } + } + + fn direct_hooks(&self) -> Arc { + self.inner.direct_hooks() + } +} + +struct DirectReadGate { + reached: mpsc::Sender<()>, + resume: mpsc::Receiver<()>, +} + +struct DirectReadPause { + reached: mpsc::Receiver<()>, + resume: mpsc::Sender<()>, +} + +impl DirectReadPause { + fn wait_until_reached(&self) { + self.reached.recv().expect("read pause should be reached"); + } + + fn resume(self) { + self.resume.send(()).expect("read pause should resume"); + } +} + +#[async_trait] +impl SqliteTransport for PausableDirectTransport { + async fn get_pages( + &self, + request: protocol::SqliteGetPagesRequest, + ) -> anyhow::Result { + let response = self.inner.get_pages(request).await; + let gate = self.next_read.lock().take(); + if let Some(gate) = gate { + let _ = gate.reached.send(()); + let _ = gate.resume.recv(); + } + response + } + + async fn commit( + &self, + request: protocol::SqliteCommitRequest, + ) -> anyhow::Result { + self.inner.commit(request).await + } + + async fn commit_stage_begin( + &self, + request: protocol::SqliteCommitStageBeginRequest, + ) -> anyhow::Result { + self.inner.commit_stage_begin(request).await + } + + async fn commit_stage_segment( + &self, + request: protocol::SqliteCommitStageSegmentRequest, + ) -> anyhow::Result { + self.inner.commit_stage_segment(request).await + } + + async fn commit_finalize( + &self, + request: protocol::SqliteCommitFinalizeRequest, + ) -> anyhow::Result { + self.inner.commit_finalize(request).await + } +} + struct CappedAtomicCommitTransport { inner: DirectDepotTransport, max_dirty_bytes: usize, @@ -591,19 +690,21 @@ impl DirectEngineHarness { actor_id: &str, config: VfsConfig, ) -> NativeDatabase { - let initial_main_page = runtime - .block_on(fetch_initial_main_page_for_registration( + let initial_pages = runtime + .block_on(fetch_initial_pages_for_registration( transport.clone(), actor_id, + 0, + &config, )) - .expect("initial main page preload should succeed"); - let vfs = SqliteVfs::register_with_transport_and_initial_page( + .expect("initial page preload should succeed"); + let vfs = SqliteVfs::register_with_transport_and_initial_pages( &next_test_name("sqlite-direct-vfs"), transport, actor_id.to_string(), runtime.handle().clone(), config, - initial_main_page, + initial_pages, None, ) .expect("v2 vfs should register"); @@ -659,6 +760,16 @@ fn open_worker_handle_with_vfs( let engine = runtime.block_on(harness.open_engine()); let transport = Arc::new(DirectDepotTransport::new(engine)); let config = VfsConfig::default(); + open_worker_handle_with_transport(runtime, harness, transport, config, metrics) +} + +fn open_worker_handle_with_transport( + runtime: &tokio::runtime::Runtime, + harness: &DirectEngineHarness, + transport: SqliteTransportHandle, + config: VfsConfig, + metrics: Option>, +) -> (Arc, crate::database::NativeDatabaseHandle) { let initial_pages = runtime .block_on(fetch_initial_pages_for_registration( transport.clone(), @@ -1202,6 +1313,47 @@ fn sqlite_query_text(db: *mut sqlite3, sql: &str) -> std::result::Result std::result::Result, String> { + let c_sql = CString::new(sql).map_err(|err| err.to_string())?; + let mut stmt = ptr::null_mut(); + let rc = unsafe { sqlite3_prepare_v2(db, c_sql.as_ptr(), -1, &mut stmt, ptr::null_mut()) }; + if rc != SQLITE_OK { + return Err(format!( + "`{sql}` prepare failed with code {rc}: {}", + sqlite_error_message(db) + )); + } + if stmt.is_null() { + return Err(format!("`{sql}` returned no statement")); + } + + let mut rows = Vec::new(); + loop { + match unsafe { sqlite3_step(stmt) } { + SQLITE_ROW => { + let text_ptr = unsafe { sqlite3_column_text(stmt, 0) }; + rows.push(if text_ptr.is_null() { + String::new() + } else { + unsafe { CStr::from_ptr(text_ptr.cast()) } + .to_string_lossy() + .into_owned() + }); + } + SQLITE_DONE => break, + step_rc => { + unsafe { sqlite3_finalize(stmt) }; + return Err(format!( + "`{sql}` step failed with code {step_rc}: {}", + sqlite_error_message(db) + )); + } + } + } + unsafe { sqlite3_finalize(stmt) }; + Ok(rows) +} + fn sqlite_file_control(db: *mut sqlite3, op: c_int) -> std::result::Result { let main = CString::new("main").map_err(|err| err.to_string())?; let rc = unsafe { sqlite3_file_control(db, main.as_ptr(), op, ptr::null_mut()) }; @@ -3819,7 +3971,7 @@ fn direct_engine_commits_trigger_workflow_compaction_wake() { "CREATE TABLE items (id INTEGER PRIMARY KEY, value TEXT NOT NULL);", ) .expect("create table should succeed"); - for id in 1..=40 { + for id in 1..=depot::conveyer::quota::COMPACTION_DELTA_THRESHOLD { sqlite_step_statement( db.as_ptr(), &format!("INSERT INTO items (id, value) VALUES ({id}, 'row-{id}');"), @@ -3830,7 +3982,7 @@ fn direct_engine_commits_trigger_workflow_compaction_wake() { assert_eq!( sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM items;") .expect("final row count should succeed"), - 40 + depot::conveyer::quota::COMPACTION_DELTA_THRESHOLD as i64 ); let signals = engine.compaction_signals(); assert!( @@ -3838,7 +3990,9 @@ fn direct_engine_commits_trigger_workflow_compaction_wake() { "VFS commits should wake workflow compaction once hot lag is actionable", ); assert!( - signals.iter().any(|signal| signal.observed_head_txid >= 32), + signals.iter().any(|signal| { + signal.observed_head_txid >= depot::conveyer::quota::COMPACTION_DELTA_THRESHOLD + }), "workflow wake should observe the actionable hot-lag txid: {signals:?}", ); } @@ -4516,6 +4670,7 @@ fn delayed_read_ahead_response_fails_head_fence_and_reopen_is_clean() { .expect("training read page 3 should succeed"); ctx.resolve_pages(&[4], false) .expect("training read page 4 should succeed"); + ctx.state.read().evict_target_read_pages(&[5]); delaying_transport.enable(); let vfs_a = db_a._vfs.clone(); @@ -6417,10 +6572,10 @@ fn commit_buffered_pages_uses_fast_path() { actor_id: harness.actor_id.clone(), new_db_size_pages: 1, expected_head_txid: None, - dirty_pages: vec![protocol::SqliteDirtyPage { + dirty_pages: Arc::new(vec![protocol::SqliteDirtyPage { pgno: 1, bytes: empty_db_page(), - }], + }]), }, )) .expect("fast-path commit should succeed"); @@ -6478,8 +6633,12 @@ fn partial_status_index_reduces_storage_and_cold_page_fetches() { let relaxed = std::sync::atomic::Ordering::Relaxed; let measure = |actor_id: &str, index_sql: &str| { - let db = - harness.open_db_on_engine(&runtime, engine.clone(), actor_id, VfsConfig::default()); + let cold_config = VfsConfig { + startup_preload_first_pages: false, + preload_hints_on_open: false, + ..VfsConfig::default() + }; + let db = harness.open_db_on_engine(&runtime, engine.clone(), actor_id, cold_config.clone()); sqlite_exec( db.as_ptr(), "CREATE TABLE history (id INTEGER PRIMARY KEY, result INTEGER NOT NULL, payload BLOB NOT NULL);", @@ -6495,8 +6654,7 @@ fn partial_status_index_reduces_storage_and_cold_page_fetches() { sqlite_query_i64(db.as_ptr(), "PRAGMA page_count;").expect("read database page count"); drop(db); - let reopened = - harness.open_db_on_engine(&runtime, engine.clone(), actor_id, VfsConfig::default()); + let reopened = harness.open_db_on_engine(&runtime, engine.clone(), actor_id, cold_config); let ctx = direct_vfs_ctx(&reopened); ctx.resolve_pages_fetches.store(0, relaxed); ctx.pages_fetched_total.store(0, relaxed); @@ -7398,3 +7556,2134 @@ fn direct_engine_persists_a_vacuum_shrink_across_reopen() { 0 ); } + +fn deferred_test_config() -> VfsConfig { + VfsConfig { + commit_mode: CommitMode::Deferred, + deferred_commit: DeferredCommitConfig { + retry_deadline: Duration::from_millis(500), + retry_backoff_min: Duration::from_millis(5), + retry_backoff_max: Duration::from_millis(20), + max_unflushed_bytes: 1024 * 1024, + }, + ..VfsConfig::default() + } +} + +fn wait_for_deferred_flush(runtime: &tokio::runtime::Runtime, db: &NativeDatabase) { + let seq = db.commit_seq(); + runtime + .block_on(db._vfs.wait_for_flush(seq)) + .expect("deferred commit should become durable"); + assert_eq!(db._vfs.flushed_seq(), seq); +} + +fn wait_for_deferred_flush_with_context( + runtime: &tokio::runtime::Runtime, + db: &NativeDatabase, + context: &str, +) { + let seq = db.commit_seq(); + runtime + .block_on(db._vfs.wait_for_flush(seq)) + .unwrap_or_else(|error| panic!("{context}: deferred flush failed: {error}")); + assert_eq!(db._vfs.flushed_seq(), seq, "{context}"); +} + +fn close_deferred_database(runtime: &tokio::runtime::Runtime, db: NativeDatabase) { + db._vfs.begin_close(); + runtime + .block_on(db._vfs.drain_and_shutdown_flusher(Duration::from_secs(2))) + .expect("deferred flusher should drain"); + drop(db); +} + +fn close_deferred_database_with_context( + runtime: &tokio::runtime::Runtime, + db: NativeDatabase, + context: &str, +) { + db._vfs.begin_close(); + runtime + .block_on(db._vfs.drain_and_shutdown_flusher(Duration::from_secs(2))) + .unwrap_or_else(|error| panic!("{context}: deferred close drain failed: {error}")); + drop(db); +} + +fn assert_deferred_rows_durable( + runtime: &tokio::runtime::Runtime, + harness: &DirectEngineHarness, + engine: Arc, + expected: I, +) where + I: IntoIterator, + S: AsRef, +{ + let mut config = VfsConfig::default(); + config.assert_batch_atomic = false; + let reopened = harness.open_db_on_engine(runtime, engine, &harness.actor_id, config); + let expected = expected + .into_iter() + .map(|value| value.as_ref().to_owned()) + .collect::>(); + assert_eq!( + sqlite_query_text_rows( + reopened.as_ptr(), + "SELECT value FROM deferred_items ORDER BY id;" + ) + .expect("durability oracle query should succeed"), + expected, + ); +} + +fn assert_deferred_rows_durable_with_context( + runtime: &tokio::runtime::Runtime, + harness: &DirectEngineHarness, + engine: Arc, + expected: I, + context: &str, +) where + I: IntoIterator, + S: AsRef, +{ + let mut config = VfsConfig::default(); + config.assert_batch_atomic = false; + let reopened = harness.open_db_on_engine(runtime, engine, &harness.actor_id, config); + let expected = expected + .into_iter() + .map(|value| value.as_ref().to_owned()) + .collect::>(); + let actual = sqlite_query_text_rows( + reopened.as_ptr(), + "SELECT value FROM deferred_items ORDER BY id;", + ) + .unwrap_or_else(|error| panic!("{context}: durability oracle query failed: {error}")); + assert_eq!(actual, expected, "{context}"); +} + +fn assert_durable_table_exists( + runtime: &tokio::runtime::Runtime, + harness: &DirectEngineHarness, + engine: Arc, + table: &str, + exists: bool, +) { + let mut config = VfsConfig::default(); + config.assert_batch_atomic = false; + let reopened = harness.open_db_on_engine(runtime, engine, &harness.actor_id, config); + let query = + format!("SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = '{table}';"); + assert_eq!( + sqlite_query_i64(reopened.as_ptr(), &query) + .expect("durability table oracle should succeed"), + i64::from(exists), + ); +} + +// Scripted-context tests below are unit-level VFS state-machine checks. Durable SQL state is +// asserted separately by the DirectEngineHarness end-to-end tests. +#[derive(Default)] +struct ScriptedDeferredReadTransport { + requests: SyncMutex>, + head: SyncMutex>, + extra_pages: SyncMutex>, +} + +impl ScriptedDeferredReadTransport { + fn with_head(head: u64) -> Self { + Self { + head: SyncMutex::new(Some(head)), + ..Self::default() + } + } +} + +#[async_trait] +impl SqliteTransport for ScriptedDeferredReadTransport { + async fn get_pages( + &self, + request: protocol::SqliteGetPagesRequest, + ) -> anyhow::Result { + self.requests.lock().push(request.clone()); + let mut pages = request + .pgnos + .iter() + .map(|pgno| protocol::SqliteFetchedPage { + pgno: *pgno, + bytes: Some(vec![*pgno as u8; DEFAULT_PAGE_SIZE]), + }) + .collect::>(); + pages.extend(self.extra_pages.lock().clone()); + Ok(protocol::SqliteGetPagesResponse::SqliteGetPagesOk( + protocol::SqliteGetPagesOk { + pages, + head_txid: *self.head.lock(), + }, + )) + } + + async fn commit( + &self, + _request: protocol::SqliteCommitRequest, + ) -> anyhow::Result { + anyhow::bail!("scripted deferred read transport does not commit") + } + + async fn commit_stage_begin( + &self, + _request: protocol::SqliteCommitStageBeginRequest, + ) -> anyhow::Result { + anyhow::bail!("scripted deferred read transport does not stage commits") + } + + async fn commit_stage_segment( + &self, + _request: protocol::SqliteCommitStageSegmentRequest, + ) -> anyhow::Result { + anyhow::bail!("scripted deferred read transport does not stage commits") + } + + async fn commit_finalize( + &self, + _request: protocol::SqliteCommitFinalizeRequest, + ) -> anyhow::Result { + anyhow::bail!("scripted deferred read transport does not stage commits") + } +} + +fn deferred_context( + transport: SqliteTransportHandle, +) -> (tokio::runtime::Runtime, Arc) { + let runtime = direct_runtime(); + let ctx = VfsContext::new( + next_test_name("sqlite-deferred-context"), + None, + runtime.handle().clone(), + transport, + VfsConfig { + page_cache_mode: SqliteVfsPageCacheMode::All, + ..deferred_test_config() + }, + unsafe { std::mem::zeroed() }, + InitialPages { + pages: Vec::new(), + head_txid: Some(0), + requested_page_count: 1, + }, + None, + ) + .expect("deferred test context should build"); + (runtime, Arc::new(ctx)) +} + +#[test] +fn deferred_commit_returns_before_engine_ack() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let engine = runtime.block_on(harness.open_engine()); + let transport = Arc::new(DirectDepotTransport::new(engine.clone())); + let hooks = transport.direct_hooks(); + let db = harness.open_db_with_transport( + &runtime, + transport, + &harness.actor_id, + deferred_test_config(), + ); + sqlite_exec( + db.as_ptr(), + "CREATE TABLE deferred_items (id INTEGER PRIMARY KEY, value TEXT);", + ) + .unwrap(); + wait_for_deferred_flush(&runtime, &db); + let pause = hooks.pause_next_commit(); + sqlite_exec( + db.as_ptr(), + "INSERT INTO deferred_items(value) VALUES ('before-ack');", + ) + .unwrap(); + pause.wait_until_reached(); + assert!(db.commit_seq() > db._vfs.flushed_seq()); + pause.resume(); + wait_for_deferred_flush(&runtime, &db); + close_deferred_database(&runtime, db); + assert_deferred_rows_durable(&runtime, &harness, engine, ["before-ack"]); +} + +#[test] +fn read_only_transaction_does_not_advance_commit_seq() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let engine = runtime.block_on(harness.open_engine()); + let db = harness.open_db_on_engine( + &runtime, + engine.clone(), + &harness.actor_id, + deferred_test_config(), + ); + sqlite_exec( + db.as_ptr(), + "CREATE TABLE deferred_items (id INTEGER PRIMARY KEY, value TEXT);", + ) + .unwrap(); + wait_for_deferred_flush(&runtime, &db); + let before = db.commit_seq(); + sqlite_exec( + db.as_ptr(), + "BEGIN; SELECT COUNT(*) FROM deferred_items; COMMIT;", + ) + .unwrap(); + assert_eq!(db.commit_seq(), before); + close_deferred_database(&runtime, db); + assert_deferred_rows_durable(&runtime, &harness, engine, [] as [&str; 0]); +} + +#[test] +fn wait_for_flush_snapshot_ignores_later_commits() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let engine = runtime.block_on(harness.open_engine()); + let transport = Arc::new(DirectDepotTransport::new(engine.clone())); + let hooks = transport.direct_hooks(); + let db = harness.open_db_with_transport( + &runtime, + transport, + &harness.actor_id, + deferred_test_config(), + ); + sqlite_exec( + db.as_ptr(), + "CREATE TABLE deferred_items (id INTEGER PRIMARY KEY, value TEXT);", + ) + .unwrap(); + wait_for_deferred_flush(&runtime, &db); + + let first_pause = hooks.pause_next_commit(); + sqlite_step_statement( + db.as_ptr(), + "INSERT INTO deferred_items(value) VALUES ('first');", + ) + .unwrap(); + first_pause.wait_until_reached(); + let captured = db.commit_seq(); + + sqlite_step_statement( + db.as_ptr(), + "INSERT INTO deferred_items(value) VALUES ('later');", + ) + .unwrap(); + assert!(db.commit_seq() > captured); + let later_pause = hooks.pause_next_commit(); + first_pause.resume(); + later_pause.wait_until_reached(); + runtime + .block_on(db._vfs.wait_for_flush(captured)) + .expect("captured sequence should flush before the later batch"); + assert!(db._vfs.flushed_seq() < db.commit_seq()); + later_pause.resume(); + wait_for_deferred_flush(&runtime, &db); + close_deferred_database(&runtime, db); + assert_deferred_rows_durable(&runtime, &harness, engine, ["first", "later"]); +} + +#[test] +fn wait_for_flush_rejects_future_sequence() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let db = harness.open_db_on_engine( + &runtime, + runtime.block_on(harness.open_engine()), + &harness.actor_id, + deferred_test_config(), + ); + let requested = db.commit_seq() + 1; + assert!(matches!( + runtime.block_on(db._vfs.wait_for_flush(requested)), + Err(FlushError::InvalidSequence { .. }) + )); + close_deferred_database(&runtime, db); +} + +#[test] +fn wait_for_flush_zero_is_immediate() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let db = harness.open_db_on_engine( + &runtime, + runtime.block_on(harness.open_engine()), + &harness.actor_id, + deferred_test_config(), + ); + runtime + .block_on(db._vfs.wait_for_flush(0)) + .expect("sequence zero should already be durable"); + close_deferred_database(&runtime, db); +} + +#[test] +fn transient_errors_retry_then_succeed() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let engine = runtime.block_on(harness.open_engine()); + let transport = Arc::new(DirectDepotTransport::new(engine.clone())); + let hooks = transport.direct_hooks(); + let db = harness.open_db_with_transport( + &runtime, + transport, + &harness.actor_id, + deferred_test_config(), + ); + wait_for_deferred_flush(&runtime, &db); + hooks.fail_next_commits(2, "transient test failure"); + sqlite_exec( + db.as_ptr(), + "CREATE TABLE deferred_items (id INTEGER PRIMARY KEY, value TEXT); INSERT INTO deferred_items(value) VALUES ('retried');", + ) + .unwrap(); + wait_for_deferred_flush(&runtime, &db); + assert!(hooks.commit_requests().len() >= 3); + close_deferred_database(&runtime, db); + assert_deferred_rows_durable(&runtime, &harness, engine, ["retried"]); +} + +#[test] +fn commit_ok_with_wrong_head_breaks_database() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let engine = runtime.block_on(harness.open_engine()); + let transport = Arc::new(DirectDepotTransport::new(engine.clone())); + let hooks = transport.direct_hooks(); + let db = harness.open_db_with_transport( + &runtime, + transport, + &harness.actor_id, + deferred_test_config(), + ); + wait_for_deferred_flush(&runtime, &db); + hooks.return_next_commit_head(Some(u64::MAX)); + sqlite_exec( + db.as_ptr(), + "CREATE TABLE deferred_items (id INTEGER PRIMARY KEY, value TEXT);", + ) + .unwrap(); + let error = runtime + .block_on(db._vfs.wait_for_flush(db.commit_seq())) + .expect_err("wrong acknowledgement head should break the database"); + assert!(matches!(error, FlushError::HeadDiverged { .. })); + assert!(direct_vfs_ctx(&db).state.read().dead); + db._vfs.begin_close(); + let _ = runtime.block_on(db._vfs.drain_and_shutdown_flusher(Duration::from_secs(1))); + drop(db); + assert_deferred_rows_durable(&runtime, &harness, engine, [] as [&str; 0]); +} + +#[test] +fn commit_ok_without_head_is_accepted() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let engine = runtime.block_on(harness.open_engine()); + let transport = Arc::new(DirectDepotTransport::new(engine.clone())); + let hooks = transport.direct_hooks(); + let db = harness.open_db_with_transport( + &runtime, + transport, + &harness.actor_id, + deferred_test_config(), + ); + wait_for_deferred_flush(&runtime, &db); + hooks.return_next_commit_head(None); + sqlite_exec( + db.as_ptr(), + "CREATE TABLE deferred_items (id INTEGER PRIMARY KEY, value TEXT);", + ) + .unwrap(); + wait_for_deferred_flush(&runtime, &db); + close_deferred_database(&runtime, db); + assert_deferred_rows_durable(&runtime, &harness, engine, [] as [&str; 0]); +} + +#[test] +fn flusher_panic_breaks_database() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let db = harness.open_db_on_engine( + &runtime, + runtime.block_on(harness.open_engine()), + &harness.actor_id, + deferred_test_config(), + ); + wait_for_deferred_flush(&runtime, &db); + direct_vfs_ctx(&db) + .flush + .panic_requested + .store(true, Ordering::Release); + direct_vfs_ctx(&db).flush.wake.notify_one(); + let failure = runtime.block_on(db._vfs.wait_for_failure()); + assert!(matches!( + failure, + DatabaseFailure::Flush(FlushError::Aborted(_)) + )); + db._vfs.begin_close(); + let _ = runtime.block_on(db._vfs.drain_and_shutdown_flusher(Duration::from_secs(1))); + drop(db); +} + +#[test] +fn awaited_mode_advances_sequences_on_ack() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let db = harness.open_db(&runtime); + let before = db.commit_seq(); + sqlite_exec(db.as_ptr(), "CREATE TABLE awaited_sequence (id INTEGER);").unwrap(); + assert!(db.commit_seq() > before); + assert_eq!(db.commit_seq(), db._vfs.flushed_seq()); +} + +#[test] +fn commit_seq_continues_across_reopen() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let engine = runtime.block_on(harness.open_engine()); + let mut config = deferred_test_config(); + config.assert_batch_atomic = false; + let db = harness.open_db_on_engine(&runtime, engine.clone(), &harness.actor_id, config.clone()); + sqlite_exec( + db.as_ptr(), + "CREATE TABLE deferred_items (id INTEGER PRIMARY KEY, value TEXT);", + ) + .unwrap(); + wait_for_deferred_flush(&runtime, &db); + let seq = db.commit_seq(); + close_deferred_database(&runtime, db); + config.initial_commit_seq = seq; + let reopened = harness.open_db_on_engine(&runtime, engine, &harness.actor_id, config); + assert_eq!(reopened.commit_seq(), seq); + close_deferred_database(&runtime, reopened); +} + +#[test] +fn lost_ack_resend_hits_fence_and_breaks_database() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let engine = runtime.block_on(harness.open_engine()); + let transport = Arc::new(DirectDepotTransport::new(engine.clone())); + let hooks = transport.direct_hooks(); + let db = harness.open_db_with_transport( + &runtime, + transport, + &harness.actor_id, + deferred_test_config(), + ); + sqlite_exec( + db.as_ptr(), + "CREATE TABLE deferred_items (id INTEGER PRIMARY KEY, value TEXT);", + ) + .unwrap(); + wait_for_deferred_flush(&runtime, &db); + let flushed_before = db._vfs.flushed_seq(); + hooks.fail_next_commit_after_apply("lost acknowledgement"); + sqlite_exec( + db.as_ptr(), + "INSERT INTO deferred_items(value) VALUES ('durable-but-indeterminate');", + ) + .unwrap(); + assert!(matches!( + runtime.block_on(db._vfs.wait_for_flush(db.commit_seq())), + Err(FlushError::HeadDiverged { .. }) + )); + assert!( + runtime + .block_on(db._vfs.wait_for_flush(flushed_before)) + .is_err(), + "a terminal flush error wins even for an earlier durable sequence" + ); + db._vfs.begin_close(); + let _ = runtime.block_on(db._vfs.drain_and_shutdown_flusher(Duration::from_secs(1))); + drop(db); + assert_deferred_rows_durable(&runtime, &harness, engine, ["durable-but-indeterminate"]); +} + +#[test] +fn wait_for_flush_rejects_after_break_even_for_flushed_sequence() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let db = harness.open_db_on_engine( + &runtime, + runtime.block_on(harness.open_engine()), + &harness.actor_id, + deferred_test_config(), + ); + wait_for_deferred_flush(&runtime, &db); + let flushed = db._vfs.flushed_seq(); + direct_vfs_ctx(&db).break_database(FlushError::Aborted("test break".to_owned())); + assert!(runtime.block_on(db._vfs.wait_for_flush(flushed)).is_err()); + db._vfs.begin_close(); + let _ = runtime.block_on(db._vfs.drain_and_shutdown_flusher(Duration::from_secs(1))); + drop(db); +} + +#[test] +fn retry_deadline_breaks_database_and_rejects_waiters() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let engine = runtime.block_on(harness.open_engine()); + let transport = Arc::new(DirectDepotTransport::new(engine.clone())); + let hooks = transport.direct_hooks(); + let mut config = deferred_test_config(); + config.deferred_commit.retry_deadline = Duration::from_millis(40); + let db = harness.open_db_with_transport(&runtime, transport, &harness.actor_id, config); + wait_for_deferred_flush(&runtime, &db); + hooks.fail_next_commits(100, "persistent transient failure"); + sqlite_exec( + db.as_ptr(), + "CREATE TABLE deferred_items (id INTEGER PRIMARY KEY, value TEXT);", + ) + .unwrap(); + assert!(matches!( + runtime.block_on(db._vfs.wait_for_flush(db.commit_seq())), + Err(FlushError::RetryDeadlineExceeded { .. }) + )); + db._vfs.begin_close(); + let _ = runtime.block_on(db._vfs.drain_and_shutdown_flusher(Duration::from_secs(1))); + drop(db); + assert_durable_table_exists(&runtime, &harness, engine, "deferred_items", false); +} + +#[test] +fn hung_commit_is_bounded_by_deadline() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let engine = runtime.block_on(harness.open_engine()); + let transport = Arc::new(DirectDepotTransport::new(engine.clone())); + let hooks = transport.direct_hooks(); + let mut config = deferred_test_config(); + config.deferred_commit.retry_deadline = Duration::from_millis(40); + let db = harness.open_db_with_transport(&runtime, transport, &harness.actor_id, config); + wait_for_deferred_flush(&runtime, &db); + hooks.hang_next_commit(); + sqlite_exec( + db.as_ptr(), + "CREATE TABLE deferred_items (id INTEGER PRIMARY KEY, value TEXT);", + ) + .unwrap(); + let started = Instant::now(); + assert!(matches!( + runtime.block_on(db._vfs.wait_for_flush(db.commit_seq())), + Err(FlushError::RetryDeadlineExceeded { .. }) + )); + assert!( + started.elapsed() < Duration::from_secs(1), + "hung commit must be bounded by the configured retry deadline" + ); + db._vfs.begin_close(); + let _ = runtime.block_on(db._vfs.drain_and_shutdown_flusher(Duration::from_secs(1))); + drop(db); + assert_durable_table_exists(&runtime, &harness, engine, "deferred_items", false); +} + +#[test] +fn awaited_mode_ignores_deferred_byte_limit_with_small_pages() { + let runtime = direct_runtime(); + let mut config = VfsConfig::default(); + config.commit_mode = CommitMode::Awaited; + config.deferred_commit.max_unflushed_bytes = usize::MAX; + let mut page = empty_db_page(); + page.truncate(512); + page[16..18].copy_from_slice(&512_u16.to_be_bytes()); + let result = VfsContext::new( + "awaited-small-page".to_owned(), + None, + runtime.handle().clone(), + Arc::new(RecordingInitialPagesTransport::default()), + config, + unsafe { std::mem::zeroed() }, + InitialPages { + pages: vec![(1, page)], + head_txid: Some(0), + requested_page_count: 1, + }, + None, + ); + assert!(result.is_ok()); +} + +#[test] +fn commits_during_in_flight_batch_coalesce_into_one_request() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let engine = runtime.block_on(harness.open_engine()); + let transport = Arc::new(DirectDepotTransport::new(engine.clone())); + let hooks = transport.direct_hooks(); + let db = harness.open_db_with_transport( + &runtime, + transport, + &harness.actor_id, + deferred_test_config(), + ); + sqlite_exec( + db.as_ptr(), + "CREATE TABLE deferred_items (id INTEGER PRIMARY KEY, value TEXT);", + ) + .unwrap(); + wait_for_deferred_flush(&runtime, &db); + let baseline = hooks.commit_requests().len(); + + let pause = hooks.pause_next_commit(); + for value in ["first", "second", "third"] { + sqlite_step_statement( + db.as_ptr(), + &format!("INSERT INTO deferred_items(value) VALUES ('{value}');"), + ) + .unwrap(); + if value == "first" { + pause.wait_until_reached(); + } + } + pause.resume(); + wait_for_deferred_flush(&runtime, &db); + let requests = hooks.commit_requests(); + assert_eq!( + requests.len() - baseline, + 2, + "the two commits staged behind the in-flight request should share one batch", + ); + assert_eq!( + requests[baseline + 1].expected_head_txid, + requests[baseline] + .expected_head_txid + .map(|head| head.saturating_add(1)), + ); + drop(requests); + close_deferred_database(&runtime, db); + assert_deferred_rows_durable(&runtime, &harness, engine, ["first", "second", "third"]); +} + +#[test] +fn transient_error_resend_is_byte_identical_and_applies_once() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let engine = runtime.block_on(harness.open_engine()); + let transport = Arc::new(DirectDepotTransport::new(engine.clone())); + let hooks = transport.direct_hooks(); + let db = harness.open_db_with_transport( + &runtime, + transport, + &harness.actor_id, + deferred_test_config(), + ); + sqlite_exec( + db.as_ptr(), + "CREATE TABLE deferred_items (id INTEGER PRIMARY KEY, value TEXT);", + ) + .unwrap(); + wait_for_deferred_flush(&runtime, &db); + let (_, head_before) = runtime + .block_on(engine.read_branch_head(&harness.actor_id)) + .expect("durability oracle should read the initial branch head"); + let baseline = hooks.commit_requests().len(); + hooks.fail_next_commit("retry this exact request"); + sqlite_step_statement( + db.as_ptr(), + "INSERT INTO deferred_items(value) VALUES ('retried');", + ) + .unwrap(); + wait_for_deferred_flush(&runtime, &db); + let requests = hooks.commit_requests(); + let first = &requests[baseline]; + let retry = &requests[baseline + 1]; + assert_eq!(first.expected_head_txid, retry.expected_head_txid); + assert_eq!(first.db_size_pages, retry.db_size_pages); + assert_eq!(first.dirty_pages.len(), retry.dirty_pages.len()); + for (first, retry) in first.dirty_pages.iter().zip(&retry.dirty_pages) { + assert_eq!(first.pgno, retry.pgno); + assert_eq!(first.bytes, retry.bytes); + } + drop(requests); + let (_, head_after) = runtime + .block_on(engine.read_branch_head(&harness.actor_id)) + .expect("durability oracle should read the flushed branch head"); + assert_eq!(head_after, head_before + 1); + close_deferred_database(&runtime, db); + assert_deferred_rows_durable(&runtime, &harness, engine, ["retried"]); +} + +#[test] +fn no_requests_after_close_returns() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let engine = runtime.block_on(harness.open_engine()); + let transport = Arc::new(DirectDepotTransport::new(engine)); + let hooks = transport.direct_hooks(); + let mut config = deferred_test_config(); + config.deferred_commit.retry_deadline = Duration::from_secs(10); + let (vfs, db) = open_worker_handle_with_transport(&runtime, &harness, transport, config, None); + runtime + .block_on(db.execute( + "CREATE TABLE close_abort (id INTEGER PRIMARY KEY, value TEXT);".to_owned(), + None, + )) + .unwrap(); + runtime + .block_on(db.wait_for_flush(db.commit_seq())) + .unwrap(); + let pause = hooks.pause_next_commit(); + runtime + .block_on(db.execute( + "INSERT INTO close_abort(value) VALUES ('batch-a');".to_owned(), + None, + )) + .unwrap(); + pause.wait_until_reached(); + runtime + .block_on(db.execute( + "INSERT INTO close_abort(value) VALUES ('batch-b');".to_owned(), + None, + )) + .unwrap(); + let error = runtime + .block_on( + db.close_with_timeouts_for_test(Duration::from_secs(1), Duration::from_millis(50)), + ) + .expect_err("the close drain must hit its deadline while batch A is paused"); + assert!(matches!( + error.downcast_ref::(), + Some(FlushError::Aborted(message)) if message == "close deadline" + )); + let requests_after_close = hooks.commit_requests().len(); + pause.resume(); + assert_eq!(hooks.commit_requests().len(), requests_after_close); + assert!(matches!( + vfs.flush_error(), + Some(FlushError::Aborted(message)) if message == "close deadline" + )); +} + +#[test] +fn worker_timeout_aborts_flusher_before_paused_io_resumes() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let engine = runtime.block_on(harness.open_engine()); + let setup = harness.open_db_on_engine( + &runtime, + engine.clone(), + &harness.actor_id, + VfsConfig::default(), + ); + sqlite_exec( + setup.as_ptr(), + "CREATE TABLE worker_timeout (id INTEGER PRIMARY KEY, value TEXT); INSERT INTO worker_timeout VALUES (1, 'durable');", + ) + .unwrap(); + drop(setup); + + let transport = Arc::new(PausableDirectTransport::new(engine)); + let hooks = transport.direct_hooks(); + let (vfs, db) = open_worker_handle_with_transport( + &runtime, + &harness, + transport.clone(), + deferred_test_config(), + None, + ); + vfs.ctx().state.write().invalidate_page_cache(); + let pause = transport.pause_next_read(); + let query = runtime.spawn({ + let db = db.clone(); + async move { + db.query( + "SELECT value FROM worker_timeout WHERE id = 1;".to_owned(), + None, + ) + .await + } + }); + pause.wait_until_reached(); + let request_count = hooks.commit_requests().len(); + let error = runtime + .block_on( + db.close_with_timeouts_for_test(Duration::from_millis(50), Duration::from_secs(1)), + ) + .expect_err("a worker paused in VFS I/O must hit the worker close timeout"); + assert!( + error + .downcast_ref::() + .is_some() + ); + assert!(matches!( + vfs.flush_error(), + Some(FlushError::Aborted(message)) if message == "worker close timeout" + )); + pause.resume(); + let _ = runtime.block_on(query); + assert_eq!(hooks.commit_requests().len(), request_count); +} + +#[test] +fn close_drains_pending_flushes() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let engine = runtime.block_on(harness.open_engine()); + let transport = Arc::new(DirectDepotTransport::new(engine.clone())); + let hooks = transport.direct_hooks(); + let db = harness.open_db_with_transport( + &runtime, + transport, + &harness.actor_id, + deferred_test_config(), + ); + wait_for_deferred_flush(&runtime, &db); + let pause = hooks.pause_next_commit(); + sqlite_exec( + db.as_ptr(), + "CREATE TABLE deferred_items (id INTEGER PRIMARY KEY, value TEXT); INSERT INTO deferred_items VALUES (1, 'close');", + ) + .unwrap(); + pause.wait_until_reached(); + db._vfs.begin_close(); + let drain = runtime.spawn({ + let vfs = db._vfs.clone(); + async move { vfs.drain_and_shutdown_flusher(Duration::from_secs(1)).await } + }); + assert!(!drain.is_finished()); + pause.resume(); + runtime + .block_on(drain) + .expect("drain task should complete") + .expect("close should drain its pending batch"); + drop(db); + assert_deferred_rows_durable(&runtime, &harness, engine, ["close"]); +} + +#[test] +fn close_returns_flush_error_when_broken() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let engine = runtime.block_on(harness.open_engine()); + let transport = Arc::new(DirectDepotTransport::new(engine.clone())); + let hooks = transport.direct_hooks(); + let db = harness.open_db_with_transport( + &runtime, + transport, + &harness.actor_id, + deferred_test_config(), + ); + wait_for_deferred_flush(&runtime, &db); + hooks.return_next_commit_head(Some(u64::MAX)); + sqlite_exec(db.as_ptr(), "CREATE TABLE close_broken (id INTEGER);").unwrap(); + let expected = runtime + .block_on(db._vfs.wait_for_flush(db.commit_seq())) + .expect_err("wrong head should break the flusher"); + db._vfs.begin_close(); + let close_error = runtime + .block_on(db._vfs.drain_and_shutdown_flusher(Duration::from_secs(1))) + .expect_err("close must preserve the terminal flush error"); + assert_eq!(close_error.to_string(), expected.to_string()); + drop(db); + assert_durable_table_exists(&runtime, &harness, engine, "close_broken", true); +} + +#[test] +fn drop_without_close_stages_only_and_is_short() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let engine = runtime.block_on(harness.open_engine()); + let transport = Arc::new(DirectDepotTransport::new(engine.clone())); + let hooks = transport.direct_hooks(); + let db = harness.open_db_with_transport( + &runtime, + transport, + &harness.actor_id, + deferred_test_config(), + ); + wait_for_deferred_flush(&runtime, &db); + let pause = hooks.pause_next_commit(); + sqlite_exec( + db.as_ptr(), + "CREATE TABLE deferred_items (id INTEGER PRIMARY KEY, value TEXT); INSERT INTO deferred_items VALUES (1, 'drop');", + ) + .unwrap(); + pause.wait_until_reached(); + let vfs = db._vfs.clone(); + let started = Instant::now(); + drop(db); + assert!(started.elapsed() < Duration::from_secs(1)); + pause.resume(); + runtime + .block_on(vfs.wait_for_flush(vfs.commit_seq())) + .expect("the already-staged batch should remain flushable after connection drop"); + vfs.begin_close(); + runtime + .block_on(vfs.drain_and_shutdown_flusher(Duration::from_secs(1))) + .unwrap(); + assert_deferred_rows_durable(&runtime, &harness, engine, ["drop"]); +} + +#[test] +fn awaited_mode_is_unchanged() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let engine = runtime.block_on(harness.open_engine()); + let transport = Arc::new(DirectDepotTransport::new(engine.clone())); + let hooks = transport.direct_hooks(); + let db = harness.open_db_with_transport( + &runtime, + transport, + &harness.actor_id, + VfsConfig::default(), + ); + let baseline = hooks.commit_requests().len(); + sqlite_exec( + db.as_ptr(), + "CREATE TABLE deferred_items (id INTEGER PRIMARY KEY, value TEXT);", + ) + .unwrap(); + assert_eq!(db.commit_seq(), db._vfs.flushed_seq()); + let after_create = hooks.commit_requests().len(); + assert_eq!(after_create, baseline + 1); + sqlite_exec( + db.as_ptr(), + "INSERT INTO deferred_items VALUES (1, 'awaited');", + ) + .unwrap(); + assert_eq!(db.commit_seq(), db._vfs.flushed_seq()); + assert_eq!(hooks.commit_requests().len(), after_create + 1); + drop(db); + assert_deferred_rows_durable(&runtime, &harness, engine, ["awaited"]); +} + +#[test] +fn execute_result_reports_readonly_and_commit_seq() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let engine = runtime.block_on(harness.open_engine()); + let transport = Arc::new(DirectDepotTransport::new(engine.clone())); + let config = deferred_test_config(); + let initial_pages = runtime + .block_on(fetch_initial_pages_for_registration( + transport.clone(), + &harness.actor_id, + 0, + &config, + )) + .unwrap(); + let vfs = Arc::new( + SqliteVfs::register_with_transport_and_initial_pages( + &next_test_name("sqlite-deferred-worker-vfs"), + transport, + harness.actor_id.clone(), + runtime.handle().clone(), + config, + initial_pages, + None, + ) + .unwrap(), + ); + let db = crate::database::NativeDatabaseHandle::new(vfs, harness.actor_id.clone()).unwrap(); + let ddl = runtime + .block_on(db.execute( + "CREATE TABLE deferred_items (id INTEGER PRIMARY KEY, value TEXT);".to_owned(), + None, + )) + .unwrap(); + assert_eq!(ddl.readonly, Some(false)); + assert_eq!(ddl.commit_seq, Some(db.commit_seq())); + let read = runtime + .block_on(db.execute("SELECT * FROM deferred_items".to_owned(), None)) + .unwrap(); + assert_eq!(read.readonly, Some(true)); + assert_eq!(read.commit_seq, None); + runtime.block_on(db.close()).unwrap(); + assert_deferred_rows_durable(&runtime, &harness, engine, [] as [&str; 0]); +} + +#[test] +fn backpressure_blocks_commit_until_flush_progress() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let engine = runtime.block_on(harness.open_engine()); + let transport = Arc::new(DirectDepotTransport::new(engine)); + let hooks = transport.direct_hooks(); + let mut config = deferred_test_config(); + config.deferred_commit.max_unflushed_bytes = 1; + let db = harness.open_db_with_transport(&runtime, transport, &harness.actor_id, config); + let pause = hooks.pause_next_commit(); + let writer = thread::spawn(move || { + sqlite_exec(db.as_ptr(), "CREATE TABLE backpressure (id INTEGER);").unwrap(); + db + }); + pause.wait_until_reached(); + assert!( + !writer.is_finished(), + "commit must wait while overlay exceeds its byte bound" + ); + pause.resume(); + let db = writer.join().unwrap(); + wait_for_deferred_flush(&runtime, &db); + close_deferred_database(&runtime, db); +} + +#[test] +fn backpressure_returns_when_closing() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let engine = runtime.block_on(harness.open_engine()); + let transport = Arc::new(DirectDepotTransport::new(engine)); + let hooks = transport.direct_hooks(); + let mut config = deferred_test_config(); + config.deferred_commit.max_unflushed_bytes = 1; + let db = harness.open_db_with_transport(&runtime, transport, &harness.actor_id, config); + let vfs = db._vfs.clone(); + let pause = hooks.pause_next_commit(); + let (writer_done_tx, writer_done_rx) = std::sync::mpsc::channel(); + let writer = thread::spawn(move || { + sqlite_exec( + db.as_ptr(), + "CREATE TABLE closing_backpressure (id INTEGER);", + ) + .unwrap(); + writer_done_tx.send(()).unwrap(); + db + }); + pause.wait_until_reached(); + assert!(!writer.is_finished()); + vfs.begin_close(); + writer_done_rx + .recv_timeout(Duration::from_secs(1)) + .expect("closing must release the commit callback without waiting for the engine"); + let db = writer.join().unwrap(); + pause.resume(); + runtime + .block_on(vfs.drain_and_shutdown_flusher(Duration::from_secs(1))) + .unwrap(); + drop(db); +} + +#[test] +fn read_your_writes_while_batch_in_flight() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let engine = runtime.block_on(harness.open_engine()); + let transport = Arc::new(DirectDepotTransport::new(engine.clone())); + let hooks = transport.direct_hooks(); + let db = harness.open_db_with_transport( + &runtime, + transport, + &harness.actor_id, + deferred_test_config(), + ); + sqlite_exec( + db.as_ptr(), + "CREATE TABLE deferred_items (id INTEGER PRIMARY KEY, value TEXT);", + ) + .unwrap(); + wait_for_deferred_flush(&runtime, &db); + let pause = hooks.pause_next_commit(); + sqlite_step_statement( + db.as_ptr(), + "INSERT INTO deferred_items(value) VALUES ('local');", + ) + .unwrap(); + pause.wait_until_reached(); + let (pgno, local_bytes) = { + let state = direct_vfs_ctx(&db).state.read(); + let (pgno, page) = state + .overlay + .pages + .iter() + .next() + .expect("the in-flight write should remain pinned in the overlay"); + (*pgno, page.bytes.clone()) + }; + direct_vfs_ctx(&db).state.write().invalidate_page_cache(); + assert_eq!( + direct_vfs_ctx(&db).resolve_pages(&[pgno], false).unwrap()[&pgno], + Some(local_bytes), + "overlay bytes must win even after every evictable cache is invalidated", + ); + assert_eq!( + sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM deferred_items;").unwrap(), + 1, + ); + pause.resume(); + wait_for_deferred_flush(&runtime, &db); + close_deferred_database(&runtime, db); + assert_deferred_rows_durable(&runtime, &harness, engine, ["local"]); +} + +#[test] +fn overflow_expanded_read_does_not_overwrite_overlay() { + let transport = Arc::new(ScriptedDeferredReadTransport::with_head(0)); + transport + .extra_pages + .lock() + .push(protocol::SqliteFetchedPage { + pgno: 2, + bytes: Some(vec![0xaa; DEFAULT_PAGE_SIZE]), + }); + let (_runtime, ctx) = deferred_context(transport); + let local = vec![0xbb; DEFAULT_PAGE_SIZE]; + { + let mut state = ctx.state.write(); + state.db_size_pages = 3; + state.overlay.db_size_pages = 3; + state.overlay.commit_seq = 1; + state.overlay.bytes = local.len(); + state.overlay.pages.insert( + 2, + OverlayPage { + bytes: local.clone(), + seq: 1, + }, + ); + } + ctx.resolve_pages(&[3], false).unwrap(); + assert_eq!(ctx.resolve_pages(&[2], false).unwrap()[&2], Some(local)); + assert!(ctx.state.read().cached_page(&ctx.config, 2).is_none()); +} + +#[test] +fn read_between_staging_and_in_flight_omits_head_fence() { + let transport = Arc::new(ScriptedDeferredReadTransport::with_head(0)); + let (_runtime, ctx) = deferred_context(transport.clone()); + { + let mut state = ctx.state.write(); + state.db_size_pages = 3; + state.overlay.db_size_pages = 3; + state.overlay.commit_seq = 1; + } + ctx.resolve_pages(&[2], false).unwrap(); + assert_eq!(transport.requests.lock()[0].expected_head_txid, None); + assert!(ctx.state.read().overlay.in_flight.is_none()); +} + +#[test] +fn stale_unfenced_read_does_not_regress_durable_head() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let engine = runtime.block_on(harness.open_engine()); + let transport = Arc::new(PausableDirectTransport::new(engine)); + let hooks = transport.direct_hooks(); + let db = harness.open_db_with_transport( + &runtime, + transport.clone(), + &harness.actor_id, + deferred_test_config(), + ); + sqlite_exec( + db.as_ptr(), + "CREATE TABLE deferred_items (id INTEGER PRIMARY KEY, value TEXT); CREATE TABLE read_pad (payload BLOB); INSERT INTO read_pad VALUES (zeroblob(65536));", + ) + .unwrap(); + wait_for_deferred_flush(&runtime, &db); + let pause_commit = hooks.pause_next_commit(); + sqlite_step_statement( + db.as_ptr(), + "INSERT INTO deferred_items(value) VALUES ('first');", + ) + .unwrap(); + pause_commit.wait_until_reached(); + let (read_pgno, first_seq) = { + let mut state = direct_vfs_ctx(&db).state.write(); + let pgno = (1..=state.db_size_pages) + .find(|pgno| !state.overlay.pages.contains_key(pgno)) + .expect("padding should leave a durable page outside the overlay"); + state.invalidate_page_cache(); + (pgno, state.overlay.commit_seq) + }; + let pause_read = transport.pause_next_read(); + let reader = thread::spawn({ + let vfs = db._vfs.clone(); + move || vfs.ctx().resolve_pages(&[read_pgno], false) + }); + pause_read.wait_until_reached(); + pause_commit.resume_and_wait_until_applied(); + runtime + .block_on(db._vfs.wait_for_flush(first_seq)) + .expect("the first commit acknowledgement should advance the durable head"); + sqlite_step_statement( + db.as_ptr(), + "INSERT INTO deferred_items(value) VALUES ('second');", + ) + .unwrap(); + pause_read.resume(); + reader.join().unwrap().unwrap(); + wait_for_deferred_flush(&runtime, &db); + let requests = hooks.commit_requests(); + let second = requests.last().expect("second commit should be captured"); + assert_eq!( + second.expected_head_txid, + requests[requests.len() - 2] + .expected_head_txid + .map(|head| head + 1), + "a stale read response must not regress the next commit's expected head", + ); + drop(requests); + close_deferred_database(&runtime, db); +} + +#[test] +fn read_window_accepts_response_served_before_ack_processed_after() { + let transport = Arc::new(ScriptedDeferredReadTransport::with_head(1)); + let (_runtime, ctx) = deferred_context(transport); + { + let mut state = ctx.state.write(); + state.db_size_pages = 3; + state.overlay.db_size_pages = 3; + state.overlay.commit_seq = 1; + state.overlay.in_flight = Some(InFlightBatch { + seq: 1, + expected_head_txid: 0, + db_size_pages: 3, + pages: Arc::new(Vec::new()), + started_at: tokio::time::Instant::now(), + attempts: 1, + }); + } + assert!(ctx.resolve_pages(&[2], false).is_ok()); + assert!(!ctx.state.read().dead); +} + +#[test] +fn read_window_rejects_foreign_head() { + let transport = Arc::new(ScriptedDeferredReadTransport::with_head(1)); + let (_runtime, ctx) = deferred_context(transport); + { + let mut state = ctx.state.write(); + state.db_size_pages = 3; + state.overlay.db_size_pages = 3; + state.overlay.commit_seq = 1; + } + assert!(matches!( + ctx.resolve_pages(&[2], false), + Err(GetPagesError::FenceMismatch(_)) + )); + assert!(ctx.state.read().dead); + assert!(matches!( + ctx.flush_error(), + Some(FlushError::HeadDiverged { + expected: 0, + actual: Some(1), + }) + )); +} + +#[test] +fn prefetch_and_has_readable_page_skip_overlay_pages() { + let transport = Arc::new(ScriptedDeferredReadTransport::with_head(0)); + let (_runtime, ctx) = deferred_context(transport.clone()); + { + let mut state = ctx.state.write(); + state.db_size_pages = 8; + state.overlay.db_size_pages = 8; + state.overlay.commit_seq = 1; + state.overlay.pages.insert( + 5, + OverlayPage { + bytes: vec![5; DEFAULT_PAGE_SIZE], + seq: 1, + }, + ); + state.overlay.bytes = DEFAULT_PAGE_SIZE; + for pgno in [1, 2, 3] { + state.predictor.record(PageClass::Btree, pgno); + } + assert!(state.has_readable_page(&ctx.config, 5)); + } + ctx.resolve_pages(&[4], true).unwrap(); + let requests = transport.requests.lock(); + let request = &requests[0]; + assert!( + !request.pgnos.contains(&5), + "the predicted overlay page must not be fetched", + ); + assert!( + request.pgnos.contains(&6), + "another stride-predicted page proves prefetch still ran", + ); +} + +#[test] +fn empty_page_synthesis_disabled_after_local_commit() { + let (_runtime, ctx) = deferred_context(Arc::new(MissingDbTransport)); + { + let mut state = ctx.state.write(); + state.overlay.commit_seq = 1; + state.page_cache.invalidate(&1); + state.committed_page_cache.invalidate(&1); + } + assert!(ctx.resolve_pages(&[1], false).is_err()); +} + +#[test] +fn oversized_transaction_is_rejected_before_merge() { + let transport = Arc::new(ScriptedDeferredReadTransport::with_head(0)); + let (_runtime, ctx) = deferred_context(transport); + let page_limit = depot_client_types::MAX_COMMIT_DIRTY_PAGES; + { + let mut state = ctx.state.write(); + for pgno in 1..=(page_limit as u32 + 1) { + state.write_buffer.dirty.insert(pgno, Vec::new()); + } + } + assert!(ctx.stage_deferred_local_commit(false).is_err()); + let state = ctx.state.read(); + assert_eq!(state.overlay.commit_seq, 0); + assert!(state.overlay.pages.is_empty()); + assert_eq!(state.write_buffer.dirty.len(), page_limit + 1); + drop(state); + ctx.state.write().write_buffer.dirty.clear(); + ctx.state.write().write_buffer.dirty.insert(1, Vec::new()); + assert!(ctx.stage_deferred_local_commit(false).is_ok()); +} + +#[test] +fn closing_drains_page_cap_before_merging_another_commit() { + let transport = Arc::new(ScriptedDeferredReadTransport::with_head(0)); + let (_runtime, ctx) = deferred_context(transport); + { + let mut state = ctx.state.write(); + for pgno in 1..=depot_client_types::MAX_COMMIT_DIRTY_PAGES as u32 { + state.overlay.pages.insert( + pgno, + OverlayPage { + bytes: Vec::new(), + seq: 1, + }, + ); + } + state.overlay.commit_seq = 1; + state.write_buffer.dirty.insert( + depot_client_types::MAX_COMMIT_DIRTY_PAGES as u32 + 1, + Vec::new(), + ); + } + ctx.begin_close(); + let stager = thread::spawn({ + let ctx = ctx.clone(); + move || ctx.stage_deferred_local_commit(false) + }); + assert!( + !stager.is_finished(), + "the hard page cap must drain even during close" + ); + ctx.state.write().overlay.pages.remove(&1); + ctx.flush.progress_changed.send_modify(|_| {}); + assert!(stager.join().unwrap().is_ok()); + assert_eq!( + ctx.state.read().overlay.pages.len(), + depot_client_types::MAX_COMMIT_DIRTY_PAGES, + ); +} + +#[test] +fn oversized_sql_transaction_rolls_back_and_connection_remains_usable() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let engine = runtime.block_on(harness.open_engine()); + let transport = Arc::new(DirectDepotTransport::new(engine.clone())); + let mut config = deferred_test_config(); + config.max_commit_dirty_pages = 8; + let (vfs, db) = open_worker_handle_with_transport(&runtime, &harness, transport, config, None); + runtime + .block_on(db.exec( + "CREATE TABLE oversized_payload (id INTEGER PRIMARY KEY, payload BLOB);".to_owned(), + )) + .expect("create table should succeed"); + runtime + .block_on(db.wait_for_flush(db.commit_seq())) + .unwrap(); + let ctx = vfs.ctx(); + let atomic_before = ctx.commit_atomic_attempt_count.load(Ordering::Relaxed); + let rollback_before = ctx.rollback_atomic_count.load(Ordering::Relaxed); + let aux_write_before = ctx.aux_write_count.load(Ordering::Relaxed); + let sync_before = ctx.main_sync_count.load(Ordering::Relaxed); + + let mut sql = String::from("BEGIN IMMEDIATE;"); + for id in 1..=24 { + sql.push_str(&format!( + "INSERT INTO oversized_payload (id, payload) VALUES ({id}, randomblob(4096));", + )); + } + sql.push_str("COMMIT;"); + let error = runtime + .block_on(db.exec(sql)) + .expect_err("the real batch-atomic commit must reject the configured page cap"); + assert!( + error.to_string().contains("disk I/O error"), + "unexpected oversized commit error: {error:#}", + ); + assert!( + ctx.commit_atomic_attempt_count.load(Ordering::Relaxed) > atomic_before, + "SQLite must reach COMMIT_ATOMIC_WRITE", + ); + assert!( + ctx.rollback_atomic_count.load(Ordering::Relaxed) > rollback_before, + "SQLite must roll back the failed atomic write", + ); + assert!( + ctx.aux_write_count.load(Ordering::Relaxed) > aux_write_before, + "SQLite must write its rollback journal", + ); + assert!( + ctx.main_sync_count.load(Ordering::Relaxed) > sync_before, + "the deferred transient error must surface through the main-file xSync path", + ); + + runtime + .block_on(db.execute( + "INSERT INTO oversized_payload (id, payload) VALUES (100, zeroblob(32));".to_owned(), + None, + )) + .expect("the connection must accept a later statement after rollback"); + runtime + .block_on(db.wait_for_flush(db.commit_seq())) + .unwrap(); + let rows = runtime + .block_on(db.query( + "SELECT id FROM oversized_payload ORDER BY id;".to_owned(), + None, + )) + .expect("the recovery row should be readable"); + assert_eq!(rows.rows, vec![vec![ColumnValue::Integer(100)]]); + runtime.block_on(db.close()).expect("database should close"); + let mut reopen_config = VfsConfig::default(); + reopen_config.assert_batch_atomic = false; + let reopened = harness.open_db_on_engine(&runtime, engine, &harness.actor_id, reopen_config); + assert_eq!( + sqlite_query_i64(reopened.as_ptr(), "SELECT id FROM oversized_payload;") + .expect("the recovery row should be durable"), + 100, + ); +} + +#[test] +fn native_close_waits_for_real_sql_drain_before_merge() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let engine = runtime.block_on(harness.open_engine()); + let transport = Arc::new(DirectDepotTransport::new(engine)); + let hooks = transport.direct_hooks(); + let mut config = deferred_test_config(); + config.max_commit_dirty_pages = 2; + let (vfs, db) = open_worker_handle_with_transport(&runtime, &harness, transport, config, None); + runtime + .block_on( + db.exec("CREATE TABLE close_page_cap (id INTEGER PRIMARY KEY, value TEXT);".to_owned()), + ) + .expect("create table should succeed"); + runtime + .block_on(db.wait_for_flush(db.commit_seq())) + .unwrap(); + let baseline_seq = db.commit_seq(); + let pause = hooks.pause_next_commit(); + let execute = runtime.spawn({ + let db = db.clone(); + async move { + db.exec( + "BEGIN; INSERT INTO close_page_cap VALUES (1, 'first'); PRAGMA user_version = 1; COMMIT; \ + BEGIN; INSERT INTO close_page_cap VALUES (2, 'second'); PRAGMA user_version = 2; COMMIT;" + .to_owned(), + ) + .await + } + }); + pause.wait_until_reached(); + runtime + .block_on(async { + tokio::time::timeout(Duration::from_secs(1), async { + loop { + let blocked_in_second_commit = { + let state = vfs.ctx().state.read(); + state.overlay.commit_seq == baseline_seq + 1 + && state.write_buffer.in_atomic_write + && !state.write_buffer.dirty.is_empty() + }; + if blocked_in_second_commit { + break; + } + tokio::task::yield_now().await; + } + }) + .await + }) + .expect("real multi-statement SQL should block in drain-before-merge"); + assert!(!execute.is_finished()); + + let close = runtime.spawn({ + let db = db.clone(); + async move { db.close().await } + }); + runtime.block_on(wait_worker_closing(&db)); + assert!( + !close.is_finished(), + "close must wait for the active SQL callback blocked on the page cap", + ); + pause.resume(); + runtime + .block_on(execute) + .expect("multi-statement worker task should join") + .expect("multi-statement SQL should finish after flush progress"); + runtime + .block_on(close) + .expect("close task should join") + .expect("close should drain the staged second commit"); + assert_eq!(vfs.commit_seq(), vfs.flushed_seq()); +} + +#[test] +fn foreign_writer_at_expected_plus_one_breaks_database() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let engine = runtime.block_on(harness.open_engine()); + let setup = harness.open_db_on_engine( + &runtime, + engine.clone(), + &harness.actor_id, + VfsConfig::default(), + ); + sqlite_exec( + setup.as_ptr(), + "CREATE TABLE deferred_items (id INTEGER PRIMARY KEY, value TEXT);", + ) + .unwrap(); + drop(setup); + // Both handles must open at the same durable head. The normal open-time + // batch-atomic probe writes to the database, which would make the first + // handle stale before this test's deliberate foreign write. + let mut deferred_config = deferred_test_config(); + deferred_config.assert_batch_atomic = false; + let deferred = + harness.open_db_on_engine(&runtime, engine.clone(), &harness.actor_id, deferred_config); + let mut foreign_config = VfsConfig::default(); + foreign_config.assert_batch_atomic = false; + let foreign = + harness.open_db_on_engine(&runtime, engine.clone(), &harness.actor_id, foreign_config); + sqlite_step_statement( + foreign.as_ptr(), + "INSERT INTO deferred_items VALUES (1, 'foreign');", + ) + .unwrap(); + drop(foreign); + sqlite_step_statement( + deferred.as_ptr(), + "INSERT INTO deferred_items VALUES (2, 'local');", + ) + .unwrap(); + assert!(matches!( + runtime.block_on(deferred._vfs.wait_for_flush(deferred.commit_seq())), + Err(FlushError::HeadDiverged { .. }) + )); + deferred._vfs.begin_close(); + let _ = runtime.block_on( + deferred + ._vfs + .drain_and_shutdown_flusher(Duration::from_secs(1)), + ); + drop(deferred); + assert_deferred_rows_durable(&runtime, &harness, engine, ["foreign"]); +} + +#[test] +fn late_ack_after_break_does_not_publish_progress() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let engine = runtime.block_on(harness.open_engine()); + let transport = Arc::new(DirectDepotTransport::new(engine.clone())); + let hooks = transport.direct_hooks(); + let db = harness.open_db_with_transport( + &runtime, + transport, + &harness.actor_id, + deferred_test_config(), + ); + sqlite_exec( + db.as_ptr(), + "CREATE TABLE deferred_items (id INTEGER PRIMARY KEY, value TEXT);", + ) + .unwrap(); + wait_for_deferred_flush(&runtime, &db); + let (_, durable_head_before) = runtime + .block_on(engine.read_branch_head(&harness.actor_id)) + .unwrap(); + let pause = hooks.pause_next_commit(); + sqlite_step_statement( + db.as_ptr(), + "INSERT INTO deferred_items VALUES (1, 'late');", + ) + .unwrap(); + pause.wait_until_reached(); + let flushed_before = db._vfs.flushed_seq(); + let break_pause = direct_vfs_ctx(&db).pause_next_break_after_fatal_marker(); + let breaker = thread::spawn({ + let vfs = db._vfs.clone(); + move || { + vfs.ctx() + .break_database(FlushError::Aborted("test break".to_owned())); + } + }); + break_pause.wait_until_reached(); + assert!(direct_vfs_ctx(&db).state.read().dead); + assert!( + db._vfs.flush_error().is_none(), + "the test must hold the break between the fatal marker and progress publication", + ); + pause.resume_and_wait_until_applied(); + runtime + .block_on(async { + tokio::time::timeout(Duration::from_secs(1), async { + loop { + if direct_vfs_ctx(&db) + .flush + .task + .lock() + .as_ref() + .is_some_and(|task| task.is_finished()) + { + break; + } + tokio::task::yield_now().await; + } + }) + .await + }) + .expect("late acknowledgement should observe the fatal marker and stop"); + assert_eq!(db._vfs.flushed_seq(), flushed_before); + assert!( + db._vfs.flush_error().is_none(), + "the acknowledgement must not synthesize a competing terminal error", + ); + break_pause.resume(); + breaker.join().expect("database break should finish"); + let (_, durable_head_after) = runtime + .block_on(engine.read_branch_head(&harness.actor_id)) + .unwrap(); + assert!(durable_head_after > durable_head_before); + assert_eq!(db._vfs.flushed_seq(), flushed_before); + assert!( + runtime + .block_on(db._vfs.wait_for_flush(flushed_before)) + .is_err() + ); + db._vfs.begin_close(); + let _ = runtime.block_on(db._vfs.drain_and_shutdown_flusher(Duration::from_secs(1))); + drop(db); + assert_deferred_rows_durable(&runtime, &harness, engine, ["late"]); +} + +#[test] +fn truncate_with_dirty_pages_defers_staging_to_commit_boundary() { + let transport = Arc::new(ScriptedDeferredReadTransport::with_head(0)); + let (_runtime, ctx) = deferred_context(transport); + { + let mut state = ctx.state.write(); + state.db_size_pages = 4; + state.committed_db_size_pages = 4; + state.overlay.db_size_pages = 4; + state + .write_buffer + .dirty + .insert(1, vec![1; DEFAULT_PAGE_SIZE]); + } + assert!(!ctx.truncate_main_file((2 * DEFAULT_PAGE_SIZE) as i64)); + assert_eq!(ctx.commit_seq(), 0); + ctx.stage_deferred_local_commit(false).unwrap(); + let state = ctx.state.read(); + assert_eq!(state.overlay.commit_seq, 1); + assert_eq!(state.overlay.db_size_pages, 2); + assert!(state.overlay.pages.contains_key(&1)); + drop(state); +} + +#[test] +fn drop_with_open_transaction_stages_nothing() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let engine = runtime.block_on(harness.open_engine()); + let transport = Arc::new(DirectDepotTransport::new(engine.clone())); + let hooks = transport.direct_hooks(); + let db = harness.open_db_with_transport( + &runtime, + transport, + &harness.actor_id, + deferred_test_config(), + ); + sqlite_exec( + db.as_ptr(), + "CREATE TABLE deferred_items (id INTEGER PRIMARY KEY, value TEXT);", + ) + .unwrap(); + wait_for_deferred_flush(&runtime, &db); + let baseline_seq = db.commit_seq(); + let baseline_requests = hooks.commit_requests().len(); + sqlite_exec( + db.as_ptr(), + "BEGIN; INSERT INTO deferred_items VALUES (1, 'rolled-back');", + ) + .unwrap(); + let vfs = db._vfs.clone(); + drop(db); + assert_eq!(vfs.commit_seq(), baseline_seq); + assert_eq!(hooks.commit_requests().len(), baseline_requests); + vfs.begin_close(); + runtime + .block_on(vfs.drain_and_shutdown_flusher(Duration::from_secs(1))) + .unwrap(); + assert_deferred_rows_durable(&runtime, &harness, engine, [] as [&str; 0]); +} + +#[test] +fn size_only_truncate_is_a_local_commit() { + let transport = Arc::new(ScriptedDeferredReadTransport::with_head(0)); + let (_runtime, ctx) = deferred_context(transport); + { + let mut state = ctx.state.write(); + state.db_size_pages = 4; + state.committed_db_size_pages = 4; + state.overlay.db_size_pages = 4; + } + assert!(ctx.truncate_main_file((2 * DEFAULT_PAGE_SIZE) as i64)); + ctx.flush_dirty_pages().unwrap(); + let state = ctx.state.read(); + assert_eq!(state.overlay.commit_seq, 1); + assert_eq!(state.overlay.db_size_pages, 2); + assert!(state.overlay.pages.is_empty()); + drop(state); +} + +#[test] +fn shrink_evicts_overlay_pages_above_size() { + let transport = Arc::new(ScriptedDeferredReadTransport::with_head(0)); + let (_runtime, ctx) = deferred_context(transport); + { + let mut state = ctx.state.write(); + state.db_size_pages = 5; + state.committed_db_size_pages = 5; + state.overlay.db_size_pages = 5; + for pgno in [4, 5] { + state.overlay.pages.insert( + pgno, + OverlayPage { + bytes: vec![pgno as u8; DEFAULT_PAGE_SIZE], + seq: 1, + }, + ); + } + state.overlay.bytes = 2 * DEFAULT_PAGE_SIZE; + state.overlay.commit_seq = 1; + } + assert!(ctx.truncate_main_file((3 * DEFAULT_PAGE_SIZE) as i64)); + ctx.stage_deferred_local_commit(false).unwrap(); + let state = ctx.state.read(); + assert!(state.overlay.pages.keys().all(|pgno| *pgno <= 3)); + assert_eq!(state.overlay.bytes, 0); + drop(state); +} + +#[test] +fn shrink_while_expansion_in_flight() { + let transport = Arc::new(ScriptedDeferredReadTransport::with_head(0)); + let (_runtime, ctx) = deferred_context(transport); + { + let mut state = ctx.state.write(); + state.db_size_pages = 5; + state.committed_db_size_pages = 5; + state.overlay.db_size_pages = 5; + state.overlay.commit_seq = 1; + state.overlay.pages.insert( + 5, + OverlayPage { + bytes: vec![5; DEFAULT_PAGE_SIZE], + seq: 1, + }, + ); + state.overlay.bytes = DEFAULT_PAGE_SIZE; + state.overlay.in_flight = Some(InFlightBatch { + seq: 1, + expected_head_txid: 0, + db_size_pages: 5, + pages: Arc::new(vec![protocol::SqliteDirtyPage { + pgno: 5, + bytes: vec![5; DEFAULT_PAGE_SIZE], + }]), + started_at: tokio::time::Instant::now(), + attempts: 1, + }); + } + assert!(ctx.truncate_main_file((3 * DEFAULT_PAGE_SIZE) as i64)); + ctx.stage_deferred_local_commit(false).unwrap(); + let state = ctx.state.read(); + assert_eq!(state.overlay.db_size_pages, 3); + assert!(!state.overlay.pages.contains_key(&5)); + assert_eq!(state.overlay.in_flight.as_ref().unwrap().db_size_pages, 5); + drop(state); +} + +#[test] +fn xsync_during_atomic_write_does_not_stage() { + let transport = Arc::new(ScriptedDeferredReadTransport::with_head(0)); + let (_runtime, ctx) = deferred_context(transport); + { + let mut state = ctx.state.write(); + state.write_buffer.in_atomic_write = true; + state + .write_buffer + .dirty + .insert(1, vec![1; DEFAULT_PAGE_SIZE]); + } + ctx.flush_dirty_pages().unwrap(); + let state = ctx.state.read(); + assert_eq!(state.overlay.commit_seq, 0); + assert!(state.write_buffer.in_atomic_write); + assert!(state.write_buffer.dirty.contains_key(&1)); + drop(state); +} + +#[test] +fn rollback_atomic_write_leaves_overlay() { + let transport = Arc::new(ScriptedDeferredReadTransport::with_head(0)); + let (_runtime, ctx) = deferred_context(transport); + let committed = vec![1; DEFAULT_PAGE_SIZE]; + { + let mut state = ctx.state.write(); + state.db_size_pages = 2; + state.overlay.db_size_pages = 2; + state.overlay.commit_seq = 1; + state.overlay.pages.insert( + 1, + OverlayPage { + bytes: committed.clone(), + seq: 1, + }, + ); + state.overlay.bytes = DEFAULT_PAGE_SIZE; + state.write_buffer.in_atomic_write = true; + state.write_buffer.saved_db_size = 2; + state + .write_buffer + .dirty + .insert(1, vec![2; DEFAULT_PAGE_SIZE]); + } + ctx.rollback_atomic_write(); + let state = ctx.state.read(); + assert_eq!(state.overlay.commit_seq, 1); + assert_eq!(state.overlay.pages[&1].bytes, committed); + assert_eq!(state.overlay.bytes, DEFAULT_PAGE_SIZE); + drop(state); +} + +#[test] +fn deferred_random() { + let seed = std::env::var("DEFERRED_RANDOM_SEED") + .ok() + .map(|value| { + value + .parse::() + .unwrap_or_else(|error| panic!("invalid DEFERRED_RANDOM_SEED `{value}`: {error}")) + }) + .unwrap_or(0x5eed_c0de_u64); + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let engine = runtime.block_on(harness.open_engine()); + let mut transport = Arc::new(DirectDepotTransport::new(engine.clone())); + let mut hooks = transport.direct_hooks(); + let mut initial_config = deferred_test_config(); + initial_config.assert_batch_atomic = false; + let mut db = harness.open_db_with_transport( + &runtime, + transport.clone(), + &harness.actor_id, + initial_config, + ); + sqlite_exec( + db.as_ptr(), + "CREATE TABLE deferred_items (id INTEGER PRIMARY KEY, value TEXT);", + ) + .unwrap_or_else(|error| panic!("seed {seed}: create table: {error}")); + struct Model { + local: Vec, + acked_prefix: usize, + } + let mut model = Model { + local: Vec::new(), + acked_prefix: 0, + }; + let mut state = seed; + let mut pause = None; + let mut next_value = 0_u64; + for iteration in 0..100 { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + let operation = (state % 11) as u8; + let context = || format!("seed {seed}, iteration {iteration}, operation {operation}"); + match operation { + 0 => { + let value = format!("value-{next_value}-{state}"); + next_value += 1; + sqlite_step_statement( + db.as_ptr(), + &format!("INSERT INTO deferred_items(value) VALUES ('{value}');"), + ) + .unwrap_or_else(|error| panic!("{}: {error}", context())); + model.local.push(value); + } + 1 => assert_eq!( + sqlite_query_text_rows( + db.as_ptr(), + "SELECT value FROM deferred_items ORDER BY id;", + ) + .unwrap_or_else(|error| panic!("{}: {error}", context())), + model.local, + "{}", + context(), + ), + 2 => { + if pause.is_some() { + continue; + } + wait_for_deferred_flush_with_context(&runtime, &db, &context()); + model.acked_prefix = model.local.len(); + } + 3 => direct_vfs_ctx(&db).state.write().invalidate_page_cache(), + 4 => { + // VACUUM exercises SQLite's size-only xTruncate boundary after the + // temporary payload is deleted. + sqlite_exec( + db.as_ptr(), + "CREATE TABLE IF NOT EXISTS random_pad (payload BLOB); INSERT INTO random_pad VALUES (zeroblob(32768)); DELETE FROM random_pad; VACUUM;", + ) + .unwrap_or_else(|error| panic!("{}: {error}", context())); + } + 5 => { + if pause.is_none() { + wait_for_deferred_flush_with_context(&runtime, &db, &context()); + model.acked_prefix = model.local.len(); + let held = hooks.pause_next_commit(); + let value = format!("held-{next_value}-{state}"); + next_value += 1; + sqlite_step_statement( + db.as_ptr(), + &format!("INSERT INTO deferred_items(value) VALUES ('{value}');"), + ) + .unwrap_or_else(|error| panic!("{}: {error}", context())); + model.local.push(value); + held.wait_until_reached_with_context(&context()); + pause = Some(held); + } + } + 6 => { + if let Some(held) = pause.take() { + held.resume(); + wait_for_deferred_flush_with_context(&runtime, &db, &context()); + model.acked_prefix = model.local.len(); + } + } + 7 => { + if pause.is_none() { + hooks.fail_next_commit(format!("{} transient", context())); + let value = format!("retry-{next_value}-{state}"); + next_value += 1; + sqlite_step_statement( + db.as_ptr(), + &format!("INSERT INTO deferred_items(value) VALUES ('{value}');"), + ) + .unwrap_or_else(|error| panic!("{}: {error}", context())); + model.local.push(value); + } + } + 8 | 9 => { + if pause.is_some() { + continue; + } + wait_for_deferred_flush_with_context(&runtime, &db, &context()); + model.acked_prefix = model.local.len(); + if operation == 8 { + hooks.fail_next_commit_after_apply(format!("{} lost ack", context())); + } else { + hooks.return_next_commit_head(Some(u64::MAX)); + } + let value = format!("fault-{next_value}-{state}"); + next_value += 1; + sqlite_step_statement( + db.as_ptr(), + &format!("INSERT INTO deferred_items(value) VALUES ('{value}');"), + ) + .unwrap_or_else(|error| panic!("{}: {error}", context())); + model.local.push(value); + let initial_commit_seq = db.commit_seq(); + runtime + .block_on(db._vfs.wait_for_flush(initial_commit_seq)) + .expect_err(&context()); + db._vfs.begin_close(); + let _ = + runtime.block_on(db._vfs.drain_and_shutdown_flusher(Duration::from_secs(1))); + drop(db); + let expected_durable = model.local[..model.acked_prefix + 1].to_vec(); + model.acked_prefix += 1; + assert_deferred_rows_durable_with_context( + &runtime, + &harness, + engine.clone(), + &expected_durable, + &context(), + ); + transport = Arc::new(DirectDepotTransport::new(engine.clone())); + hooks = transport.direct_hooks(); + let mut config = deferred_test_config(); + config.initial_commit_seq = initial_commit_seq; + config.assert_batch_atomic = false; + db = harness.open_db_with_transport( + &runtime, + transport.clone(), + &harness.actor_id, + config, + ); + } + 10 => { + if pause.is_some() { + continue; + } + wait_for_deferred_flush_with_context(&runtime, &db, &context()); + model.acked_prefix = model.local.len(); + let initial_commit_seq = db.commit_seq(); + close_deferred_database_with_context(&runtime, db, &context()); + assert_deferred_rows_durable_with_context( + &runtime, + &harness, + engine.clone(), + &model.local, + &context(), + ); + transport = Arc::new(DirectDepotTransport::new(engine.clone())); + hooks = transport.direct_hooks(); + let mut config = deferred_test_config(); + config.initial_commit_seq = initial_commit_seq; + config.assert_batch_atomic = false; + db = harness.open_db_with_transport( + &runtime, + transport.clone(), + &harness.actor_id, + config, + ); + } + _ => unreachable!(), + } + } + if let Some(held) = pause.take() { + held.resume(); + } + wait_for_deferred_flush_with_context(&runtime, &db, &format!("seed {seed}: final flush")); + model.acked_prefix = model.local.len(); + close_deferred_database_with_context(&runtime, db, &format!("seed {seed}: final close")); + assert_eq!( + model.acked_prefix, + model.local.len(), + "seed {seed}: acknowledged prefix" + ); + assert_deferred_rows_durable_with_context( + &runtime, + &harness, + engine, + &model.local, + &format!("seed {seed}: final durability oracle"), + ); +} diff --git a/engine/packages/depot-client/tests/inline/vfs_support.rs b/engine/packages/depot-client/tests/inline/vfs_support.rs index 34555e6d73..a354784759 100644 --- a/engine/packages/depot-client/tests/inline/vfs_support.rs +++ b/engine/packages/depot-client/tests/inline/vfs_support.rs @@ -18,6 +18,7 @@ use depot::{ use parking_lot::Mutex; use rivet_envoy_protocol as protocol; use rivet_pools::{__rivet_util::Id, NodeId}; +use tokio::sync::oneshot; use universaldb::utils::IsolationLevel::Serializable; use super::super::SqliteTransport; @@ -299,7 +300,8 @@ impl SqliteTransport for DirectDepotTransport { &self, request: protocol::SqliteCommitRequest, ) -> Result { - self.storage + let applied = self + .storage .hooks .apply_commit_hooks(request.clone()) .await?; @@ -326,13 +328,19 @@ impl SqliteTransport for DirectDepotTransport { .await { Ok(result) => { + if let Some(applied) = applied { + let _ = applied.send(()); + } if let Some(message) = self.storage.hooks.take_commit_after_apply_error() { return Err(anyhow::anyhow!(message)); } + let head_txid = self + .storage + .hooks + .take_commit_response_head() + .unwrap_or(Some(result.head_txid)); Ok(protocol::SqliteCommitResponse::SqliteCommitOk( - protocol::SqliteCommitOk { - head_txid: Some(result.head_txid), - }, + protocol::SqliteCommitOk { head_txid }, )) } Err(err) => Ok(protocol::SqliteCommitResponse::SqliteErrorResponse( @@ -468,7 +476,8 @@ impl SqliteTransport for DirectMirrorTransport { &self, request: protocol::SqliteCommitRequest, ) -> Result { - self.storage + let applied = self + .storage .hooks .apply_commit_hooks(request.clone()) .await?; @@ -484,9 +493,14 @@ impl SqliteTransport for DirectMirrorTransport { .apply_commit(&actor_id, dirty_pages, request.db_size_pages) .await { - Ok(()) => Ok(protocol::SqliteCommitResponse::SqliteCommitOk( - protocol::SqliteCommitOk { head_txid: None }, - )), + Ok(()) => { + if let Some(applied) = applied { + let _ = applied.send(()); + } + Ok(protocol::SqliteCommitResponse::SqliteCommitOk( + protocol::SqliteCommitOk { head_txid: None }, + )) + } Err(err) => Ok(protocol::SqliteCommitResponse::SqliteErrorResponse( sqlite_error_response(&err), )), @@ -534,22 +548,35 @@ struct DirectStorageCounters { #[derive(Default)] pub(crate) struct DirectTransportHooks { - fail_next_commit: Mutex>, - fail_next_commit_after_apply: Mutex>, + fail_next_commit: Mutex>, + fail_next_commit_after_apply: Mutex>, fail_next_get_pages: Mutex>, hang_next_commit: Mutex, pause_next_commit: Mutex>, get_pages_requests: Mutex>, commit_requests: Mutex>, + commit_response_head: Mutex>>, } impl DirectTransportHooks { pub(crate) fn fail_next_commit(&self, message: impl Into) { - *self.fail_next_commit.lock() = Some(message.into()); + self.fail_next_commits(1, message); + } + + pub(crate) fn fail_next_commits(&self, count: usize, message: impl Into) { + *self.fail_next_commit.lock() = Some((count, message.into())); } pub(crate) fn fail_next_commit_after_apply(&self, message: impl Into) { - *self.fail_next_commit_after_apply.lock() = Some(message.into()); + self.fail_next_commits_after_apply(1, message); + } + + pub(crate) fn fail_next_commits_after_apply(&self, count: usize, message: impl Into) { + *self.fail_next_commit_after_apply.lock() = Some((count, message.into())); + } + + pub(crate) fn return_next_commit_head(&self, head: Option) { + *self.commit_response_head.lock() = Some(head); } pub(crate) fn fail_next_get_pages(&self, message: impl Into) { @@ -582,23 +609,30 @@ impl DirectTransportHooks { pub(crate) fn pause_next_commit(&self) -> DirectCommitPause { let (reached_tx, reached_rx) = mpsc::channel(); - let (resume_tx, resume_rx) = mpsc::channel(); + let (resume_tx, resume_rx) = oneshot::channel(); + let (applied_tx, applied_rx) = mpsc::channel(); *self.pause_next_commit.lock() = Some(DirectCommitGate { reached: reached_tx, resume: resume_rx, + applied: applied_tx, }); DirectCommitPause { reached: reached_rx, resume: resume_tx, + applied: applied_rx, } } pub(crate) fn take_commit_error(&self) -> Option { - self.fail_next_commit.lock().take() + take_counted_error(&self.fail_next_commit) } pub(crate) fn take_commit_after_apply_error(&self) -> Option { - self.fail_next_commit_after_apply.lock().take() + take_counted_error(&self.fail_next_commit_after_apply) + } + + pub(crate) fn take_commit_response_head(&self) -> Option> { + self.commit_response_head.lock().take() } pub(crate) fn take_get_pages_error(&self) -> Option { @@ -612,18 +646,19 @@ impl DirectTransportHooks { should_hang } - pub(crate) fn pause_commit_if_requested(&self) { + pub(crate) async fn pause_commit_if_requested(&self) -> Option> { let Some(gate) = self.pause_next_commit.lock().take() else { - return; + return None; }; let _ = gate.reached.send(()); - let _ = gate.resume.recv(); + let _ = gate.resume.await; + Some(gate.applied) } pub(crate) async fn apply_commit_hooks( &self, req: protocol::SqliteCommitRequest, - ) -> Result<()> { + ) -> Result>> { self.record_commit_request(req); if self.take_commit_hang() { std::future::pending().await @@ -631,14 +666,25 @@ impl DirectTransportHooks { if let Some(message) = self.take_commit_error() { return Err(anyhow::anyhow!(message)); } - self.pause_commit_if_requested(); - Ok(()) + Ok(self.pause_commit_if_requested().await) } } +fn take_counted_error(slot: &Mutex>) -> Option { + let mut slot = slot.lock(); + let (remaining, message) = slot.as_mut()?; + let output = message.clone(); + *remaining = remaining.saturating_sub(1); + if *remaining == 0 { + *slot = None; + } + Some(output) +} + pub(crate) struct DirectCommitPause { reached: mpsc::Receiver<()>, - resume: mpsc::Sender<()>, + resume: oneshot::Sender<()>, + applied: mpsc::Receiver<()>, } impl DirectCommitPause { @@ -646,14 +692,28 @@ impl DirectCommitPause { self.reached.recv().expect("commit pause should be reached"); } + pub(crate) fn wait_until_reached_with_context(&self, context: &str) { + self.reached + .recv() + .unwrap_or_else(|error| panic!("{context}: commit pause should be reached: {error}")); + } + pub(crate) fn resume(self) { + let _ = self.resume.send(()); + } + + pub(crate) fn resume_and_wait_until_applied(self) { self.resume.send(()).expect("commit pause should resume"); + self.applied + .recv() + .expect("resumed commit should be applied"); } } struct DirectCommitGate { reached: mpsc::Sender<()>, - resume: mpsc::Receiver<()>, + resume: oneshot::Receiver<()>, + applied: mpsc::Sender<()>, } pub(crate) fn protocol_fetched_page( diff --git a/rivetkit-rust/engine/artifacts/errors/sqlite.deferred_commits_unsupported.json b/rivetkit-rust/engine/artifacts/errors/sqlite.deferred_commits_unsupported.json new file mode 100644 index 0000000000..fe95f5c5ac --- /dev/null +++ b/rivetkit-rust/engine/artifacts/errors/sqlite.deferred_commits_unsupported.json @@ -0,0 +1,5 @@ +{ + "code": "deferred_commits_unsupported", + "group": "sqlite", + "message": "Deferred SQLite commits are unsupported." +} \ No newline at end of file diff --git a/rivetkit-rust/engine/artifacts/errors/sqlite.flush_failed.json b/rivetkit-rust/engine/artifacts/errors/sqlite.flush_failed.json new file mode 100644 index 0000000000..069e4be485 --- /dev/null +++ b/rivetkit-rust/engine/artifacts/errors/sqlite.flush_failed.json @@ -0,0 +1,5 @@ +{ + "code": "flush_failed", + "group": "sqlite", + "message": "SQLite durability flush failed." +} \ No newline at end of file diff --git a/rivetkit-rust/engine/artifacts/errors/sqlite.invalid_argument.json b/rivetkit-rust/engine/artifacts/errors/sqlite.invalid_argument.json new file mode 100644 index 0000000000..ffc9d20880 --- /dev/null +++ b/rivetkit-rust/engine/artifacts/errors/sqlite.invalid_argument.json @@ -0,0 +1,5 @@ +{ + "code": "invalid_argument", + "group": "sqlite", + "message": "Invalid SQLite argument." +} \ No newline at end of file diff --git a/rivetkit-rust/engine/artifacts/errors/sqlite.transaction_active.json b/rivetkit-rust/engine/artifacts/errors/sqlite.transaction_active.json new file mode 100644 index 0000000000..98fdc2d6cf --- /dev/null +++ b/rivetkit-rust/engine/artifacts/errors/sqlite.transaction_active.json @@ -0,0 +1,5 @@ +{ + "code": "transaction_active", + "group": "sqlite", + "message": "A synchronous SQLite transaction is active." +} \ No newline at end of file diff --git a/rivetkit-rust/engine/artifacts/errors/sqlite.transaction_closed.json b/rivetkit-rust/engine/artifacts/errors/sqlite.transaction_closed.json index 657c229e9e..2b60a41cb6 100644 --- a/rivetkit-rust/engine/artifacts/errors/sqlite.transaction_closed.json +++ b/rivetkit-rust/engine/artifacts/errors/sqlite.transaction_closed.json @@ -1,5 +1,5 @@ { "code": "transaction_closed", "group": "sqlite", - "message": "SQLite transaction coordinator is closed." + "message": "SQLite transaction is closed." } \ No newline at end of file diff --git a/rivetkit-rust/packages/rivetkit-core/src/actor/actor_runtime_socket.rs b/rivetkit-rust/packages/rivetkit-core/src/actor/actor_runtime_socket.rs index b5a017496d..d9da5c5552 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/actor/actor_runtime_socket.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/actor/actor_runtime_socket.rs @@ -26,9 +26,8 @@ use vbare::OwnedVersionedData; use crate::actor::sqlite::{ BindParam, ColumnValue, ExecuteResult, SqliteBackend, SqliteDb, SqliteTransaction, - TRANSACTION_COORDINATOR_QUEUE_CAPACITY, TransactionCoordinatorClosedError, - TransactionExpiredError, TransactionInvalidArgumentError, TransactionQueueFullError, - TransactionTerminalError, TransactionUnknownError, + TRANSACTION_COORDINATOR_QUEUE_CAPACITY, TransactionClosedError, TransactionExpiredError, + TransactionInvalidArgumentError, TransactionQueueFullError, TransactionUnknownError, }; const DEFAULT_MAX_FRAME_BYTES: u32 = 32 * 1024 * 1024; @@ -113,7 +112,7 @@ impl EndpointTransaction for CoreEndpointTransaction { } async fn commit(&self) -> Result<()> { - self.0.commit().await + self.0.commit().await.map(|_| ()) } async fn rollback(&self) -> Result<()> { @@ -214,6 +213,13 @@ impl ActorRuntimeSocketEndpoint { }; if let Some(mut serving) = serving { serving.cancel.cancel(); + // Unlink before waiting for the listener task so destroy cannot expose the + // previous generation's socket path while shutdown is still joining it. + if let Err(error) = std::fs::remove_file(&serving.info.path) { + if error.kind() != io::ErrorKind::NotFound { + tracing::warn!(path = %serving.info.path, %error, "failed to remove Actor Runtime Socket"); + } + } match tokio::time::timeout(LISTENER_SHUTDOWN_TIMEOUT, &mut serving.task).await { Ok(Ok(())) => {} Ok(Err(error)) => { @@ -224,11 +230,6 @@ impl ActorRuntimeSocketEndpoint { let _ = serving.task.await; } } - if let Err(error) = std::fs::remove_file(&serving.info.path) { - if error.kind() != io::ErrorKind::NotFound { - tracing::warn!(path = %serving.info.path, %error, "failed to remove Actor Runtime Socket"); - } - } } } } @@ -958,14 +959,12 @@ fn map_error(error: anyhow::Error) -> wire::ResponsePayload { if let Some(error) = error.downcast_ref::() { return invalid_lease(&error.to_string()); } - if let Some(error) = error.downcast_ref::() { - return invalid_lease(&error.to_string()); - } - if error - .downcast_ref::() - .is_some() - { - return wire::ResponsePayload::EndpointClosed; + if let Some(error) = error.downcast_ref::() { + return if error.coordinator { + wire::ResponsePayload::EndpointClosed + } else { + invalid_lease(&error.to_string()) + }; } if error .downcast_ref::() diff --git a/rivetkit-rust/packages/rivetkit-core/src/actor/config.rs b/rivetkit-rust/packages/rivetkit-core/src/actor/config.rs index e9519f8824..4e81905eec 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/actor/config.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/actor/config.rs @@ -24,6 +24,13 @@ const DEFAULT_MAX_INCOMING_MESSAGE_SIZE: u32 = 65_536; const DEFAULT_MAX_OUTGOING_MESSAGE_SIZE: u32 = 1_048_576; pub(crate) const MAX_SQLITE_TRANSACTION_TRACE_STATEMENTS: usize = 32; +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum SqliteCommitMode { + #[default] + Awaited, + Deferred, +} + #[derive(Clone)] pub enum CanHibernateWebSocket { Bool(bool), @@ -151,6 +158,7 @@ pub struct ActorConfig { /// on the TS side). Gates the inspector database tab. pub has_database: bool, pub remote_sqlite: bool, + pub sqlite_commit_mode: SqliteCommitMode, pub sqlite_profiling: SqliteProfilingConfig, /// Enables the experimental Actor Runtime Socket. pub enable_actor_runtime_socket: bool, @@ -190,6 +198,7 @@ pub struct ActorConfigInput { pub icon: Option, pub has_database: Option, pub remote_sqlite: Option, + pub sqlite_commit_mode: Option, pub sqlite_profiling: Option, pub enable_actor_runtime_socket: Option, pub has_state: Option, @@ -222,6 +231,7 @@ impl ActorConfig { icon: config.icon, has_database: config.has_database.unwrap_or(false), remote_sqlite: config.remote_sqlite.unwrap_or(false), + sqlite_commit_mode: config.sqlite_commit_mode.unwrap_or_default(), sqlite_profiling: config .sqlite_profiling .map(SqliteProfilingConfig::from_input) @@ -340,6 +350,7 @@ impl Default for ActorConfig { icon: None, has_database: false, remote_sqlite: false, + sqlite_commit_mode: SqliteCommitMode::Awaited, sqlite_profiling: SqliteProfilingConfig::default(), enable_actor_runtime_socket: false, has_state: false, diff --git a/rivetkit-rust/packages/rivetkit-core/src/actor/mod.rs b/rivetkit-rust/packages/rivetkit-core/src/actor/mod.rs index 837629ac4c..758dbf79b6 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/actor/mod.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/actor/mod.rs @@ -32,7 +32,9 @@ pub(crate) mod work_registry; pub use action::ActionDispatchError; #[cfg(feature = "sqlite-local")] pub use actor_runtime_socket::ActorRuntimeSocketEndpointInfo; -pub use config::{ActionDefinition, ActorConfig, ActorConfigOverrides, CanHibernateWebSocket}; +pub use config::{ + ActionDefinition, ActorConfig, ActorConfigOverrides, CanHibernateWebSocket, SqliteCommitMode, +}; pub use connection::ConnHandle; pub use context::{ActorContext, ActorWorkRegion, KeepAwakeRegion, WebSocketCallbackRegion}; pub use factory::{ActorEntryFn, ActorFactory}; diff --git a/rivetkit-rust/packages/rivetkit-core/src/actor/sqlite/mod.rs b/rivetkit-rust/packages/rivetkit-core/src/actor/sqlite/mod.rs index 717a971c23..bd829f26ea 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/actor/sqlite/mod.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/actor/sqlite/mod.rs @@ -2,7 +2,7 @@ use std::collections::HashSet; use std::io::Cursor; use std::sync::{ Arc, - atomic::{AtomicBool, Ordering}, + atomic::{AtomicBool, AtomicU64, Ordering}, }; use anyhow::{Context, Result}; @@ -30,10 +30,10 @@ mod profiling; mod tx; pub use tx::{ - DEFAULT_TRANSACTION_TIMEOUT, SqliteTransaction, TRANSACTION_COORDINATOR_QUEUE_CAPACITY, - TransactionConnectionLostError, TransactionCoordinatorClosedError, TransactionExpiredError, - TransactionInvalidArgumentError, TransactionQueueFullError, TransactionTerminalError, - TransactionUnknownError, + BridgeTransactionReservation, CallMode, DEFAULT_TRANSACTION_TIMEOUT, SqliteTransaction, + TRANSACTION_COORDINATOR_QUEUE_CAPACITY, TransactionClosedError, TransactionConnectionLostError, + TransactionExpiredError, TransactionInvalidArgumentError, TransactionOrigin, + TransactionQueueFullError, TransactionUnknownError, }; #[cfg(test)] use tx::{ @@ -42,6 +42,7 @@ use tx::{ }; use tx::{TransactionCoordinator, run_detached_transaction_task}; +use crate::SqliteCommitMode; #[cfg(feature = "sqlite-local")] use crate::error::ActorLifecycle; use crate::error::SqliteRuntimeError; @@ -95,6 +96,7 @@ pub enum SqliteBackend { struct ProfiledBackendResult { result: Result, profile: Option, + post_autocommit: Option, } impl SqliteDb { @@ -105,15 +107,18 @@ impl SqliteDb { Ok(result) => ProfiledBackendResult { result: result.result, profile: Some(result.profile), + post_autocommit: Some(result.post_autocommit), }, Err(error) => ProfiledBackendResult { result: Err(error), profile: None, + post_autocommit: None, }, }, SqliteBackend::RemoteEnvoy => ProfiledBackendResult { result: self.remote_exec(sql).await, profile: None, + post_autocommit: None, }, } } @@ -129,15 +134,18 @@ impl SqliteDb { Ok(result) => ProfiledBackendResult { result: result.result, profile: Some(result.profile), + post_autocommit: Some(result.post_autocommit), }, Err(error) => ProfiledBackendResult { result: Err(error), profile: None, + post_autocommit: None, }, }, SqliteBackend::RemoteEnvoy => ProfiledBackendResult { result: self.remote_execute(sql, params).await, profile: None, + post_autocommit: None, }, } } @@ -149,17 +157,21 @@ impl SqliteDb { } } + #[cfg(not(feature = "sqlite-local"))] async fn exec_backend_in_session( &self, sql: String, expected_session: Option, - ) -> Result<(QueryResult, Option)> { + ) -> Result<(QueryResult, Option, Option)> { match self.backend { - SqliteBackend::LocalNative => self.local_exec(sql).await.map(|result| (result, None)), + SqliteBackend::LocalNative => self + .local_exec(sql) + .await + .map(|result| (result, None, None)), SqliteBackend::RemoteEnvoy => self .remote_exec_with_session(sql, expected_session) .await - .map(|(result, session)| (result, Some(session))), + .map(|(result, session)| (result, Some(session), None)), } } @@ -209,16 +221,26 @@ impl SqliteDb { sql: String, params: Option>, expected_session: Option, - ) -> Result<(ExecuteResult, Option)> { + ) -> Result<(ExecuteResult, Option, Option)> { match self.backend { - SqliteBackend::LocalNative => self - .local_execute(sql, params) - .await - .map(|result| (result, None)), + SqliteBackend::LocalNative => { + #[cfg(feature = "sqlite-local")] + { + let profiled = self.local_execute_profiled(sql, params).await?; + let post_autocommit = profiled.post_autocommit; + Ok((profiled.result?, None, Some(post_autocommit))) + } + #[cfg(not(feature = "sqlite-local"))] + { + self.local_execute(sql, params) + .await + .map(|result| (result, None, None)) + } + } SqliteBackend::RemoteEnvoy => self .remote_execute_with_session(sql, params, expected_session) .await - .map(|(result, session)| (result, Some(session))), + .map(|(result, session)| (result, Some(session), None)), } } } @@ -230,6 +252,8 @@ pub struct SqliteDb { actor_key: Option, generation: Option, backend: SqliteBackend, + commit_mode: SqliteCommitMode, + last_commit_seq: Arc, /// Mirrors the user's actor-config `db({...})` declaration. The envoy /// always sets up sqlite storage under the hood, so handle/actor_id are /// not a reliable signal for whether the user opted in; this flag is. @@ -262,6 +286,8 @@ impl Default for SqliteDb { } else { SqliteBackend::RemoteEnvoy }, + commit_mode: SqliteCommitMode::Awaited, + last_commit_seq: Default::default(), enabled: false, #[cfg(feature = "sqlite-local")] db: Default::default(), @@ -281,7 +307,15 @@ impl Default for SqliteDb { impl SqliteDb { pub fn new(handle: EnvoyHandle, actor_id: impl Into, enabled: bool) -> Result { - Self::new_with_remote_sqlite(handle, actor_id, None, None, enabled, false) + Self::new_with_remote_sqlite( + handle, + actor_id, + None, + None, + enabled, + false, + SqliteCommitMode::Awaited, + ) } pub fn new_with_remote_sqlite( @@ -291,13 +325,16 @@ impl SqliteDb { generation: Option, enabled: bool, remote_sqlite: bool, + commit_mode: SqliteCommitMode, ) -> Result { Ok(Self { handle: Some(handle), actor_id: Some(actor_id.into()), actor_key, generation, - backend: select_sqlite_backend(remote_sqlite)?, + backend: select_sqlite_backend(remote_sqlite, commit_mode)?, + commit_mode, + last_commit_seq: Default::default(), enabled, #[cfg(feature = "sqlite-local")] db: Default::default(), @@ -314,6 +351,11 @@ impl SqliteDb { }) } + pub(crate) fn with_commit_sequence_state(mut self, state: Arc) -> Self { + self.last_commit_seq = state; + self + } + #[cfg(feature = "sqlite-local")] pub(crate) fn set_vfs_metrics(&mut self, metrics: Arc) { self.vfs_metrics = Some(metrics); @@ -351,6 +393,62 @@ impl SqliteDb { self.backend } + pub fn commit_mode(&self) -> SqliteCommitMode { + self.commit_mode + } + + pub fn commit_seq(&self) -> u64 { + #[cfg(feature = "sqlite-local")] + if let Some(db) = self.db.lock().as_ref() { + return db.commit_seq(); + } + self.last_commit_seq.load(Ordering::Acquire) + } + + pub fn flushed_seq(&self) -> u64 { + #[cfg(feature = "sqlite-local")] + if let Some(db) = self.db.lock().as_ref() { + return db.flushed_seq(); + } + self.last_commit_seq.load(Ordering::Acquire) + } + + pub fn flush_error(&self) -> Option { + #[cfg(feature = "sqlite-local")] + if let Some(db) = self.db.lock().as_ref() { + return db.flush_error().map(|error| error.to_string()); + } + None + } + + pub async fn wait_for_flush(&self, seq: u64) -> Result<()> { + #[cfg(feature = "sqlite-local")] + let db = { self.db.lock().as_ref().cloned() }; + #[cfg(feature = "sqlite-local")] + if let Some(db) = db { + return db.wait_for_flush(seq).await.map_err(|error| match error { + depot_client::vfs::FlushError::InvalidSequence { .. } => { + SqliteRuntimeError::InvalidArgument { + message: error.to_string(), + } + .build() + } + _ => SqliteRuntimeError::FlushFailed { + message: error.to_string(), + } + .build(), + }); + } + if seq <= self.last_commit_seq.load(Ordering::Acquire) { + Ok(()) + } else { + Err(SqliteRuntimeError::InvalidArgument { + message: format!("flush sequence {seq} is ahead of the current commit sequence"), + } + .build()) + } + } + pub async fn get_pages( &self, request: protocol::SqliteGetPagesRequest, @@ -390,6 +488,13 @@ impl SqliteDb { .ok_or_else(|| sqlite_not_configured("generation"))?, rt_handle, vfs_metrics, + match self.commit_mode { + SqliteCommitMode::Awaited => depot_client::vfs::CommitMode::Awaited, + SqliteCommitMode::Deferred => { + depot_client::vfs::CommitMode::Deferred + } + }, + self.last_commit_seq.load(Ordering::Acquire), ) .await, )?; @@ -512,6 +617,14 @@ impl SqliteDb { } pub async fn exec(&self, sql: impl Into) -> Result { + self.exec_with_call_mode(sql, CallMode::Async).await + } + + pub async fn exec_with_call_mode( + &self, + sql: impl Into, + call_mode: CallMode, + ) -> Result { let sql = sql.into(); let sql_for_log = sql.clone(); #[cfg(feature = "sqlite-local")] @@ -522,7 +635,7 @@ impl SqliteDb { .then(crate::time::Instant::now); #[cfg(feature = "sqlite-local")] let transaction_wait_started_at = started_at.map(|_| crate::time::Instant::now()); - let guard = self.begin_regular_operation().await; + let guard = self.begin_regular_operation(call_mode).await; #[cfg(feature = "sqlite-local")] let transaction_wait = transaction_wait_started_at.map(|started| started.elapsed()); #[cfg(feature = "sqlite-local")] @@ -564,6 +677,16 @@ impl SqliteDb { &self, sql: impl Into, params: Option>, + ) -> Result { + self.query_with_call_mode(sql, params, CallMode::Async) + .await + } + + pub async fn query_with_call_mode( + &self, + sql: impl Into, + params: Option>, + call_mode: CallMode, ) -> Result { let sql = sql.into(); let sql_for_log = sql.clone(); @@ -576,7 +699,7 @@ impl SqliteDb { .then(crate::time::Instant::now); #[cfg(feature = "sqlite-local")] let transaction_wait_started_at = started_at.map(|_| crate::time::Instant::now()); - let guard = self.begin_regular_operation().await; + let guard = self.begin_regular_operation(call_mode).await; #[cfg(feature = "sqlite-local")] let transaction_wait = transaction_wait_started_at.map(|started| started.elapsed()); #[cfg(feature = "sqlite-local")] @@ -587,6 +710,7 @@ impl SqliteDb { profiled.result.map(|result| QueryResult { columns: result.columns, rows: result.rows, + readonly: result.readonly, }), profiled.profile, ) @@ -624,6 +748,15 @@ impl SqliteDb { &self, sql: impl Into, params: Option>, + ) -> Result { + self.run_with_call_mode(sql, params, CallMode::Async).await + } + + pub async fn run_with_call_mode( + &self, + sql: impl Into, + params: Option>, + call_mode: CallMode, ) -> Result { let sql = sql.into(); let sql_for_log = sql.clone(); @@ -636,7 +769,7 @@ impl SqliteDb { .then(crate::time::Instant::now); #[cfg(feature = "sqlite-local")] let transaction_wait_started_at = started_at.map(|_| crate::time::Instant::now()); - let guard = self.begin_regular_operation().await; + let guard = self.begin_regular_operation(call_mode).await; #[cfg(feature = "sqlite-local")] let transaction_wait = transaction_wait_started_at.map(|started| started.elapsed()); #[cfg(feature = "sqlite-local")] @@ -683,6 +816,16 @@ impl SqliteDb { &self, sql: impl Into, params: Option>, + ) -> Result { + self.execute_with_call_mode(sql, params, CallMode::Async) + .await + } + + pub async fn execute_with_call_mode( + &self, + sql: impl Into, + params: Option>, + call_mode: CallMode, ) -> Result { let sql = sql.into(); let sql_for_log = sql.clone(); @@ -695,7 +838,7 @@ impl SqliteDb { .then(crate::time::Instant::now); #[cfg(feature = "sqlite-local")] let transaction_wait_started_at = started_at.map(|_| crate::time::Instant::now()); - let guard = self.begin_regular_operation().await; + let guard = self.begin_regular_operation(call_mode).await; #[cfg(feature = "sqlite-local")] let transaction_wait = transaction_wait_started_at.map(|started| started.elapsed()); #[cfg(feature = "sqlite-local")] @@ -743,7 +886,7 @@ impl SqliteDb { .map(|statement| bind_param_count(&statement.params)) .sum(); let result = if self.backend == SqliteBackend::RemoteEnvoy { - match self.begin_regular_operation().await { + match self.begin_regular_operation(CallMode::Async).await { Ok(_guard) => self.remote_execute_batch(statements).await, Err(error) => Err(error), } @@ -810,8 +953,13 @@ impl SqliteDb { { let native_db = self.db.lock().take(); if let Some(native_db) = native_db { - let result = self.map_local_worker_result(native_db.close().await); + // An explicit close owns reporting any drain failure to its caller. + // Stop the background monitor first so the same failure does not + // also terminate the actor while shutdown is already in progress. self.abort_worker_failure_monitor(); + let result = self.map_local_worker_result(native_db.close().await); + self.last_commit_seq + .store(native_db.commit_seq(), Ordering::Release); if let Some(metrics) = self.vfs_metrics.as_ref() { metrics.set_worker_active(false); } @@ -846,6 +994,7 @@ impl SqliteDb { self.generation, self.enabled, true, + self.commit_mode, ) .expect("remote sqlite test database should be configured") } @@ -895,11 +1044,10 @@ impl SqliteDb { let Ok(config) = self.runtime_config() else { return; }; - report_sqlite_worker_fatal( - &self.worker_fatal_reported, - config, - sqlite_worker_fatal_message(error), - ); + let message = self + .flush_error() + .unwrap_or_else(|| sqlite_worker_fatal_message(error)); + report_sqlite_worker_fatal(&self.worker_fatal_reported, config, message); } #[cfg(feature = "sqlite-local")] @@ -911,13 +1059,20 @@ impl SqliteDb { self.abort_worker_failure_monitor(); let reported = Arc::clone(&self.worker_fatal_reported); let task = RuntimeSpawner::spawn(async move { - if native_db.wait_for_worker_failure().await { - report_sqlite_worker_fatal( - &reported, - config, - "sqlite worker thread stopped unexpectedly".to_string(), - ); - } + let reason = native_db.wait_for_failure().await; + let Some(message) = (match reason { + depot_client::vfs::DatabaseFailure::Closed => None, + depot_client::vfs::DatabaseFailure::WorkerStopped => { + Some("sqlite worker thread stopped unexpectedly".to_string()) + } + depot_client::vfs::DatabaseFailure::Flush(error) => Some(error.to_string()), + }) else { + return; + }; + let message = native_db + .flush_error() + .map_or(message, |error| error.to_string()); + report_sqlite_worker_fatal(&reported, config, message); }); *self.worker_failure_task.lock() = Some(task); } @@ -929,6 +1084,14 @@ impl SqliteDb { } } + #[cfg(all(test, feature = "sqlite-local"))] + async fn wait_for_worker_failure_monitor_for_test(&self) { + let task = self.worker_failure_task.lock().take(); + if let Some(task) = task { + let _ = task.await; + } + } + pub fn metrics(&self) -> Option { #[cfg(feature = "sqlite-local")] { @@ -1195,6 +1358,7 @@ impl SqliteDb { encode_json_as_cbor(&query_result_to_json_rows(&QueryResult { columns: result.columns, rows: result.rows, + readonly: result.readonly, })) } @@ -1243,7 +1407,15 @@ struct RemoteSqliteConfig { generation: u64, } -fn select_sqlite_backend(remote_sqlite: bool) -> Result { +fn select_sqlite_backend( + remote_sqlite: bool, + commit_mode: SqliteCommitMode, +) -> Result { + if commit_mode == SqliteCommitMode::Deferred + && (remote_sqlite || !cfg!(feature = "sqlite-local")) + { + return Err(SqliteRuntimeError::DeferredCommitsUnsupported.build()); + } if remote_sqlite { return Ok(SqliteBackend::RemoteEnvoy); } @@ -1280,7 +1452,7 @@ fn is_fatal_worker_error(error: &anyhow::Error) -> bool { #[cfg(feature = "sqlite-local")] fn sqlite_worker_fatal_message(error: &anyhow::Error) -> String { if let Some(error) = error.downcast_ref::() { - return format!("sqlite fatal storage error: {}", error.message()); + return error.message().to_owned(); } format!("sqlite worker failed: {error}") @@ -1342,6 +1514,7 @@ fn query_result_from_protocol(result: protocol::SqliteQueryResult) -> QueryResul .into_iter() .map(|row| row.into_iter().map(column_value_from_protocol).collect()) .collect(), + readonly: None, } } @@ -1355,6 +1528,8 @@ fn execute_result_from_protocol(result: protocol::SqliteExecuteResult) -> Execut .collect(), changes: result.changes, last_insert_row_id: result.last_insert_row_id, + readonly: None, + commit_seq: None, } } diff --git a/rivetkit-rust/packages/rivetkit-core/src/actor/sqlite/tx.rs b/rivetkit-rust/packages/rivetkit-core/src/actor/sqlite/tx.rs index 1f011fcff7..8ef9fb25b8 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/actor/sqlite/tx.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/actor/sqlite/tx.rs @@ -1,5 +1,5 @@ use std::{ - collections::{BTreeMap, VecDeque}, + collections::{BTreeMap, BTreeSet, VecDeque}, error::Error, fmt, future::Future, @@ -11,12 +11,13 @@ use std::{ }; use anyhow::{Context, Result}; +use parking_lot::Mutex as SyncMutex; use serde::Serialize; #[cfg(target_arch = "wasm32")] use tokio::sync::oneshot; use tokio::sync::{ - Mutex as AsyncMutex, OwnedRwLockReadGuard, OwnedRwLockWriteGuard, OwnedSemaphorePermit, RwLock, - Semaphore, TryAcquireError, + Mutex as AsyncMutex, Notify, OwnedRwLockReadGuard, OwnedRwLockWriteGuard, OwnedSemaphorePermit, + RwLock, Semaphore, TryAcquireError, }; use tokio_util::sync::CancellationToken; @@ -35,6 +36,18 @@ pub(super) const TRANSACTION_TERMINAL_CAPACITY: usize = 1024; #[cfg(feature = "sqlite-local")] static UNNAMED_TRANSACTION_WARNINGS: AtomicU64 = AtomicU64::new(0); +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CallMode { + Async, + SyncBlocking, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TransactionOrigin { + Bridge, + Internal, +} + #[derive(Clone)] pub struct SqliteTransaction { db: SqliteDb, @@ -60,12 +73,15 @@ impl SqliteTransaction { .await } - pub async fn commit(&self) -> Result<()> { + pub async fn commit(&self) -> Result> { self.db.finish_transaction(&self.key, true).await } pub async fn rollback(&self) -> Result<()> { - self.db.finish_transaction(&self.key, false).await + self.db + .finish_transaction(&self.key, false) + .await + .map(|_| ()) } pub async fn expire(&self) -> Result<()> { @@ -77,12 +93,29 @@ pub(super) struct TransactionCoordinator { pub(super) gate: Arc>, pub(super) admission: Arc, pub(super) state: AsyncMutex, + bridge_pending: SyncMutex>, + bridge_pending_notify: Notify, epoch: AtomicU64, waiters: AtomicU64, } +pub struct BridgeTransactionReservation { + coordinator: Arc, + key: String, + armed: bool, +} + +impl Drop for BridgeTransactionReservation { + fn drop(&mut self) { + if self.armed { + self.coordinator.bridge_pending.lock().remove(&self.key); + } + } +} + pub(super) struct TransactionCoordinatorState { pub(super) active: Option, + pub(super) pending: BTreeMap, pub(super) terminal: BTreeMap, pub(super) terminal_order: VecDeque, pub(super) poisoned: BTreeMap, @@ -92,6 +125,7 @@ pub(super) struct TransactionCoordinatorState { pub(super) struct ActiveTransaction { key: String, + origin: TransactionOrigin, timeout: Duration, pub(super) expiring: bool, connection_lost: bool, @@ -164,12 +198,15 @@ impl Default for TransactionCoordinator { admission: Arc::new(Semaphore::new(TRANSACTION_COORDINATOR_QUEUE_CAPACITY)), state: AsyncMutex::new(TransactionCoordinatorState { active: None, + pending: BTreeMap::new(), terminal: BTreeMap::new(), terminal_order: VecDeque::new(), poisoned: BTreeMap::new(), last_expired_timeout: None, closed: false, }), + bridge_pending: SyncMutex::new(BTreeSet::new()), + bridge_pending_notify: Notify::new(), epoch: AtomicU64::new(0), waiters: AtomicU64::new(0), } @@ -177,6 +214,159 @@ impl Default for TransactionCoordinator { } impl SqliteDb { + fn bridge_reserved(&self, exclude_key: Option<&str>) -> bool { + self.transaction_coordinator + .bridge_pending + .lock() + .iter() + .any(|key| Some(key.as_str()) != exclude_key) + } + + async fn acquire_sync_regular_gate(&self) -> Result> { + loop { + let notified = self + .transaction_coordinator + .bridge_pending_notify + .notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + { + let state = self.transaction_coordinator.state.lock().await; + let bridge_transaction = self.bridge_reserved(None) + || state + .pending + .values() + .any(|origin| *origin == TransactionOrigin::Bridge) + || state + .active + .as_ref() + .is_some_and(|active| active.origin == TransactionOrigin::Bridge); + if bridge_transaction { + return Err(crate::error::SqliteRuntimeError::TransactionActive.build()); + } + if let Ok(gate) = Arc::clone(&self.transaction_coordinator.gate).try_read_owned() { + return Ok(gate); + } + } + + tokio::select! { + _ = &mut notified => continue, + gate = Arc::clone(&self.transaction_coordinator.gate).read_owned() => { + let state = self.transaction_coordinator.state.lock().await; + let bridge_transaction = self.bridge_reserved(None) + || state.pending.values().any(|origin| *origin == TransactionOrigin::Bridge) + || state.active.as_ref().is_some_and(|active| active.origin == TransactionOrigin::Bridge); + if bridge_transaction { + return Err(crate::error::SqliteRuntimeError::TransactionActive.build()); + } + return Ok(gate); + } + } + } + } + + async fn acquire_sync_transaction_gate(&self, key: &str) -> Result> { + loop { + let notified = self + .transaction_coordinator + .bridge_pending_notify + .notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + { + let state = self.transaction_coordinator.state.lock().await; + let bridge_transaction = self.bridge_reserved(Some(key)) + || state.pending.iter().any(|(pending_key, origin)| { + pending_key != key && *origin == TransactionOrigin::Bridge + }) || state + .active + .as_ref() + .is_some_and(|active| active.origin == TransactionOrigin::Bridge); + if bridge_transaction { + return Err(crate::error::SqliteRuntimeError::TransactionActive.build()); + } + if let Ok(gate) = Arc::clone(&self.transaction_coordinator.gate).try_write_owned() { + return Ok(gate); + } + } + + tokio::select! { + _ = &mut notified => continue, + gate = Arc::clone(&self.transaction_coordinator.gate).write_owned() => { + let state = self.transaction_coordinator.state.lock().await; + let bridge_transaction = self.bridge_reserved(Some(key)) + || state.pending.iter().any(|(pending_key, origin)| { + pending_key != key && *origin == TransactionOrigin::Bridge + }) + || state.active.as_ref().is_some_and(|active| active.origin == TransactionOrigin::Bridge); + if bridge_transaction { + return Err(crate::error::SqliteRuntimeError::TransactionActive.build()); + } + return Ok(gate); + } + } + } + } + + pub fn reserve_bridge_transaction(&self) -> BridgeTransactionReservation { + let key = uuid::Uuid::new_v4().to_string(); + self.transaction_coordinator + .bridge_pending + .lock() + .insert(key.clone()); + self.transaction_coordinator + .bridge_pending_notify + .notify_waiters(); + BridgeTransactionReservation { + coordinator: Arc::clone(&self.transaction_coordinator), + key, + armed: true, + } + } + + pub async fn begin_reserved_bridge_transaction( + &self, + reservation: BridgeTransactionReservation, + name: Option, + timeout: Option, + ) -> Result { + if !Arc::ptr_eq(&reservation.coordinator, &self.transaction_coordinator) { + return Err(transaction_invalid_argument_error( + "bridge transaction reservation belongs to another database", + )); + } + let key = reservation.key.clone(); + self.validate_transaction_name(name.as_deref())?; + self.begin_transaction_with_key_and_name( + key, + name, + timeout, + TransactionOrigin::Bridge, + CallMode::Async, + ) + .await + } + + fn validate_transaction_name(&self, name: Option<&str>) -> Result<()> { + #[cfg(feature = "sqlite-local")] + let max_name_bytes = self.profiling.config.max_transaction_name_bytes; + #[cfg(not(feature = "sqlite-local"))] + let max_name_bytes = MAX_TRANSACTION_NAME_BYTES; + if let Some(name) = name { + if name.is_empty() { + return Err(transaction_invalid_argument_error( + "transaction name must not be empty", + )); + } + if name.len() > max_name_bytes { + return Err(transaction_invalid_argument_error( + "transaction name exceeds the configured byte limit", + )); + } + } + Ok(()) + } + pub(super) fn try_transaction_admission(&self) -> Result { match Arc::clone(&self.transaction_coordinator.admission).try_acquire_owned() { Ok(permit) => Ok(permit), @@ -185,13 +375,21 @@ impl SqliteDb { } } - pub(super) async fn begin_regular_operation(&self) -> Result { + pub(super) async fn begin_regular_operation( + &self, + call_mode: CallMode, + ) -> Result { let epoch = self.transaction_coordinator.epoch.load(Ordering::Acquire); let permit = self.try_transaction_admission()?; let wait = CoordinatorWaitGuard::new(self); - let gate = Arc::clone(&self.transaction_coordinator.gate) - .read_owned() - .await; + let gate = match call_mode { + CallMode::Async => { + Arc::clone(&self.transaction_coordinator.gate) + .read_owned() + .await + } + CallMode::SyncBlocking => self.acquire_sync_regular_gate().await?, + }; drop(wait); let state = self.transaction_coordinator.state.lock().await; if state.closed { @@ -220,26 +418,29 @@ impl SqliteDb { name: Option<&str>, timeout: Option, ) -> Result { - #[cfg(feature = "sqlite-local")] - let max_name_bytes = self.profiling.config.max_transaction_name_bytes; - #[cfg(not(feature = "sqlite-local"))] - let max_name_bytes = MAX_TRANSACTION_NAME_BYTES; - if let Some(name) = name { - if name.is_empty() { - return Err(transaction_invalid_argument_error( - "transaction name must not be empty", - )); - } - if name.len() > max_name_bytes { - return Err(transaction_invalid_argument_error( - "transaction name exceeds the configured byte limit", - )); - } - } + self.begin_named_transaction_with_mode( + name, + timeout, + TransactionOrigin::Internal, + CallMode::Async, + ) + .await + } + + pub async fn begin_named_transaction_with_mode( + &self, + name: Option<&str>, + timeout: Option, + origin: TransactionOrigin, + call_mode: CallMode, + ) -> Result { + self.validate_transaction_name(name)?; self.begin_transaction_with_key_and_name( uuid::Uuid::new_v4().to_string(), name.map(ToOwned::to_owned), timeout, + origin, + call_mode, ) .await } @@ -249,8 +450,14 @@ impl SqliteDb { key: impl Into, timeout: Option, ) -> Result { - self.begin_transaction_with_key_and_name(key, None, timeout) - .await + self.begin_transaction_with_key_and_name( + key, + None, + timeout, + TransactionOrigin::Internal, + CallMode::Async, + ) + .await } async fn begin_transaction_with_key_and_name( @@ -258,6 +465,8 @@ impl SqliteDb { key: impl Into, name: Option, timeout: Option, + origin: TransactionOrigin, + call_mode: CallMode, ) -> Result { #[cfg(feature = "sqlite-local")] let started_at = (self.profiling.config.enabled @@ -277,12 +486,26 @@ impl SqliteDb { } let db = self.clone(); + { + let mut state = self.transaction_coordinator.state.lock().await; + if state.closed { + return Err(transaction_coordinator_closed_error()); + } + state.pending.insert(key.clone(), origin); + if origin == TransactionOrigin::Bridge { + self.transaction_coordinator + .bridge_pending_notify + .notify_waiters(); + } + } run_detached_transaction_task( async move { db.begin_transaction_profiled_inner( key, timeout, name, + origin, + call_mode, #[cfg(feature = "sqlite-local")] started_at, ) @@ -303,6 +526,8 @@ impl SqliteDb { key, timeout, None, + TransactionOrigin::Internal, + CallMode::Async, #[cfg(feature = "sqlite-local")] (self.profiling.config.enabled && self.backend() == super::SqliteBackend::LocalNative) .then(crate::time::Instant::now), @@ -315,25 +540,56 @@ impl SqliteDb { key: String, timeout: Duration, _name: Option, + origin: TransactionOrigin, + call_mode: CallMode, #[cfg(feature = "sqlite-local")] started_at: Option, ) -> Result { #[cfg(feature = "sqlite-local")] let transaction_wait_started_at = started_at.map(|_| crate::time::Instant::now()); let epoch = self.transaction_coordinator.epoch.load(Ordering::Acquire); - let permit = self.try_transaction_admission()?; + let permit = match self.try_transaction_admission() { + Ok(permit) => permit, + Err(error) => { + self.transaction_coordinator + .state + .lock() + .await + .pending + .remove(&key); + return Err(error); + } + }; let wait = CoordinatorWaitGuard::new(self); - let gate_guard = Arc::clone(&self.transaction_coordinator.gate) - .write_owned() - .await; + let gate_guard = match call_mode { + CallMode::Async => { + Arc::clone(&self.transaction_coordinator.gate) + .write_owned() + .await + } + CallMode::SyncBlocking => match self.acquire_sync_transaction_gate(&key).await { + Ok(gate) => gate, + Err(error) => { + self.transaction_coordinator + .state + .lock() + .await + .pending + .remove(&key); + return Err(error); + } + }, + }; drop(wait); #[cfg(feature = "sqlite-local")] let transaction_wait = transaction_wait_started_at.map(|started| started.elapsed()); { - let state = self.transaction_coordinator.state.lock().await; + let mut state = self.transaction_coordinator.state.lock().await; if state.closed { + state.pending.remove(&key); return Err(transaction_coordinator_closed_error()); } if self.transaction_coordinator.epoch.load(Ordering::Acquire) != epoch { + state.pending.remove(&key); return Err(transaction_expired_error( state .last_expired_timeout @@ -341,6 +597,7 @@ impl SqliteDb { )); } if let Some(error) = transaction_known_state_error(&state, &key) { + state.pending.remove(&key); return Err(error); } } @@ -353,7 +610,9 @@ impl SqliteDb { .execute_backend_profiled("BEGIN".to_owned(), None) .await; ( - profiled.result.map(|result| (result, None)), + profiled + .result + .map(|result| (result, None, profiled.post_autocommit)), profiled.profile, ) } else { @@ -369,7 +628,18 @@ impl SqliteDb { let begin_result = self .execute_backend_in_session("BEGIN".to_owned(), None, None) .await; - let (_, remote_session) = begin_result.map_err(map_transaction_connection_error)?; + let (_, remote_session, _) = match begin_result { + Ok(result) => result, + Err(error) => { + self.transaction_coordinator + .state + .lock() + .await + .pending + .remove(&key); + return Err(map_transaction_connection_error(error)); + } + }; // A successful BEGIN response and the coordinator state update are two // separate async events. If the socket disconnected in that narrow gap, // pegboard-envoy has already dropped the connection-owned database handle @@ -378,11 +648,18 @@ impl SqliteDb { if let Some(session) = remote_session && self.handle()?.connection_session() != Some(session) { + self.transaction_coordinator + .state + .lock() + .await + .pending + .remove(&key); return Err(transaction_connection_lost_error()); } let operation = Arc::new(AsyncMutex::new(())); { let mut state = self.transaction_coordinator.state.lock().await; + state.pending.remove(&key); if state.closed { drop(state); if let Err(error) = self @@ -395,6 +672,7 @@ impl SqliteDb { } state.active = Some(ActiveTransaction { key: key.clone(), + origin, timeout, expiring: false, connection_lost: false, @@ -513,28 +791,52 @@ impl SqliteDb { let transaction_wait = started_at.map(|started| started.elapsed()); self.ensure_active_transaction(key).await?; #[cfg(feature = "sqlite-local")] - if let Some(started_at) = started_at { + { let sql_for_profile = sql.clone(); - let profiled = self.exec_backend_profiled(sql).await; - let result = profiled.result; - let profile = profiled.profile; - if let Some(observation) = self.observe_statement_profile( - &sql_for_profile, - started_at, - transaction_wait.unwrap_or_default(), - profile, - if result.is_ok() { "success" } else { "error" }, - "explicit", - ) { - self.record_transaction_statement(key, &observation).await; + let (result, profile, post_autocommit) = match self.backend() { + super::SqliteBackend::LocalNative => { + let profiled = self.exec_backend_profiled(sql).await; + (profiled.result, profiled.profile, profiled.post_autocommit) + } + super::SqliteBackend::RemoteEnvoy => ( + self.remote_exec_with_session(sql, remote_session) + .await + .map(|(result, _)| result), + None, + None, + ), + }; + if let Some(started_at) = started_at { + if let Some(observation) = self.observe_statement_profile( + &sql_for_profile, + started_at, + transaction_wait.unwrap_or_default(), + profile, + if result.is_ok() { "success" } else { "error" }, + "explicit", + ) { + self.record_transaction_statement(key, &observation).await; + } } - return match result { + let output = match result { Ok(result) => Ok(result), Err(error) => Err(self.handle_transaction_backend_error(key, error).await), }; + if post_autocommit == Some(true) { + self.release_transaction(key, TransactionTerminalState::RolledBack, false) + .await; + } + return output; } + #[cfg(not(feature = "sqlite-local"))] match self.exec_backend_in_session(sql, remote_session).await { - Ok((result, _)) => Ok(result), + Ok((result, _, post_autocommit)) => { + if post_autocommit == Some(true) { + self.release_transaction(key, TransactionTerminalState::RolledBack, false) + .await; + } + Ok(result) + } Err(error) => Err(self.handle_transaction_backend_error(key, error).await), } } @@ -570,31 +872,55 @@ impl SqliteDb { let transaction_wait = started_at.map(|started| started.elapsed()); self.ensure_active_transaction(key).await?; #[cfg(feature = "sqlite-local")] - if let Some(started_at) = started_at { + { let sql_for_profile = sql.clone(); - let profiled = self.execute_backend_profiled(sql, params).await; - let result = profiled.result; - let profile = profiled.profile; - if let Some(observation) = self.observe_statement_profile( - &sql_for_profile, - started_at, - transaction_wait.unwrap_or_default(), - profile, - if result.is_ok() { "success" } else { "error" }, - "explicit", - ) { - self.record_transaction_statement(key, &observation).await; + let (result, profile, post_autocommit) = match self.backend() { + super::SqliteBackend::LocalNative => { + let profiled = self.execute_backend_profiled(sql, params).await; + (profiled.result, profiled.profile, profiled.post_autocommit) + } + super::SqliteBackend::RemoteEnvoy => ( + self.remote_execute_with_session(sql, params, remote_session) + .await + .map(|(result, _)| result), + None, + None, + ), + }; + if let Some(started_at) = started_at { + if let Some(observation) = self.observe_statement_profile( + &sql_for_profile, + started_at, + transaction_wait.unwrap_or_default(), + profile, + if result.is_ok() { "success" } else { "error" }, + "explicit", + ) { + self.record_transaction_statement(key, &observation).await; + } } - return match result { + let output = match result { Ok(result) => Ok(result), Err(error) => Err(self.handle_transaction_backend_error(key, error).await), }; + if post_autocommit == Some(true) { + self.release_transaction(key, TransactionTerminalState::RolledBack, false) + .await; + } + return output; } + #[cfg(not(feature = "sqlite-local"))] match self .execute_backend_in_session(sql, params, remote_session) .await { - Ok((result, _)) => Ok(result), + Ok((result, _, post_autocommit)) => { + if post_autocommit == Some(true) { + self.release_transaction(key, TransactionTerminalState::RolledBack, false) + .await; + } + Ok(result) + } Err(error) => Err(self.handle_transaction_backend_error(key, error).await), } } @@ -613,7 +939,7 @@ impl SqliteDb { } } - async fn finish_transaction(&self, key: &str, commit: bool) -> Result<()> { + async fn finish_transaction(&self, key: &str, commit: bool) -> Result> { let db = self.clone(); let key = key.to_owned(); run_detached_transaction_task( @@ -623,8 +949,19 @@ impl SqliteDb { .await } - async fn finish_transaction_inner(&self, key: &str, commit: bool) -> Result<()> { - let (operation, remote_session) = self.transaction_operation(key).await?; + async fn finish_transaction_inner(&self, key: &str, commit: bool) -> Result> { + let (operation, remote_session) = match self.transaction_operation(key).await { + Ok(operation) => operation, + Err(error) + if !commit + && error + .downcast_ref::() + .is_some_and(|terminal| terminal.state == Some("rolled back")) => + { + return Ok(None); + } + Err(error) => return Err(error), + }; let _operation = operation.lock().await; self.ensure_active_transaction(key).await?; @@ -639,7 +976,9 @@ impl SqliteDb { .execute_backend_profiled(statement.to_owned(), None) .await; ( - profiled.result.map(|result| (result, None)), + profiled + .result + .map(|result| (result, None, profiled.post_autocommit)), profiled.profile, ) } else { @@ -663,6 +1002,10 @@ impl SqliteDb { ) .await; } + let commit_seq = result + .as_ref() + .ok() + .and_then(|(result, _, _)| result.commit_seq); if let Err(primary) = result { if is_remote_connection_error(&primary) { self.release_transaction(key, TransactionTerminalState::ConnectionLost, false) @@ -692,7 +1035,7 @@ impl SqliteDb { if is_no_active_transaction_error(&primary) { self.release_transaction(key, TransactionTerminalState::RolledBack, false) .await; - return Ok(()); + return Ok(None); } self.release_transaction(key, TransactionTerminalState::RolledBack, true) @@ -710,7 +1053,7 @@ impl SqliteDb { false, ) .await; - Ok(()) + Ok(if commit { commit_seq } else { None }) } #[cfg(feature = "sqlite-local")] @@ -845,6 +1188,13 @@ impl SqliteDb { key: &str, error: anyhow::Error, ) -> anyhow::Error { + #[cfg(feature = "sqlite-local")] + if error + .downcast_ref::() + .is_some() + { + return transaction_terminal_error(key, TransactionTerminalState::RolledBack); + } if is_remote_connection_error(&error) { self.release_transaction(key, TransactionTerminalState::ConnectionLost, false) .await; @@ -1029,20 +1379,21 @@ impl fmt::Display for TransactionQueueFullError { impl Error for TransactionQueueFullError {} #[derive(rivet_error::RivetError, Debug, Serialize)] -#[error( - "sqlite", - "transaction_closed", - "SQLite transaction coordinator is closed." -)] -pub struct TransactionCoordinatorClosedError; +#[error("sqlite", "transaction_closed", "SQLite transaction is closed.")] +pub struct TransactionClosedError { + pub coordinator: bool, + pub key: Option, + pub state: Option<&'static str>, + pub message: String, +} -impl fmt::Display for TransactionCoordinatorClosedError { +impl fmt::Display for TransactionClosedError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str("sqlite transaction coordinator is closed") + f.write_str(&self.message) } } -impl Error for TransactionCoordinatorClosedError {} +impl Error for TransactionClosedError {} #[derive(rivet_error::RivetError, Debug, Serialize)] #[error( @@ -1101,30 +1452,6 @@ impl fmt::Display for TransactionUnknownError { impl Error for TransactionUnknownError {} -#[derive(rivet_error::RivetError, Debug, Serialize)] -#[error( - "sqlite", - "transaction_terminal", - "SQLite transaction handle is terminal.", - "SQLite transaction handle `{key}` is already {state}." -)] -pub struct TransactionTerminalError { - pub key: String, - pub state: &'static str, -} - -impl fmt::Display for TransactionTerminalError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "sqlite transaction handle `{}` is already {}", - self.key, self.state - ) - } -} - -impl Error for TransactionTerminalError {} - #[derive(rivet_error::RivetError, Debug, Serialize)] #[error( "sqlite", @@ -1223,7 +1550,7 @@ fn spawn_transaction_timeout( .await }; if let Err(error) = result { - if error.downcast_ref::().is_none() { + if error.downcast_ref::().is_none() { tracing::error!(%error, "sqlite transaction terminal cleanup failed"); } } @@ -1320,9 +1647,20 @@ fn transaction_queue_full_error() -> anyhow::Error { } fn transaction_coordinator_closed_error() -> anyhow::Error { - TransactionCoordinatorClosedError - .build() - .context(TransactionCoordinatorClosedError) + let message = "sqlite transaction coordinator is closed".to_string(); + TransactionClosedError { + coordinator: true, + key: None, + state: None, + message: message.clone(), + } + .build() + .context(TransactionClosedError { + coordinator: true, + key: None, + state: None, + message, + }) } fn transaction_unknown_error(key: &str) -> anyhow::Error { @@ -1390,13 +1728,18 @@ fn map_transaction_connection_error(error: anyhow::Error) -> anyhow::Error { } fn transaction_terminal_state_error(key: &str, state: &'static str) -> anyhow::Error { - TransactionTerminalError { - key: key.to_owned(), - state, + let message = format!("sqlite transaction handle `{key}` is already {state}"); + TransactionClosedError { + coordinator: false, + key: Some(key.to_owned()), + state: Some(state), + message: message.clone(), } .build() - .context(TransactionTerminalError { - key: key.to_owned(), - state, + .context(TransactionClosedError { + coordinator: false, + key: Some(key.to_owned()), + state: Some(state), + message, }) } diff --git a/rivetkit-rust/packages/rivetkit-core/src/actor/state.rs b/rivetkit-rust/packages/rivetkit-core/src/actor/state.rs index 17b79e2b7f..62448e4a9b 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/actor/state.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/actor/state.rs @@ -28,7 +28,10 @@ use crate::actor::task_types::StateMutationReason; use crate::error::ActorRuntime; #[cfg(feature = "wasm-runtime")] use crate::runtime::RuntimeSpawner; -use crate::sqlite::{BindParam, ExecuteResult, SqliteTransaction}; +use crate::sqlite::{ + BindParam, BridgeTransactionReservation, CallMode, ExecuteResult, SqliteTransaction, + TransactionOrigin, +}; use crate::types::SaveStateOpts; #[cfg(test)] @@ -303,6 +306,45 @@ impl ActorContext { pub async fn begin_state_transaction( &self, timeout: Option, + ) -> Result { + self.begin_state_transaction_with_origin(timeout, TransactionOrigin::Internal) + .await + } + + pub async fn begin_state_transaction_with_origin( + &self, + timeout: Option, + origin: TransactionOrigin, + ) -> Result { + self.begin_state_transaction_with_reservation(timeout, origin, None) + .await + } + + /// Reserves Bridge transaction admission synchronously, before a foreign + /// runtime creates or starts polling its asynchronous state-transaction + /// Promise. + pub fn reserve_bridge_state_transaction(&self) -> BridgeTransactionReservation { + self.sql().reserve_bridge_transaction() + } + + pub async fn begin_reserved_bridge_state_transaction( + &self, + reservation: BridgeTransactionReservation, + timeout: Option, + ) -> Result { + self.begin_state_transaction_with_reservation( + timeout, + TransactionOrigin::Bridge, + Some(reservation), + ) + .await + } + + async fn begin_state_transaction_with_reservation( + &self, + timeout: Option, + origin: TransactionOrigin, + reservation: Option, ) -> Result { self.clear_pending_save(); let save_guard = Arc::clone(&self.0.save_guard).lock_owned().await; @@ -315,7 +357,19 @@ impl ActorContext { .filter(|conn| conn.is_hibernatable()) .map(|conn| (conn.id().to_owned(), conn.state())) .collect(); - let transaction = match self.sql().begin_transaction(timeout).await { + let transaction_result = match reservation { + Some(reservation) => { + self.sql() + .begin_reserved_bridge_transaction(reservation, None, timeout) + .await + } + None => { + self.sql() + .begin_named_transaction_with_mode(None, timeout, origin, CallMode::Async) + .await + } + }; + let transaction = match transaction_result { Ok(transaction) => transaction, Err(error) => { self.0 diff --git a/rivetkit-rust/packages/rivetkit-core/src/actor/task.rs b/rivetkit-rust/packages/rivetkit-core/src/actor/task.rs index c3cdbe3d22..985bbdb8ea 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/actor/task.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/actor/task.rs @@ -1894,10 +1894,19 @@ impl ActorTask { ); #[cfg(feature = "sqlite-local")] ctx.shutdown_actor_runtime_socket().await; - ctx.sql() + if let Err(error) = ctx + .sql() .cleanup_for_shutdown(reason == ShutdownKind::Sleep) .await - .with_context(|| format!("cleanup sqlite during {reason_label} shutdown"))?; + .with_context(|| format!("cleanup sqlite during {reason_label} shutdown")) + { + tracing::error!( + actor_id = %actor_id, + reason = reason_label, + %error, + "lost unflushed sqlite data during shutdown" + ); + } trim_native_allocator_after_shutdown(&actor_id, reason_label); tracing::debug!( actor_id = %actor_id, diff --git a/rivetkit-rust/packages/rivetkit-core/src/error.rs b/rivetkit-rust/packages/rivetkit-core/src/error.rs index 195d7ff837..d092921d36 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/error.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/error.rs @@ -336,4 +336,32 @@ pub(crate) enum SqliteRuntimeError { "Remote SQLite generation is stale: {reason}" )] RemoteFenceMismatch { reason: String }, + + #[error( + "flush_failed", + "SQLite durability flush failed.", + "SQLite durability flush failed: {message}" + )] + FlushFailed { message: String }, + + #[error( + "invalid_argument", + "Invalid SQLite argument.", + "Invalid SQLite argument: {message}" + )] + InvalidArgument { message: String }, + + #[error( + "deferred_commits_unsupported", + "Deferred SQLite commits are unsupported.", + "Deferred SQLite commits require the local native SQLite backend." + )] + DeferredCommitsUnsupported, + + #[error( + "transaction_active", + "A synchronous SQLite transaction is active.", + "This synchronous SQLite operation would block on an active JavaScript transaction." + )] + TransactionActive, } diff --git a/rivetkit-rust/packages/rivetkit-core/src/lib.rs b/rivetkit-rust/packages/rivetkit-core/src/lib.rs index 2676b4e9cd..52e8cec395 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/lib.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/lib.rs @@ -124,7 +124,7 @@ pub use actor::{kv, sqlite}; pub use actor::action::ActionDispatchError; pub use actor::config::{ ActionDefinition, ActorConfig, ActorConfigInput, ActorConfigOverrides, CanHibernateWebSocket, - SqliteProfilingConfig, SqliteProfilingConfigInput, + SqliteCommitMode, SqliteProfilingConfig, SqliteProfilingConfigInput, }; pub use actor::connection::ConnHandle; pub use actor::context::{ diff --git a/rivetkit-rust/packages/rivetkit-core/src/registry/mod.rs b/rivetkit-rust/packages/rivetkit-core/src/registry/mod.rs index 24c897a7be..95e5b3a6d8 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/registry/mod.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/registry/mod.rs @@ -3,7 +3,7 @@ use std::env; use std::io::Cursor; use std::path::PathBuf; use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::time::Duration; use crate::time::{Instant, timeout}; @@ -169,6 +169,7 @@ pub(crate) struct RegistryDispatcher { actor_instances: SccHashMap, starting_instances: SccHashMap>, pending_stops: SccHashMap, + sqlite_commit_sequences: SccHashMap>, region: String, handle_inspector_http_in_runtime: bool, } @@ -704,6 +705,7 @@ impl RegistryDispatcher { actor_instances: SccHashMap::new(), starting_instances: SccHashMap::new(), pending_stops: SccHashMap::new(), + sqlite_commit_sequences: SccHashMap::new(), region: env::var("RIVET_REGION").unwrap_or_default(), handle_inspector_http_in_runtime, } @@ -1038,6 +1040,12 @@ impl RegistryDispatcher { reason: protocol::StopActorReason, stop_handle: ActorStopHandle, ) -> Result<()> { + if matches!(map_envoy_stop_reason(&reason), ShutdownKind::Destroy) { + let _ = self + .sqlite_commit_sequences + .remove_async(&actor_id.to_owned()) + .await; + } if self .starting_instances .get_async(&actor_id.to_owned()) @@ -1177,6 +1185,12 @@ impl RegistryDispatcher { } let final_result = shutdown_result.and(join_result); + if matches!(task_stop_reason, ShutdownKind::Destroy) { + let _ = self + .sqlite_commit_sequences + .remove_async(&actor_id.to_owned()) + .await; + } match &final_result { Ok(_) => { let _ = stop_handle.complete(); @@ -1221,6 +1235,23 @@ impl RegistryDispatcher { factory: &ActorFactory, ) -> Result { let formatted_key = format_actor_key(&key); + let sqlite = SqliteDb::new_with_remote_sqlite( + handle.clone(), + actor_id.to_owned(), + Some(formatted_key), + Some(generation as u64), + factory.config().has_database, + factory.config().remote_sqlite, + factory.config().sqlite_commit_mode, + )?; + let commit_sequence = match self.sqlite_commit_sequences.entry_sync(actor_id.to_owned()) { + SccEntry::Occupied(entry) => Arc::clone(entry.get()), + SccEntry::Vacant(entry) => { + let state = Arc::new(AtomicU64::new(0)); + entry.insert_entry(Arc::clone(&state)); + state + } + }; let ctx = ActorContext::build( actor_id.to_owned(), actor_name.to_owned(), @@ -1230,14 +1261,7 @@ impl RegistryDispatcher { handle.get_envoy_key().to_owned(), factory.config().clone(), LegacyActorKv::new(handle.clone(), actor_id.to_owned()), - SqliteDb::new_with_remote_sqlite( - handle.clone(), - actor_id.to_owned(), - Some(formatted_key), - Some(generation as u64), - factory.config().has_database, - factory.config().remote_sqlite, - )?, + sqlite.with_commit_sequence_state(commit_sequence), ); ctx.configure_envoy(handle, Some(generation)); Ok(ctx) diff --git a/rivetkit-rust/packages/rivetkit-core/src/testing.rs b/rivetkit-rust/packages/rivetkit-core/src/testing.rs index 3459e20c56..51e54a59ff 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/testing.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/testing.rs @@ -81,6 +81,7 @@ impl ActorContextHarness { generation.map(u64::from), false, true, + crate::SqliteCommitMode::Awaited, ) .expect("test remote sqlite should be configured"); let ctx = ActorContext::build( diff --git a/rivetkit-rust/packages/rivetkit-core/tests/actor_runtime_socket.rs b/rivetkit-rust/packages/rivetkit-core/tests/actor_runtime_socket.rs index ac7b4e0e18..96a6f61c3f 100644 --- a/rivetkit-rust/packages/rivetkit-core/tests/actor_runtime_socket.rs +++ b/rivetkit-rust/packages/rivetkit-core/tests/actor_runtime_socket.rs @@ -266,6 +266,8 @@ fn query_connection( rows, changes, last_insert_row_id: (changes > 0).then(|| connection.last_insert_rowid()), + readonly: None, + commit_seq: None, }) } @@ -1167,7 +1169,15 @@ fn coordinator_errors_keep_endpoint_level_meaning() { wire::ResponsePayload::QueueFull(_) )); assert!(matches!( - map_error(TransactionCoordinatorClosedError.into()), + map_error( + TransactionClosedError { + coordinator: true, + key: None, + state: None, + message: "sqlite transaction coordinator is closed".to_string(), + } + .into() + ), wire::ResponsePayload::EndpointClosed )); assert!(matches!( diff --git a/rivetkit-rust/packages/rivetkit-core/tests/context.rs b/rivetkit-rust/packages/rivetkit-core/tests/context.rs index 00d4b28582..7c9f123f06 100644 --- a/rivetkit-rust/packages/rivetkit-core/tests/context.rs +++ b/rivetkit-rust/packages/rivetkit-core/tests/context.rs @@ -144,8 +144,16 @@ fn build_ctx_with_remote_sqlite( sql_handle.get_envoy_key().to_owned(), config, kv, - SqliteDb::new_with_remote_sqlite(sql_handle.clone(), actor_id, None, Some(1), false, true) - .expect("test remote sqlite should be configured"), + SqliteDb::new_with_remote_sqlite( + sql_handle.clone(), + actor_id, + None, + Some(1), + false, + true, + crate::SqliteCommitMode::Awaited, + ) + .expect("test remote sqlite should be configured"), ); ctx.configure_envoy(sql_handle, Some(1)); ctx @@ -834,6 +842,7 @@ mod moved_tests { Some(1), false, true, + crate::SqliteCommitMode::Awaited, ) .expect("test remote sqlite should be configured"), ); diff --git a/rivetkit-rust/packages/rivetkit-core/tests/metrics.rs b/rivetkit-rust/packages/rivetkit-core/tests/metrics.rs index 1d38a1f452..f98b31431d 100644 --- a/rivetkit-rust/packages/rivetkit-core/tests/metrics.rs +++ b/rivetkit-rust/packages/rivetkit-core/tests/metrics.rs @@ -76,6 +76,27 @@ mod moved_tests { ), ) } + + async fn commit_stage_begin( + &self, + _request: rivet_envoy_client::protocol::SqliteCommitStageBeginRequest, + ) -> anyhow::Result { + anyhow::bail!("staged commits are not used by the metrics test transport") + } + + async fn commit_stage_segment( + &self, + _request: rivet_envoy_client::protocol::SqliteCommitStageSegmentRequest, + ) -> anyhow::Result { + anyhow::bail!("staged commits are not used by the metrics test transport") + } + + async fn commit_finalize( + &self, + _request: rivet_envoy_client::protocol::SqliteCommitFinalizeRequest, + ) -> anyhow::Result { + anyhow::bail!("staged commits are not used by the metrics test transport") + } } #[test] @@ -557,6 +578,8 @@ mod moved_tests { 1, tokio::runtime::Handle::current(), Some(metric_sink.clone()), + depot_client::vfs::CommitMode::Awaited, + 0, ) .await .expect("native database should open"); @@ -632,6 +655,8 @@ mod moved_tests { 2, tokio::runtime::Handle::current(), Some(metric_sink.clone()), + depot_client::vfs::CommitMode::Awaited, + 0, ) .await .expect("native database should reopen"); diff --git a/rivetkit-rust/packages/rivetkit-core/tests/migrate_kv_to_sqlite.rs b/rivetkit-rust/packages/rivetkit-core/tests/migrate_kv_to_sqlite.rs index cd344d5ebe..7d2ae021c8 100644 --- a/rivetkit-rust/packages/rivetkit-core/tests/migrate_kv_to_sqlite.rs +++ b/rivetkit-rust/packages/rivetkit-core/tests/migrate_kv_to_sqlite.rs @@ -179,6 +179,7 @@ fn sqlite_ctx_with_harness_enabled( Some(1), enabled, true, + crate::SqliteCommitMode::Awaited, ) .expect("test remote sqlite should be configured"); let ctx = ActorContext::build( diff --git a/rivetkit-rust/packages/rivetkit-core/tests/sqlite.rs b/rivetkit-rust/packages/rivetkit-core/tests/sqlite.rs index 8e2fe8bb9d..b750461f34 100644 --- a/rivetkit-rust/packages/rivetkit-core/tests/sqlite.rs +++ b/rivetkit-rust/packages/rivetkit-core/tests/sqlite.rs @@ -1,7 +1,8 @@ use std::collections::{BTreeMap, HashMap}; use std::sync::Arc; use std::sync::Mutex as StdMutex; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::time::Duration; use super::*; use depot_client_types::{HEAD_FENCE_MISMATCH_CODE, HEAD_FENCE_MISMATCH_GROUP}; @@ -13,10 +14,12 @@ use rivet_envoy_client::context::{SharedContext, WsTxMessage}; use rivet_envoy_client::envoy::ToEnvoyMessage; use rivet_envoy_client::handle::EnvoyHandle; use rivet_envoy_client::sqlite::{ - RemoteSqliteRequest, RemoteSqliteResponse, RemoteSqliteResponseEnvelope, + RemoteSqliteRequest, RemoteSqliteResponse, RemoteSqliteResponseEnvelope, SqliteRequest, + SqliteResponse, }; use tokio::sync::{Mutex as AsyncMutex, mpsc, oneshot}; use tracing::field::{Field, Visit}; +use tracing::instrument::WithSubscriber; use tracing::{Event, Subscriber}; use tracing_subscriber::layer::{Context as LayerContext, Layer}; use tracing_subscriber::prelude::*; @@ -308,21 +311,249 @@ fn send_execute_batch_ok( .expect("remote sqlite requester dropped response"); } +#[cfg(feature = "sqlite-local")] +#[derive(Default)] +struct MemorySqliteState { + pages: BTreeMap>, + db_size_pages: u32, + head_txid: u64, +} + +#[cfg(feature = "sqlite-local")] +#[derive(Default)] +struct MemorySqliteTransport { + state: StdMutex, + wrong_next_commit_head: AtomicBool, + wrong_commit_started: tokio::sync::Notify, + wrong_commit_release: tokio::sync::Notify, +} + +#[cfg(feature = "sqlite-local")] +impl MemorySqliteTransport { + fn wrong_next_commit_head(&self) { + self.wrong_next_commit_head.store(true, Ordering::Release); + } + + async fn wait_for_wrong_commit(&self) { + self.wrong_commit_started.notified().await; + } + + fn release_wrong_commit(&self) { + self.wrong_commit_release.notify_one(); + } + + fn fence_error(expected: Option, actual: u64) -> protocol::SqliteErrorResponse { + protocol::SqliteErrorResponse { + group: HEAD_FENCE_MISMATCH_GROUP.to_owned(), + code: HEAD_FENCE_MISMATCH_CODE.to_owned(), + message: format!( + "test sqlite head fence mismatch: expected {expected:?}, actual {actual}" + ), + } + } +} + +#[cfg(feature = "sqlite-local")] +#[async_trait::async_trait] +impl depot_client::vfs::SqliteTransport for MemorySqliteTransport { + async fn get_pages( + &self, + request: protocol::SqliteGetPagesRequest, + ) -> anyhow::Result { + let state = self.state.lock().expect("memory sqlite state poisoned"); + if request + .expected_head_txid + .is_some_and(|expected| expected != state.head_txid) + { + return Ok(protocol::SqliteGetPagesResponse::SqliteErrorResponse( + Self::fence_error(request.expected_head_txid, state.head_txid), + )); + } + Ok(protocol::SqliteGetPagesResponse::SqliteGetPagesOk( + protocol::SqliteGetPagesOk { + pages: request + .pgnos + .into_iter() + .map(|pgno| protocol::SqliteFetchedPage { + pgno, + bytes: (pgno <= state.db_size_pages) + .then(|| state.pages.get(&pgno).cloned()) + .flatten(), + }) + .collect(), + head_txid: Some(state.head_txid), + }, + )) + } + + async fn commit( + &self, + request: protocol::SqliteCommitRequest, + ) -> anyhow::Result { + let (wrong_head, response_head) = { + let mut state = self.state.lock().expect("memory sqlite state poisoned"); + if request + .expected_head_txid + .is_some_and(|expected| expected != state.head_txid) + { + return Ok(protocol::SqliteCommitResponse::SqliteErrorResponse( + Self::fence_error(request.expected_head_txid, state.head_txid), + )); + } + state.db_size_pages = request.db_size_pages; + state.pages.retain(|pgno, _| *pgno <= request.db_size_pages); + for page in request.dirty_pages { + state.pages.insert(page.pgno, page.bytes); + } + state.head_txid = state.head_txid.saturating_add(1); + let wrong_head = self.wrong_next_commit_head.swap(false, Ordering::AcqRel); + ( + wrong_head, + if wrong_head { + Some(u64::MAX) + } else { + Some(state.head_txid) + }, + ) + }; + if wrong_head { + self.wrong_commit_started.notify_one(); + self.wrong_commit_release.notified().await; + } + Ok(protocol::SqliteCommitResponse::SqliteCommitOk( + protocol::SqliteCommitOk { + head_txid: response_head, + }, + )) + } + + async fn commit_stage_begin( + &self, + _request: protocol::SqliteCommitStageBeginRequest, + ) -> anyhow::Result { + anyhow::bail!("memory sqlite test transport does not stage commits") + } + + async fn commit_stage_segment( + &self, + _request: protocol::SqliteCommitStageSegmentRequest, + ) -> anyhow::Result { + anyhow::bail!("memory sqlite test transport does not stage commits") + } + + async fn commit_finalize( + &self, + _request: protocol::SqliteCommitFinalizeRequest, + ) -> anyhow::Result { + anyhow::bail!("memory sqlite test transport does not stage commits") + } +} + +#[cfg(feature = "sqlite-local")] +async fn serve_memory_sqlite_envoy( + mut envoy_rx: mpsc::UnboundedReceiver, + transport: Arc, +) { + while let Some(message) = envoy_rx.recv().await { + let ToEnvoyMessage::SqliteRequest { + request, + response_tx, + } = message + else { + continue; + }; + let response = match request { + SqliteRequest::GetPages(request) => { + depot_client::vfs::SqliteTransport::get_pages(&*transport, request) + .await + .map(SqliteResponse::GetPages) + } + SqliteRequest::Commit(request) => { + depot_client::vfs::SqliteTransport::commit(&*transport, request) + .await + .map(SqliteResponse::Commit) + } + SqliteRequest::CommitStageBegin(request) => { + depot_client::vfs::SqliteTransport::commit_stage_begin(&*transport, request) + .await + .map(SqliteResponse::CommitStageBegin) + } + SqliteRequest::CommitStageSegment(request) => { + depot_client::vfs::SqliteTransport::commit_stage_segment(&*transport, request) + .await + .map(SqliteResponse::CommitStageSegment) + } + SqliteRequest::CommitFinalize(request) => { + depot_client::vfs::SqliteTransport::commit_finalize(&*transport, request) + .await + .map(SqliteResponse::CommitFinalize) + } + }; + assert!( + response_tx.send(response).is_ok(), + "local sqlite requester dropped its response" + ); + } +} + +#[cfg(feature = "sqlite-local")] +static NATIVE_SQLITE_TEST_ID: AtomicU64 = AtomicU64::new(1); + +#[cfg(feature = "sqlite-local")] +async fn open_memory_native_database( + transport: Arc, + mode: depot_client::vfs::CommitMode, + initial_commit_seq: u64, +) -> depot_client::database::NativeDatabaseHandle { + let id = NATIVE_SQLITE_TEST_ID.fetch_add(1, Ordering::Relaxed); + depot_client::database::open_database_from_transport( + transport, + format!("core-deferred-test-{id}"), + id, + tokio::runtime::Handle::current(), + None, + mode, + initial_commit_seq, + ) + .await + .expect("memory-backed native sqlite database should open") +} + +#[cfg(feature = "sqlite-local")] +fn core_db_from_native( + native_db: depot_client::database::NativeDatabaseHandle, + mode: SqliteCommitMode, + handle: Option, +) -> SqliteDb { + SqliteDb { + handle, + actor_id: Some("core-deferred-test".to_owned()), + generation: Some(1), + backend: SqliteBackend::LocalNative, + commit_mode: mode, + enabled: true, + db: Arc::new(parking_lot::Mutex::new(Some(native_db))), + ..SqliteDb::default() + } +} + #[test] fn remote_backend_selection_is_independent_of_user_database_flag() { assert_eq!( - select_sqlite_backend(true).expect("remote sqlite should always be available"), + select_sqlite_backend(true, SqliteCommitMode::Awaited) + .expect("remote sqlite should always be available"), SqliteBackend::RemoteEnvoy ); assert_eq!( - select_sqlite_backend(true).expect("remote sqlite should ignore public database opt-in"), + select_sqlite_backend(true, SqliteCommitMode::Awaited) + .expect("remote sqlite should ignore public database opt-in"), SqliteBackend::RemoteEnvoy ); #[cfg(feature = "sqlite-local")] { assert_eq!( - select_sqlite_backend(false) + select_sqlite_backend(false, SqliteCommitMode::Awaited) .expect("local sqlite feature should select native backend"), SqliteBackend::LocalNative ); @@ -398,8 +629,16 @@ fn protocol_conversion_preserves_bind_and_result_values() { #[tokio::test] async fn transaction_arguments_are_structured_errors() { let (handle, _) = test_envoy_handle(); - let db = SqliteDb::new_with_remote_sqlite(handle, "actor-a", None, Some(7), true, true) - .expect("test remote sqlite should be configured"); + let db = SqliteDb::new_with_remote_sqlite( + handle, + "actor-a", + None, + Some(7), + true, + true, + SqliteCommitMode::Awaited, + ) + .expect("test remote sqlite should be configured"); for error in [ db.begin_transaction_with_key("", None) .await @@ -464,13 +703,14 @@ async fn remote_execute_logs_operation_context_at_source() { Some(7), true, true, + SqliteCommitMode::Awaited, ) .expect("test remote sqlite should be configured"); let records = Arc::new(StdMutex::new(Vec::new())); let subscriber = Registry::default().with(SqliteOperationLogLayer { records: records.clone(), }); - let _guard = tracing::subscriber::set_default(subscriber); + let dispatch = tracing::Dispatch::new(subscriber); let result = db .execute( @@ -480,6 +720,7 @@ async fn remote_execute_logs_operation_context_at_source() { BindParam::Text("two".to_owned()), ]), ) + .with_subscriber(dispatch) .await; assert!(result.is_err()); @@ -515,8 +756,16 @@ async fn remote_execute_logs_operation_context_at_source() { #[tokio::test] async fn remote_execute_batch_uses_one_coordinated_transaction() { let (handle, mut envoy_rx) = test_envoy_handle(); - let db = SqliteDb::new_with_remote_sqlite(handle, "actor-a", None, Some(7), true, true) - .expect("test remote sqlite should be configured"); + let db = SqliteDb::new_with_remote_sqlite( + handle, + "actor-a", + None, + Some(7), + true, + true, + SqliteCommitMode::Awaited, + ) + .expect("test remote sqlite should be configured"); let batch = tokio::spawn({ let db = db.clone(); @@ -552,8 +801,16 @@ async fn remote_execute_batch_uses_one_coordinated_transaction() { #[tokio::test] async fn remote_execute_batch_rolls_back_after_statement_failure() { let (handle, mut envoy_rx) = test_envoy_handle(); - let db = SqliteDb::new_with_remote_sqlite(handle, "actor-a", None, Some(7), true, true) - .expect("test remote sqlite should be configured"); + let db = SqliteDb::new_with_remote_sqlite( + handle, + "actor-a", + None, + Some(7), + true, + true, + SqliteCommitMode::Awaited, + ) + .expect("test remote sqlite should be configured"); let batch = tokio::spawn({ let db = db.clone(); @@ -595,8 +852,16 @@ async fn remote_execute_batch_rolls_back_after_statement_failure() { #[tokio::test] async fn remote_transactions_park_ordinary_work_and_unpark_in_order() { let (handle, mut envoy_rx) = test_envoy_handle(); - let db = SqliteDb::new_with_remote_sqlite(handle, "actor-a", None, Some(7), true, true) - .expect("test remote sqlite should be configured"); + let db = SqliteDb::new_with_remote_sqlite( + handle, + "actor-a", + None, + Some(7), + true, + true, + SqliteCommitMode::Awaited, + ) + .expect("test remote sqlite should be configured"); let begin = tokio::spawn({ let db = db.clone(); @@ -633,8 +898,16 @@ async fn remote_transactions_park_ordinary_work_and_unpark_in_order() { #[tokio::test] async fn transaction_gate_serves_registered_waiters_in_fifo_order() { let (handle, mut envoy_rx) = test_envoy_handle(); - let db = SqliteDb::new_with_remote_sqlite(handle, "actor-a", None, Some(7), true, true) - .expect("test remote sqlite should be configured"); + let db = SqliteDb::new_with_remote_sqlite( + handle, + "actor-a", + None, + Some(7), + true, + true, + SqliteCommitMode::Awaited, + ) + .expect("test remote sqlite should be configured"); let active_gate = Arc::clone(&db.transaction_coordinator.gate) .write_owned() .await; @@ -679,8 +952,16 @@ async fn transaction_gate_serves_registered_waiters_in_fifo_order() { #[tokio::test] async fn committed_and_rolled_back_transaction_handles_are_terminal() { let (handle, mut envoy_rx) = test_envoy_handle(); - let db = SqliteDb::new_with_remote_sqlite(handle, "actor-a", None, Some(7), true, true) - .expect("test remote sqlite should be configured"); + let db = SqliteDb::new_with_remote_sqlite( + handle, + "actor-a", + None, + Some(7), + true, + true, + SqliteCommitMode::Awaited, + ) + .expect("test remote sqlite should be configured"); let begin = tokio::spawn({ let db = db.clone(); @@ -699,7 +980,7 @@ async fn committed_and_rolled_back_transaction_handles_are_terminal() { .execute("must-not-run-after-commit", None) .await .unwrap_err() - .downcast_ref::() + .downcast_ref::() .is_some() ); @@ -720,7 +1001,7 @@ async fn committed_and_rolled_back_transaction_handles_are_terminal() { .execute("must-not-run-after-rollback", None) .await .unwrap_err() - .downcast_ref::() + .downcast_ref::() .is_some() ); assert!(envoy_rx.try_recv().is_err()); @@ -729,8 +1010,16 @@ async fn committed_and_rolled_back_transaction_handles_are_terminal() { #[tokio::test] async fn ordinary_remote_work_can_pipeline_without_a_transaction() { let (handle, mut envoy_rx) = test_envoy_handle(); - let db = SqliteDb::new_with_remote_sqlite(handle, "actor-a", None, Some(7), true, true) - .expect("test remote sqlite should be configured"); + let db = SqliteDb::new_with_remote_sqlite( + handle, + "actor-a", + None, + Some(7), + true, + true, + SqliteCommitMode::Awaited, + ) + .expect("test remote sqlite should be configured"); let first = tokio::spawn({ let db = db.clone(); @@ -752,8 +1041,16 @@ async fn ordinary_remote_work_can_pipeline_without_a_transaction() { #[tokio::test] async fn expired_remote_transaction_rolls_back_and_rejects_parked_work() { let (handle, mut envoy_rx) = test_envoy_handle(); - let db = SqliteDb::new_with_remote_sqlite(handle, "actor-a", None, Some(7), true, true) - .expect("test remote sqlite should be configured"); + let db = SqliteDb::new_with_remote_sqlite( + handle, + "actor-a", + None, + Some(7), + true, + true, + SqliteCommitMode::Awaited, + ) + .expect("test remote sqlite should be configured"); let begin = tokio::spawn({ let db = db.clone(); @@ -802,8 +1099,16 @@ async fn expired_remote_transaction_rolls_back_and_rejects_parked_work() { #[tokio::test] async fn deadline_waits_for_in_flight_transaction_work_before_rollback() { let (handle, mut envoy_rx) = test_envoy_handle(); - let db = SqliteDb::new_with_remote_sqlite(handle, "actor-a", None, Some(7), true, true) - .expect("test remote sqlite should be configured"); + let db = SqliteDb::new_with_remote_sqlite( + handle, + "actor-a", + None, + Some(7), + true, + true, + SqliteCommitMode::Awaited, + ) + .expect("test remote sqlite should be configured"); let begin = tokio::spawn({ let db = db.clone(); @@ -868,8 +1173,16 @@ async fn deadline_waits_for_in_flight_transaction_work_before_rollback() { #[tokio::test] async fn commit_that_owns_operation_lock_beats_deadline_without_poisoning_parked_work() { let (handle, mut envoy_rx) = test_envoy_handle(); - let db = SqliteDb::new_with_remote_sqlite(handle, "actor-a", None, Some(7), true, true) - .expect("test remote sqlite should be configured"); + let db = SqliteDb::new_with_remote_sqlite( + handle, + "actor-a", + None, + Some(7), + true, + true, + SqliteCommitMode::Awaited, + ) + .expect("test remote sqlite should be configured"); let begin = tokio::spawn({ let db = db.clone(); @@ -908,8 +1221,16 @@ async fn commit_that_owns_operation_lock_beats_deadline_without_poisoning_parked #[tokio::test] async fn cancelled_begin_still_installs_and_expires_the_transaction() { let (handle, mut envoy_rx) = test_envoy_handle(); - let db = SqliteDb::new_with_remote_sqlite(handle, "actor-a", None, Some(7), true, true) - .expect("test remote sqlite should be configured"); + let db = SqliteDb::new_with_remote_sqlite( + handle, + "actor-a", + None, + Some(7), + true, + true, + SqliteCommitMode::Awaited, + ) + .expect("test remote sqlite should be configured"); let begin = tokio::spawn({ let db = db.clone(); @@ -938,8 +1259,16 @@ async fn cancelled_begin_still_installs_and_expires_the_transaction() { #[tokio::test] async fn cancelled_commit_still_releases_the_transaction() { let (handle, mut envoy_rx) = test_envoy_handle(); - let db = SqliteDb::new_with_remote_sqlite(handle, "actor-a", None, Some(7), true, true) - .expect("test remote sqlite should be configured"); + let db = SqliteDb::new_with_remote_sqlite( + handle, + "actor-a", + None, + Some(7), + true, + true, + SqliteCommitMode::Awaited, + ) + .expect("test remote sqlite should be configured"); let begin = tokio::spawn({ let db = db.clone(); @@ -968,8 +1297,16 @@ async fn cancelled_commit_still_releases_the_transaction() { #[tokio::test] async fn cancelled_transaction_operation_settles_before_expiry_rollback() { let (handle, mut envoy_rx) = test_envoy_handle(); - let db = SqliteDb::new_with_remote_sqlite(handle, "actor-a", None, Some(7), true, true) - .expect("test remote sqlite should be configured"); + let db = SqliteDb::new_with_remote_sqlite( + handle, + "actor-a", + None, + Some(7), + true, + true, + SqliteCommitMode::Awaited, + ) + .expect("test remote sqlite should be configured"); let begin = tokio::spawn({ let db = db.clone(); @@ -1012,8 +1349,16 @@ async fn cancelled_transaction_operation_settles_before_expiry_rollback() { #[tokio::test] async fn shutdown_during_begin_rolls_back_without_orphaning_the_gate() { let (handle, mut envoy_rx) = test_envoy_handle(); - let db = SqliteDb::new_with_remote_sqlite(handle, "actor-a", None, Some(7), true, true) - .expect("test remote sqlite should be configured"); + let db = SqliteDb::new_with_remote_sqlite( + handle, + "actor-a", + None, + Some(7), + true, + true, + SqliteCommitMode::Awaited, + ) + .expect("test remote sqlite should be configured"); let begin = tokio::spawn({ let db = db.clone(); @@ -1036,17 +1381,13 @@ async fn shutdown_during_begin_rolls_back_without_orphaning_the_gate() { let Err(error) = begin.await.unwrap() else { panic!("begin must fail when shutdown wins"); }; - assert!( - error - .downcast_ref::() - .is_some() - ); + assert!(error.downcast_ref::().is_some()); close.await.unwrap().unwrap(); assert!( db.execute("must-not-run", None) .await .unwrap_err() - .downcast_ref::() + .downcast_ref::() .is_some() ); } @@ -1054,8 +1395,16 @@ async fn shutdown_during_begin_rolls_back_without_orphaning_the_gate() { #[tokio::test] async fn shutdown_rechecks_owner_after_concurrent_commit() { let (handle, mut envoy_rx) = test_envoy_handle(); - let db = SqliteDb::new_with_remote_sqlite(handle, "actor-a", None, Some(7), true, true) - .expect("test remote sqlite should be configured"); + let db = SqliteDb::new_with_remote_sqlite( + handle, + "actor-a", + None, + Some(7), + true, + true, + SqliteCommitMode::Awaited, + ) + .expect("test remote sqlite should be configured"); let begin = tokio::spawn({ let db = db.clone(); @@ -1085,8 +1434,16 @@ async fn shutdown_rechecks_owner_after_concurrent_commit() { #[tokio::test] async fn failed_begin_releases_owner_and_allows_key_retry() { let (handle, mut envoy_rx) = test_envoy_handle(); - let db = SqliteDb::new_with_remote_sqlite(handle, "actor-a", None, Some(7), true, true) - .expect("test remote sqlite should be configured"); + let db = SqliteDb::new_with_remote_sqlite( + handle, + "actor-a", + None, + Some(7), + true, + true, + SqliteCommitMode::Awaited, + ) + .expect("test remote sqlite should be configured"); let failed_begin = tokio::spawn({ let db = db.clone(); @@ -1112,8 +1469,16 @@ async fn failed_begin_releases_owner_and_allows_key_retry() { #[tokio::test] async fn disconnect_during_begin_never_publishes_a_transaction_handle() { let (handle, mut envoy_rx) = test_envoy_handle(); - let db = SqliteDb::new_with_remote_sqlite(handle, "actor-a", None, Some(7), true, true) - .expect("test remote sqlite should be configured"); + let db = SqliteDb::new_with_remote_sqlite( + handle, + "actor-a", + None, + Some(7), + true, + true, + SqliteCommitMode::Awaited, + ) + .expect("test remote sqlite should be configured"); let begin = tokio::spawn({ let db = db.clone(); async move { db.begin_transaction(None).await } @@ -1146,8 +1511,16 @@ async fn disconnect_during_begin_never_publishes_a_transaction_handle() { #[tokio::test] async fn disconnect_during_statement_terminalizes_the_transaction() { let (handle, mut envoy_rx) = test_envoy_handle(); - let db = SqliteDb::new_with_remote_sqlite(handle, "actor-a", None, Some(7), true, true) - .expect("test remote sqlite should be configured"); + let db = SqliteDb::new_with_remote_sqlite( + handle, + "actor-a", + None, + Some(7), + true, + true, + SqliteCommitMode::Awaited, + ) + .expect("test remote sqlite should be configured"); let begin = tokio::spawn({ let db = db.clone(); async move { db.begin_transaction(None).await } @@ -1186,8 +1559,16 @@ async fn disconnect_during_statement_terminalizes_the_transaction() { #[tokio::test] async fn disconnect_during_commit_stays_indeterminate_and_releases_waiters() { let (handle, mut envoy_rx) = test_envoy_handle(); - let db = SqliteDb::new_with_remote_sqlite(handle, "actor-a", None, Some(7), true, true) - .expect("test remote sqlite should be configured"); + let db = SqliteDb::new_with_remote_sqlite( + handle, + "actor-a", + None, + Some(7), + true, + true, + SqliteCommitMode::Awaited, + ) + .expect("test remote sqlite should be configured"); let begin = tokio::spawn({ let db = db.clone(); async move { db.begin_transaction(None).await } @@ -1218,8 +1599,16 @@ async fn disconnect_during_commit_stays_indeterminate_and_releases_waiters() { #[tokio::test] async fn failed_commit_rolls_back_and_releases_the_transaction() { let (handle, mut envoy_rx) = test_envoy_handle(); - let db = SqliteDb::new_with_remote_sqlite(handle, "actor-a", None, Some(7), true, true) - .expect("test remote sqlite should be configured"); + let db = SqliteDb::new_with_remote_sqlite( + handle, + "actor-a", + None, + Some(7), + true, + true, + SqliteCommitMode::Awaited, + ) + .expect("test remote sqlite should be configured"); let begin = tokio::spawn({ let db = db.clone(); @@ -1247,8 +1636,16 @@ async fn failed_commit_rolls_back_and_releases_the_transaction() { #[tokio::test] async fn failed_commit_accepts_sqlite_auto_rollback_as_cleanup_success() { let (handle, mut envoy_rx) = test_envoy_handle(); - let db = SqliteDb::new_with_remote_sqlite(handle, "actor-a", None, Some(7), true, true) - .expect("test remote sqlite should be configured"); + let db = SqliteDb::new_with_remote_sqlite( + handle, + "actor-a", + None, + Some(7), + true, + true, + SqliteCommitMode::Awaited, + ) + .expect("test remote sqlite should be configured"); let begin = tokio::spawn({ let db = db.clone(); @@ -1285,8 +1682,16 @@ async fn failed_commit_accepts_sqlite_auto_rollback_as_cleanup_success() { #[tokio::test] async fn failed_rollback_closes_the_coordinator() { let (handle, mut envoy_rx) = test_envoy_handle(); - let db = SqliteDb::new_with_remote_sqlite(handle, "actor-a", None, Some(7), true, true) - .expect("test remote sqlite should be configured"); + let db = SqliteDb::new_with_remote_sqlite( + handle, + "actor-a", + None, + Some(7), + true, + true, + SqliteCommitMode::Awaited, + ) + .expect("test remote sqlite should be configured"); let begin = tokio::spawn({ let db = db.clone(); @@ -1301,15 +1706,11 @@ async fn failed_rollback_closes_the_coordinator() { .expect("failed rollback requester dropped response"); assert!(rollback.await.unwrap().is_err()); let error = db.execute("must-not-run", None).await.unwrap_err(); - assert!( - error - .downcast_ref::() - .is_some() - ); + assert!(error.downcast_ref::().is_some()); assert!( db.try_transaction_admission() .unwrap_err() - .downcast_ref::() + .downcast_ref::() .is_some() ); } @@ -1317,8 +1718,16 @@ async fn failed_rollback_closes_the_coordinator() { #[tokio::test] async fn close_rolls_back_active_transaction_and_rejects_later_work() { let (handle, mut envoy_rx) = test_envoy_handle(); - let db = SqliteDb::new_with_remote_sqlite(handle, "actor-a", None, Some(7), true, true) - .expect("test remote sqlite should be configured"); + let db = SqliteDb::new_with_remote_sqlite( + handle, + "actor-a", + None, + Some(7), + true, + true, + SqliteCommitMode::Awaited, + ) + .expect("test remote sqlite should be configured"); let begin = tokio::spawn({ let db = db.clone(); @@ -1334,19 +1743,23 @@ async fn close_rolls_back_active_transaction_and_rejects_later_work() { respond_to_execute(&mut envoy_rx, "ROLLBACK").await; close.await.unwrap().unwrap(); let error = db.execute("must-not-run", None).await.unwrap_err(); - assert!( - error - .downcast_ref::() - .is_some() - ); + assert!(error.downcast_ref::().is_some()); assert!(envoy_rx.try_recv().is_err()); } #[tokio::test] async fn cancelled_close_still_finishes_rollback_and_release() { let (handle, mut envoy_rx) = test_envoy_handle(); - let db = SqliteDb::new_with_remote_sqlite(handle, "actor-a", None, Some(7), true, true) - .expect("test remote sqlite should be configured"); + let db = SqliteDb::new_with_remote_sqlite( + handle, + "actor-a", + None, + Some(7), + true, + true, + SqliteCommitMode::Awaited, + ) + .expect("test remote sqlite should be configured"); let begin = tokio::spawn({ let db = db.clone(); async move { db.begin_transaction(None).await } @@ -1384,11 +1797,533 @@ async fn cancelled_close_still_finishes_rollback_and_release() { db.execute("must-not-run", None) .await .unwrap_err() - .downcast_ref::() + .downcast_ref::() .is_some() ); } +#[test] +fn deferred_core_rejects_remote_backend_at_construction() { + let (handle, _envoy_rx) = test_envoy_handle(); + let error = SqliteDb::new_with_remote_sqlite( + handle, + "deferred-remote", + None, + Some(1), + true, + true, + SqliteCommitMode::Deferred, + ) + .expect_err("deferred commits require local native SQLite"); + assert_eq!( + rivet_error::RivetError::extract(&error).code(), + "deferred_commits_unsupported" + ); +} + +#[tokio::test] +async fn sqlite_sync_call_fails_while_bridge_lease_is_pending_and_active() { + let (handle, mut envoy_rx) = test_envoy_handle(); + let db = SqliteDb::new_with_remote_sqlite( + handle, + "actor-a", + None, + Some(7), + true, + true, + SqliteCommitMode::Awaited, + ) + .expect("remote sqlite should be configured"); + let begin = tokio::spawn({ + let db = db.clone(); + async move { + db.begin_named_transaction_with_mode( + Some("bridge"), + None, + TransactionOrigin::Bridge, + CallMode::Async, + ) + .await + } + }); + let begin_response = receive_execute(&mut envoy_rx, "BEGIN").await; + let pending_error = db + .execute_with_call_mode("blocked-pending", None, CallMode::SyncBlocking) + .await + .expect_err("sync call must not wait on a pending bridge lease"); + assert_eq!( + rivet_error::RivetError::extract(&pending_error).code(), + "transaction_active" + ); + send_execute_ok(begin_response); + let transaction = begin.await.unwrap().unwrap(); + let active_error = db + .execute_with_call_mode("blocked-active", None, CallMode::SyncBlocking) + .await + .expect_err("sync call must not wait on an active bridge lease"); + assert_eq!( + rivet_error::RivetError::extract(&active_error).code(), + "transaction_active" + ); + let rollback = tokio::spawn(async move { transaction.rollback().await }); + respond_to_execute(&mut envoy_rx, "ROLLBACK").await; + rollback.await.unwrap().unwrap(); +} + +#[tokio::test] +async fn sqlite_sync_call_fails_while_bridge_lease_is_queued_behind_reader() { + let (handle, mut envoy_rx) = test_envoy_handle(); + let db = SqliteDb::new_with_remote_sqlite( + handle, + "actor-a", + None, + Some(7), + true, + true, + SqliteCommitMode::Awaited, + ) + .expect("remote sqlite should be configured"); + let reader = db + .begin_regular_operation(CallMode::Async) + .await + .expect("reader should acquire the coordinator gate"); + let begin = tokio::spawn({ + let db = db.clone(); + async move { + db.begin_named_transaction_with_mode( + Some("bridge"), + None, + TransactionOrigin::Bridge, + CallMode::Async, + ) + .await + } + }); + wait_for_coordinator_state(&db, "bridge lease should become pending", |state| { + state + .pending + .values() + .any(|origin| *origin == TransactionOrigin::Bridge) + }) + .await; + + let pending_error = db + .execute_with_call_mode("blocked-behind-reader", None, CallMode::SyncBlocking) + .await + .expect_err("sync call must fail while a bridge lease is queued"); + assert_eq!( + rivet_error::RivetError::extract(&pending_error).code(), + "transaction_active" + ); + + drop(reader); + respond_to_execute(&mut envoy_rx, "BEGIN").await; + let transaction = begin.await.unwrap().unwrap(); + let rollback = tokio::spawn(async move { transaction.rollback().await }); + respond_to_execute(&mut envoy_rx, "ROLLBACK").await; + rollback.await.unwrap().unwrap(); +} + +#[tokio::test] +async fn sqlite_sync_call_fails_for_synchronous_bridge_reservation_window() { + let (handle, _envoy_rx) = test_envoy_handle(); + let db = SqliteDb::new_with_remote_sqlite( + handle, + "actor-a", + None, + Some(7), + true, + true, + SqliteCommitMode::Awaited, + ) + .expect("remote sqlite should be configured"); + let reader = db + .begin_regular_operation(CallMode::Async) + .await + .expect("reader should acquire the coordinator gate"); + let reservation = db.reserve_bridge_transaction(); + let error = db + .execute_with_call_mode("blocked-before-bridge-future", None, CallMode::SyncBlocking) + .await + .expect_err("the synchronous reservation must close the pre-future deadlock window"); + assert_eq!( + rivet_error::RivetError::extract(&error).code(), + "transaction_active" + ); + drop(reservation); + drop(reader); +} + +#[tokio::test] +async fn sqlite_sync_call_still_waits_for_internal_lease() { + let (handle, mut envoy_rx) = test_envoy_handle(); + let db = SqliteDb::new_with_remote_sqlite( + handle, + "actor-a", + None, + Some(7), + true, + true, + SqliteCommitMode::Awaited, + ) + .expect("remote sqlite should be configured"); + let begin = tokio::spawn({ + let db = db.clone(); + async move { db.begin_transaction(None).await } + }); + let begin_response = receive_execute(&mut envoy_rx, "BEGIN").await; + let operation = tokio::spawn({ + let db = db.clone(); + async move { + db.execute_with_call_mode("after-internal", None, CallMode::SyncBlocking) + .await + } + }); + assert!( + tokio::time::timeout(Duration::from_millis(20), async { + while !operation.is_finished() { + tokio::task::yield_now().await; + } + }) + .await + .is_err(), + "sync call should wait rather than fail against an internal lease" + ); + send_execute_ok(begin_response); + let transaction = begin.await.unwrap().unwrap(); + let rollback = tokio::spawn(async move { transaction.rollback().await }); + respond_to_execute(&mut envoy_rx, "ROLLBACK").await; + rollback.await.unwrap().unwrap(); + respond_to_execute(&mut envoy_rx, "after-internal").await; + operation.await.unwrap().unwrap(); +} + +#[tokio::test] +async fn sqlite_remote_commit_returns_no_local_sequence() { + let (handle, mut envoy_rx) = test_envoy_handle(); + let db = SqliteDb::new_with_remote_sqlite( + handle, + "actor-a", + None, + Some(7), + true, + true, + SqliteCommitMode::Awaited, + ) + .expect("remote sqlite should be configured"); + let begin = tokio::spawn(async move { db.begin_transaction(None).await }); + respond_to_execute(&mut envoy_rx, "BEGIN").await; + let transaction = begin.await.unwrap().unwrap(); + let commit = tokio::spawn(async move { transaction.commit().await }); + respond_to_execute(&mut envoy_rx, "COMMIT").await; + assert_eq!(commit.await.unwrap().unwrap(), None); +} + +#[cfg(feature = "sqlite-local")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn statement_first_fatal_report_preserves_exact_flush_message() { + let (handle, mut envoy_rx) = test_envoy_handle(); + let reported = AtomicBool::new(false); + let expected = "sqlite durable head diverged: expected 7, got 9"; + let statement_error: anyhow::Error = SqliteWorkerFatalError::new(expected.to_owned()).into(); + report_sqlite_worker_fatal( + &reported, + SqliteRuntimeConfig { + handle: handle.clone(), + actor_id: "statement-first-fatal".to_owned(), + generation: Some(1), + }, + sqlite_worker_fatal_message(&statement_error), + ); + // The monitor may observe the terminal progress shortly afterward. The + // statement-first report must already carry the exact shared message, and + // the later path must not replace it with differently prefixed text. + report_sqlite_worker_fatal( + &reported, + SqliteRuntimeConfig { + handle, + actor_id: "statement-first-fatal".to_owned(), + generation: Some(1), + }, + "later monitor report".to_owned(), + ); + + let ToEnvoyMessage::ActorIntent { + error: Some(message), + .. + } = envoy_rx + .recv() + .await + .expect("statement failure should stop actor") + else { + panic!("expected actor stop intent"); + }; + assert_eq!(message, expected); + assert!( + envoy_rx.try_recv().is_err(), + "fatal error should report once" + ); +} + +#[cfg(feature = "sqlite-local")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn deferred_core_wait_maps_flush_errors_and_stops_actor_once() { + let transport = Arc::new(MemorySqliteTransport::default()); + let native = open_memory_native_database( + transport.clone(), + depot_client::vfs::CommitMode::Deferred, + 0, + ) + .await; + let (handle, mut envoy_rx) = test_envoy_handle(); + let db = core_db_from_native(native, SqliteCommitMode::Deferred, Some(handle)); + db.start_worker_failure_monitor(db.native_db_handle().unwrap(), db.runtime_config().unwrap()); + db.execute( + "CREATE TABLE deferred_core_rows (id INTEGER PRIMARY KEY)", + None, + ) + .await + .unwrap(); + db.wait_for_flush(db.commit_seq()).await.unwrap(); + transport.wrong_next_commit_head(); + let write = tokio::spawn({ + let db = db.clone(); + async move { + db.execute("INSERT INTO deferred_core_rows VALUES (1)", None) + .await + } + }); + transport.wait_for_wrong_commit().await; + let write = tokio::time::timeout(Duration::from_secs(1), write) + .await + .expect("deferred write must return before the flush acknowledgement") + .unwrap() + .unwrap(); + transport.release_wrong_commit(); + let seq = write + .commit_seq + .expect("deferred write should report its local sequence"); + let error = db + .wait_for_flush(seq) + .await + .expect_err("wrong acknowledgement head should fail the flush wait"); + assert_eq!( + rivet_error::RivetError::extract(&error).code(), + "flush_failed" + ); + assert!(db.flush_error().is_some()); + + let message = tokio::time::timeout(Duration::from_secs(1), envoy_rx.recv()) + .await + .expect("flush failure should stop the actor") + .expect("envoy channel should stay open"); + let ToEnvoyMessage::ActorIntent { + intent, + error: Some(message), + .. + } = message + else { + panic!("expected one stop intent with the flush error"); + }; + assert_eq!(intent, protocol::ActorIntent::ActorIntentStop); + assert!(message.contains("durable head diverged")); + db.wait_for_worker_failure_monitor_for_test().await; + assert!( + envoy_rx.try_recv().is_err(), + "stop_actor must be reported once" + ); + let _ = db.close_backend().await; +} + +#[cfg(feature = "sqlite-local")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn deferred_core_commit_sequence_survives_close_and_reopen() { + let transport = Arc::new(MemorySqliteTransport::default()); + let commit_sequence = Arc::new(AtomicU64::new(0)); + let (handle, envoy_rx) = test_envoy_handle(); + let responder = tokio::spawn(serve_memory_sqlite_envoy(envoy_rx, transport)); + let open_db = || { + SqliteDb::new_with_remote_sqlite( + handle.clone(), + "core-deferred-reopen", + None, + Some(1), + true, + false, + SqliteCommitMode::Deferred, + ) + .expect("local deferred sqlite should be configured") + .with_commit_sequence_state(Arc::clone(&commit_sequence)) + }; + let db = open_db(); + db.execute( + "CREATE TABLE deferred_core_rows (id INTEGER PRIMARY KEY)", + None, + ) + .await + .unwrap(); + db.wait_for_flush(db.commit_seq()).await.unwrap(); + let before_close = db.commit_seq(); + db.close_backend().await.unwrap(); + assert_eq!(db.commit_seq(), before_close); + assert_eq!(db.flushed_seq(), before_close); + + let reopened = open_db(); + assert_eq!( + reopened.commit_seq(), + before_close, + "the registry-owned seed should be visible before lazy open", + ); + reopened.open().await.unwrap(); + assert_eq!( + reopened.commit_seq(), + before_close + 1, + "the real open-path probe must continue exactly from the registry seed", + ); + reopened + .wait_for_flush(reopened.commit_seq()) + .await + .unwrap(); + assert_eq!(reopened.flushed_seq(), reopened.commit_seq()); + let before_write = reopened.commit_seq(); + let write = reopened + .execute("INSERT INTO deferred_core_rows VALUES (1)", None) + .await + .unwrap(); + assert_eq!(write.commit_seq, Some(before_write + 1)); + reopened + .wait_for_flush(reopened.commit_seq()) + .await + .unwrap(); + reopened.close_backend().await.unwrap(); + responder.abort(); + assert!(responder.await.unwrap_err().is_cancelled()); +} + +#[cfg(feature = "sqlite-local")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn sqlite_auto_rollback_closes_lease_on_success_and_error() { + let transport = Arc::new(MemorySqliteTransport::default()); + let native = + open_memory_native_database(transport, depot_client::vfs::CommitMode::Awaited, 0).await; + let db = core_db_from_native(native, SqliteCommitMode::Awaited, None); + db.exec("CREATE TABLE deferred_core_unique (id INTEGER PRIMARY KEY); INSERT INTO deferred_core_unique VALUES (1);".to_owned()) + .await + .unwrap(); + + let success = db + .begin_named_transaction_with_mode( + Some("success-auto-rollback"), + None, + TransactionOrigin::Bridge, + CallMode::Async, + ) + .await + .unwrap(); + success.execute("ROLLBACK", None).await.unwrap(); + let error = success + .execute("SELECT 1", None) + .await + .expect_err("successful SQLite rollback should terminalize the lease"); + assert_eq!( + rivet_error::RivetError::extract(&error).code(), + "transaction_closed" + ); + assert!(success.rollback().await.is_ok()); + + let failure = db + .begin_named_transaction_with_mode( + Some("error-auto-rollback"), + None, + TransactionOrigin::Bridge, + CallMode::Async, + ) + .await + .unwrap(); + let _ = failure + .execute( + "INSERT OR ROLLBACK INTO deferred_core_unique VALUES (1)", + None, + ) + .await + .expect_err("constraint conflict should auto-roll back SQLite"); + let error = failure + .commit() + .await + .expect_err("auto-rolled-back lease must not commit later"); + assert_eq!( + rivet_error::RivetError::extract(&error).code(), + "transaction_closed" + ); + assert!(failure.rollback().await.is_ok()); + db.close_backend().await.unwrap(); +} + +#[cfg(feature = "sqlite-local")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn sqlite_transaction_exec_stops_after_manual_commit() { + let transport = Arc::new(MemorySqliteTransport::default()); + let native = + open_memory_native_database(transport, depot_client::vfs::CommitMode::Awaited, 0).await; + let db = core_db_from_native(native, SqliteCommitMode::Awaited, None); + db.execute( + "CREATE TABLE deferred_core_rows (id INTEGER PRIMARY KEY)", + None, + ) + .await + .unwrap(); + let transaction = db.begin_transaction(None).await.unwrap(); + let error = transaction + .exec("COMMIT; INSERT INTO deferred_core_rows VALUES (1);") + .await + .expect_err("multi-statement exec must stop after SQLite leaves the lease"); + assert_eq!( + rivet_error::RivetError::extract(&error).code(), + "transaction_closed" + ); + assert_eq!( + db.query("SELECT COUNT(*) FROM deferred_core_rows".to_owned(), None) + .await + .unwrap() + .rows[0][0], + ColumnValue::Integer(0), + ); + db.close_backend().await.unwrap(); +} + +#[cfg(feature = "sqlite-local")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn deferred_core_local_commit_returns_sequence_or_none() { + let transport = Arc::new(MemorySqliteTransport::default()); + let native = + open_memory_native_database(transport, depot_client::vfs::CommitMode::Deferred, 0).await; + let db = core_db_from_native(native, SqliteCommitMode::Deferred, None); + db.execute( + "CREATE TABLE deferred_core_rows (id INTEGER PRIMARY KEY)", + None, + ) + .await + .unwrap(); + db.wait_for_flush(db.commit_seq()).await.unwrap(); + let write = db.begin_transaction(None).await.unwrap(); + write + .execute("INSERT INTO deferred_core_rows VALUES (1)", None) + .await + .unwrap(); + let write_seq = write + .commit() + .await + .unwrap() + .expect("write transaction should report its local sequence"); + assert_eq!(write_seq, db.commit_seq()); + + let read_only = db.begin_transaction(None).await.unwrap(); + read_only.execute("SELECT 1", None).await.unwrap(); + assert_eq!(read_only.commit().await.unwrap(), None); + db.wait_for_flush(write_seq).await.unwrap(); + db.close_backend().await.unwrap(); +} + #[test] fn transaction_deadline_defaults_to_sixty_seconds() { assert_eq!( @@ -1401,6 +2336,7 @@ fn transaction_deadline_defaults_to_sixty_seconds() { fn terminal_transaction_state_is_bounded() { let mut state = TransactionCoordinatorState { active: None, + pending: BTreeMap::new(), terminal: BTreeMap::new(), terminal_order: std::collections::VecDeque::new(), poisoned: BTreeMap::new(), @@ -1436,8 +2372,16 @@ fn terminal_transaction_state_is_bounded() { async fn admission_reports_queue_full_and_closed_distinctly() { let (handle, envoy_rx) = test_envoy_handle(); drop(envoy_rx); - let db = SqliteDb::new_with_remote_sqlite(handle, "actor-a", None, Some(7), true, true) - .expect("test remote sqlite should be configured"); + let db = SqliteDb::new_with_remote_sqlite( + handle, + "actor-a", + None, + Some(7), + true, + true, + SqliteCommitMode::Awaited, + ) + .expect("test remote sqlite should be configured"); let permits = (0..TRANSACTION_COORDINATOR_QUEUE_CAPACITY) .map(|_| db.try_transaction_admission().unwrap()) .collect::>(); @@ -1446,18 +2390,22 @@ async fn admission_reports_queue_full_and_closed_distinctly() { drop(permits); db.transaction_coordinator.admission.close(); let closed = db.try_transaction_admission().unwrap_err(); - assert!( - closed - .downcast_ref::() - .is_some() - ); + assert!(closed.downcast_ref::().is_some()); } #[tokio::test] async fn mismatched_release_does_not_drop_the_active_transaction() { let (handle, mut envoy_rx) = test_envoy_handle(); - let db = SqliteDb::new_with_remote_sqlite(handle, "actor-a", None, Some(7), true, true) - .expect("test remote sqlite should be configured"); + let db = SqliteDb::new_with_remote_sqlite( + handle, + "actor-a", + None, + Some(7), + true, + true, + SqliteCommitMode::Awaited, + ) + .expect("test remote sqlite should be configured"); let begin = tokio::spawn({ let db = db.clone(); async move { db.begin_transaction_with_key("owner", None).await } @@ -1481,8 +2429,16 @@ async fn mismatched_release_does_not_drop_the_active_transaction() { #[tokio::test] async fn remote_disconnect_terminalizes_transaction_and_unparks_new_work() { let (handle, mut envoy_rx, shared) = test_envoy_handle_with_shared(); - let db = SqliteDb::new_with_remote_sqlite(handle, "actor-a", None, Some(7), true, true) - .expect("test remote sqlite should be configured"); + let db = SqliteDb::new_with_remote_sqlite( + handle, + "actor-a", + None, + Some(7), + true, + true, + SqliteCommitMode::Awaited, + ) + .expect("test remote sqlite should be configured"); let begin = tokio::spawn({ let db = db.clone(); async move { db.begin_transaction_with_key("session-owned", None).await } @@ -1530,8 +2486,16 @@ async fn remote_disconnect_terminalizes_transaction_and_unparks_new_work() { #[test] fn remote_head_fence_mismatch_stops_actor_once() { let (handle, mut envoy_rx) = test_envoy_handle(); - let db = SqliteDb::new_with_remote_sqlite(handle, "actor-a", None, Some(7), true, true) - .expect("test remote sqlite should be configured"); + let db = SqliteDb::new_with_remote_sqlite( + handle, + "actor-a", + None, + Some(7), + true, + true, + SqliteCommitMode::Awaited, + ) + .expect("test remote sqlite should be configured"); let mapped = db.remote_sqlite_error_response(protocol::SqliteErrorResponse { group: HEAD_FENCE_MISMATCH_GROUP.to_string(), diff --git a/rivetkit-rust/packages/rivetkit-core/tests/state.rs b/rivetkit-rust/packages/rivetkit-core/tests/state.rs index 2cd3b5cf34..082a098eae 100644 --- a/rivetkit-rust/packages/rivetkit-core/tests/state.rs +++ b/rivetkit-rust/packages/rivetkit-core/tests/state.rs @@ -24,7 +24,7 @@ mod moved_tests { use crate::actor::messages::StateDelta; use crate::actor::task::LifecycleEvent; use crate::kv::tests::new_in_memory; - use crate::sqlite::BindParam; + use crate::sqlite::{BindParam, CallMode}; use crate::{ActorContext, RequestSaveOpts}; use super::{ @@ -437,6 +437,52 @@ mod moved_tests { assert_eq!(ctx.state_transaction_epoch(), epoch_before + 2); } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn sqlite_sync_call_fails_during_reserved_bridge_state_transaction() { + let ctx = new_with_kv( + "actor-state-tx-bridge-reservation", + "state-tx-bridge-reservation", + Vec::new(), + "local", + new_in_memory(), + ); + let save_guard = Arc::clone(&ctx.0.save_guard).lock_owned().await; + let reservation = ctx.reserve_bridge_state_transaction(); + let begin = tokio::spawn({ + let ctx = ctx.clone(); + async move { + ctx.begin_reserved_bridge_state_transaction(reservation, None) + .await + } + }); + tokio::task::yield_now().await; + assert!( + !begin.is_finished(), + "the state transaction should still be waiting for save exclusion", + ); + + let error = ctx + .sql() + .execute_with_call_mode("SELECT 1".to_owned(), None, CallMode::SyncBlocking) + .await + .expect_err("the synchronous reservation must be visible before the Promise runs"); + assert_eq!( + rivet_error::RivetError::extract(&error).code(), + "transaction_active", + ); + + drop(save_guard); + let transaction = tokio::time::timeout(Duration::from_secs(1), begin) + .await + .expect("state transaction should acquire save exclusion") + .expect("state transaction task should join") + .expect("state transaction should begin"); + transaction + .rollback() + .await + .expect("state transaction should roll back"); + } + #[tokio::test] async fn state_transaction_callback_rollback_reschedules_state_and_reverts_sql() { let ctx = new_with_kv( diff --git a/rivetkit-rust/packages/rivetkit-core/tests/task.rs b/rivetkit-rust/packages/rivetkit-core/tests/task.rs index 587776925e..290efe55da 100644 --- a/rivetkit-rust/packages/rivetkit-core/tests/task.rs +++ b/rivetkit-rust/packages/rivetkit-core/tests/task.rs @@ -181,6 +181,7 @@ pub(crate) mod moved_tests { Some(1), false, true, + crate::SqliteCommitMode::Awaited, ) .expect("test remote sqlite should be configured"), ); diff --git a/rivetkit-typescript/packages/rivetkit-napi/index.d.ts b/rivetkit-typescript/packages/rivetkit-napi/index.d.ts index de062a406a..5ac77adf61 100644 --- a/rivetkit-typescript/packages/rivetkit-napi/index.d.ts +++ b/rivetkit-typescript/packages/rivetkit-napi/index.d.ts @@ -93,6 +93,7 @@ export interface JsActorConfig { icon?: string hasDatabase?: boolean remoteSqlite?: boolean + sqliteCommitMode?: string sqliteProfiling?: JsSqliteProfilingConfig enableActorRuntimeSocket?: boolean hasState?: boolean @@ -135,12 +136,15 @@ export interface ExecuteResult { export interface QueryResult { columns: Array rows: Array> + readonly?: boolean } export interface NativeExecuteResult { columns: Array rows: Array> changes: number lastInsertRowId?: number + readonly?: boolean + commitSeq?: number } export interface JsSqliteBatchStatement { sql: string @@ -376,6 +380,11 @@ export declare class ConnHandle { export declare class JsNativeDatabase { takeLastKvError(): string | null metrics(): JsSqliteVfsMetrics | null + commitSeq(): number + flushedSeq(): number + flushError(): string | null + supportsSyncMetadata(): boolean + waitForFlush(seq: number): Promise run(sql: string, params?: Array | undefined | null): Promise query(sql: string, params?: Array | undefined | null): Promise execute(sql: string, params?: Array | undefined | null): Promise @@ -392,8 +401,8 @@ export declare class JsSqliteTransaction { executeSync(sql: string, params?: Array | undefined | null): NativeExecuteResult exec(sql: string): Promise execSync(sql: string): QueryResult - commit(): Promise - commitSync(): void + commit(): Promise + commitSync(): number | null rollback(): Promise rollbackSync(): void } diff --git a/rivetkit-typescript/packages/rivetkit-napi/src/actor_context.rs b/rivetkit-typescript/packages/rivetkit-napi/src/actor_context.rs index 49478854b7..d6c9131175 100644 --- a/rivetkit-typescript/packages/rivetkit-napi/src/actor_context.rs +++ b/rivetkit-typescript/packages/rivetkit-napi/src/actor_context.rs @@ -414,17 +414,22 @@ impl ActorContext { .map_err(napi_anyhow_error) } - #[napi] - pub async fn begin_state_transaction( + #[napi(ts_return_type = "Promise")] + pub fn begin_state_transaction( &self, + env: Env, timeout_ms: Option, - ) -> napi::Result { + ) -> napi::Result { let timeout = timeout_ms.map(transaction_timeout).transpose()?; - self.inner - .begin_state_transaction(timeout) - .await - .map(JsActorStateTransaction::new) - .map_err(napi_anyhow_error) + let reservation = self.inner.reserve_bridge_state_transaction(); + let inner = self.inner.clone(); + env.spawn_future(async move { + inner + .begin_reserved_bridge_state_transaction(reservation, timeout) + .await + .map(JsActorStateTransaction::new) + .map_err(napi_anyhow_error) + }) } #[napi] diff --git a/rivetkit-typescript/packages/rivetkit-napi/src/actor_factory.rs b/rivetkit-typescript/packages/rivetkit-napi/src/actor_factory.rs index d01c1c0187..cba5dc9da8 100644 --- a/rivetkit-typescript/packages/rivetkit-napi/src/actor_factory.rs +++ b/rivetkit-typescript/packages/rivetkit-napi/src/actor_factory.rs @@ -11,7 +11,7 @@ use rivet_error::{ActorSpecifier, RivetError, RivetErrorKind}; use rivetkit_core::inspector::InspectorTabEntry; use rivetkit_core::{ ActionDefinition, ActorConfig, ActorConfigInput, ActorContext as CoreActorContext, - ActorFactory as CoreActorFactory, ConnHandle as CoreConnHandle, Request, + ActorFactory as CoreActorFactory, ConnHandle as CoreConnHandle, Request, SqliteCommitMode, SqliteProfilingConfigInput, WebSocket as CoreWebSocket, }; @@ -97,6 +97,7 @@ pub struct JsActorConfig { pub icon: Option, pub has_database: Option, pub remote_sqlite: Option, + pub sqlite_commit_mode: Option, pub sqlite_profiling: Option, pub enable_actor_runtime_socket: Option, pub has_state: Option, @@ -334,7 +335,9 @@ impl NapiActorFactory { let adapter_config = Arc::new(AdapterConfig::from_js_config(&js_config)); let adapter_bindings = Arc::clone(&bindings); let loop_config = Arc::clone(&adapter_config); - let actor_config = ActorConfig::from_input(ActorConfigInput::from(js_config)); + let actor_config = ActorConfig::from_input( + ActorConfigInput::try_from(js_config).map_err(napi_anyhow_error)?, + ); // Reject malformed config (empty ids/labels, duplicate ids, custom // tabs colliding with built-in ids, etc.) before the actor starts. actor_config.validate().map_err(napi_anyhow_error)?; @@ -988,13 +991,27 @@ pub(crate) fn callback_error(callback_name: &str, error: napi::Error) -> anyhow: anyhow::anyhow!(reason) } -impl From for ActorConfigInput { - fn from(value: JsActorConfig) -> Self { - Self { +impl TryFrom for ActorConfigInput { + type Error = anyhow::Error; + + fn try_from(value: JsActorConfig) -> Result { + let sqlite_commit_mode = match value.sqlite_commit_mode.as_deref() { + None | Some("awaited") => Some(SqliteCommitMode::Awaited), + Some("deferred") => Some(SqliteCommitMode::Deferred), + Some(other) => { + return Err(NapiInvalidArgument { + argument: "sqliteCommitMode".to_owned(), + reason: format!("must be `awaited` or `deferred`, received `{other}`"), + } + .build()); + } + }; + Ok(Self { name: value.name, icon: value.icon, has_database: value.has_database, remote_sqlite: value.remote_sqlite, + sqlite_commit_mode, sqlite_profiling: value.sqlite_profiling.map(Into::into), enable_actor_runtime_socket: value.enable_actor_runtime_socket, has_state: value.has_state, @@ -1056,7 +1073,7 @@ impl From for ActorConfigInput { }) .collect() }), - } + }) } } diff --git a/rivetkit-typescript/packages/rivetkit-napi/src/database.rs b/rivetkit-typescript/packages/rivetkit-napi/src/database.rs index f4d1dfe61e..118e642c14 100644 --- a/rivetkit-typescript/packages/rivetkit-napi/src/database.rs +++ b/rivetkit-typescript/packages/rivetkit-napi/src/database.rs @@ -1,13 +1,14 @@ use std::{future::Future, time::Duration}; use crate::actor_context::{StateDeltaPayload, state_deltas_from_payload}; -use napi::bindgen_prelude::Buffer; +use napi::JsObject; +use napi::bindgen_prelude::{Buffer, Env}; use napi_derive::napi; use rivetkit_core::ActorStateTransaction as CoreActorStateTransaction; use rivetkit_core::sqlite::{ - BindParam, ColumnValue, ExecuteResult as CoreExecuteResult, QueryResult as CoreQueryResult, - SqliteBatchStatement as CoreSqliteBatchStatement, SqliteDb as CoreSqliteDb, - SqliteTransaction as CoreSqliteTransaction, + BindParam, CallMode, ColumnValue, ExecuteResult as CoreExecuteResult, + QueryResult as CoreQueryResult, SqliteBatchStatement as CoreSqliteBatchStatement, + SqliteDb as CoreSqliteDb, SqliteTransaction as CoreSqliteTransaction, TransactionOrigin, }; use crate::{NapiInvalidArgument, napi_anyhow_error}; @@ -74,6 +75,7 @@ pub struct ExecuteResult { pub struct QueryResult { pub columns: Vec, pub rows: Vec>, + pub readonly: Option, } #[napi(object)] @@ -82,6 +84,8 @@ pub struct NativeExecuteResult { pub rows: Vec>, pub changes: i64, pub last_insert_row_id: Option, + pub readonly: Option, + pub commit_seq: Option, } #[napi(object)] @@ -129,6 +133,43 @@ impl JsNativeDatabase { }) } + #[napi] + pub fn commit_seq(&self) -> f64 { + self.db.commit_seq() as f64 + } + + #[napi] + pub fn flushed_seq(&self) -> f64 { + self.db.flushed_seq() as f64 + } + + #[napi] + pub fn flush_error(&self) -> Option { + self.db.flush_error() + } + + #[napi] + pub fn supports_sync_metadata(&self) -> bool { + self.db.backend() == rivetkit_core::sqlite::SqliteBackend::LocalNative + } + + #[napi] + pub async fn wait_for_flush(&self, seq: f64) -> napi::Result<()> { + if !seq.is_finite() || seq < 0.0 || seq.fract() != 0.0 || seq > 9_007_199_254_740_991.0 { + return Err(napi_anyhow_error( + NapiInvalidArgument { + argument: "seq".to_owned(), + reason: "must be a non-negative safe integer".to_owned(), + } + .build(), + )); + } + self.db + .wait_for_flush(seq as u64) + .await + .map_err(crate::napi_anyhow_error) + } + #[napi] pub async fn run( &self, @@ -184,8 +225,11 @@ impl JsNativeDatabase { ) -> napi::Result { let params = params.map(js_bind_params_to_core).transpose()?; let db = self.db.clone(); - wait_for_runtime(async move { db.execute(sql, params).await }) - .map(core_execute_result_to_js) + wait_for_runtime(async move { + db.execute_with_call_mode(sql, params, CallMode::SyncBlocking) + .await + }) + .map(core_execute_result_to_js) } #[napi] @@ -211,7 +255,8 @@ impl JsNativeDatabase { #[napi] pub fn exec_sync(&self, sql: String) -> napi::Result { let db = self.db.clone(); - wait_for_runtime(async move { db.exec(sql).await }).map(core_query_result_to_js) + wait_for_runtime(async move { db.exec_with_call_mode(sql, CallMode::SyncBlocking).await }) + .map(core_query_result_to_js) } #[napi] @@ -219,19 +264,23 @@ impl JsNativeDatabase { self.db.close().await.map_err(crate::napi_anyhow_error) } - #[napi] - pub async fn begin_transaction( + #[napi(ts_return_type = "Promise")] + pub fn begin_transaction( &self, + env: Env, timeout_ms: Option, name: Option, - ) -> napi::Result { + ) -> napi::Result { let timeout = timeout_ms.map(transaction_timeout).transpose()?; - let transaction = self - .db - .begin_named_transaction(name.as_deref(), timeout) - .await - .map_err(crate::napi_anyhow_error)?; - Ok(JsSqliteTransaction { transaction }) + let reservation = self.db.reserve_bridge_transaction(); + let db = self.db.clone(); + env.spawn_future(async move { + let transaction = db + .begin_reserved_bridge_transaction(reservation, name, timeout) + .await + .map_err(crate::napi_anyhow_error)?; + Ok(JsSqliteTransaction { transaction }) + }) } #[napi] @@ -242,10 +291,15 @@ impl JsNativeDatabase { ) -> napi::Result { let timeout = timeout_ms.map(transaction_timeout).transpose()?; let db = self.db.clone(); - let transaction = - wait_for_runtime( - async move { db.begin_named_transaction(name.as_deref(), timeout).await }, - )?; + let transaction = wait_for_runtime(async move { + db.begin_named_transaction_with_mode( + name.as_deref(), + timeout, + TransactionOrigin::Bridge, + CallMode::SyncBlocking, + ) + .await + })?; Ok(JsSqliteTransaction { transaction }) } } @@ -294,17 +348,19 @@ impl JsSqliteTransaction { } #[napi] - pub async fn commit(&self) -> napi::Result<()> { + pub async fn commit(&self) -> napi::Result> { self.transaction .commit() .await + .map(|seq| seq.map(|seq| seq as f64)) .map_err(crate::napi_anyhow_error) } #[napi] - pub fn commit_sync(&self) -> napi::Result<()> { + pub fn commit_sync(&self) -> napi::Result> { let transaction = self.transaction.clone(); wait_for_runtime(async move { transaction.commit().await }) + .map(|seq| seq.map(|seq| seq as f64)) } #[napi] @@ -453,6 +509,7 @@ fn core_query_result_to_js(result: CoreQueryResult) -> QueryResult { .into_iter() .map(|row| row.into_iter().map(column_value_to_json).collect()) .collect(), + readonly: result.readonly, } } @@ -466,6 +523,8 @@ fn core_execute_result_to_js(result: CoreExecuteResult) -> NativeExecuteResult { .collect(), changes: result.changes, last_insert_row_id: result.last_insert_row_id, + readonly: result.readonly, + commit_seq: result.commit_seq.map(|seq| seq as f64), } } diff --git a/rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs b/rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs index f1efda5650..161e2873b1 100644 --- a/rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs +++ b/rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs @@ -202,6 +202,7 @@ pub struct WasmActorConfig { pub icon: Option, pub has_database: Option, pub remote_sqlite: Option, + pub sqlite_commit_mode: Option, pub sqlite_profiling: Option, pub enable_actor_runtime_socket: Option, pub has_state: Option, @@ -231,6 +232,13 @@ pub struct WasmActorConfig { pub actions: Option>, } +#[derive(Clone, Copy, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum WasmSqliteCommitMode { + Awaited, + Deferred, +} + impl From for ActorConfigInput { fn from(config: WasmActorConfig) -> Self { Self { @@ -238,6 +246,10 @@ impl From for ActorConfigInput { icon: config.icon, has_database: config.has_database, remote_sqlite: config.remote_sqlite, + sqlite_commit_mode: config.sqlite_commit_mode.map(|mode| match mode { + WasmSqliteCommitMode::Awaited => rivetkit_core::SqliteCommitMode::Awaited, + WasmSqliteCommitMode::Deferred => rivetkit_core::SqliteCommitMode::Deferred, + }), sqlite_profiling: config.sqlite_profiling.map(Into::into), enable_actor_runtime_socket: config.enable_actor_runtime_socket, has_state: config.has_state, diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actor-db-deferred.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actor-db-deferred.ts new file mode 100644 index 0000000000..f29c8f0b63 --- /dev/null +++ b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actor-db-deferred.ts @@ -0,0 +1,155 @@ +import { actor } from "rivetkit"; +import { db } from "@/common/database/mod"; + +const deferredDatabase = () => + db({ + commitMode: "deferred", + onMigrate: async (database) => { + await database.execute( + "CREATE TABLE IF NOT EXISTS deferred_items (id INTEGER PRIMARY KEY AUTOINCREMENT, value TEXT NOT NULL)", + ); + }, + }); + +export const dbActorDeferred = actor({ + db: deferredDatabase(), + actions: { + writeAndRead: (c, value: string) => { + c.db.executeSync( + "INSERT INTO deferred_items(value) VALUES (?)", + value, + ); + return c.db.executeSync<{ value: string }>( + "SELECT value FROM deferred_items ORDER BY id", + ); + }, + write: (c, value: string) => { + const before = c.db.commitSeq(); + c.db.executeSync( + "INSERT INTO deferred_items(value) VALUES (?)", + value, + ); + return { + before, + after: c.db.commitSeq(), + flushed: c.db.flushedSeq(), + }; + }, + readSequence: (c) => { + const before = c.db.commitSeq(); + c.db.executeSync("SELECT COUNT(*) FROM deferred_items"); + return { before, after: c.db.commitSeq() }; + }, + waitForFlush: async (c, seq?: number) => { + await c.db.waitForFlush(seq); + return { commit: c.db.commitSeq(), flushed: c.db.flushedSeq() }; + }, + waitSnapshotThenWrite: async (c, value: string) => { + const captured = c.db.commitSeq(); + const waiting = c.db.waitForFlush(captured); + const later = c.db.beginTransactionSync({ name: "snapshot-later" }); + later.executeSync( + "INSERT INTO deferred_items(value) VALUES (?)", + value, + ); + await waiting; + const flushedAtEarlierWait = c.db.flushedSeq(); + const after = later.commitSync(); + return { captured, after, flushedAtEarlierWait }; + }, + readonlyMetadata: (c) => ({ + select: c.db.executeSyncRaw("SELECT 1").readonly, + insert: c.db.executeSyncRaw( + "INSERT INTO deferred_items(value) VALUES ('metadata')", + ).readonly, + ddl: c.db.executeSyncRaw( + "CREATE TABLE IF NOT EXISTS deferred_metadata (id INTEGER)", + ).readonly, + }), + handleTransaction: async (c, value: string) => { + const handle = c.db.beginTransactionSync({ name: "deferred-turn" }); + handle.executeSyncRaw( + "INSERT INTO deferred_items(value) VALUES (?)", + value, + ); + let baseSyncError = ""; + try { + c.db.executeSync("SELECT 1"); + } catch (error) { + baseSyncError = + error instanceof Error ? error.message : String(error); + } + const queued = c.db.execute<{ count: number }>( + "SELECT COUNT(*) AS count FROM deferred_items", + ); + let resolvedWhileOpen = false; + void queued.then(() => { + resolvedWhileOpen = handle.isOpen; + }); + let committedSequence: number | null = null; + await new Promise((resolve) => + setImmediate(() => { + committedSequence = handle.commitSync(); + resolve(); + }), + ); + const rows = await queued; + if (committedSequence !== null) { + await c.db.waitForFlush(committedSequence); + } + return { + baseSyncError, + isOpen: handle.isOpen, + resolvedWhileOpen, + count: rows[0]?.count ?? 0, + committedSequence, + sequence: c.db.commitSeq(), + }; + }, + readOnlyHandle: (c) => { + const handle = c.db.beginTransactionSync({ + name: "read-only-turn", + }); + handle.executeSync("SELECT COUNT(*) FROM deferred_items"); + return handle.commitSync(); + }, + values: (c) => + c.db.executeSync<{ value: string }>( + "SELECT value FROM deferred_items ORDER BY id", + ), + }, +}); + +export const sleepDbActorDeferred = actor({ + db: deferredDatabase(), + actions: { + writeAndRead: (c, value: string) => { + c.db.executeSync( + "INSERT INTO deferred_items(value) VALUES (?)", + value, + ); + return c.db.executeSync<{ value: string }>( + "SELECT value FROM deferred_items ORDER BY id", + ); + }, + waitForFlush: async (c) => { + await c.db.waitForFlush(); + return { commit: c.db.commitSeq(), flushed: c.db.flushedSeq() }; + }, + writeAndFlush: async (c, value: string) => { + c.db.executeSync( + "INSERT INTO deferred_items(value) VALUES (?)", + value, + ); + await c.db.waitForFlush(); + return c.db.commitSeq(); + }, + triggerSleep: (c) => c.sleep(), + values: (c) => + c.db.executeSync<{ value: string }>( + "SELECT value FROM deferred_items ORDER BY id", + ), + sequence: (c) => c.db.commitSeq(), + }, + options: { sleepTimeout: 100 }, +}); diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/registry-static.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/registry-static.ts index e9249303d4..ff15495e64 100644 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/registry-static.ts +++ b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/registry-static.ts @@ -24,6 +24,7 @@ import { dbActorDrizzleMigration, dbActorDrizzleMigrationRollback, } from "./actor-db-drizzle-transaction"; +import { dbActorDeferred, sleepDbActorDeferred } from "./actor-db-deferred"; import { actorRuntimeSocketWithoutDb, dbActorManualWarningsDisabled, @@ -353,6 +354,8 @@ export const registry = setup({ dbActorManualWarningsDisabled, actorRuntimeSocketWithoutDb, dbActorRaw, + dbActorDeferred, + sleepDbActorDeferred, dbActorRuntimeSocketDisabled, dbRemoteLifecycleProbe, // From actor-db-drizzle-transaction.ts diff --git a/rivetkit-typescript/packages/rivetkit/src/client/client.ts b/rivetkit-typescript/packages/rivetkit/src/client/client.ts index 33262b0096..9cd69c0017 100644 --- a/rivetkit-typescript/packages/rivetkit/src/client/client.ts +++ b/rivetkit-typescript/packages/rivetkit/src/client/client.ts @@ -542,6 +542,9 @@ export function createActorProxy( if (typeof prop === "symbol") return Reflect.get(target, prop); if (prop === "then") return undefined; + if (prop === "bind") { + return Function.prototype.bind.bind(target); + } return actionPath(`${name}.${prop}`); }, }, diff --git a/rivetkit-typescript/packages/rivetkit/src/common/database/config.ts b/rivetkit-typescript/packages/rivetkit/src/common/database/config.ts index 465a954554..e76a16a785 100644 --- a/rivetkit-typescript/packages/rivetkit/src/common/database/config.ts +++ b/rivetkit-typescript/packages/rivetkit/src/common/database/config.ts @@ -12,15 +12,18 @@ export type InferDatabaseClient = : RawAccess; export type SqliteBindings = unknown[] | Record; +export type SqliteCommitMode = "awaited" | "deferred"; export interface SqliteQueryResult { columns: string[]; rows: unknown[][]; + readonly?: boolean; } export interface SqliteExecuteResult extends SqliteQueryResult { changes: number; lastInsertRowId?: number | null; + commitSeq?: number; } export interface SqliteBatchStatement { @@ -87,7 +90,7 @@ export interface SqliteDatabase { execSync?( sql: string, callback?: (row: unknown[], columns: string[]) => void, - ): void; + ): { readonly?: boolean }; execute(sql: string, params?: SqliteBindings): Promise; executeSync?(sql: string, params?: SqliteBindings): SqliteExecuteResult; executeBatch( @@ -101,6 +104,11 @@ export interface SqliteDatabase { timeoutMs?: number, name?: string, ): SynchronousSqliteTransactionDatabase; + commitSeq?(): number; + flushedSeq?(): number; + waitForFlush?(seq: number): Promise; + flushError?(): string | null; + supportsSyncMetadata?(): boolean; run(sql: string, params?: SqliteBindings): Promise; query(sql: string, params?: SqliteBindings): Promise; nativeMetrics?(): @@ -118,20 +126,25 @@ export interface SqliteTransactionDatabase { execSync?( sql: string, callback?: (row: unknown[], columns: string[]) => void, - ): void; + ): { readonly?: boolean }; execute(sql: string, params?: SqliteBindings): Promise; executeSync?(sql: string, params?: SqliteBindings): SqliteExecuteResult; - commit(): Promise; + commit(): Promise; rollback(): Promise; + commitSeq?(): number; + flushedSeq?(): number; + waitForFlush?(seq: number): Promise; + flushError?(): string | null; + supportsSyncMetadata?(): boolean; } export type SynchronousSqliteTransactionDatabase = SqliteTransactionDatabase & { execSync( sql: string, callback?: (row: unknown[], columns: string[]) => void, - ): void; + ): { readonly?: boolean }; executeSync(sql: string, params?: SqliteBindings): SqliteExecuteResult; - commitSync(): void; + commitSync(): number | null; rollbackSync(): void; }; @@ -192,6 +205,8 @@ export type DatabaseProvider = { * subject to change without notice. */ sqliteProfiling?: SqliteProfilingOptions; + /** Durability behavior for native SQLite commits. Defaults to `"awaited"`. */ + sqliteCommitMode?: SqliteCommitMode; /** * Creates a new database client for the actor. * The result is passed to the actor context as `c.db`. @@ -238,6 +253,10 @@ type ExecuteSyncFunction = < /** SQL operations available inside a synchronous transaction callback. */ export type SynchronousTransactionAccess = { executeSync: ExecuteSyncFunction; + commitSeq(): number; + flushedSeq(): number; + waitForFlush(seq?: number): Promise; + flushError(): string | null; }; export type RawAccess = { @@ -250,6 +269,10 @@ export type RawAccess = { * This blocks the Node.js event loop. Prefer `execute` for normal use. */ executeSync?: ExecuteSyncFunction; + commitSeq?(): number; + flushedSeq?(): number; + waitForFlush?(seq?: number): Promise; + flushError?(): string | null; /** Runs a callback in an isolated SQLite transaction. */ transaction: ( callback: (tx: RawAccess) => Promise | T, @@ -271,6 +294,17 @@ export type RawAccess = { /** Raw database access with synchronous operations provided by the Node.js runtime. */ export type SynchronousRawAccess = RawAccess & { executeSync: ExecuteSyncFunction; + executeSyncRaw( + query: string, + ...args: unknown[] + ): SqliteExecuteResult & { readonly: boolean }; + commitSeq(): number; + flushedSeq(): number; + waitForFlush(seq?: number): Promise; + flushError(): string | null; + beginTransactionSync( + options?: Omit, + ): SynchronousTransactionHandle; /** * Runs a synchronous callback in an isolated SQLite transaction. * The callback must not return a promise. @@ -280,3 +314,25 @@ export type SynchronousRawAccess = RawAccess & { options?: Omit, ) => T; }; + +export interface SynchronousTransactionHandle { + executeSync = Record>( + query: string, + ...args: unknown[] + ): TRow[]; + executeSyncRaw( + query: string, + ...args: unknown[] + ): SqliteExecuteResult & { readonly: boolean }; + execSync( + sql: string, + callback?: (row: unknown[], columns: string[]) => void, + ): { readonly?: boolean }; + commitSeq(): number; + flushedSeq(): number; + waitForFlush(seq?: number): Promise; + flushError(): string | null; + commitSync(): number | null; + rollbackSync(): void; + readonly isOpen: boolean; +} diff --git a/rivetkit-typescript/packages/rivetkit/src/common/database/mod.test.ts b/rivetkit-typescript/packages/rivetkit/src/common/database/mod.test.ts index 40e25800b2..3965e092a2 100644 --- a/rivetkit-typescript/packages/rivetkit/src/common/database/mod.test.ts +++ b/rivetkit-typescript/packages/rivetkit/src/common/database/mod.test.ts @@ -19,6 +19,9 @@ class FakeSqliteDatabase implements SqliteDatabase { stateTransactionTimeouts: Array = []; transactionTimeouts: Array = []; transactionNames: Array = []; + commitSequence = 3; + flushedSequence = 2; + waitedSequences: number[] = []; async exec( sql: string, @@ -30,9 +33,10 @@ class FakeSqliteDatabase implements SqliteDatabase { execSync( sql: string, callback?: (row: unknown[], columns: string[]) => void, - ): void { + ): { readonly?: boolean } { this.record(sql); callback?.([1], ["value"]); + return { readonly: isReadonlySql(sql) }; } async execute( @@ -45,7 +49,7 @@ class FakeSqliteDatabase implements SqliteDatabase { executeSync(sql: string, params?: SqliteBindings): SqliteExecuteResult { this.record(sql, params); - return emptyResult(); + return emptyResult(isReadonlySql(sql)); } async beginTransaction( @@ -64,17 +68,23 @@ class FakeSqliteDatabase implements SqliteDatabase { this.record("BEGIN"); return { exec: async () => {}, - execSync: () => {}, + execSync: (sql) => ({ readonly: isReadonlySql(sql) }), execute: async (sql, params) => { this.record(sql, params); return emptyResult(); }, executeSync: (sql, params) => { this.record(sql, params); - return emptyResult(); + return emptyResult(isReadonlySql(sql)); + }, + commit: async () => { + this.record("COMMIT"); + return null; + }, + commitSync: () => { + this.record("COMMIT"); + return null; }, - commit: async () => this.record("COMMIT"), - commitSync: () => this.record("COMMIT"), rollback: async () => this.record("ROLLBACK"), rollbackSync: () => this.record("ROLLBACK"), }; @@ -86,16 +96,19 @@ class FakeSqliteDatabase implements SqliteDatabase { this.record("BEGIN_STATE"); return { exec: async () => {}, - execSync: () => {}, + execSync: (sql) => ({ readonly: isReadonlySql(sql) }), execute: async (sql, params) => { this.record(sql, params); return emptyResult(); }, executeSync: (sql, params) => { this.record(sql, params); - return emptyResult(); + return emptyResult(isReadonlySql(sql)); + }, + commit: async () => { + this.record("COMMIT"); + return null; }, - commit: async () => this.record("COMMIT"), rollback: async () => this.record("ROLLBACK"), }; } @@ -121,6 +134,26 @@ class FakeSqliteDatabase implements SqliteDatabase { async close(): Promise {} + commitSeq(): number { + return this.commitSequence; + } + + flushedSeq(): number { + return this.flushedSequence; + } + + async waitForFlush(seq: number): Promise { + this.waitedSequences.push(seq); + } + + flushError(): string | null { + return null; + } + + supportsSyncMetadata(): boolean { + return true; + } + private record(sql: string, params?: SqliteBindings): void { this.executeCalls.push({ sql, params }); const error = this.failSql.get(sql); @@ -128,12 +161,17 @@ class FakeSqliteDatabase implements SqliteDatabase { } } -function emptyResult(): SqliteExecuteResult { +function isReadonlySql(sql: string): boolean { + return /^\s*(?:SELECT|PRAGMA|WITH)\b/i.test(sql); +} + +function emptyResult(readonly = false): SqliteExecuteResult { return { columns: [], rows: [], changes: 0, lastInsertRowId: null, + readonly, }; } @@ -170,6 +208,42 @@ describe("db", () => { ); }); + test("plumbs deferred mode and exposes flush sequencing", async () => { + const nativeDb = new FakeSqliteDatabase(); + const provider = db({ commitMode: "deferred" }); + const client = await provider.createClient( + testProviderContext(nativeDb), + ); + + expect(provider.sqliteCommitMode).toBe("deferred"); + expect(client.commitSeq()).toBe(3); + expect(client.flushedSeq()).toBe(2); + const waiting = client.waitForFlush(); + nativeDb.commitSequence = 4; + await waiting; + expect(nativeDb.waitedSequences).toEqual([3]); + expect( + client.executeSyncRaw("INSERT INTO test VALUES (1)").readonly, + ).toBe(false); + }); + + test("handle-form synchronous transactions guard the base sync client", async () => { + const nativeDb = new FakeSqliteDatabase(); + const client = await db().createClient(testProviderContext(nativeDb)); + const transaction = client.beginTransactionSync({ name: "turn" }); + + expect(transaction.isOpen).toBe(true); + expect(() => client.executeSync("SELECT 1")).toThrow( + "Use the open synchronous transaction handle", + ); + expect(transaction.executeSyncRaw("SELECT 1").readonly).toBe(true); + expect(transaction.commitSync()).toBeNull(); + expect(transaction.isOpen).toBe(false); + expect(() => transaction.executeSync("SELECT 1")).toThrow( + "transaction handle is closed", + ); + }); + test("runs onMigrate through the shared transaction handle", async () => { const nativeDb = new FakeSqliteDatabase(); const provider = db({ @@ -299,7 +373,13 @@ describe("db", () => { nativeDb.executeCalls = []; let outerQuery: Promise[]> | undefined; client.transactionSync((tx) => { - expect(Object.keys(tx)).toEqual(["executeSync"]); + expect(Object.keys(tx)).toEqual([ + "executeSync", + "commitSeq", + "flushedSeq", + "waitForFlush", + "flushError", + ]); expect(() => client.executeSync("SELECT 1")).toThrow( "transaction callback's tx value", ); diff --git a/rivetkit-typescript/packages/rivetkit/src/common/database/mod.ts b/rivetkit-typescript/packages/rivetkit/src/common/database/mod.ts index b1226940ca..f447a0599c 100644 --- a/rivetkit-typescript/packages/rivetkit/src/common/database/mod.ts +++ b/rivetkit-typescript/packages/rivetkit/src/common/database/mod.ts @@ -4,16 +4,20 @@ import type { NativeDatabaseProvider, RawAccess, SqliteDatabase, + SqliteExecuteResult, SqliteProfilingOptions, + SqliteCommitMode, SqliteTransactionDatabase, SqliteTransactionOptions, SynchronousRawAccess, + SynchronousTransactionHandle, SynchronousTransactionAccess, } from "./config"; import { isManualTransactionControl, isSqliteBindingObject, MIGRATION_TRANSACTION_TIMEOUT_MS, + normalizeSqliteBindings, runSqliteTransactionSync, toSqliteBindings, validateTransactionName, @@ -32,6 +36,8 @@ export interface DatabaseFactoryConfig { * subject to change without notice. */ profiling?: SqliteProfilingOptions; + /** Native SQLite commit durability mode. Defaults to `"awaited"`. */ + commitMode?: SqliteCommitMode; } const nativeStateTransactionOpeners = new WeakMap< NativeDatabaseProvider, @@ -83,13 +89,25 @@ function hasMultipleStatements(query: string): boolean { return trimmed.includes(";"); } +function isTerminalTransactionError(error: unknown): boolean { + if (typeof error !== "object" || error === null) return false; + const code = (error as { code?: unknown }).code; + return ( + code === "transaction_closed" || + code === "transaction_terminal" || + code === "transaction_expired" + ); +} + export function db({ onMigrate, warnOnManualTransactions = true, profiling, + commitMode = "awaited", }: DatabaseFactoryConfig = {}): DatabaseProvider { const provider: DatabaseProvider = { sqliteProfiling: profiling, + sqliteCommitMode: commitMode, createClient: async (ctx) => { const nativeDatabaseProvider = ctx.nativeDatabaseProvider; if (!nativeDatabaseProvider) { @@ -101,7 +119,8 @@ export function db({ const db = await nativeDatabaseProvider.open(ctx.actorId); let closed = false; let manualTransactionWarned = false; - let synchronousTransactionActive = false; + let synchronousTransactionKind: "callback" | "handle" | undefined; + let closeSynchronousHandle: (() => void) | undefined; const ensureOpen = () => { if (closed) { throw new Error( @@ -111,18 +130,42 @@ export function db({ }; const ensureSynchronousTransactionClient = ( transactionScoped: boolean, + asyncMember = false, ) => { - if (!transactionScoped && synchronousTransactionActive) { + if ( + !transactionScoped && + synchronousTransactionKind === "callback" + ) { throw new Error( "Use the transaction callback's tx value for queries inside db.transactionSync().", ); } + if ( + !transactionScoped && + !asyncMember && + synchronousTransactionKind === "handle" + ) { + throw new Error( + "Use the open synchronous transaction handle until it is committed or rolled back.", + ); + } + }; + const recoverSynchronousHandle = (error: unknown) => { + if (typeof error !== "object" || error === null) return; + const code = (error as { code?: unknown }).code; + if ( + code === "transaction_active" || + code === "transaction_closed" + ) { + closeSynchronousHandle?.(); + } }; const createClient = ( target: SqliteDatabase | SqliteTransactionDatabase, transactionScoped = false, stateTransactionContext?: NativeStateTransactionContext, + sequencing: SqliteDatabase | SqliteTransactionDatabase = db, ): SynchronousRawAccess => { const client: SynchronousRawAccess = { execute: async < @@ -135,7 +178,10 @@ export function db({ ...args: unknown[] ): Promise => { ensureOpen(); - ensureSynchronousTransactionClient(transactionScoped); + ensureSynchronousTransactionClient( + transactionScoped, + true, + ); if ( !transactionScoped && warnOnManualTransactions && @@ -263,6 +309,10 @@ export function db({ } return execMultiStatementSync(target, query); + } catch (error) { + if (!transactionScoped) + recoverSynchronousHandle(error); + throw error; } finally { const durationMs = performance.now() - start; ctx.metrics?.trackSql(query, durationMs); @@ -281,12 +331,51 @@ export function db({ } } }, + executeSyncRaw: ( + query: string, + ...args: unknown[] + ): SqliteExecuteResult & { readonly: boolean } => { + ensureOpen(); + ensureSynchronousTransactionClient(transactionScoped); + if (!target.executeSync) { + throw new Error( + "Synchronous SQLite queries are only available in the Node.js native runtime.", + ); + } + if (sequencing.supportsSyncMetadata?.() !== true) { + throw new Error( + "Synchronous SQLite metadata is only available for local native SQLite.", + ); + } + let result: SqliteExecuteResult; + try { + result = target.executeSync( + query, + normalizeSqliteBindings(args), + ); + } catch (error) { + if (!transactionScoped) + recoverSynchronousHandle(error); + throw error; + } + if (result.readonly === undefined) { + throw new Error( + "Synchronous SQLite metadata is only available in the Node.js native runtime.", + ); + } + return result as SqliteExecuteResult & { + readonly: boolean; + }; + }, transaction: async ( callback: (tx: RawAccess) => Promise | T, options?: SqliteTransactionOptions, ): Promise => { ensureOpen(); - ensureSynchronousTransactionClient(transactionScoped); + ensureSynchronousTransactionClient( + transactionScoped, + true, + ); validateTransactionTimeout(options?.timeout); validateTransactionName(options?.name); if ( @@ -325,7 +414,12 @@ export function db({ options?.timeout, options?.name, ); - const tx = createClient(transaction, true); + const tx = createClient( + transaction, + true, + undefined, + sequencing, + ); try { const result = await callback(tx); await transaction.commit(); @@ -352,7 +446,7 @@ export function db({ >, ): T => { ensureOpen(); - if (transactionScoped || synchronousTransactionActive) { + if (transactionScoped || synchronousTransactionKind) { throw new Error( "Nested synchronous SQLite transactions are not supported.", ); @@ -363,20 +457,146 @@ export function db({ const transactionClient = createClient( transaction, true, + undefined, + sequencing, ); const tx: SynchronousTransactionAccess = { executeSync: transactionClient.executeSync, + commitSeq: transactionClient.commitSeq, + flushedSeq: transactionClient.flushedSeq, + waitForFlush: + transactionClient.waitForFlush, + flushError: transactionClient.flushError, }; - synchronousTransactionActive = true; + synchronousTransactionKind = "callback"; try { return callback(tx); } finally { - synchronousTransactionActive = false; + synchronousTransactionKind = undefined; } }, options, ); }, + commitSeq: () => sequencing.commitSeq!(), + flushedSeq: () => sequencing.flushedSeq!(), + waitForFlush: async (seq?: number) => { + const targetSeq = seq ?? sequencing.commitSeq!(); + if (!Number.isSafeInteger(targetSeq) || targetSeq < 0) { + throw new Error( + "flush sequence must be a non-negative safe integer", + ); + } + await sequencing.waitForFlush!(targetSeq); + }, + flushError: () => sequencing.flushError!(), + beginTransactionSync: ( + options?: Omit< + SqliteTransactionOptions, + "experimental" + >, + ): SynchronousTransactionHandle => { + ensureOpen(); + if (transactionScoped || synchronousTransactionKind) { + throw new Error( + "Nested synchronous SQLite transactions are not supported.", + ); + } + validateTransactionTimeout(options?.timeout); + validateTransactionName(options?.name); + if (!db.beginTransactionSync) { + throw new Error( + "Synchronous SQLite transactions are only available in the Node.js native runtime.", + ); + } + const transaction = db.beginTransactionSync( + options?.timeout, + options?.name, + ); + const transactionClient = createClient( + transaction, + true, + undefined, + sequencing, + ); + let isOpen = true; + synchronousTransactionKind = "handle"; + const finish = () => { + isOpen = false; + if (closeSynchronousHandle === finish) { + closeSynchronousHandle = undefined; + synchronousTransactionKind = undefined; + } + }; + closeSynchronousHandle = finish; + const closeOnTerminal = (error: unknown) => { + if (isTerminalTransactionError(error)) { + finish(); + } + throw error; + }; + const requireOpen = () => { + if (!isOpen) + throw new Error( + "SQLite transaction handle is closed.", + ); + }; + return { + executeSync: (query, ...args) => { + requireOpen(); + try { + return transactionClient.executeSync( + query, + ...args, + ); + } catch (error) { + return closeOnTerminal(error); + } + }, + executeSyncRaw: (query, ...args) => { + requireOpen(); + try { + return transactionClient.executeSyncRaw( + query, + ...args, + ); + } catch (error) { + return closeOnTerminal(error); + } + }, + execSync: (sql, callback) => { + requireOpen(); + try { + return transaction.execSync(sql, callback); + } catch (error) { + return closeOnTerminal(error); + } + }, + commitSync: () => { + requireOpen(); + try { + return transaction.commitSync(); + } finally { + finish(); + } + }, + rollbackSync: () => { + requireOpen(); + try { + transaction.rollbackSync(); + } finally { + finish(); + } + }, + get isOpen() { + return isOpen; + }, + commitSeq: transactionClient.commitSeq, + flushedSeq: transactionClient.flushedSeq, + waitForFlush: transactionClient.waitForFlush, + flushError: transactionClient.flushError, + }; + }, close: async () => { if (!closed) { closed = true; @@ -387,7 +607,7 @@ export function db({ }; if (!transactionScoped) { nativeStateTransactionClientBinders.set(client, (context) => - createClient(target, false, context), + createClient(target, false, context, sequencing), ); } return client; diff --git a/rivetkit-typescript/packages/rivetkit/src/common/database/native-database.test.ts b/rivetkit-typescript/packages/rivetkit/src/common/database/native-database.test.ts index 491a0bb5f3..672df2f171 100644 --- a/rivetkit-typescript/packages/rivetkit/src/common/database/native-database.test.ts +++ b/rivetkit-typescript/packages/rivetkit/src/common/database/native-database.test.ts @@ -39,9 +39,11 @@ class FakeNativeDatabase implements JsNativeDatabaseLike { this.executeSync(sql, params), commit: async () => { this.transactionEvents.push("COMMIT"); + return 1; }, commitSync: () => { this.transactionEvents.push("COMMIT_SYNC"); + return 1; }, rollback: async () => { this.transactionEvents.push("ROLLBACK"); @@ -56,6 +58,9 @@ class FakeNativeDatabase implements JsNativeDatabaseLike { closed = false; executeCalls: { sql: string; params?: NativeParams; write: boolean }[] = []; transactionEvents: string[] = []; + commitSequence = 0; + flushedSequence = 0; + waitedSequences: number[] = []; #pending: ReturnType>[] = []; async exec() { @@ -94,6 +99,26 @@ class FakeNativeDatabase implements JsNativeDatabaseLike { return null; } + commitSeq() { + return this.commitSequence; + } + + flushedSeq() { + return this.flushedSequence; + } + + async waitForFlush(seq: number) { + this.waitedSequences.push(seq); + } + + flushError() { + return null; + } + + supportsSyncMetadata() { + return true; + } + async close() { this.closed = true; } @@ -248,9 +273,22 @@ describe("wrapJsNativeDatabase", () => { const fallback = db.execute("SELECT last_insert_rowid()"); await expect(fallback).resolves.toMatchObject({ rows: [[7]], + readonly: true, }); }); + test("delegates commit and flush sequences", async () => { + const native = new FakeNativeDatabase(); + native.commitSequence = 5; + native.flushedSequence = 3; + const db = wrapJsNativeDatabase(native); + + expect(db.commitSeq?.()).toBe(5); + expect(db.flushedSeq?.()).toBe(3); + await db.waitForFlush?.(5); + expect(native.waitedSequences).toEqual([5]); + }); + test("close waits for admitted native calls and rejects new work", async () => { const native = new FakeNativeDatabase(); const db = wrapJsNativeDatabase(native); diff --git a/rivetkit-typescript/packages/rivetkit/src/common/database/native-database.ts b/rivetkit-typescript/packages/rivetkit/src/common/database/native-database.ts index 81cc981ed5..4d1d582c07 100644 --- a/rivetkit-typescript/packages/rivetkit/src/common/database/native-database.ts +++ b/rivetkit-typescript/packages/rivetkit/src/common/database/native-database.ts @@ -50,6 +50,7 @@ type NativeBindParam = interface NativeExecResult { columns: string[]; rows: unknown[][]; + readonly?: boolean; } interface NativeQueryResult { @@ -66,6 +67,8 @@ interface NativeExecuteResult { rows: unknown[][]; changes: number; lastInsertRowId?: number | null; + readonly?: boolean; + commitSeq?: number; } interface NativeBatchStatement { @@ -108,6 +111,11 @@ export interface JsNativeDatabaseLike { params?: NativeBindParam[] | null, ): Promise; metrics?(): SqliteNativeMetrics | null; + commitSeq(): number; + flushedSeq(): number; + waitForFlush(seq: number): Promise; + flushError(): string | null; + supportsSyncMetadata(): boolean; takeLastKvError?(): string | null; close(): Promise; } @@ -129,13 +137,13 @@ export interface JsNativeTransactionLike { sql: string, params?: NativeBindParam[] | null, ): NativeExecuteResult; - commit(): Promise; + commit(): Promise; rollback(): Promise; } export interface JsNativeSynchronousTransactionLike extends JsNativeTransactionLike { - commitSync(): void; + commitSync(): number | null; rollbackSync(): void; } @@ -373,6 +381,7 @@ export function wrapJsNativeDatabase( rows: [[lastInsertRowId ?? 0]], changes: 0, lastInsertRowId, + readonly: true, }; } @@ -401,6 +410,7 @@ export function wrapJsNativeDatabase( rows: [[lastInsertRowId ?? 0]], changes: 0, lastInsertRowId, + readonly: true, }; } @@ -443,7 +453,7 @@ export function wrapJsNativeDatabase( execSync( sql: string, callback?: (row: unknown[], columns: string[]) => void, - ): void { + ): { readonly?: boolean } { const release = gate.enter(); let result: NativeExecResult; try { @@ -458,6 +468,7 @@ export function wrapJsNativeDatabase( callback(row, result.columns); } } + return { readonly: result.readonly }; }, async execute( sql: string, @@ -585,6 +596,21 @@ export function wrapJsNativeDatabase( nativeMetrics(): SqliteNativeMetrics | null { return normalizeNativeMetrics(database.metrics?.()); }, + commitSeq(): number { + return database.commitSeq(); + }, + flushedSeq(): number { + return database.flushedSeq(); + }, + async waitForFlush(seq: number): Promise { + await database.waitForFlush(seq); + }, + flushError(): string | null { + return database.flushError(); + }, + supportsSyncMetadata(): boolean { + return database.supportsSyncMetadata(); + }, async close(): Promise { closePromise ??= gate.close(() => database.close()); await closePromise; @@ -638,6 +664,7 @@ function wrapTransaction( if (callback) { for (const row of result.rows) callback(row, result.columns); } + return { readonly: result.readonly }; }, async execute(sql, params) { const release = gate.enter(); @@ -672,7 +699,7 @@ function wrapTransaction( async commit() { const release = gate.enter(); try { - await transaction.commit(); + return (await transaction.commit()) ?? null; } catch (error) { enrichNativeDatabaseError(database, error); } finally { @@ -689,6 +716,11 @@ function wrapTransaction( release(); } }, + commitSeq: () => database.commitSeq(), + flushedSeq: () => database.flushedSeq(), + waitForFlush: async (seq) => await database.waitForFlush(seq), + flushError: () => database.flushError(), + supportsSyncMetadata: () => database.supportsSyncMetadata(), }; if (isSynchronousTransaction(transaction)) { @@ -696,7 +728,7 @@ function wrapTransaction( commitSync() { const release = gate.enter(); try { - transaction.commitSync(); + return transaction.commitSync() ?? null; } catch (error) { enrichNativeDatabaseError(database, error); } finally { diff --git a/rivetkit-typescript/packages/rivetkit/src/common/database/shared.ts b/rivetkit-typescript/packages/rivetkit/src/common/database/shared.ts index fcd47d774b..eb056fd7bf 100644 --- a/rivetkit-typescript/packages/rivetkit/src/common/database/shared.ts +++ b/rivetkit-typescript/packages/rivetkit/src/common/database/shared.ts @@ -137,6 +137,15 @@ export function toSqliteBindings( throw new Error("unsupported sqlite binding collection"); } +export function normalizeSqliteBindings( + args: unknown[], +): SqliteBindings | undefined { + if (args.length === 0) return undefined; + return args.length === 1 && isSqliteBindingObject(args[0]) + ? toSqliteBindings(args[0]) + : toSqliteBindings(args); +} + /** * Serialize async operations on a shared non-reentrant resource. */ diff --git a/rivetkit-typescript/packages/rivetkit/src/db/drizzle.test.ts b/rivetkit-typescript/packages/rivetkit/src/db/drizzle.test.ts index 615c4e469f..ea54c55478 100644 --- a/rivetkit-typescript/packages/rivetkit/src/db/drizzle.test.ts +++ b/rivetkit-typescript/packages/rivetkit/src/db/drizzle.test.ts @@ -25,9 +25,10 @@ class FakeSqliteDatabase implements SqliteDatabase { execSync( sql: string, callback?: (row: unknown[], columns: string[]) => void, - ): void { + ): { readonly?: boolean } { this.executeCalls.push({ sql }); callback?.([1], ["value"]); + return { readonly: isReadonlySql(sql) }; } async execute( @@ -40,7 +41,7 @@ class FakeSqliteDatabase implements SqliteDatabase { executeSync(sql: string, params?: SqliteBindings): SqliteExecuteResult { this.executeCalls.push({ sql, params }); - return emptyResult(); + return emptyResult(isReadonlySql(sql)); } async executeBatch( @@ -78,20 +79,22 @@ class FakeSqliteDatabase implements SqliteDatabase { this.executeCalls.push({ sql: "BEGIN" }); return { exec: async () => {}, - execSync: () => {}, + execSync: (sql) => ({ readonly: isReadonlySql(sql) }), execute: async (sql, params) => { this.executeCalls.push({ sql, params }); return emptyResult(); }, executeSync: (sql, params) => { this.executeCalls.push({ sql, params }); - return emptyResult(); + return emptyResult(isReadonlySql(sql)); }, commit: async () => { this.executeCalls.push({ sql: "COMMIT" }); + return null; }, commitSync: () => { this.executeCalls.push({ sql: "COMMIT" }); + return null; }, rollback: async () => { this.executeCalls.push({ sql: "ROLLBACK" }); @@ -112,14 +115,37 @@ class FakeSqliteDatabase implements SqliteDatabase { } async close(): Promise {} + + commitSeq(): number { + return 0; + } + + flushedSeq(): number { + return 0; + } + + async waitForFlush(): Promise {} + + flushError(): string | null { + return null; + } + + supportsSyncMetadata(): boolean { + return true; + } +} + +function isReadonlySql(sql: string): boolean { + return /^\s*(?:SELECT|PRAGMA|WITH)\b/i.test(sql); } -function emptyResult(): SqliteExecuteResult { +function emptyResult(readonly = false): SqliteExecuteResult { return { columns: [], rows: [], changes: 0, lastInsertRowId: null, + readonly, }; } diff --git a/rivetkit-typescript/packages/rivetkit/src/db/drizzle.ts b/rivetkit-typescript/packages/rivetkit/src/db/drizzle.ts index f9e39f7055..ef268b672d 100644 --- a/rivetkit-typescript/packages/rivetkit/src/db/drizzle.ts +++ b/rivetkit-typescript/packages/rivetkit/src/db/drizzle.ts @@ -6,16 +6,20 @@ import { import type { DatabaseProvider, DatabaseProviderContext, + SqliteCommitMode, SqliteDatabase, + SqliteExecuteResult, SqliteProfilingOptions, SqliteTransactionDatabase, SqliteTransactionOptions, SynchronousRawAccess, SynchronousTransactionAccess, + SynchronousTransactionHandle, } from "@/common/database/config"; import { isManualTransactionControl, MIGRATION_TRANSACTION_TIMEOUT_MS, + normalizeSqliteBindings, runSqliteTransactionSync, toSqliteBindings, validateTransactionName, @@ -63,12 +67,24 @@ interface DrizzleMigrations { migrations: Record; } +function isTerminalTransactionError(error: unknown): boolean { + if (!error || typeof error !== "object") return false; + const code = + "code" in error ? (error as { code?: unknown }).code : undefined; + return ( + code === "transaction_closed" || + code === "transaction_terminal" || + code === "transaction_expired" + ); +} + export interface DrizzleDatabaseFactoryConfig { schema?: TSchema; migrations?: DrizzleMigrations; onMigrate?: (db: DrizzleDatabase) => Promise | void; warnOnManualTransactions?: boolean; profiling?: SqliteProfilingOptions; + commitMode?: SqliteCommitMode; } interface DrizzleKitConfig { @@ -93,11 +109,13 @@ export function db>({ onMigrate, warnOnManualTransactions = true, profiling, + commitMode = "awaited", }: DrizzleDatabaseFactoryConfig = {}): DatabaseProvider< DrizzleDatabase > { return { sqliteProfiling: profiling, + sqliteCommitMode: commitMode, createClient: async (ctx) => { const override = ctx.overrideDrizzleDatabaseClient ? await ctx.overrideDrizzleDatabaseClient() @@ -116,7 +134,8 @@ export function db>({ const nativeDb = await nativeDatabaseProvider.open(ctx.actorId); let closed = false; let manualTransactionWarned = false; - let synchronousTransactionActive = false; + let synchronousTransactionKind: "callback" | "handle" | undefined; + let closeSynchronousHandle: (() => void) | undefined; const ensureOpen = () => { if (closed) { throw new Error( @@ -126,17 +145,46 @@ export function db>({ }; const ensureSynchronousTransactionClient = ( transactionScoped: boolean, + asyncMember = false, ) => { - if (!transactionScoped && synchronousTransactionActive) { + if ( + !transactionScoped && + synchronousTransactionKind === "callback" + ) { throw new Error( "Use the transaction callback's tx value for queries inside db.transactionSync().", ); } + if ( + !transactionScoped && + !asyncMember && + synchronousTransactionKind === "handle" + ) { + throw new Error( + "Use the open synchronous transaction handle until it is committed or rolled back.", + ); + } + }; + const recoverSynchronousHandle = (error: unknown) => { + if (!error || typeof error !== "object") return; + const code = + "code" in error + ? (error as { code?: unknown }).code + : undefined; + if ( + code === "transaction_active" || + code === "transaction_closed" + ) { + closeSynchronousHandle?.(); + } }; const createDrizzleClient = ( target: SqliteDatabase | SqliteTransactionDatabase, transactionScoped = false, + sequencing: + | SqliteDatabase + | SqliteTransactionDatabase = nativeDb, ): DrizzleDatabase => { const runSql = async ( query: string, @@ -144,7 +192,7 @@ export function db>({ method: "run" | "all" | "values" | "get", ) => { ensureOpen(); - ensureSynchronousTransactionClient(transactionScoped); + ensureSynchronousTransactionClient(transactionScoped, true); warnForManualTransaction(query, transactionScoped); const start = performance.now(); @@ -199,7 +247,7 @@ export function db>({ query: string, ...args: unknown[] ): Promise => { - ensureSynchronousTransactionClient(transactionScoped); + ensureSynchronousTransactionClient(transactionScoped, true); return await executeRaw( target, ctx, @@ -220,15 +268,23 @@ export function db>({ ...args: unknown[] ): TRow[] => { ensureSynchronousTransactionClient(transactionScoped); - return executeRawSync( - target, - ctx, - ensureOpen, - query, - args, - () => - warnForManualTransaction(query, transactionScoped), - ); + try { + return executeRawSync( + target, + ctx, + ensureOpen, + query, + args, + () => + warnForManualTransaction( + query, + transactionScoped, + ), + ); + } catch (error) { + if (!transactionScoped) recoverSynchronousHandle(error); + throw error; + } }; drizzleDb.transaction = async ( transactionCallback: ( @@ -237,14 +293,18 @@ export function db>({ options?: SqliteTransactionOptions, ): Promise => { ensureOpen(); - ensureSynchronousTransactionClient(transactionScoped); + ensureSynchronousTransactionClient(transactionScoped, true); validateTransactionTimeout(options?.timeout); validateTransactionName(options?.name); const transaction = await nativeDb.beginTransaction( options?.timeout, options?.name, ); - const tx = createDrizzleClient(transaction, true); + const tx = createDrizzleClient( + transaction, + true, + sequencing, + ); try { const result = await transactionCallback(tx); await transaction.commit(); @@ -265,7 +325,7 @@ export function db>({ options?: Omit, ): T => { ensureOpen(); - if (transactionScoped || synchronousTransactionActive) { + if (transactionScoped || synchronousTransactionKind) { throw new Error( "Nested synchronous SQLite transactions are not supported.", ); @@ -276,20 +336,173 @@ export function db>({ const transactionClient = createDrizzleClient( transaction, true, + sequencing, ); const tx: SynchronousTransactionAccess = { executeSync: transactionClient.executeSync, + commitSeq: transactionClient.commitSeq, + flushedSeq: transactionClient.flushedSeq, + waitForFlush: transactionClient.waitForFlush, + flushError: transactionClient.flushError, }; - synchronousTransactionActive = true; + synchronousTransactionKind = "callback"; try { return transactionCallback(tx); } finally { - synchronousTransactionActive = false; + synchronousTransactionKind = undefined; } }, options, ); }; + drizzleDb.executeSyncRaw = ( + query: string, + ...args: unknown[] + ): SqliteExecuteResult & { readonly: boolean } => { + ensureOpen(); + ensureSynchronousTransactionClient(transactionScoped); + if (!target.executeSync) { + throw new Error( + "Synchronous SQLite queries are only available in the Node.js native runtime.", + ); + } + if (sequencing.supportsSyncMetadata?.() !== true) { + throw new Error( + "Synchronous SQLite metadata is only available for local native SQLite.", + ); + } + let result: SqliteExecuteResult; + try { + result = target.executeSync( + query, + normalizeSqliteBindings(args), + ); + } catch (error) { + if (!transactionScoped) recoverSynchronousHandle(error); + throw error; + } + if (result.readonly === undefined) { + throw new Error( + "Synchronous SQLite metadata is only available in the Node.js native runtime.", + ); + } + return result as SqliteExecuteResult & { + readonly: boolean; + }; + }; + drizzleDb.commitSeq = () => sequencing.commitSeq!(); + drizzleDb.flushedSeq = () => sequencing.flushedSeq!(); + drizzleDb.waitForFlush = async (seq?: number) => { + const targetSeq = seq ?? sequencing.commitSeq!(); + if (!Number.isSafeInteger(targetSeq) || targetSeq < 0) { + throw new Error( + "flush sequence must be a non-negative safe integer", + ); + } + await sequencing.waitForFlush!(targetSeq); + }; + drizzleDb.flushError = () => sequencing.flushError!(); + drizzleDb.beginTransactionSync = ( + options?: Omit, + ): SynchronousTransactionHandle => { + ensureOpen(); + if (transactionScoped || synchronousTransactionKind) { + throw new Error( + "Nested synchronous SQLite transactions are not supported.", + ); + } + validateTransactionTimeout(options?.timeout); + validateTransactionName(options?.name); + if (!nativeDb.beginTransactionSync) { + throw new Error( + "Synchronous SQLite transactions are only available in the Node.js native runtime.", + ); + } + const transaction = nativeDb.beginTransactionSync( + options?.timeout, + options?.name, + ); + const transactionClient = createDrizzleClient( + transaction, + true, + sequencing, + ); + let isOpen = true; + synchronousTransactionKind = "handle"; + const finish = () => { + isOpen = false; + if (closeSynchronousHandle === finish) { + closeSynchronousHandle = undefined; + synchronousTransactionKind = undefined; + } + }; + closeSynchronousHandle = finish; + const closeOnTerminal = (error: unknown): never => { + if (isTerminalTransactionError(error)) finish(); + throw error; + }; + const requireOpen = () => { + if (!isOpen) + throw new Error( + "SQLite transaction handle is closed.", + ); + }; + return { + executeSync: (query, ...args) => { + requireOpen(); + try { + return transactionClient.executeSync( + query, + ...args, + ); + } catch (error) { + return closeOnTerminal(error); + } + }, + executeSyncRaw: (query, ...args) => { + requireOpen(); + try { + return transactionClient.executeSyncRaw( + query, + ...args, + ); + } catch (error) { + return closeOnTerminal(error); + } + }, + execSync: (sql, callback) => { + requireOpen(); + try { + return transaction.execSync(sql, callback); + } catch (error) { + return closeOnTerminal(error); + } + }, + commitSync: () => { + requireOpen(); + try { + return transaction.commitSync(); + } finally { + finish(); + } + }, + rollbackSync: () => { + requireOpen(); + try { + transaction.rollbackSync(); + } finally { + finish(); + } + }, + get isOpen() { + return isOpen; + }, + commitSeq: transactionClient.commitSeq, + flushedSeq: transactionClient.flushedSeq, + waitForFlush: transactionClient.waitForFlush, + flushError: transactionClient.flushError, + }; + }; drizzleDb.close = async () => { if (!closed) { closed = true; diff --git a/rivetkit-typescript/packages/rivetkit/src/registry/napi-runtime.ts b/rivetkit-typescript/packages/rivetkit/src/registry/napi-runtime.ts index 6c419c8e7a..95f4803ee4 100644 --- a/rivetkit-typescript/packages/rivetkit/src/registry/napi-runtime.ts +++ b/rivetkit-typescript/packages/rivetkit/src/registry/napi-runtime.ts @@ -846,12 +846,14 @@ export class NapiCoreRuntime implements CoreRuntime { async actorSqlTransactionCommit( transaction: SqliteTransactionHandle, - ): Promise { - await asNativeSqlTransaction(transaction).commit(); + ): Promise { + return await asNativeSqlTransaction(transaction).commit(); } - actorSqlTransactionCommitSync(transaction: SqliteTransactionHandle): void { - asNativeSqlTransaction(transaction).commitSync(); + actorSqlTransactionCommitSync( + transaction: SqliteTransactionHandle, + ): number | null { + return asNativeSqlTransaction(transaction).commitSync(); } async actorSqlTransactionRollback( @@ -936,6 +938,29 @@ export class NapiCoreRuntime implements CoreRuntime { return this.#actorSql(ctx).metrics?.() ?? null; } + actorSqlCommitSeq(ctx: ActorContextHandle): number { + return this.#actorSql(ctx).commitSeq(); + } + + actorSqlFlushedSeq(ctx: ActorContextHandle): number { + return this.#actorSql(ctx).flushedSeq(); + } + + async actorSqlWaitForFlush( + ctx: ActorContextHandle, + seq: number, + ): Promise { + await this.#actorSql(ctx).waitForFlush(seq); + } + + actorSqlFlushError(ctx: ActorContextHandle): string | null { + return this.#actorSql(ctx).flushError(); + } + + actorSqlSupportsSyncMetadata(ctx: ActorContextHandle): boolean { + return this.#actorSql(ctx).supportsSyncMetadata(); + } + actorSqlTakeLastKvError(ctx: ActorContextHandle): string | null { return this.#actorSql(ctx).takeLastKvError?.() ?? null; } diff --git a/rivetkit-typescript/packages/rivetkit/src/registry/native.ts b/rivetkit-typescript/packages/rivetkit/src/registry/native.ts index 7514492b77..555b3d9712 100644 --- a/rivetkit-typescript/packages/rivetkit/src/registry/native.ts +++ b/rivetkit-typescript/packages/rivetkit/src/registry/native.ts @@ -669,6 +669,11 @@ function getOrCreateNativeSqlDatabase( query: (sql, params) => runtime.actorSqlQuery(ctx, sql, params), run: (sql, params) => runtime.actorSqlRun(ctx, sql, params), metrics: () => runtime.actorSqlMetrics(ctx), + commitSeq: () => runtime.actorSqlCommitSeq(ctx), + flushedSeq: () => runtime.actorSqlFlushedSeq(ctx), + waitForFlush: (seq) => runtime.actorSqlWaitForFlush(ctx, seq), + flushError: () => runtime.actorSqlFlushError(ctx), + supportsSyncMetadata: () => runtime.actorSqlSupportsSyncMetadata(ctx), takeLastKvError: () => runtime.actorSqlTakeLastKvError(ctx), close: () => runtime.actorSqlClose(ctx), }); @@ -3838,12 +3843,16 @@ function buildActorConfig( const sqliteProfiling = ( config.db as { sqliteProfiling?: SqliteProfilingOptions } | undefined )?.sqliteProfiling; + const sqliteCommitMode = ( + config.db as { sqliteCommitMode?: "awaited" | "deferred" } | undefined + )?.sqliteCommitMode; return { name: options.name as string | undefined, icon: options.icon as string | undefined, hasDatabase: true, remoteSqlite: usesRemoteSqlite, + sqliteCommitMode, sqliteProfiling, enableActorRuntimeSocket: options.enableActorRuntimeSocket === true, hasState: @@ -4761,7 +4770,8 @@ export function buildNativeFactory( } } finally { resolveNativeDestroy(runtime, ctx); - await actorCtx.closeDatabase(); + // Core owns destroy-time database closure. Waiting for it here delays the + // callback that lets core unlink the Actor Runtime Socket for this generation. clearNativeRuntimeState(runtime, ctx); await actorCtx.dispose(); } diff --git a/rivetkit-typescript/packages/rivetkit/src/registry/runtime.ts b/rivetkit-typescript/packages/rivetkit/src/registry/runtime.ts index 6915f9531c..2130409923 100644 --- a/rivetkit-typescript/packages/rivetkit/src/registry/runtime.ts +++ b/rivetkit-typescript/packages/rivetkit/src/registry/runtime.ts @@ -1,4 +1,5 @@ import type { + SqliteCommitMode, SqliteNativeMetrics, SqliteProfilingOptions, } from "@/common/database/config"; @@ -221,6 +222,7 @@ export interface RuntimeActorRuntimeSocketEndpointInfo { export interface RuntimeSqlQueryResult { columns: string[]; rows: unknown[][]; + readonly?: boolean; } export type RuntimeSqlExecResult = RuntimeSqlQueryResult; @@ -228,6 +230,7 @@ export type RuntimeSqlExecResult = RuntimeSqlQueryResult; export interface RuntimeSqlExecuteResult extends RuntimeSqlQueryResult { changes: number; lastInsertRowId?: number | null; + commitSeq?: number; } export interface RuntimeSqlBatchStatement { @@ -278,6 +281,9 @@ export interface RuntimeSqlDatabase { timeoutMs?: number, name?: string, ): RuntimeSqlTransactionDatabase; + commitSeq(): number; + flushedSeq(): number; + waitForFlush(seq: number): Promise; metrics?(): SqliteNativeMetrics | null; takeLastKvError?(): string | null; close(): Promise; @@ -294,8 +300,8 @@ export interface RuntimeSqlTransactionDatabase { sql: string, params?: RuntimeSqlBindParams, ): RuntimeSqlExecuteResult; - commit(): Promise; - commitSync(): void; + commit(): Promise; + commitSync(): number | null; rollback(): Promise; rollbackSync(): void; } @@ -305,6 +311,7 @@ export interface RuntimeActorConfig { icon?: string; hasDatabase?: boolean; remoteSqlite?: boolean; + sqliteCommitMode?: SqliteCommitMode; sqliteProfiling?: SqliteProfilingOptions; enableActorRuntimeSocket?: boolean; hasState?: boolean; @@ -679,8 +686,10 @@ export interface CoreRuntime { ): RuntimeSqlExecuteResult; actorSqlTransactionCommit( transaction: SqliteTransactionHandle, - ): Promise; - actorSqlTransactionCommitSync(transaction: SqliteTransactionHandle): void; + ): Promise; + actorSqlTransactionCommitSync( + transaction: SqliteTransactionHandle, + ): number | null; actorSqlTransactionRollback( transaction: SqliteTransactionHandle, ): Promise; @@ -717,6 +726,11 @@ export interface CoreRuntime { params?: RuntimeSqlBindParams, ): Promise; actorSqlMetrics(ctx: ActorContextHandle): SqliteNativeMetrics | null; + actorSqlCommitSeq(ctx: ActorContextHandle): number; + actorSqlFlushedSeq(ctx: ActorContextHandle): number; + actorSqlWaitForFlush(ctx: ActorContextHandle, seq: number): Promise; + actorSqlFlushError(ctx: ActorContextHandle): string | null; + actorSqlSupportsSyncMetadata(ctx: ActorContextHandle): boolean; actorSqlTakeLastKvError(ctx: ActorContextHandle): string | null; actorSqlClose(ctx: ActorContextHandle): Promise; actorRuntimeSocketProvision( diff --git a/rivetkit-typescript/packages/rivetkit/src/registry/wasm-runtime.ts b/rivetkit-typescript/packages/rivetkit/src/registry/wasm-runtime.ts index 0386dff180..23899c2d3d 100644 --- a/rivetkit-typescript/packages/rivetkit/src/registry/wasm-runtime.ts +++ b/rivetkit-typescript/packages/rivetkit/src/registry/wasm-runtime.ts @@ -865,14 +865,17 @@ export class WasmCoreRuntime implements CoreRuntime { async actorSqlTransactionCommit( transaction: SqliteTransactionHandle, - ): Promise { + ): Promise { await callWasm(() => (transaction as unknown as { commit(): Promise }).commit(), ); + return null; } - actorSqlTransactionCommitSync(_transaction: SqliteTransactionHandle): void { - synchronousSqliteUnavailable(); + actorSqlTransactionCommitSync( + _transaction: SqliteTransactionHandle, + ): number | null { + return synchronousSqliteUnavailable(); } async actorSqlTransactionRollback( @@ -975,6 +978,27 @@ export class WasmCoreRuntime implements CoreRuntime { return this.#actorSql(ctx).metrics?.() ?? null; } + actorSqlCommitSeq(_ctx: ActorContextHandle): number { + return 0; + } + + actorSqlFlushedSeq(_ctx: ActorContextHandle): number { + return 0; + } + + async actorSqlWaitForFlush( + _ctx: ActorContextHandle, + _seq: number, + ): Promise {} + + actorSqlFlushError(_ctx: ActorContextHandle): string | null { + return null; + } + + actorSqlSupportsSyncMetadata(_ctx: ActorContextHandle): boolean { + return false; + } + actorSqlTakeLastKvError(ctx: ActorContextHandle): string | null { return this.#actorSql(ctx).takeLastKvError?.() ?? null; } diff --git a/rivetkit-typescript/packages/rivetkit/tests/driver/actor-db-deferred.test.ts b/rivetkit-typescript/packages/rivetkit/tests/driver/actor-db-deferred.test.ts new file mode 100644 index 0000000000..726bfc5eb9 --- /dev/null +++ b/rivetkit-typescript/packages/rivetkit/tests/driver/actor-db-deferred.test.ts @@ -0,0 +1,105 @@ +import { expect, test } from "vitest"; +import { + describeDriverMatrix, + SQLITE_DRIVER_MATRIX_OPTIONS, +} from "./shared-matrix"; +import { setupDriverTest } from "./shared-utils"; + +describeDriverMatrix( + "Actor database deferred commits", + (driverTestConfig) => { + const supported = + driverTestConfig.runtime === "native" && + driverTestConfig.sqliteBackend === "local"; + + if (!supported) { + test("rejects deferred commits on non-local runtimes", async (c) => { + const { client, getRuntimeOutput } = await setupDriverTest( + c, + driverTestConfig, + ); + const instance = client.dbActorDeferred.getOrCreate([ + "unsupported-deferred", + ]); + await expect(instance.ready).rejects.toMatchObject({ + code: "actor_wake_retries_exceeded", + }); + expect(getRuntimeOutput()).toContain( + "deferred_commits_unsupported: Deferred SQLite commits are unsupported.", + ); + }); + return; + } + + test("reads local writes before and after an explicit flush", async (c) => { + const { client } = await setupDriverTest(c, driverTestConfig); + const key = ["visibility"]; + const instance = client.sleepDbActorDeferred + .getOrCreate(key) + .connect(); + await instance.ready; + const rows = await instance.writeAndRead("visible"); + expect(rows).toEqual([{ value: "visible" }]); + const progress = await instance.waitForFlush(); + expect(progress.flushed).toBeGreaterThanOrEqual(progress.commit); + await instance.triggerSleep(); + await instance.dispose(); + const reopened = client.sleepDbActorDeferred.getOrCreate(key); + expect(await reopened.values()).toEqual([{ value: "visible" }]); + expect(await reopened.sequence()).toBeGreaterThan(progress.commit); + }); + + test("advances commit sequence for writes but not reads", async (c) => { + const { client } = await setupDriverTest(c, driverTestConfig); + const instance = client.dbActorDeferred.getOrCreate(["sequences"]); + const write = await instance.write("one"); + expect(write.after).toBeGreaterThan(write.before); + const read = await instance.readSequence(); + expect(read.after).toBe(read.before); + const flushed = await instance.waitForFlush(write.after); + expect(flushed.flushed).toBeGreaterThanOrEqual(write.after); + const snapshot = await instance.waitSnapshotThenWrite("two"); + expect(snapshot.after).toBeGreaterThan(snapshot.captured); + expect(snapshot.flushedAtEarlierWait).toBeLessThan( + snapshot.after ?? 0, + ); + }); + + test("reports readonly metadata and supports a turn-spanning handle", async (c) => { + const { client } = await setupDriverTest(c, driverTestConfig); + const instance = client.dbActorDeferred.getOrCreate(["handle"]); + expect(await instance.readonlyMetadata()).toEqual({ + select: true, + insert: false, + ddl: false, + }); + const result = await instance.handleTransaction("turn"); + expect(result.baseSyncError).toContain( + "Use the open synchronous transaction handle", + ); + expect(result.isOpen).toBe(false); + expect(result.resolvedWhileOpen).toBe(false); + expect(result.count).toBe(2); + expect(result.committedSequence).toBeTypeOf("number"); + expect(result.committedSequence).toBe(result.sequence); + expect(result.sequence).toBeGreaterThan(0); + expect(await instance.readOnlyHandle()).toBeNull(); + }); + + test("survives a sleep after flushing", async (c) => { + const { client } = await setupDriverTest(c, driverTestConfig); + const key = ["sleep"]; + const instance = client.sleepDbActorDeferred + .getOrCreate(key) + .connect(); + await instance.ready; + const before = await instance.writeAndFlush("durable"); + await instance.triggerSleep(); + await instance.dispose(); + const woke = client.sleepDbActorDeferred.getOrCreate(key); + expect(await woke.values()).toEqual([{ value: "durable" }]); + expect(await woke.sequence()).toBeGreaterThan(before); + }); + }, + SQLITE_DRIVER_MATRIX_OPTIONS, +); diff --git a/rivetkit-typescript/packages/rivetkit/tests/driver/actor-db.test.ts b/rivetkit-typescript/packages/rivetkit/tests/driver/actor-db.test.ts index 7fdd8a82b3..569fe9becf 100644 --- a/rivetkit-typescript/packages/rivetkit/tests/driver/actor-db.test.ts +++ b/rivetkit-typescript/packages/rivetkit/tests/driver/actor-db.test.ts @@ -731,7 +731,7 @@ describeDriverMatrix( actor.synchronousQueries("sync"), ).resolves.toEqual({ value: "sync", - multiStatementValues: [1, 2], + multiStatementValues: [2], transactionCount: 2, rollbackCount: 0, }); @@ -1178,7 +1178,7 @@ describeDriverMatrix( ]); expect( await actor.terminalTransactionDiagnostic(), - ).toContain("already committed"); + ).toContain("SQLite transaction is closed."); }, dbTestTimeout, ); diff --git a/rivetkit-typescript/packages/rivetkit/tests/nested-actions.test.ts b/rivetkit-typescript/packages/rivetkit/tests/nested-actions.test.ts index bb04fd0173..9a344f7f3e 100644 --- a/rivetkit-typescript/packages/rivetkit/tests/nested-actions.test.ts +++ b/rivetkit-typescript/packages/rivetkit/tests/nested-actions.test.ts @@ -108,6 +108,20 @@ describe("nested actions", () => { expect(Object.getOwnPropertyDescriptor(handle, "then")).toBeUndefined(); }); + test("supports binding action proxy functions", async () => { + const action = vi.fn().mockResolvedValue("pong"); + const handle = createActorProxy({ + action, + } as unknown as ActorHandleRaw) as any; + const bound = handle.ping.bind(handle); + + await expect(bound("value")).resolves.toBe("pong"); + expect(action).toHaveBeenCalledWith({ + name: "ping", + args: ["value"], + }); + }); + test("preserves dotted namespace segments when matching nested schemas", () => { const create = () => "created"; const schema = z.tuple([z.string()]);