diff --git a/.agent/notes/sqlite-worker-executor-spec-review.md b/.agent/notes/sqlite-worker-executor-spec-review.md new file mode 100644 index 0000000000..16a12cae01 --- /dev/null +++ b/.agent/notes/sqlite-worker-executor-spec-review.md @@ -0,0 +1,119 @@ +# SQLite Worker Executor Spec Review + +Source spec: `.agent/specs/sqlite-worker-executor-read-pool.md` + +## Critical + +1. **Reader handles must be recycled after writer commits.** + - Current spec only calls out recycling after schema-changing writes. + - Risk: SQLite readonly connections have their own pager/schema caches. Sharing the VFS `moka` page cache does not prove reused reader handles observe committed writer changes. + - Needed decision: require closing/reopening all reader SQLite handles after every writer commit unless a tested SQLite lock/change-counter design proves reuse is safe. + +2. **Idle readwrite connection semantics are unsafe/unclear.** + - Current spec implies a long-lived writer worker owns a readwrite `sqlite3*`. + - Risk: current code uses `PRAGMA locking_mode = EXCLUSIVE` and VFS lock callbacks are no-ops. An idle open writer connection plus reader connections may violate SQLite/VFS assumptions. + - Needed decision: close writer handle between jobs when read pooling is enabled, make idle writer block readers, or implement a real lock ladder. + +3. **Reader VFS reads must not see the dirty write buffer.** + - Current spec says readers must not mutate the dirty buffer, but does not explicitly forbid reading it. + - Risk: current VFS resolves `write_buffer.dirty` before committed cache pages. A reader overlapping a dirty writer path could observe uncommitted pages. + - Needed decision: role-aware read resolution. Reader handles read committed state only; writer handles may read dirty buffer. + +4. **VFS unregister after close timeout can be unsafe.** + - Current spec says active jobs finish or hit a bounded close timeout, then workers close and VFS unregisters. + - Risk: without `sqlite3_interrupt`, timed-out workers may still be inside SQLite/VFS. Dropping or unregistering the VFS would risk use-after-unregister. + - Needed decision: close timeout may return an error, but the VFS must remain alive until every worker thread has actually exited, or the design must intentionally leak/retain the VFS and mark it dead. + +5. **`SELECT` is not enough for reader routing.** + - Current spec treats parser-proven `SELECT` as reader-candidate. + - Risk: read-only SQL can be connection-affine. Examples: `last_insert_rowid()`, `changes()`, `total_changes()`, temp schema reads, or other session-state queries. + - Needed decision: reject connection-affine functions and temp schema reads from the reader allowlist, or route all connection-affine/session-state SQL to writer. + +## High + +6. **VFS role tagging needs a concrete mechanism.** + - Current spec says reader-owned and writer-owned file handles, but not how callbacks know the role. + - Risk: role checks are aspirational unless `VfsFile` stores role and callbacks enforce it. + - Needed decision: store role on `VfsFile` at `xOpen`, derived from open flags plus coordinator-issued capability/epoch, and check it in read/write/file-control/delete/truncate/sync paths. + +7. **Aux/temp file behavior is underspecified.** + - Current spec shares the aux-file registry and says reader aux creation should fail if it implies writes. + - Risk: readonly SELECTs can still require temp storage. Shared aux files are mutable VFS state. + - Needed decision: require `PRAGMA temp_store = MEMORY` on readers and fail all reader aux writes, or make aux files per-connection/private. + +8. **Manual transaction routing wording can deadlock.** + - Current spec says reads under writer pressure/manual transaction may “wait or route writer,” then later says manual transaction routes all work to writer. + - Risk: `BEGIN; INSERT; SELECT; COMMIT` can block itself if the SELECT waits behind the transaction. + - Needed decision: manual transaction pins all work to writer until autocommit returns. No waiting/read-lane routing inside the manual transaction. + +9. **Queued-read behavior under writer pressure is too loose.** + - Current spec says pending writer stops new reader admission. + - Risk: reads already in `pending_reads` could still drain ahead of a writer. + - Needed decision: once `pending_writer_count > 0`, no queued read dispatches to reader workers until the writer completes. + +10. **Backpressure needs exact bounded-queue semantics.** + - Current spec says bounded command queue and queue-full returns `actor.overloaded`. + - Risk: awaiting channel capacity creates hidden backpressure instead of explicit overload. + - Needed decision: public sends use `try_reserve`/`try_send`; internal pending queues and worker queues are bounded; full public or worker queues return `actor.overloaded`. + +11. **Cancellation must update coordinator state.** + - Current spec says queued requests with dropped receivers can be dropped before dispatch. + - Risk: cancelling a queued writer may leave `pending_writer_count` elevated, freezing readers. + - Needed decision: cancellation decrements writer/read counters, frees queue capacity, wakes coordinator, and recomputes writer pressure. + +12. **Prepare errors must not become classifier mismatches.** + - Current spec could classify all reader-worker classification disagreement as internal mismatch. + - Risk: `sqlparser` can parse SQL that SQLite later rejects. That is a user SQL error, not an internal parser bug. + - Needed decision: mismatch only when SQLite prepare succeeds but tail/readonly/authorizer says non-reader-eligible. Prepare errors remain normal SQLite/user errors. + +13. **Writer-pressure routing needs exact state rules.** + - Current spec says “wait or route writer according to current semantics.” + - Risk: ambiguous ordering and hidden behavior changes. + - Needed decision: specify behavior for pending writer, active writer, idle writer, and manual transaction separately. + +14. **Migration from `NativeConnectionManager` needs a compatibility checklist.** + - Current spec says keep old manager as fallback but not how `NativeDatabaseHandle` switches. + - Risk: clone semantics, idempotent close, `take_last_kv_error`, preload hints, metrics, initialization, and test-only snapshots can regress. + - Needed decision: require a backend enum/trait plus compatibility tests for old manager versus worker executor. + +## Medium + +15. **The `sqlparser` allowlist needs exact AST rules.** + - Current spec says `Statement::Query`/SELECT in prose. + - Risk: accepting broad query AST nodes admits unsupported or connection-affine SQL. + - Needed decision: define exact accepted `sqlparser` AST variants, recursive checks for CTEs/subqueries/set ops/table factors/expressions, and route unknown AST nodes to writer. + +16. **Mismatch/error metadata is too thin.** + - Current spec metadata has only parser route, readonly, tail, and authorizer write flag. + - Risk: hard to distinguish parser drift, user SQL error, VFS role bug, readonly open failure, query-only failure, or authorizer denial. + - Needed decision: include prepare status, SQLite code/message class, denied authorizer action, failure phase, and route decision metadata. + +17. **Rollout flag matrix is underspecified.** + - Current spec adds `RIVETKIT_SQLITE_OPT_WORKER_EXECUTOR`, plus existing read-pool flags. + - Risk: `worker_executor=1` with `read_pool_enabled=false` or `max_readers=0` could accidentally use legacy path or error. + - Needed decision: define precedence. Likely: worker executor enabled with read pool disabled means writer-only worker executor. + +18. **Default-on gate is too broad.** + - Current spec says native driver tests, depot-client VFS tests, wasm/remote tests, parity and stress tests. + - Risk: important failure modes are not named as release blockers. + - Needed decision: add explicit gates for fault/chaos tests, close during active read/write, close with queued work, close timeout behavior, schema change plus reader reuse/recycle, worker panic/channel close, metrics parity, and wasm dependency checks. + +19. **Worker thread implementation choice is still open.** + - Current spec leaves OS threads versus stable `spawn_blocking` loops open. + - Risk: cancellation, joining, panic handling, and runtime shutdown behavior differ. + - Needed decision: pick one for v1 or define acceptance criteria for either. + +20. **Read-only PRAGMAs need a later allowlist decision.** + - Current spec routes PRAGMAs to writer in v1. + - Risk: conservative but may leave performance on table. + - Needed decision: keep writer-only in v1; add a follow-up only after parser/SQLite/connection-affinity behavior is tested. + +21. **Cache invalidation on truncate needs explicit tests.** + - Current spec asks whether `moka` invalidation on truncate is sufficient. + - Risk: stale pages after truncate or file-size changes. + - Needed decision: add truncate tests covering reader recycle and page-cache invalidation after writer work. + +22. **Reader open must be proven network-free.** + - Current design assumes fresh reader opens are cheap because they reuse the shared VFS and cache. + - Risk: SQLite open/setup could accidentally trigger depot/envoy transport through page fetch, schema preload, or VFS bootstrap behavior. + - Needed decision: add a debug/test-only VFS no-network guard around readonly reader open and setup. Any `get_pages` or commit transport during that region should panic in tests or return an internal assertion error in debug builds. diff --git a/.agent/specs/sqlite-channel-worker-executor.md b/.agent/specs/sqlite-channel-worker-executor.md new file mode 100644 index 0000000000..38c3cde0dd --- /dev/null +++ b/.agent/specs/sqlite-channel-worker-executor.md @@ -0,0 +1,408 @@ +# SQLite Channel Worker Executor + +## Goal + +Replace the current async lease-based native SQLite connection manager with a single channel-driven SQLite worker. The worker owns the native SQLite connection for its lifetime, and async callers interact with it through bounded messages. + +This intentionally removes parallel reader semantics from the active design. Parallel reads are deferred to `.agent/todo/sqlite-parallel-read-workers.md`. + +This is the minimal final native SQLite design. Do not keep legacy read-pool or lease-manager behavior behind a long-term compatibility path. + +## Current Problem + +The current native SQLite path uses `NativeConnectionManager` to lease `NativeConnection` values to async callers. Each query moves a SQLite connection into `tokio::task::spawn_blocking`, runs a closure, then moves the connection back into the manager. + +That shape works, but it creates avoidable complexity: + +- SQLite handles move across blocking-pool tasks. +- `NativeDatabase` needs `Send` for query execution. +- Connection close/drop must be carefully pushed through blocking contexts. +- Shutdown has to coordinate async manager state with blocking SQLite cleanup. +- Read/write pool state is more complicated than the current safety target requires. + +## Implementation Strategy + +Prefer deleting and replacing the lease-manager/read-pool implementation over incrementally mutating it into a worker. Build the worker executor as a fresh module with a narrow public surface, port the necessary VFS/query helpers into that shape, then remove the old manager code, read-pool flags, read-pool metrics, `ExecuteRoute::Read`, and separate native `execute_write` path once tests pass. + +## Proposed Shape + +Introduce one actor-database-local SQLite executor: + +```text +NativeDatabaseHandle + SqliteWorkerHandle + bounded command sender + priority close/control signal + shared state for close/idempotence/metrics/worker death + OS worker thread + owns NativeVfsHandle + owns one readwrite sqlite3* +``` + +All SQL work runs on that one worker. There are no reader workers, no read pool, no parser admission layer, and no cross-connection routing. + +Async callers send a command over a bounded channel and await a `oneshot` reply. The worker executes commands serially against its owned SQLite connection. + +## Non-Goals + +- Do not run parallel reads in v1. +- Do not add an SQL parser in v1. +- Do not open readonly SQLite connections in v1. +- Do not implement a SQLite lock ladder in v1. +- Do not change depot storage semantics or VFS page format. +- Do not keep `execute_write` as a separate native worker operation. +- Do not keep `ExecuteRoute::Read`. +- Do not keep read-pool metrics or read-pool optimization flags. + +## Core Invariants + +- Exactly one SQLite connection exists per native actor database handle. +- The worker is the only owner of the `sqlite3*`. +- The worker is the only code path that calls SQLite execution APIs for that handle. +- The VFS is registered once before worker execution and unregistered only after the worker has closed the SQLite connection and exited. +- Commands are executed in receive order. +- Queue-full returns `actor.overloaded`; callers must not await channel capacity. +- Close is idempotent and prevents new work from being accepted. +- Worker failure is fatal to the owning actor. A panicked or unexpectedly exited SQLite worker must stop/crash the actor instead of letting the actor continue with a broken database handle. + +## Command API + +Public native database methods become worker commands: + +```rust +enum SqliteCommand { + Execute { + sql: String, + params: Option>, + reply: oneshot::Sender>, + }, + Exec { + sql: String, + reply: oneshot::Sender>, + }, +} +``` + +The exact type names can differ. The important boundary: + +- Use a bounded channel. +- Use `try_reserve` or `try_send`. +- Map full queue to `actor.overloaded`. +- Do not expose `sqlite3*` or `NativeConnection` outside the worker. +- Treat a closed worker channel as a structured SQLite closed/shutdown error. +- Do not send close over the bounded SQL command queue. Close uses a priority control signal that cannot be blocked by queued SQL work. +- Default SQL command queue capacity is 128 commands per actor database. This is intentionally bounded per actor so a single actor cannot build an unbounded native SQL backlog. + +Prefer Tokio-shaped APIs where they fit. `tokio::sync::mpsc::Receiver::blocking_recv` is available, so a standalone sync thread can receive from Tokio mpsc. However, the worker also needs priority close/control observation. If Tokio mpsc cannot express that without polling, sleeps, runtime dependencies, or close messages queued behind SQL work, use `crossbeam_channel::bounded` for worker SQL/control channels and keep `tokio::sync::oneshot` for replies. + +The worker itself should be a real OS thread, not a Tokio task. SQLite and the custom VFS are synchronous. A dedicated OS thread gives the connection, final VFS handle, panic boundary, and join handle one clear owner. The concern with `spawn_blocking` is that it is meant for short blocking closures, cannot truly abort running blocking work, depends on the Tokio runtime during shutdown, and can become detached if the join handle is dropped. + +Preferred implementation order: + +1. Try `tokio::sync::mpsc` with `blocking_recv` only if close can still bypass SQL queue capacity and be observed before queued SQL dispatch. +2. Otherwise use `crossbeam_channel::bounded` for worker SQL/control channels. +3. Use `tokio::sync::oneshot` for async command replies in either case. + +Do not use polling sleeps to bridge async and sync channel APIs. + +## Worker Execution + +The worker opens and configures one readwrite connection: + +```text +open shared VFS +sqlite3_open_v2(... SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE ...) +configure_connection_for_database +verify_batch_atomic_writes +receive commands until close +close sqlite3* +drop NativeVfsHandle +``` + +`execute` and `exec` reuse the single-connection query implementation: + +- `exec_statements` +- `execute_single_statement` +- `configure_connection_for_database` +- `verify_batch_atomic_writes` + +Because all work runs on one connection, statement classification is not used in the native worker executor. `execute_single_statement` remains responsible for single-statement validation. Delete `ExecuteRoute::Read` from the shared SQLite result surface. There is no separate native `execute_write` path in this design. `execute` reports `WriteFallback`. + +## Routing Semantics + +There is no reader/writer lane selection. + +- `exec(sql)` runs on the worker connection. +- `execute(sql, params)` runs on the worker connection and reports `ExecuteRoute::WriteFallback`. +- `query` and `run` remain projections over `execute` where possible. +- Any public `execute_write` compatibility wrapper should be removed or collapsed to `execute` before this design is considered complete. +- Manual transaction statements naturally stay on the same connection because every command uses the same worker-owned handle. +- There is no legacy read route. Remove `ExecuteRoute::Read` rather than preserving a dead route variant. + +This intentionally preserves connection-affine behavior for: + +- `last_insert_rowid()` +- `changes()` +- `total_changes()` +- temp tables +- PRAGMAs +- explicit transactions +- user/session connection state + +This is desired behavior. Treat it as the baseline native SQLite semantics, not as compatibility with the removed read-pool design. + +## VFS And Page Cache + +Keep one shared `SqliteVfs` / `VfsContext` for the worker connection. The VFS page cache can remain `moka::sync::Cache`. + +Since v1 has only one SQLite connection, the current VFS assumptions remain valid: + +- no parallel reader handles +- no reader role tagging +- no reader access to dirty writer pages +- no need for per-reader snapshot management +- no read-only open path + +The worker design should still keep VFS lifecycle explicit: + +- VFS registration happens before worker connection open. +- The worker owns a `NativeVfsHandle` clone for as long as SQLite may call VFS callbacks. +- Close waits for the worker to close SQLite before dropping the final VFS handle. +- A close timeout may return an error to the caller, but must not unregister/drop the VFS while the worker may still be inside SQLite/VFS. +- If a close timeout happens while the worker is still executing SQLite/VFS work, the worker continues to own the SQLite connection and `NativeVfsHandle` until it naturally exits. The actor must remain in stopping/crashed state and must not accept new work. +- If SQLite/VFS attempts envoy/depot transport after actor shutdown has begun, map the rejected transport call to a `sqlite` VFS error, set `last_error`, return the appropriate SQLite I/O error code from the callback, and let that bubble back through SQLite to the worker command result or close path. The VFS may mark itself dead, but the worker still owns and closes the connection before VFS unregister. + +## Backpressure + +The public command queue is bounded. Sending work must not await capacity. + +Required behavior: + +- `try_send`/`try_reserve` success enqueues work. +- queue full returns `actor.overloaded`. This is an intentional behavior change from waiting on internal SQLite capacity. +- worker closed returns a structured closed/shutdown error. +- cancelled queued requests are dropped before execution when observed. It is acceptable in v1 for cancelled queued requests to occupy bounded channel capacity until the worker observes them. +- cancelled active requests are allowed to finish; the reply is discarded if the receiver is gone. +- SQL command queue capacity defaults to 128 commands per actor database. Make it a constant first; add configuration later only if production data shows it is needed. + +Do not add retry loops or larger waits to hide overload. + +## Shutdown + +Close sequence: + +1. Mark handle closing so new calls fail fast. +2. Send a priority close/control signal that bypasses the bounded SQL command queue. +3. Worker stops accepting further SQL commands. +4. Queued-but-not-active SQL commands are failed with a structured closing/shutdown error when the worker observes them. +5. The active command, if any, is allowed to finish. v1 does not use `sqlite3_interrupt`. +6. Worker closes the SQLite connection on its own thread. +7. Worker exits. +8. Final `NativeVfsHandle` drops after the worker has exited. + +If a worker cannot be joined before the close budget, the handle may report close timeout, but implementation must keep the VFS and connection ownership alive until the worker actually exits. Do not unregister the VFS underneath a potentially running SQLite callback. + +The worker event loop must give the close/control signal priority over SQL commands. If it waits on both control and SQL channels, it must check the close signal before dispatching any queued SQL. If the worker is currently inside a synchronous SQLite call, the close signal is observed only after that call returns. + +The close/control signal does not introduce parallel SQL execution. Acceptable implementations include a priority select over control and SQL channels, a separate atomic closing flag plus wake signal, or another equivalent mechanism. In all cases, the worker dispatches at most one SQL command at a time and checks close state before dispatching the next queued SQL command. + +The worker loop is synchronous/blocking. A concrete implementation can look like this: + +```rust +struct SqliteWorkerHandle { + sql_tx: crossbeam_channel::Sender, + close_tx: crossbeam_channel::Sender, + closing: Arc, + join: std::thread::JoinHandle, +} + +impl SqliteWorkerHandle { + fn execute(&self, command: SqliteCommand) -> Result<()> { + if self.closing.load(Ordering::Acquire) { + return Err(sqlite_closing_error()); + } + self.sql_tx.try_send(command).map_err(|err| match err { + TrySendError::Full(_) => actor_overloaded_error(), + TrySendError::Disconnected(_) => sqlite_worker_dead_error(), + }) + } + + fn close(&self, request: CloseRequest) { + if !self.closing.swap(true, Ordering::AcqRel) { + // This is a control path, not SQL work, so it must not be blocked by + // a full SQL queue. + let _ = self.close_tx.try_send(request); + } + } +} + +fn worker_loop( + sql_rx: crossbeam_channel::Receiver, + close_rx: crossbeam_channel::Receiver, + mut db: NativeConnection, + vfs: NativeVfsHandle, +) { + loop { + if let Ok(close) = close_rx.try_recv() { + fail_queued_sql(&sql_rx, sqlite_closing_error()); + break; + } + + crossbeam_channel::select_biased! { + recv(close_rx) -> close => { + let _ = close; + fail_queued_sql(&sql_rx, sqlite_closing_error()); + break; + } + recv(sql_rx) -> command => { + let Ok(command) = command else { + tracing::error!("sqlite worker command channel dropped without clean close"); + break; + }; + if command.reply_is_closed() { + continue; + } + run_one_sql_command(&mut db, command); + } + } + } + + drop(db); + drop(vfs); +} +``` + +The exact channel library can differ, but the implementation must keep the same properties: non-awaiting bounded SQL send, close path independent of SQL queue capacity, biased close observation before SQL dispatch, and synchronous one-command-at-a-time SQLite execution. `tokio::sync::mpsc::blocking_recv` is acceptable for a single queue, but a two-queue priority close design may be cleaner with `crossbeam_channel::select_biased!`. If Tokio mpsc is used, tests must prove close while the SQL queue is full does not block and queued SQL does not dispatch after close starts. If the close-control channel has capacity one, `Full` means close was already requested and is not an overload condition. + +If the SQL command channel is dropped without a clean close request, the worker must stop after logging an unclean-close error. It should close the SQLite connection and drop the VFS in the normal worker-owned order. + +If the worker panics or exits unexpectedly, the shared handle state is marked dead and the owning actor is stopped/crashed. Future SQL calls fail with a structured worker-dead error. Queued callers should receive a structured worker-dead error where possible. + +Worker failure must cross the depot-client/core boundary as a fatal SQLite runtime event. `rivetkit-core` should translate that event into the actor's crashed/stopping path and report it to envoy with the existing stop/error path. Today that means using the actor stop path that ultimately sends `EnvoyHandle::stop_actor(actor_id, generation, Some(error_message))` or fails the existing stop handle so envoy receives `ActorStateStopped` with `StopCode::Error`. Depot-client should not talk to envoy directly. The actor must not keep serving work after the SQLite worker has died. + +## Required Implementation Comments + +Add short comments in the implementation for each non-obvious behavior below: + +- Why close uses a priority control signal instead of the bounded SQL queue. +- Why the worker checks close state before dispatching queued SQL. +- Why close timeout does not drop or unregister the VFS while the worker may still be inside SQLite/VFS. +- Why envoy/depot shutdown rejection is returned through VFS as a SQLite I/O error. +- Why dropping the SQL command channel without clean close is logged as an unclean close. +- Why worker panic/unexpected exit is fatal to the actor and must be reported through core lifecycle. +- Why `actor.overloaded` is returned instead of waiting for SQL queue capacity. +- Why the SQL command queue capacity is fixed at 128 in the first implementation. +- Why native worker `execute` returns `WriteFallback` and why `execute_write` was removed/collapsed. +- Why cancelled queued commands may occupy bounded queue capacity until the worker observes them. + +## Handle Semantics + +`NativeDatabaseHandle` remains cloneable, but all clones point at one worker. + +- clones share the same worker sender and close state +- `close()` is idempotent across clones +- `take_last_kv_error()` still reads the shared VFS last error +- `snapshot_preload_hints()` still reads shared VFS hints +- worker metrics replace read-pool metrics +- initialization errors fail `open_database_from_envoy` +- dropped handles do not close the worker until explicit close or the owning actor shutdown path +- the owning actor shutdown path holds the authoritative worker handle until join or timeout bookkeeping is complete + +## Metrics And Flags + +Remove read-pool metrics and flags from the native SQLite path: + +- `sqlite_read_pool_*` gauges, counters, histograms, and mode-transition metrics +- `RIVETKIT_SQLITE_OPT_READ_POOL_ENABLED` +- `RIVETKIT_SQLITE_OPT_READ_POOL_MAX_READERS` +- `RIVETKIT_SQLITE_OPT_READ_POOL_IDLE_TTL_MS` + +Replace them with worker metrics: + +- SQL command queue depth +- SQL command queue overload count +- SQL command duration +- SQL command error count by code +- worker close duration +- worker close timeout count +- worker crash count +- unclean channel close count + +## Superseded SQLite Fix Coverage + +The worker executor is intended to supersede several SQLite-specific driver-test complaint branches. Do not delete those branches unless the worker implementation covers their edge cases with tests. + +Required coverage: + +- **Shutdown database stays closed.** Once actor shutdown or SQLite close begins, new SQL work must fail with a structured closing/shutdown error. This covers the edge case from `05-02-fix_sqlite_keep_shutdown_database_closed`. +- **Reject work after shutdown close.** Queued-but-not-active SQL must not run after close starts. Future calls through any clone must fail fast. This covers the edge case from `driver-test-complaints/close-sqlite-on-shutdown`. +- **VFS registration from async context is safe.** Opening the worker database must not panic by calling `Handle::block_on` directly from inside an active Tokio runtime. If synchronous VFS registration still needs async transport, bridge it with a documented blocking-safe path and explicitly fail unsupported current-thread runtimes. This covers the registration edge of `driver-test-complaints/fix-vfs-register-block-on`. +- **Connection close/drop runs on the worker owner.** SQLite close and any final dirty-page flush happen on the worker-owned context, not on arbitrary async tasks. Close timeout must not unregister/drop the VFS while the worker may still be inside SQLite/VFS. This covers the close/drop edge of `driver-test-complaints/fix-vfs-register-block-on`. + +## Rollout + +This design is intended to replace the current lease manager, not live beside it permanently. During development, a short-lived opt flag is acceptable for testing, but the final implementation should delete legacy read-pool/lease-manager code once the worker path passes the gates below. + +Read-pool flags and metrics should be deleted with the lease-manager/read-pool implementation. Parallel readers remain deferred future work in `.agent/todo/sqlite-parallel-read-workers.md`. + +Rollout phases: + +1. Add worker handle and command loop. +2. Run worker for all SQL methods. +3. Define final route metadata: delete `Read`; `execute` returns `WriteFallback`. +4. Delete legacy read-pool/lease-manager code, read-pool flags, read-pool metrics, and separate native `execute_write` after worker coverage passes. +5. Run depot-client tests. +6. Run native driver test matrix. +7. Run wasm dependency checks to verify native worker code stays behind native features. +8. Consider making worker executor default only after close, overload, and lifecycle tests pass. + +## Test Plan + +All new worker-executor tests must live in the `depot-client` crate, under `engine/packages/depot-client/`. Higher-layer driver tests can cover integration behavior, but the worker, channel, shutdown, and VFS-lifetime invariants belong in depot-client tests. + +Worker behavior: + +- executes `exec`, `query`, `run`, and `execute` +- preserves `last_insert_rowid()`, `changes()`, and temp table behavior across calls +- explicit `BEGIN; ...; COMMIT` sequences stay on the same connection +- command ordering is FIFO +- queue full returns `actor.overloaded` +- closed worker returns structured shutdown error +- worker panic stops/crashes the actor +- worker fatal event is surfaced to the owning runtime so the actor reports crashed/stopping to envoy +- worker channel dropped without clean close logs an unclean-close error and exits +- `execute` returns `WriteFallback` +- `ExecuteRoute::Read` is removed from the native/shared SQLite result surface +- separate native `execute_write` is removed or collapsed to `execute` + +Shutdown: + +- close while idle +- close while command is active +- close with queued commands +- close while SQL command queue is full +- close prevents queued-but-not-active SQL from running +- new SQL through any clone fails after close starts +- close is idempotent across clones +- VFS is not dropped before worker exit +- close timeout does not unregister VFS under a running worker +- VFS registration/open from an async runtime does not panic +- envoy/depot transport failures during shutdown set VFS last error, return SQLite I/O errors through callbacks, and do not break worker-owned close ordering + +Handle behavior: + +- final route/result behavior for representative read/write statements +- `take_last_kv_error()` behavior preserved +- `snapshot_preload_hints()` behavior preserved +- worker metrics render and read-pool metrics are gone +- initialization failure propagates from `open_database_from_envoy` + +Driver gates: + +- run depot-client VFS tests +- run native static/http/bare driver verifier files from `.agent/notes/driver-test-progress.md` +- run wasm dependency gate to ensure native worker is not pulled into wasm builds + +## Deferred Parallel Read Design + +Parallel read workers are intentionally out of scope. The prior design surfaced unresolved issues around SQLite per-connection caches, idle writer handles, role-aware VFS callbacks, temp files, parser admission, and reader-open network assertions. Keep those notes in `.agent/todo/sqlite-parallel-read-workers.md` for a later design pass. diff --git a/.agent/todo/sqlite-parallel-read-workers.md b/.agent/todo/sqlite-parallel-read-workers.md new file mode 100644 index 0000000000..81080dc137 --- /dev/null +++ b/.agent/todo/sqlite-parallel-read-workers.md @@ -0,0 +1,72 @@ +# SQLite Parallel Read Workers + +## Status + +Deferred. The active design is `.agent/specs/sqlite-channel-worker-executor.md`, which uses one worker-owned readwrite SQLite connection and no parallel readers. + +## Why Deferred + +Parallel reader workers looked attractive, but adversarial review found enough unresolved correctness hazards that they should not be part of the first worker-executor refactor. + +## Design Notes To Preserve + +1. **Reader handles likely need recycling after every writer commit.** + - SQLite readonly connections have their own pager/schema caches. + - Sharing the VFS `moka` page cache does not prove reused reader handles observe committed writer changes. + +2. **Idle readwrite connection semantics need a real decision.** + - Current code uses `PRAGMA locking_mode = EXCLUSIVE`. + - Current VFS lock callbacks are no-ops for a single-connection world. + - A long-lived idle writer plus readonly readers may violate those assumptions. + +3. **Reader VFS reads must not see writer dirty pages.** + - Current VFS resolves `write_buffer.dirty` before committed cache pages. + - Reader-owned handles would need role-aware committed-only read resolution. + +4. **VFS role tagging must be concrete.** + - `VfsFile` would need a role stored at `xOpen`. + - Role should be derived from open flags plus a coordinator-issued capability or epoch. + - Every mutating callback and relevant read path must check the role. + +5. **Aux/temp files are a shared mutable surface.** + - Readers should probably use `PRAGMA temp_store = MEMORY`. + - Reader aux writes should fail closed, or aux files should become per-connection/private. + +6. **Reader open must be proven network-free.** + - Fresh reader opens should reuse the shared VFS and cache. + - Add a debug/test-only VFS no-network guard around readonly reader open/setup before revisiting this. + +7. **Parser admission is tricky.** + - `sqlparser` with `SQLiteDialect` is acceptable only as a strict opt-in scheduler filter. + - `SELECT` is not enough because connection-affine reads exist. + - Examples that should not go to reader workers without special handling: `last_insert_rowid()`, `changes()`, `total_changes()`, temp schema reads, and session-state queries. + +8. **Classifier mismatch should not reroute.** + - If a strict parser admits a reader candidate and SQLite classification proves it is write-required, that is an internal classifier bug. + - SQLite prepare errors are user/query errors, not classifier mismatches. + +9. **Manual transactions pin all work to writer.** + - `BEGIN; INSERT; SELECT; COMMIT` must not block itself behind a read lane. + +10. **Queued read behavior under writer pressure must be exact.** + - Once a writer is pending, no queued read should dispatch to readers until the writer completes. + +11. **Shutdown cannot unregister VFS under active workers.** + - Close timeout may report an error, but VFS must remain alive while any worker may still be inside SQLite/VFS. + +12. **Backpressure and cancellation need exact accounting.** + - Sends should use non-awaiting bounded queue operations. + - Cancelling queued writers must decrement writer pressure and wake scheduling. + +## Revisit Criteria + +Only revisit parallel readers after the single-worker executor is stable and measured. + +Required before implementation: + +- Benchmark single-worker throughput and identify read parallelism as a real bottleneck. +- Prove fresh reader open/setup is network-free with a VFS no-network assertion. +- Define exact VFS role model. +- Define exact parser allowlist and connection-affinity denylist. +- Add tests for reader recycling after writer commits. +- Add tests for temp files, truncate, schema changes, close timeout, and writer pressure. diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock index 38f0b83908..744e53fe1f 100644 --- a/.claude/scheduled_tasks.lock +++ b/.claude/scheduled_tasks.lock @@ -1 +1 @@ -{"sessionId":"a4f149a1-ef06-4680-a966-97a7309cfe7c","pid":729442,"acquiredAt":1776851184668} \ No newline at end of file +{"sessionId":"c093af1a-f110-4b76-b744-ee93ecc131c6","pid":3823846,"procStart":"15021522","acquiredAt":1777764221363} \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index d5362aac24..a6d1c14287 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1845,6 +1845,7 @@ version = "2.3.0-rc.4" dependencies = [ "anyhow", "async-trait", + "crossbeam-channel", "depot", "depot-client-types", "futures-util", diff --git a/engine/packages/api-peer/src/depot_inspect.rs b/engine/packages/api-peer/src/depot_inspect.rs index 555c24e259..4cad2d7344 100644 --- a/engine/packages/api-peer/src/depot_inspect.rs +++ b/engine/packages/api-peer/src/depot_inspect.rs @@ -98,13 +98,7 @@ pub async fn page_trace( ) -> Result { let branch_id = parse_database_branch_id(&path.branch_id)?; let udb = ctx.pools().udb()?; - inspect::page_trace( - &udb, - ctx.pools().node_id(), - branch_id, - path.pgno, - ) - .await + inspect::page_trace(&udb, ctx.pools().node_id(), branch_id, path.pgno).await } pub async fn branch_rows( @@ -115,14 +109,7 @@ pub async fn branch_rows( let branch_id = parse_database_branch_id(&path.branch_id)?; let family = inspect::RowFamily::parse(&path.family)?; let udb = ctx.pools().udb()?; - inspect::branch_rows( - &udb, - ctx.pools().node_id(), - branch_id, - family, - query, - ) - .await + inspect::branch_rows(&udb, ctx.pools().node_id(), branch_id, family, query).await } pub async fn raw_key( diff --git a/engine/packages/api-peer/src/router.rs b/engine/packages/api-peer/src/router.rs index 32a070815b..e9969e82c3 100644 --- a/engine/packages/api-peer/src/router.rs +++ b/engine/packages/api-peer/src/router.rs @@ -63,10 +63,7 @@ pub async fn router( "/depot/inspect/branches/{branch_id}/rows/{family}", get(depot_inspect::branch_rows), ) - .route( - "/depot/inspect/raw/key/{key}", - get(depot_inspect::raw_key), - ) + .route("/depot/inspect/raw/key/{key}", get(depot_inspect::raw_key)) .route("/depot/inspect/raw/scan", get(depot_inspect::raw_scan)) .route( "/depot/inspect/raw/decode-key/{key}", diff --git a/engine/packages/api-peer/tests/depot_inspect.rs b/engine/packages/api-peer/tests/depot_inspect.rs index 5516fd61eb..b271d8499d 100644 --- a/engine/packages/api-peer/tests/depot_inspect.rs +++ b/engine/packages/api-peer/tests/depot_inspect.rs @@ -1,5 +1,5 @@ -use axum_test::TestServer; use anyhow::Result; +use axum_test::TestServer; use base64::{Engine, prelude::BASE64_URL_SAFE_NO_PAD}; use rivet_config::config::{Database, Root, db::FileSystem}; diff --git a/engine/packages/api-public/src/actors/utils.rs b/engine/packages/api-public/src/actors/utils.rs index b63f5e4131..7d3fcd5d4e 100644 --- a/engine/packages/api-public/src/actors/utils.rs +++ b/engine/packages/api-public/src/actors/utils.rs @@ -141,10 +141,12 @@ pub async fn find_dc_for_actor_creation( ) -> Result { let requested_dc_label = if let Some(dc_name) = &dc_name { // Use user-configured DC - Some(ctx.config() - .dc_for_name(dc_name) - .ok_or_else(|| rivet_api_util::errors::Datacenter::NotFound.build())? - .datacenter_label) + Some( + ctx.config() + .dc_for_name(dc_name) + .ok_or_else(|| rivet_api_util::errors::Datacenter::NotFound.build())? + .datacenter_label, + ) } else { None }; diff --git a/engine/packages/depot-client-types/src/lib.rs b/engine/packages/depot-client-types/src/lib.rs index 6db765e2e3..c5e3dab64e 100644 --- a/engine/packages/depot-client-types/src/lib.rs +++ b/engine/packages/depot-client-types/src/lib.rs @@ -20,20 +20,12 @@ pub struct QueryResult { pub rows: Vec>, } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ExecuteRoute { - Read, - Write, - WriteFallback, -} - #[derive(Clone, Debug, PartialEq)] pub struct ExecuteResult { pub columns: Vec, pub rows: Vec>, pub changes: i64, pub last_insert_row_id: Option, - pub route: ExecuteRoute, } impl ExecuteResult { @@ -62,7 +54,7 @@ pub enum ColumnValue { #[cfg(test)] mod tests { - use super::{ColumnValue, ExecuteResult, ExecuteRoute}; + use super::{ColumnValue, ExecuteResult}; #[test] fn execute_result_preserves_result_and_route_metadata() { @@ -74,7 +66,6 @@ mod tests { ]], changes: 3, last_insert_row_id: Some(42), - route: ExecuteRoute::WriteFallback, }; assert_eq!(result.columns, vec!["id", "name"]); @@ -87,7 +78,6 @@ mod tests { ); assert_eq!(result.changes, 3); assert_eq!(result.last_insert_row_id, Some(42)); - assert_eq!(result.route, ExecuteRoute::WriteFallback); } #[test] @@ -97,7 +87,6 @@ mod tests { rows: vec![vec![ColumnValue::Integer(9)]], changes: 2, last_insert_row_id: Some(10), - route: ExecuteRoute::Write, }; let query_result = result.clone().into_query_result(); diff --git a/engine/packages/depot-client/AGENTS.md b/engine/packages/depot-client/AGENTS.md new file mode 120000 index 0000000000..681311eb9c --- /dev/null +++ b/engine/packages/depot-client/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/engine/packages/depot-client/CLAUDE.md b/engine/packages/depot-client/CLAUDE.md new file mode 100644 index 0000000000..034a71195e --- /dev/null +++ b/engine/packages/depot-client/CLAUDE.md @@ -0,0 +1,3 @@ +# Depot Client + +- Communicate between async runtime tasks and the SQLite worker thread through channels. Do not call back into the Tokio runtime from the SQLite thread. diff --git a/engine/packages/depot-client/Cargo.toml b/engine/packages/depot-client/Cargo.toml index 89e0cfaa40..45f4dad735 100644 --- a/engine/packages/depot-client/Cargo.toml +++ b/engine/packages/depot-client/Cargo.toml @@ -12,6 +12,7 @@ crate-type = ["lib"] [dependencies] anyhow.workspace = true +crossbeam-channel = "0.5" libsqlite3-sys = { version = "0.30", features = ["bundled"] } rivet-envoy-client = { workspace = true, features = ["native-transport"] } tokio.workspace = true diff --git a/engine/packages/depot-client/src/connection_manager.rs b/engine/packages/depot-client/src/connection_manager.rs deleted file mode 100644 index cc28feb1c3..0000000000 --- a/engine/packages/depot-client/src/connection_manager.rs +++ /dev/null @@ -1,675 +0,0 @@ -use std::{ - sync::Arc, - time::{Duration, Instant}, -}; - -use anyhow::{Result, anyhow}; -use libsqlite3_sys::{ - SQLITE_OPEN_CREATE, SQLITE_OPEN_READONLY, SQLITE_OPEN_READWRITE, sqlite3, - sqlite3_get_autocommit, -}; -use tokio::sync::{Mutex, Notify}; - -use crate::{ - optimization_flags::SqliteOptimizationFlags, - vfs::{NativeConnection, NativeVfsHandle, SqliteVfsMetrics, open_connection}, -}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct NativeConnectionManagerConfig { - pub read_pool_enabled: bool, - pub max_readers: usize, - pub idle_ttl: Duration, -} - -impl Default for NativeConnectionManagerConfig { - fn default() -> Self { - Self::from_optimization_flags(SqliteOptimizationFlags::default()) - } -} - -impl NativeConnectionManagerConfig { - pub fn from_optimization_flags(flags: SqliteOptimizationFlags) -> Self { - Self { - read_pool_enabled: flags.sqlite_read_pool_enabled, - max_readers: flags.sqlite_read_pool_max_readers, - idle_ttl: Duration::from_millis(flags.sqlite_read_pool_idle_ttl_ms), - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum NativeConnectionManagerMode { - Closed, - ReadMode, - WriteMode, - Closing, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct NativeConnectionManagerSnapshot { - pub mode: NativeConnectionManagerMode, - pub active_readers: usize, - pub idle_readers: usize, - pub open_readers: usize, - pub pending_writers: usize, - pub active_writer: bool, -} - -#[derive(Clone)] -pub struct NativeConnectionManager { - inner: std::sync::Arc, -} - -struct NativeConnectionManagerInner { - file_name: String, - config: NativeConnectionManagerConfig, - metrics: Option>, - state: Mutex, - changed: Notify, -} - -struct NativeConnectionManagerState { - vfs: Option, - mode: NativeConnectionManagerMode, - idle_readers: Vec, - idle_writer: Option, - active_readers: usize, - open_readers: usize, - pending_writers: usize, - active_writer: bool, - manual_transaction_started_at: Option, -} - -struct IdleReadConnection { - connection: NativeConnection, - idle_since: Instant, -} - -#[must_use = "release the read connection lease when work is complete"] -pub struct NativeReadConnectionLease { - manager: NativeConnectionManager, - connection: Option, - newly_opened: bool, -} - -#[must_use = "release the write connection lease when work is complete"] -pub struct NativeWriteConnectionLease { - manager: NativeConnectionManager, - connection: Option, - newly_opened: bool, -} - -impl NativeConnectionManager { - pub fn new( - vfs: NativeVfsHandle, - file_name: impl Into, - config: NativeConnectionManagerConfig, - ) -> Self { - Self::new_with_metrics(vfs, file_name, config, None) - } - - pub fn new_with_metrics( - vfs: NativeVfsHandle, - file_name: impl Into, - config: NativeConnectionManagerConfig, - metrics: Option>, - ) -> Self { - Self { - inner: std::sync::Arc::new(NativeConnectionManagerInner { - file_name: file_name.into(), - config, - metrics, - state: Mutex::new(NativeConnectionManagerState { - vfs: Some(vfs), - mode: NativeConnectionManagerMode::Closed, - idle_readers: Vec::new(), - idle_writer: None, - active_readers: 0, - open_readers: 0, - pending_writers: 0, - active_writer: false, - manual_transaction_started_at: None, - }), - changed: Notify::new(), - }), - } - } - - pub fn read_pool_enabled(&self) -> bool { - self.inner.config.read_pool_enabled - } - - pub async fn write_mode_active(&self) -> bool { - let state = self.inner.state.lock().await; - state.active_writer || state.idle_writer.is_some() - } - - pub async fn acquire_read(&self) -> Result { - if !self.inner.config.read_pool_enabled { - return Err(anyhow!("sqlite read connection pool is disabled")); - } - if self.inner.config.max_readers == 0 { - return Err(anyhow!("sqlite read connection manager has no reader slots")); - } - - let wait_started_at = Instant::now(); - loop { - let notified = self.inner.changed.notified(); - let open_result = { - let mut state = self.inner.state.lock().await; - let closed_readers = state.prune_expired_readers(self.inner.config.idle_ttl); - self.record_reader_closes(closed_readers); - self.record_reader_gauges(&state); - if state.vfs.is_none() { - return Err(anyhow!("sqlite connection manager is closed")); - } - if matches!(state.mode, NativeConnectionManagerMode::Closing) { - return Err(anyhow!("sqlite connection manager is closing")); - } - if state.pending_writers > 0 - || matches!(state.mode, NativeConnectionManagerMode::WriteMode) - || state.active_writer - { - None - } else if let Some(connection) = state.idle_readers.pop() { - state.active_readers += 1; - self.record_mode_transition(state.refresh_mode()); - self.record_reader_gauges(&state); - self.observe_read_wait(wait_started_at.elapsed()); - return Ok(NativeReadConnectionLease { - manager: self.clone(), - connection: Some(connection.connection), - newly_opened: false, - }); - } else if state.open_readers < self.inner.config.max_readers { - state.active_readers += 1; - state.open_readers += 1; - self.record_mode_transition(state.set_mode(NativeConnectionManagerMode::ReadMode)); - self.record_reader_gauges(&state); - Some( - state - .vfs - .as_ref() - .expect("vfs checked above") - .clone(), - ) - } else { - None - } - }; - - if let Some(vfs) = open_result { - let file_name = self.inner.file_name.clone(); - match tokio::task::spawn_blocking(move || { - open_connection(vfs, &file_name, SQLITE_OPEN_READONLY) - }) - .await? - { - Ok(connection) => { - self.record_reader_open(); - self.observe_read_wait(wait_started_at.elapsed()); - return Ok(NativeReadConnectionLease { - manager: self.clone(), - connection: Some(connection), - newly_opened: true, - }); - } - Err(err) => { - let mut state = self.inner.state.lock().await; - state.active_readers = state.active_readers.saturating_sub(1); - state.open_readers = state.open_readers.saturating_sub(1); - self.record_mode_transition(state.refresh_mode()); - self.record_reader_gauges(&state); - self.inner.changed.notify_waiters(); - return Err(anyhow!("failed to open sqlite read connection: {err}")); - } - } - } - - notified.await; - } - } - - pub async fn acquire_write(&self) -> Result { - let mut pending_registered = false; - let wait_started_at = Instant::now(); - - loop { - let notified = self.inner.changed.notified(); - let open_result = { - let mut state = self.inner.state.lock().await; - if !pending_registered { - state.pending_writers += 1; - pending_registered = true; - self.inner.changed.notify_waiters(); - } - if state.vfs.is_none() { - state.pending_writers = state.pending_writers.saturating_sub(1); - return Err(anyhow!("sqlite connection manager is closed")); - } - if matches!(state.mode, NativeConnectionManagerMode::Closing) { - state.pending_writers = state.pending_writers.saturating_sub(1); - self.inner.changed.notify_waiters(); - return Err(anyhow!("sqlite connection manager is closing")); - } - if state.active_readers == 0 && !state.active_writer { - let idle_readers = std::mem::take(&mut state.idle_readers); - state.open_readers = state.open_readers.saturating_sub(idle_readers.len()); - state.pending_writers = state.pending_writers.saturating_sub(1); - state.active_writer = true; - self.record_reader_closes(idle_readers.len()); - self.record_mode_transition(state.set_mode(NativeConnectionManagerMode::WriteMode)); - self.record_reader_gauges(&state); - if let Some(connection) = state.idle_writer.take() { - self.observe_write_wait(wait_started_at.elapsed()); - return Ok(NativeWriteConnectionLease { - manager: self.clone(), - connection: Some(connection), - newly_opened: false, - }); - } - Some(( - state - .vfs - .as_ref() - .expect("vfs checked above") - .clone(), - idle_readers, - )) - } else { - None - } - }; - - if let Some((vfs, idle_readers)) = open_result { - drop(idle_readers); - let file_name = self.inner.file_name.clone(); - match tokio::task::spawn_blocking(move || { - open_connection( - vfs, - &file_name, - SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE, - ) - }) - .await? - { - Ok(connection) => { - self.observe_write_wait(wait_started_at.elapsed()); - return Ok(NativeWriteConnectionLease { - manager: self.clone(), - connection: Some(connection), - newly_opened: true, - }); - } - Err(err) => { - let mut state = self.inner.state.lock().await; - state.active_writer = false; - self.record_mode_transition(state.refresh_mode()); - self.inner.changed.notify_waiters(); - return Err(anyhow!("failed to open sqlite write connection: {err}")); - } - } - } - - notified.await; - } - } - - pub async fn with_read_connection( - &self, - f: F, - ) -> Result - where - T: Send + 'static, - F: FnOnce(*mut sqlite3) -> Result + Send + 'static, - { - self.with_read_connection_state(move |db, _newly_opened| f(db)) - .await - } - - pub async fn with_read_connection_state( - &self, - f: F, - ) -> Result - where - T: Send + 'static, - F: FnOnce(*mut sqlite3, bool) -> Result + Send + 'static, - { - let mut lease = self.acquire_read().await?; - let newly_opened = lease.newly_opened; - let connection = lease - .connection - .take() - .expect("read connection lease should hold a connection"); - let (connection, result) = - tokio::task::spawn_blocking(move || { - let result = f(connection.as_ptr(), newly_opened); - (connection, result) - }) - .await?; - lease.connection = Some(connection); - lease.release().await; - result - } - - pub async fn with_write_connection( - &self, - f: F, - ) -> Result - where - T: Send + 'static, - F: FnOnce(*mut sqlite3) -> Result + Send + 'static, - { - self.with_write_connection_state(move |db, _newly_opened| f(db)) - .await - } - - pub async fn with_write_connection_state( - &self, - f: F, - ) -> Result - where - T: Send + 'static, - F: FnOnce(*mut sqlite3, bool) -> Result + Send + 'static, - { - let mut lease = self.acquire_write().await?; - let newly_opened = lease.newly_opened; - let connection = lease - .connection - .take() - .expect("write connection lease should hold a connection"); - let (connection, result) = - tokio::task::spawn_blocking(move || { - let result = f(connection.as_ptr(), newly_opened); - (connection, result) - }) - .await?; - lease.connection = Some(connection); - lease.release().await; - result - } - - pub async fn close(&self) -> Result<()> { - let idle_readers = { - let mut state = self.inner.state.lock().await; - if state.vfs.is_none() { - return Ok(()); - } - state.mode = NativeConnectionManagerMode::Closing; - state.open_readers = state.open_readers.saturating_sub(state.idle_readers.len()); - self.inner.changed.notify_waiters(); - state.idle_writer.take(); - self.record_reader_closes(state.idle_readers.len()); - self.record_reader_gauges(&state); - std::mem::take(&mut state.idle_readers) - }; - drop(idle_readers); - - loop { - let notified = self.inner.changed.notified(); - let vfs = { - let mut state = self.inner.state.lock().await; - if state.active_readers == 0 && !state.active_writer { - self.record_mode_transition(state.set_mode(NativeConnectionManagerMode::Closed)); - state.vfs.take() - } else { - None - } - }; - - if let Some(vfs) = vfs { - drop(vfs); - self.inner.changed.notify_waiters(); - return Ok(()); - } - - notified.await; - } - } - - pub async fn snapshot(&self) -> NativeConnectionManagerSnapshot { - let state = self.inner.state.lock().await; - state.snapshot() - } - - #[cfg(test)] - pub(crate) async fn wait_for_snapshot( - &self, - predicate: impl Fn(&NativeConnectionManagerSnapshot) -> bool, - ) -> NativeConnectionManagerSnapshot { - loop { - let notified = self.inner.changed.notified(); - let snapshot = self.snapshot().await; - if predicate(&snapshot) { - return snapshot; - } - notified.await; - } - } - - fn record_reader_gauges(&self, state: &NativeConnectionManagerState) { - if let Some(metrics) = &self.inner.metrics { - metrics.set_read_pool_active_readers(state.active_readers as u64); - metrics.set_read_pool_idle_readers(state.idle_readers.len() as u64); - } - } - - fn record_reader_open(&self) { - if let Some(metrics) = &self.inner.metrics { - metrics.record_read_pool_reader_open(); - } - } - - fn record_reader_closes(&self, count: usize) { - if count == 0 { - return; - } - if let Some(metrics) = &self.inner.metrics { - metrics.record_read_pool_reader_close(count as u64); - } - } - - fn observe_read_wait(&self, duration: Duration) { - if let Some(metrics) = &self.inner.metrics { - metrics.observe_read_pool_read_wait(duration); - } - } - - fn observe_write_wait(&self, duration: Duration) { - if let Some(metrics) = &self.inner.metrics { - metrics.observe_read_pool_write_wait(duration); - } - } - - fn record_mode_transition( - &self, - transition: Option<(NativeConnectionManagerMode, NativeConnectionManagerMode)>, - ) { - let Some((from, to)) = transition else { - return; - }; - if let Some(metrics) = &self.inner.metrics { - metrics.record_read_pool_mode_transition(from.as_metric_label(), to.as_metric_label()); - } - } - - fn observe_manual_transaction(&self, duration: Duration) { - if let Some(metrics) = &self.inner.metrics { - metrics.observe_read_pool_manual_transaction(duration); - } - } -} - -impl NativeReadConnectionLease { - pub fn as_ptr(&self) -> *mut sqlite3 { - self.connection - .as_ref() - .expect("read connection lease should hold a connection") - .as_ptr() - } - - pub async fn release(mut self) { - let Some(connection) = self.connection.take() else { - return; - }; - let idle_connection = { - let mut state = self.manager.inner.state.lock().await; - state.active_readers = state.active_readers.saturating_sub(1); - if state.vfs.is_some() - && state.pending_writers == 0 - && !matches!(state.mode, NativeConnectionManagerMode::Closing) - { - state.idle_readers.push(IdleReadConnection { - connection, - idle_since: Instant::now(), - }); - self.manager.record_mode_transition(state.refresh_mode()); - self.manager.record_reader_gauges(&state); - None - } else { - state.open_readers = state.open_readers.saturating_sub(1); - self.manager.record_mode_transition(state.refresh_mode()); - self.manager.record_reader_gauges(&state); - Some(connection) - } - }; - if idle_connection.is_some() { - self.manager.record_reader_closes(1); - } - drop(idle_connection); - self.manager.inner.changed.notify_waiters(); - } -} - -impl Drop for NativeReadConnectionLease { - fn drop(&mut self) { - if self.connection.is_some() { - tracing::warn!("sqlite read connection lease dropped without release"); - } - } -} - -impl NativeWriteConnectionLease { - pub fn as_ptr(&self) -> *mut sqlite3 { - self.connection - .as_ref() - .expect("write connection lease should hold a connection") - .as_ptr() - } - - pub fn newly_opened(&self) -> bool { - self.newly_opened - } - - pub async fn release(mut self) { - let connection = self.connection.take(); - let keep_writer_open = connection - .as_ref() - .is_some_and(|connection| { - !self.manager.inner.config.read_pool_enabled - || unsafe { sqlite3_get_autocommit(connection.as_ptr()) == 0 } - }); - let close_connection = { - let mut state = self.manager.inner.state.lock().await; - state.active_writer = false; - if keep_writer_open - && state.vfs.is_some() - && !matches!(state.mode, NativeConnectionManagerMode::Closing) - { - if state.manual_transaction_started_at.is_none() - && connection - .as_ref() - .is_some_and(|connection| unsafe { - sqlite3_get_autocommit(connection.as_ptr()) == 0 - }) - { - state.manual_transaction_started_at = Some(Instant::now()); - } - state.idle_writer = connection; - self.manager.record_mode_transition( - state.set_mode(NativeConnectionManagerMode::WriteMode), - ); - None - } else { - if let Some(started_at) = state.manual_transaction_started_at.take() { - self.manager.observe_manual_transaction(started_at.elapsed()); - } - self.manager.record_mode_transition(state.refresh_mode()); - connection - } - }; - drop(close_connection); - self.manager.inner.changed.notify_waiters(); - } -} - -impl Drop for NativeWriteConnectionLease { - fn drop(&mut self) { - if self.connection.is_some() { - tracing::warn!("sqlite write connection lease dropped without release"); - } - } -} - -impl NativeConnectionManagerState { - fn set_mode( - &mut self, - mode: NativeConnectionManagerMode, - ) -> Option<(NativeConnectionManagerMode, NativeConnectionManagerMode)> { - let previous = self.mode; - self.mode = mode; - (previous != mode).then_some((previous, mode)) - } - - fn refresh_mode(&mut self) -> Option<(NativeConnectionManagerMode, NativeConnectionManagerMode)> { - if matches!(self.mode, NativeConnectionManagerMode::Closing) { - return None; - } - let mode = if self.active_writer { - NativeConnectionManagerMode::WriteMode - } else if self.idle_writer.is_some() { - NativeConnectionManagerMode::WriteMode - } else if self.active_readers > 0 || self.open_readers > 0 { - NativeConnectionManagerMode::ReadMode - } else { - NativeConnectionManagerMode::Closed - }; - self.set_mode(mode) - } - - fn snapshot(&self) -> NativeConnectionManagerSnapshot { - NativeConnectionManagerSnapshot { - mode: self.mode, - active_readers: self.active_readers, - idle_readers: self.idle_readers.len(), - open_readers: self.open_readers, - pending_writers: self.pending_writers, - active_writer: self.active_writer, - } - } - - fn prune_expired_readers(&mut self, idle_ttl: Duration) -> usize { - let now = Instant::now(); - let before = self.idle_readers.len(); - self.idle_readers - .retain(|reader| now.duration_since(reader.idle_since) < idle_ttl); - let closed = before - self.idle_readers.len(); - self.open_readers = self.open_readers.saturating_sub(closed); - if closed > 0 { - self.refresh_mode(); - } - closed - } -} - -impl NativeConnectionManagerMode { - fn as_metric_label(self) -> &'static str { - match self { - Self::Closed => "closed", - Self::ReadMode => "read", - Self::WriteMode => "write", - Self::Closing => "closing", - } - } -} diff --git a/engine/packages/depot-client/src/database.rs b/engine/packages/depot-client/src/database.rs index 466be24b9b..dcf847bb18 100644 --- a/engine/packages/depot-client/src/database.rs +++ b/engine/packages/depot-client/src/database.rs @@ -5,29 +5,18 @@ use rivet_envoy_client::handle::EnvoyHandle; use tokio::runtime::Handle; use crate::{ - connection_manager::{NativeConnectionManager, NativeConnectionManagerConfig}, - optimization_flags::sqlite_optimization_flags, - query::{ - BindParam, ExecResult, ExecuteResult, ExecuteRoute, QueryResult, classify_statement, - exec_statements, execute_single_statement, install_reader_authorizer, - }, + query::{BindParam, ExecResult, ExecuteResult, QueryResult}, vfs::{ - NativeVfsHandle, SqliteVfs, SqliteVfsMetrics, VfsConfig, VfsPreloadHintSnapshot, - configure_connection_for_database, verify_batch_atomic_writes, + NativeVfsHandle, SqliteTransport, SqliteVfs, SqliteVfsMetrics, SqliteVfsMetricsSnapshot, + VfsConfig, VfsPreloadHintSnapshot, fetch_initial_main_page_for_registration, }, + worker::SqliteWorkerHandle, }; -enum ReadQueryRoute { - Read(ExecuteResult), - WriteRequired(ExecuteRoute), -} - #[derive(Clone)] pub struct NativeDatabaseHandle { - file_name: String, vfs: NativeVfsHandle, - manager: NativeConnectionManager, - metrics: Option>, + worker: SqliteWorkerHandle, } pub fn vfs_name_for_actor_database(actor_id: &str, generation: u64) -> String { @@ -42,22 +31,24 @@ pub async fn open_database_from_envoy( metrics: Option>, ) -> Result { let vfs_name = vfs_name_for_actor_database(&actor_id, generation); - let vfs = Arc::new(SqliteVfs::register( - &vfs_name, - handle, - actor_id.clone(), - rt_handle, - VfsConfig::default(), - metrics.clone(), - ) - .map_err(|e| anyhow!("failed to register sqlite VFS: {e}"))?); - - let native_db = NativeDatabaseHandle::new_with_metrics( - vfs, - actor_id, - NativeConnectionManagerConfig::from_optimization_flags(*sqlite_optimization_flags()), - metrics, + let transport = SqliteTransport::from_envoy(handle); + let initial_main_page = fetch_initial_main_page_for_registration(&transport, &actor_id) + .await + .map_err(|e| anyhow!("failed to preload sqlite main page: {e}"))?; + let vfs = Arc::new( + SqliteVfs::register_with_transport_and_initial_page( + &vfs_name, + transport, + actor_id.clone(), + rt_handle, + VfsConfig::default(), + initial_main_page, + metrics.clone(), + ) + .map_err(|e| anyhow!("failed to register sqlite VFS: {e}"))?, ); + + let native_db = NativeDatabaseHandle::new_with_metrics(vfs, actor_id, metrics)?; native_db.initialize().await?; Ok(native_db) } @@ -70,59 +61,46 @@ pub async fn open_database_from_conveyer( metrics: Option>, ) -> Result { let vfs_name = vfs_name_for_actor_database(&actor_id, generation); + let transport = SqliteTransport::from_conveyer(db); + let initial_main_page = fetch_initial_main_page_for_registration(&transport, &actor_id) + .await + .map_err(|e| anyhow!("failed to preload sqlite main page: {e}"))?; let vfs = Arc::new( - SqliteVfs::register_with_transport( + SqliteVfs::register_with_transport_and_initial_page( &vfs_name, - crate::vfs::SqliteTransport::from_conveyer(db), + transport, actor_id.clone(), rt_handle, VfsConfig::default(), + initial_main_page, metrics.clone(), ) .map_err(|e| anyhow!("failed to register sqlite VFS: {e}"))?, ); - let native_db = NativeDatabaseHandle::new_with_metrics( - vfs, - actor_id, - NativeConnectionManagerConfig::from_optimization_flags(*sqlite_optimization_flags()), - metrics, - ); + let native_db = NativeDatabaseHandle::new_with_metrics(vfs, actor_id, metrics)?; native_db.initialize().await?; Ok(native_db) } impl NativeDatabaseHandle { - pub fn new( - vfs: NativeVfsHandle, - file_name: String, - config: NativeConnectionManagerConfig, - ) -> Self { - Self::new_with_metrics(vfs, file_name, config, None) + pub fn new(vfs: NativeVfsHandle, file_name: String) -> Result { + Self::new_with_metrics(vfs, file_name, None) } pub fn new_with_metrics( vfs: NativeVfsHandle, file_name: String, - config: NativeConnectionManagerConfig, metrics: Option>, - ) -> Self { - Self { - file_name: file_name.clone(), - manager: NativeConnectionManager::new_with_metrics( - vfs.clone(), - file_name, - config, - metrics.clone(), - ), + ) -> Result { + Ok(Self { + worker: SqliteWorkerHandle::start(vfs.clone(), file_name, metrics)?, vfs, - metrics, - } + }) } pub async fn exec(&self, sql: String) -> Result { - self.with_configured_write_connection(move |db| exec_statements(db, &sql)) - .await + self.worker.exec(sql).await } pub async fn query(&self, sql: String, params: Option>) -> Result { @@ -143,49 +121,15 @@ impl NativeDatabaseHandle { sql: String, params: Option>, ) -> Result { - if !self.manager.read_pool_enabled() { - return self.execute_without_read_pool(sql, params).await; - } - if self.manager.write_mode_active().await { - return self.execute_on_writer_with_classification(sql, params).await; - } - - let read_sql = sql.clone(); - let read_params = params.clone(); - let route = match self.try_read_execute(read_sql, read_params).await? { - ReadQueryRoute::Read(result) => { - if let Some(metrics) = &self.metrics { - metrics.record_read_pool_routed_read_query(); - } - return Ok(result); - } - ReadQueryRoute::WriteRequired(route) => route, - }; - if matches!(route, ExecuteRoute::WriteFallback) { - if let Some(metrics) = &self.metrics { - metrics.record_read_pool_write_fallback_query(); - } - } - - self.with_configured_write_connection(move |db| { - execute_single_statement(db, &sql, params.as_deref(), route) - }) - .await + self.worker.execute(sql, params).await } - pub async fn execute_write( - &self, - sql: String, - params: Option>, - ) -> Result { - self.with_configured_write_connection(move |db| { - execute_single_statement(db, &sql, params.as_deref(), ExecuteRoute::Write) - }) - .await + pub async fn close(&self) -> Result<()> { + self.worker.close().await } - pub async fn close(&self) -> Result<()> { - self.manager.close().await + pub async fn wait_for_worker_failure(&self) -> bool { + self.worker.wait_for_failure().await } pub fn take_last_kv_error(&self) -> Option { @@ -196,136 +140,30 @@ impl NativeDatabaseHandle { self.vfs.snapshot_preload_hints() } - #[cfg(test)] - pub(crate) fn manager(&self) -> NativeConnectionManager { - self.manager.clone() - } - - async fn initialize(&self) -> Result<()> { - let vfs = self.vfs.clone(); - let file_name = self.file_name.clone(); - self.manager - .with_write_connection_state(move |db, newly_opened| { - if newly_opened { - configure_connection_for_database(db, &vfs, &file_name) - .map_err(anyhow::Error::msg)?; - } - verify_batch_atomic_writes(db, &vfs, &file_name).map_err(anyhow::Error::msg) - }) - .await - } - - async fn with_configured_write_connection(&self, f: F) -> Result - where - T: Send + 'static, - F: FnOnce(*mut libsqlite3_sys::sqlite3) -> Result + Send + 'static, - { - let vfs = self.vfs.clone(); - let file_name = self.file_name.clone(); - self.manager - .with_write_connection_state(move |db, newly_opened| { - if newly_opened { - configure_connection_for_database(db, &vfs, &file_name) - .map_err(anyhow::Error::msg)?; - } - f(db) - }) - .await + pub fn sqlite_vfs_metrics(&self) -> SqliteVfsMetricsSnapshot { + self.vfs.sqlite_vfs_metrics() } - async fn execute_without_read_pool( - &self, - sql: String, - params: Option>, - ) -> Result { - self.execute_on_writer_with_classification(sql, params).await + #[cfg(test)] + pub(crate) async fn pause_for_test(&self) -> tokio::sync::oneshot::Sender<()> { + self.worker.pause_for_test().await } - async fn execute_on_writer_with_classification( - &self, - sql: String, - params: Option>, - ) -> Result { - let metrics = self.metrics.clone(); - self.with_configured_write_connection(move |db| { - let route = classify_statement(db, &sql) - .map(|classification| write_route_for_classification(&classification)) - .unwrap_or(ExecuteRoute::WriteFallback); - if matches!(route, ExecuteRoute::WriteFallback) { - if let Some(metrics) = &metrics { - metrics.record_read_pool_write_fallback_query(); - } - } - execute_single_statement(db, &sql, params.as_deref(), route) - }) - .await + #[cfg(test)] + pub(crate) fn is_closing_for_test(&self) -> bool { + self.worker.is_closing_for_test() } - async fn try_read_execute( - &self, - sql: String, - params: Option>, - ) -> Result { - let metrics = self.metrics.clone(); - self.manager - .with_read_connection_state(move |db, newly_opened| { - if newly_opened { - configure_reader_connection(db)?; - } - - let classification = match classify_statement(db, &sql) { - Ok(classification) => classification, - Err(_) => { - return Ok(ReadQueryRoute::WriteRequired(ExecuteRoute::WriteFallback)); - } - }; - if !classification.reader_eligible() { - return Ok(ReadQueryRoute::WriteRequired(write_route_for_classification( - &classification, - ))); - } - - install_reader_authorizer(db)?; - match execute_single_statement(db, &sql, params.as_deref(), ExecuteRoute::Read) { - Ok(result) => Ok(ReadQueryRoute::Read(result)), - Err(error) => { - if reader_rejection_error(&error) { - if let Some(metrics) = &metrics { - metrics.record_read_pool_rejected_reader_mutation(); - } - return Err(error); - } - Err(error) - } - } - }) - .await + #[cfg(test)] + pub(crate) async fn panic_worker_for_test(&self) { + self.worker.panic_for_test().await } -} - -fn reader_rejection_error(error: &anyhow::Error) -> bool { - let message = error.to_string().to_ascii_lowercase(); - message.contains("not authorized") - || message.contains("readonly") - || message.contains("read-only") - || message.contains("attempt to write") -} -fn write_route_for_classification( - classification: &crate::query::StatementClassification, -) -> ExecuteRoute { - if !classification.sqlite_readonly || classification.authorizer.requires_write_route() { - ExecuteRoute::Write - } else { - ExecuteRoute::WriteFallback + async fn initialize(&self) -> Result<()> { + self.worker.wait_ready().await } } -fn configure_reader_connection(db: *mut libsqlite3_sys::sqlite3) -> Result<()> { - exec_statements(db, "PRAGMA query_only = ON;")?; - Ok(()) -} - #[cfg(test)] mod tests { use super::vfs_name_for_actor_database; diff --git a/engine/packages/depot-client/src/lib.rs b/engine/packages/depot-client/src/lib.rs index c9b1f83789..e4222e258e 100644 --- a/engine/packages/depot-client/src/lib.rs +++ b/engine/packages/depot-client/src/lib.rs @@ -14,9 +14,6 @@ //! - Delete and truncate behavior //! - Journal and BATCH_ATOMIC behavior -/// Native SQLite read-mode/write-mode connection manager. -pub mod connection_manager; - /// Unified native database handles and open helpers. pub mod database; @@ -30,3 +27,6 @@ pub use depot_client_types as types; /// Custom SQLite VFS for actor-side depot transport. pub mod vfs; + +/// Single-threaded native SQLite command worker. +pub mod worker; diff --git a/engine/packages/depot-client/src/optimization_flags.rs b/engine/packages/depot-client/src/optimization_flags.rs index 1f1b7050ca..eb068a17bf 100644 --- a/engine/packages/depot-client/src/optimization_flags.rs +++ b/engine/packages/depot-client/src/optimization_flags.rs @@ -6,8 +6,7 @@ pub const READ_AHEAD_MODE_ENV: &str = "RIVETKIT_SQLITE_OPT_READ_AHEAD_MODE"; pub const RECENT_PAGE_HINTS_ENV: &str = "RIVETKIT_SQLITE_OPT_RECENT_PAGE_HINTS"; pub const PRELOAD_HINT_FLUSH_ENV: &str = "RIVETKIT_SQLITE_OPT_PRELOAD_HINT_FLUSH"; pub const STARTUP_PRELOAD_MAX_BYTES_ENV: &str = "RIVETKIT_SQLITE_OPT_STARTUP_PRELOAD_MAX_BYTES"; -pub const STARTUP_PRELOAD_FIRST_PAGES_ENV: &str = - "RIVETKIT_SQLITE_OPT_STARTUP_PRELOAD_FIRST_PAGES"; +pub const STARTUP_PRELOAD_FIRST_PAGES_ENV: &str = "RIVETKIT_SQLITE_OPT_STARTUP_PRELOAD_FIRST_PAGES"; pub const STARTUP_PRELOAD_FIRST_PAGE_COUNT_ENV: &str = "RIVETKIT_SQLITE_OPT_STARTUP_PRELOAD_FIRST_PAGE_COUNT"; pub const PRELOAD_HINTS_ON_OPEN_ENV: &str = "RIVETKIT_SQLITE_OPT_PRELOAD_HINTS_ON_OPEN"; @@ -23,9 +22,6 @@ pub const VFS_PAGE_CACHE_MODE_ENV: &str = "RIVETKIT_SQLITE_OPT_VFS_PAGE_CACHE_MO pub const VFS_PAGE_CACHE_CAPACITY_PAGES_ENV: &str = "RIVETKIT_SQLITE_OPT_VFS_PAGE_CACHE_CAPACITY_PAGES"; pub const VFS_PROTECTED_CACHE_PAGES_ENV: &str = "RIVETKIT_SQLITE_OPT_VFS_PROTECTED_CACHE_PAGES"; -pub const SQLITE_READ_POOL_ENABLED_ENV: &str = "RIVETKIT_SQLITE_OPT_READ_POOL_ENABLED"; -pub const SQLITE_READ_POOL_MAX_READERS_ENV: &str = "RIVETKIT_SQLITE_OPT_READ_POOL_MAX_READERS"; -pub const SQLITE_READ_POOL_IDLE_TTL_MS_ENV: &str = "RIVETKIT_SQLITE_OPT_READ_POOL_IDLE_TTL_MS"; pub const DEFAULT_STARTUP_PRELOAD_MAX_BYTES: usize = 1024 * 1024; pub const MAX_STARTUP_PRELOAD_MAX_BYTES: usize = 8 * 1024 * 1024; @@ -35,10 +31,6 @@ pub const DEFAULT_VFS_PAGE_CACHE_CAPACITY_PAGES: u64 = 50_000; pub const MAX_VFS_PAGE_CACHE_CAPACITY_PAGES: u64 = 500_000; pub const DEFAULT_VFS_PROTECTED_CACHE_PAGES: usize = 512; pub const MAX_VFS_PROTECTED_CACHE_PAGES: usize = 8_192; -pub const DEFAULT_SQLITE_READ_POOL_MAX_READERS: usize = 4; -pub const MAX_SQLITE_READ_POOL_MAX_READERS: usize = 64; -pub const DEFAULT_SQLITE_READ_POOL_IDLE_TTL_MS: u64 = 60_000; -pub const MAX_SQLITE_READ_POOL_IDLE_TTL_MS: u64 = 3_600_000; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SqliteReadAheadMode { @@ -72,7 +64,10 @@ impl SqliteVfsPageCacheMode { } pub fn caches_target_pages(self) -> bool { - matches!(self, Self::Target | Self::Startup | Self::Prefetch | Self::All) + matches!( + self, + Self::Target | Self::Startup | Self::Prefetch | Self::All + ) } pub fn caches_prefetched_pages(self) -> bool { @@ -107,9 +102,6 @@ pub struct SqliteOptimizationFlags { pub vfs_page_cache_mode: SqliteVfsPageCacheMode, pub vfs_page_cache_capacity_pages: u64, pub vfs_protected_cache_pages: usize, - pub sqlite_read_pool_enabled: bool, - pub sqlite_read_pool_max_readers: usize, - pub sqlite_read_pool_idle_ttl_ms: u64, } impl Default for SqliteOptimizationFlags { @@ -136,9 +128,6 @@ impl Default for SqliteOptimizationFlags { vfs_page_cache_mode: SqliteVfsPageCacheMode::All, vfs_page_cache_capacity_pages: DEFAULT_VFS_PAGE_CACHE_CAPACITY_PAGES, vfs_protected_cache_pages: DEFAULT_VFS_PROTECTED_CACHE_PAGES, - sqlite_read_pool_enabled: true, - sqlite_read_pool_max_readers: DEFAULT_SQLITE_READ_POOL_MAX_READERS, - sqlite_read_pool_idle_ttl_ms: DEFAULT_SQLITE_READ_POOL_IDLE_TTL_MS, } } } @@ -174,7 +163,9 @@ impl SqliteOptimizationFlags { DEFAULT_STARTUP_PRELOAD_FIRST_PAGE_COUNT, MAX_STARTUP_PRELOAD_FIRST_PAGE_COUNT, ), - preload_hints_on_open: enabled_by_default(read_env(PRELOAD_HINTS_ON_OPEN_ENV).as_deref()), + preload_hints_on_open: enabled_by_default( + read_env(PRELOAD_HINTS_ON_OPEN_ENV).as_deref(), + ), preload_hint_hot_pages: enabled_by_default( read_env(PRELOAD_HINT_HOT_PAGES_ENV).as_deref(), ), @@ -205,19 +196,6 @@ impl SqliteOptimizationFlags { DEFAULT_VFS_PROTECTED_CACHE_PAGES, MAX_VFS_PROTECTED_CACHE_PAGES, ), - sqlite_read_pool_enabled: enabled_by_default( - read_env(SQLITE_READ_POOL_ENABLED_ENV).as_deref(), - ), - sqlite_read_pool_max_readers: usize_bounded_by_default( - read_env(SQLITE_READ_POOL_MAX_READERS_ENV).as_deref(), - DEFAULT_SQLITE_READ_POOL_MAX_READERS, - MAX_SQLITE_READ_POOL_MAX_READERS, - ), - sqlite_read_pool_idle_ttl_ms: u64_bounded_by_default( - read_env(SQLITE_READ_POOL_IDLE_TTL_MS_ENV).as_deref(), - DEFAULT_SQLITE_READ_POOL_IDLE_TTL_MS, - MAX_SQLITE_READ_POOL_IDLE_TTL_MS, - ), } } } @@ -329,9 +307,6 @@ mod tests { VFS_PAGE_CACHE_MODE_ENV => Some("off".to_string()), VFS_PAGE_CACHE_CAPACITY_PAGES_ENV => Some("0".to_string()), VFS_PROTECTED_CACHE_PAGES_ENV => Some("0".to_string()), - SQLITE_READ_POOL_ENABLED_ENV => Some("false".to_string()), - SQLITE_READ_POOL_MAX_READERS_ENV => Some("0".to_string()), - SQLITE_READ_POOL_IDLE_TTL_MS_ENV => Some("0".to_string()), _ => None, }); @@ -352,9 +327,6 @@ mod tests { assert_eq!(flags.vfs_page_cache_mode, SqliteVfsPageCacheMode::Off); assert_eq!(flags.vfs_page_cache_capacity_pages, 0); assert_eq!(flags.vfs_protected_cache_pages, 0); - assert!(!flags.sqlite_read_pool_enabled); - assert_eq!(flags.sqlite_read_pool_max_readers, 0); - assert_eq!(flags.sqlite_read_pool_idle_ttl_ms, 0); } #[test] @@ -364,8 +336,6 @@ mod tests { STARTUP_PRELOAD_FIRST_PAGE_COUNT_ENV => Some("nope".to_string()), VFS_PAGE_CACHE_CAPACITY_PAGES_ENV => Some("invalid".to_string()), VFS_PROTECTED_CACHE_PAGES_ENV => Some("invalid".to_string()), - SQLITE_READ_POOL_MAX_READERS_ENV => Some("invalid".to_string()), - SQLITE_READ_POOL_IDLE_TTL_MS_ENV => Some("invalid".to_string()), _ => None, }); assert_eq!( @@ -384,15 +354,6 @@ mod tests { invalid.vfs_protected_cache_pages, DEFAULT_VFS_PROTECTED_CACHE_PAGES ); - assert!(invalid.sqlite_read_pool_enabled); - assert_eq!( - invalid.sqlite_read_pool_max_readers, - DEFAULT_SQLITE_READ_POOL_MAX_READERS - ); - assert_eq!( - invalid.sqlite_read_pool_idle_ttl_ms, - DEFAULT_SQLITE_READ_POOL_IDLE_TTL_MS - ); let clamped = SqliteOptimizationFlags::from_env_reader(|key| match key { STARTUP_PRELOAD_MAX_BYTES_ENV => Some((MAX_STARTUP_PRELOAD_MAX_BYTES + 1).to_string()), @@ -403,12 +364,6 @@ mod tests { Some((MAX_VFS_PAGE_CACHE_CAPACITY_PAGES + 1).to_string()) } VFS_PROTECTED_CACHE_PAGES_ENV => Some((MAX_VFS_PROTECTED_CACHE_PAGES + 1).to_string()), - SQLITE_READ_POOL_MAX_READERS_ENV => { - Some((MAX_SQLITE_READ_POOL_MAX_READERS + 1).to_string()) - } - SQLITE_READ_POOL_IDLE_TTL_MS_ENV => { - Some((MAX_SQLITE_READ_POOL_IDLE_TTL_MS + 1).to_string()) - } _ => None, }); assert_eq!( @@ -427,13 +382,5 @@ mod tests { clamped.vfs_protected_cache_pages, MAX_VFS_PROTECTED_CACHE_PAGES ); - assert_eq!( - clamped.sqlite_read_pool_max_readers, - MAX_SQLITE_READ_POOL_MAX_READERS - ); - assert_eq!( - clamped.sqlite_read_pool_idle_ttl_ms, - MAX_SQLITE_READ_POOL_IDLE_TTL_MS - ); } } diff --git a/engine/packages/depot-client/src/query.rs b/engine/packages/depot-client/src/query.rs index bcd52bf60c..ce342cadb6 100644 --- a/engine/packages/depot-client/src/query.rs +++ b/engine/packages/depot-client/src/query.rs @@ -1,277 +1,17 @@ use std::ffi::{CStr, CString}; -use std::os::raw::{c_char, c_int, c_void}; +use std::os::raw::c_char; use std::ptr; use anyhow::{Result, anyhow}; +pub use depot_client_types::{BindParam, ColumnValue, ExecResult, ExecuteResult, QueryResult}; use libsqlite3_sys::{ - SQLITE_ALTER_TABLE, SQLITE_ANALYZE, SQLITE_ATTACH, SQLITE_BLOB, SQLITE_CREATE_INDEX, - SQLITE_CREATE_TABLE, SQLITE_CREATE_TEMP_INDEX, SQLITE_CREATE_TEMP_TABLE, - SQLITE_CREATE_TEMP_TRIGGER, SQLITE_CREATE_TEMP_VIEW, SQLITE_CREATE_TRIGGER, - SQLITE_CREATE_VIEW, SQLITE_CREATE_VTABLE, SQLITE_DELETE, SQLITE_DENY, SQLITE_DETACH, - SQLITE_DONE, SQLITE_DROP_INDEX, SQLITE_DROP_TABLE, SQLITE_DROP_TEMP_INDEX, - SQLITE_DROP_TEMP_TABLE, SQLITE_DROP_TEMP_TRIGGER, SQLITE_DROP_TEMP_VIEW, - SQLITE_DROP_TRIGGER, SQLITE_DROP_VIEW, SQLITE_DROP_VTABLE, SQLITE_FLOAT, SQLITE_FUNCTION, - SQLITE_INSERT, SQLITE_INTEGER, SQLITE_NULL, SQLITE_OK, SQLITE_PRAGMA, SQLITE_READ, - SQLITE_REINDEX, SQLITE_ROW, SQLITE_SAVEPOINT, SQLITE_SELECT, SQLITE_TEXT, - SQLITE_TRANSACTION, SQLITE_TRANSIENT, SQLITE_UPDATE, sqlite3, sqlite3_bind_blob, - sqlite3_bind_double, 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_finalize, sqlite3_last_insert_rowid, - sqlite3_prepare_v2, sqlite3_set_authorizer, sqlite3_step, sqlite3_stmt_readonly, + SQLITE_BLOB, SQLITE_DONE, SQLITE_FLOAT, SQLITE_INTEGER, SQLITE_NULL, SQLITE_OK, SQLITE_ROW, + SQLITE_TEXT, SQLITE_TRANSIENT, sqlite3, sqlite3_bind_blob, sqlite3_bind_double, + 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_finalize, sqlite3_last_insert_rowid, sqlite3_prepare_v2, sqlite3_step, }; -pub use depot_client_types::{ - BindParam, ColumnValue, ExecResult, ExecuteResult, ExecuteRoute, QueryResult, -}; - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct StatementClassification { - pub has_statement: bool, - pub sqlite_readonly: bool, - pub has_trailing_sql: bool, - pub authorizer: StatementAuthorizerSummary, -} - -impl StatementClassification { - pub fn reader_eligible(&self) -> bool { - self.has_statement - && self.sqlite_readonly - && !self.has_trailing_sql - && !self.authorizer.requires_write_route() - } -} - -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct StatementAuthorizerSummary { - pub transaction_control: bool, - pub attach: bool, - pub detach: bool, - pub schema_writes: bool, - pub temp_writes: bool, - pub pragma_usage: bool, - pub function_calls: bool, - pub write_operations: bool, - pub actions: Vec, -} - -impl StatementAuthorizerSummary { - pub fn requires_write_route(&self) -> bool { - self.transaction_control - || self.attach - || self.detach - || self.schema_writes - || self.temp_writes - || self.write_operations - } -} - -pub fn reader_authorizer_allows_classification( - classification: &StatementClassification, -) -> bool { - classification - .authorizer - .actions - .iter() - .all(reader_authorizer_allows_action) -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct StatementAuthorizerAction { - pub kind: StatementAuthorizerActionKind, - pub first_arg: Option, - pub second_arg: Option, - pub database_name: Option, - pub trigger_or_view_name: Option, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum StatementAuthorizerActionKind { - Read, - Select, - Transaction, - Savepoint, - Attach, - Detach, - Pragma, - Function, - Insert, - Update, - Delete, - CreateIndex, - CreateTable, - CreateTrigger, - CreateView, - CreateVirtualTable, - CreateTempIndex, - CreateTempTable, - CreateTempTrigger, - CreateTempView, - DropIndex, - DropTable, - DropTrigger, - DropView, - DropVirtualTable, - DropTempIndex, - DropTempTable, - DropTempTrigger, - DropTempView, - AlterTable, - Reindex, - Analyze, - Other(i32), -} - -impl StatementAuthorizerActionKind { - fn from_code(code: c_int) -> Self { - match code { - SQLITE_READ => Self::Read, - SQLITE_SELECT => Self::Select, - SQLITE_TRANSACTION => Self::Transaction, - SQLITE_SAVEPOINT => Self::Savepoint, - SQLITE_ATTACH => Self::Attach, - SQLITE_DETACH => Self::Detach, - SQLITE_PRAGMA => Self::Pragma, - SQLITE_FUNCTION => Self::Function, - SQLITE_INSERT => Self::Insert, - SQLITE_UPDATE => Self::Update, - SQLITE_DELETE => Self::Delete, - SQLITE_CREATE_INDEX => Self::CreateIndex, - SQLITE_CREATE_TABLE => Self::CreateTable, - SQLITE_CREATE_TRIGGER => Self::CreateTrigger, - SQLITE_CREATE_VIEW => Self::CreateView, - SQLITE_CREATE_VTABLE => Self::CreateVirtualTable, - SQLITE_CREATE_TEMP_INDEX => Self::CreateTempIndex, - SQLITE_CREATE_TEMP_TABLE => Self::CreateTempTable, - SQLITE_CREATE_TEMP_TRIGGER => Self::CreateTempTrigger, - SQLITE_CREATE_TEMP_VIEW => Self::CreateTempView, - SQLITE_DROP_INDEX => Self::DropIndex, - SQLITE_DROP_TABLE => Self::DropTable, - SQLITE_DROP_TRIGGER => Self::DropTrigger, - SQLITE_DROP_VIEW => Self::DropView, - SQLITE_DROP_VTABLE => Self::DropVirtualTable, - SQLITE_DROP_TEMP_INDEX => Self::DropTempIndex, - SQLITE_DROP_TEMP_TABLE => Self::DropTempTable, - SQLITE_DROP_TEMP_TRIGGER => Self::DropTempTrigger, - SQLITE_DROP_TEMP_VIEW => Self::DropTempView, - SQLITE_ALTER_TABLE => Self::AlterTable, - SQLITE_REINDEX => Self::Reindex, - SQLITE_ANALYZE => Self::Analyze, - _ => Self::Other(code), - } - } - - fn is_schema_write(&self) -> bool { - matches!( - self, - Self::CreateIndex - | Self::CreateTable - | Self::CreateTrigger - | Self::CreateView - | Self::CreateVirtualTable - | Self::DropIndex - | Self::DropTable - | Self::DropTrigger - | Self::DropView - | Self::DropVirtualTable - | Self::AlterTable - | Self::Reindex - | Self::Analyze - ) - } - - fn is_temp_schema_write(&self) -> bool { - matches!( - self, - Self::CreateTempIndex - | Self::CreateTempTable - | Self::CreateTempTrigger - | Self::CreateTempView - | Self::DropTempIndex - | Self::DropTempTable - | Self::DropTempTrigger - | Self::DropTempView - ) - } - - fn is_data_write(&self) -> bool { - matches!(self, Self::Insert | Self::Update | Self::Delete) - } -} - -pub fn classify_statement(db: *mut sqlite3, sql: &str) -> Result { - let c_sql = CString::new(sql).map_err(|err| anyhow!(err.to_string()))?; - let mut summary = StatementAuthorizerSummary::default(); - let rc = unsafe { - sqlite3_set_authorizer( - db, - Some(capture_authorizer_action), - &mut summary as *mut StatementAuthorizerSummary as *mut c_void, - ) - }; - if rc != SQLITE_OK { - return Err(sqlite_error(db, "failed to install sqlite authorizer")); - } - - let mut stmt = ptr::null_mut(); - let mut tail = ptr::null(); - let prepare_rc = unsafe { sqlite3_prepare_v2(db, c_sql.as_ptr(), -1, &mut stmt, &mut tail) }; - let prepare_error = if prepare_rc == SQLITE_OK { - None - } else { - Some(sqlite_error(db, "failed to prepare sqlite statement for classification")) - }; - - let restore_rc = unsafe { sqlite3_set_authorizer(db, None, ptr::null_mut()) }; - if restore_rc != SQLITE_OK { - if !stmt.is_null() { - unsafe { - sqlite3_finalize(stmt); - } - } - return Err(sqlite_error(db, "failed to clear sqlite authorizer")); - } - - if let Some(err) = prepare_error { - if !stmt.is_null() { - unsafe { - sqlite3_finalize(stmt); - } - } - return Err(err); - } - - if stmt.is_null() { - return Ok(StatementClassification { - has_statement: false, - sqlite_readonly: true, - has_trailing_sql: has_non_whitespace_tail(tail), - authorizer: summary, - }); - } - - let sqlite_readonly = unsafe { sqlite3_stmt_readonly(stmt) != 0 }; - unsafe { - sqlite3_finalize(stmt); - } - - Ok(StatementClassification { - has_statement: true, - sqlite_readonly, - has_trailing_sql: has_non_whitespace_tail(tail), - authorizer: summary, - }) -} - -pub fn install_reader_authorizer(db: *mut sqlite3) -> Result<()> { - let rc = unsafe { - sqlite3_set_authorizer(db, Some(reader_authorizer_action), ptr::null_mut()) - }; - if rc != SQLITE_OK { - return Err(sqlite_error(db, "failed to install sqlite reader authorizer")); - } - - Ok(()) -} pub fn execute_statement( db: *mut sqlite3, @@ -371,14 +111,16 @@ pub fn execute_single_statement( db: *mut sqlite3, sql: &str, params: Option<&[BindParam]>, - route: ExecuteRoute, ) -> Result { let c_sql = CString::new(sql).map_err(|err| anyhow!(err.to_string()))?; let mut stmt = ptr::null_mut(); let mut tail = ptr::null(); let rc = unsafe { sqlite3_prepare_v2(db, c_sql.as_ptr(), -1, &mut stmt, &mut tail) }; if rc != SQLITE_OK { - return Err(sqlite_error(db, "failed to prepare sqlite execute statement")); + return Err(sqlite_error( + db, + "failed to prepare sqlite execute statement", + )); } if has_non_whitespace_tail(tail) { if !stmt.is_null() { @@ -394,7 +136,6 @@ pub fn execute_single_statement( rows: Vec::new(), changes: 0, last_insert_row_id: None, - route, }); } @@ -427,7 +168,6 @@ pub fn execute_single_statement( rows, changes, last_insert_row_id: (changes > 0).then(|| unsafe { sqlite3_last_insert_rowid(db) }), - route, }) })(); @@ -587,203 +327,6 @@ fn column_value(stmt: *mut libsqlite3_sys::sqlite3_stmt, index: i32) -> ColumnVa } } -unsafe extern "C" fn capture_authorizer_action( - user_data: *mut c_void, - action_code: c_int, - first_arg: *const c_char, - second_arg: *const c_char, - database_name: *const c_char, - trigger_or_view_name: *const c_char, -) -> c_int { - if user_data.is_null() { - return SQLITE_OK; - } - - let summary = unsafe { &mut *(user_data as *mut StatementAuthorizerSummary) }; - let kind = StatementAuthorizerActionKind::from_code(action_code); - let database_name = unsafe { optional_c_string(database_name) }; - - match kind { - StatementAuthorizerActionKind::Transaction - | StatementAuthorizerActionKind::Savepoint => summary.transaction_control = true, - StatementAuthorizerActionKind::Attach => summary.attach = true, - StatementAuthorizerActionKind::Detach => summary.detach = true, - StatementAuthorizerActionKind::Pragma => summary.pragma_usage = true, - StatementAuthorizerActionKind::Function => summary.function_calls = true, - _ => {} - } - - if kind.is_schema_write() { - summary.schema_writes = true; - } - if kind.is_temp_schema_write() - || (kind.is_data_write() && database_name.as_deref() == Some("temp")) - { - summary.temp_writes = true; - } - if kind.is_data_write() || kind.is_schema_write() || kind.is_temp_schema_write() { - summary.write_operations = true; - } - - summary.actions.push(StatementAuthorizerAction { - kind, - first_arg: unsafe { optional_c_string(first_arg) }, - second_arg: unsafe { optional_c_string(second_arg) }, - database_name, - trigger_or_view_name: unsafe { optional_c_string(trigger_or_view_name) }, - }); - - SQLITE_OK -} - -unsafe extern "C" fn reader_authorizer_action( - _user_data: *mut c_void, - action_code: c_int, - first_arg: *const c_char, - second_arg: *const c_char, - database_name: *const c_char, - _trigger_or_view_name: *const c_char, -) -> c_int { - let kind = StatementAuthorizerActionKind::from_code(action_code); - let database_name = unsafe { optional_c_string(database_name) }; - let first_arg = unsafe { optional_c_string(first_arg) }; - let second_arg = unsafe { optional_c_string(second_arg) }; - - if reader_authorizer_allows_action(&StatementAuthorizerAction { - kind, - first_arg, - second_arg, - database_name, - trigger_or_view_name: None, - }) { - SQLITE_OK - } else { - SQLITE_DENY - } -} - -fn reader_authorizer_allows_action(action: &StatementAuthorizerAction) -> bool { - if action.kind.is_data_write() - || action.kind.is_schema_write() - || action.kind.is_temp_schema_write() - || (action.kind.is_data_write() && action.database_name.as_deref() == Some("temp")) - { - return false; - } - - match action.kind { - StatementAuthorizerActionKind::Transaction - | StatementAuthorizerActionKind::Savepoint - | StatementAuthorizerActionKind::Attach - | StatementAuthorizerActionKind::Detach => false, - StatementAuthorizerActionKind::Pragma => { - reader_pragma_allowed(action.first_arg.as_deref(), action.second_arg.as_deref()) - } - StatementAuthorizerActionKind::Function => { - reader_function_allowed(action.first_arg.as_deref(), action.second_arg.as_deref()) - } - StatementAuthorizerActionKind::Read - | StatementAuthorizerActionKind::Select - | StatementAuthorizerActionKind::Other(_) => true, - StatementAuthorizerActionKind::Insert - | StatementAuthorizerActionKind::Update - | StatementAuthorizerActionKind::Delete - | StatementAuthorizerActionKind::CreateIndex - | StatementAuthorizerActionKind::CreateTable - | StatementAuthorizerActionKind::CreateTrigger - | StatementAuthorizerActionKind::CreateView - | StatementAuthorizerActionKind::CreateVirtualTable - | StatementAuthorizerActionKind::CreateTempIndex - | StatementAuthorizerActionKind::CreateTempTable - | StatementAuthorizerActionKind::CreateTempTrigger - | StatementAuthorizerActionKind::CreateTempView - | StatementAuthorizerActionKind::DropIndex - | StatementAuthorizerActionKind::DropTable - | StatementAuthorizerActionKind::DropTrigger - | StatementAuthorizerActionKind::DropView - | StatementAuthorizerActionKind::DropVirtualTable - | StatementAuthorizerActionKind::DropTempIndex - | StatementAuthorizerActionKind::DropTempTable - | StatementAuthorizerActionKind::DropTempTrigger - | StatementAuthorizerActionKind::DropTempView - | StatementAuthorizerActionKind::AlterTable - | StatementAuthorizerActionKind::Reindex - | StatementAuthorizerActionKind::Analyze => false, - } -} - -fn reader_pragma_allowed(first_arg: Option<&str>, second_arg: Option<&str>) -> bool { - let Some(name) = first_arg else { - return false; - }; - - let name = name.to_ascii_lowercase(); - if second_arg.is_some() { - return matches!( - name.as_str(), - "foreign_key_check" - | "foreign_key_list" - | "index_info" - | "index_list" - | "index_xinfo" - | "integrity_check" - | "quick_check" - | "table_info" - | "table_xinfo" - ); - } - - matches!( - name.as_str(), - "application_id" - | "busy_timeout" - | "cache_size" - | "collation_list" - | "compile_options" - | "database_list" - | "encoding" - | "foreign_key_check" - | "foreign_key_list" - | "freelist_count" - | "function_list" - | "index_info" - | "index_list" - | "index_xinfo" - | "integrity_check" - | "journal_mode" - | "module_list" - | "page_count" - | "page_size" - | "pragma_list" - | "quick_check" - | "schema_version" - | "table_info" - | "table_list" - | "table_xinfo" - | "user_version" - ) -} - -fn reader_function_allowed(first_arg: Option<&str>, second_arg: Option<&str>) -> bool { - let name = second_arg.or(first_arg); - !matches!( - name.map(str::to_ascii_lowercase).as_deref(), - Some("load_extension") | Some("writefile") - ) -} - -unsafe fn optional_c_string(value: *const c_char) -> Option { - if value.is_null() { - None - } else { - Some( - unsafe { CStr::from_ptr(value) } - .to_string_lossy() - .into_owned(), - ) - } -} - fn has_non_whitespace_tail(tail: *const c_char) -> bool { if tail.is_null() { return false; @@ -888,21 +431,14 @@ mod tests { } #[test] - fn execute_single_statement_returns_rows_and_read_route() { + fn execute_single_statement_returns_rows_and_metadata() { let db = MemoryDb::open(); - let result = execute_single_statement( - db.as_ptr(), - "SELECT 7 AS value;", - None, - ExecuteRoute::Read, - ) - .unwrap(); + let result = execute_single_statement(db.as_ptr(), "SELECT 7 AS value;", None).unwrap(); assert_eq!(result.columns, vec!["value"]); assert_eq!(result.rows, vec![vec![ColumnValue::Integer(7)]]); assert_eq!(result.changes, 0); assert_eq!(result.last_insert_row_id, None); - assert_eq!(result.route, ExecuteRoute::Read); } #[test] @@ -918,7 +454,6 @@ mod tests { db.as_ptr(), "INSERT INTO execute_items(label) VALUES (?);", Some(&[BindParam::Text("alpha".to_owned())]), - ExecuteRoute::Write, ) .unwrap(); @@ -926,7 +461,6 @@ mod tests { assert_eq!(result.rows, Vec::>::new()); assert_eq!(result.changes, 1); assert_eq!(result.last_insert_row_id, Some(1)); - assert_eq!(result.route, ExecuteRoute::Write); } #[test] @@ -942,7 +476,6 @@ mod tests { db.as_ptr(), "INSERT INTO execute_returning(label) VALUES ('bravo') RETURNING id, label;", None, - ExecuteRoute::Write, ) .unwrap(); @@ -956,53 +489,36 @@ mod tests { ); assert_eq!(result.changes, 1); assert_eq!(result.last_insert_row_id, Some(1)); - assert_eq!(result.route, ExecuteRoute::Write); } #[test] fn execute_single_statement_collects_readonly_pragma_rows() { let db = MemoryDb::open(); - let result = - execute_single_statement(db.as_ptr(), "PRAGMA user_version;", None, ExecuteRoute::Read) - .unwrap(); + let result = execute_single_statement(db.as_ptr(), "PRAGMA user_version;", None).unwrap(); assert_eq!(result.columns, vec!["user_version"]); assert_eq!(result.rows, vec![vec![ColumnValue::Integer(0)]]); assert_eq!(result.changes, 0); - assert_eq!(result.route, ExecuteRoute::Read); } #[test] - fn execute_single_statement_runs_mutating_pragma_in_write_route() { + fn execute_single_statement_runs_mutating_pragma() { let db = MemoryDb::open(); - let result = execute_single_statement( - db.as_ptr(), - "PRAGMA user_version = 9;", - None, - ExecuteRoute::Write, - ) - .unwrap(); + let result = + execute_single_statement(db.as_ptr(), "PRAGMA user_version = 9;", None).unwrap(); assert_eq!(result.columns, Vec::::new()); assert_eq!(result.rows, Vec::>::new()); - assert_eq!(result.route, ExecuteRoute::Write); - let version = - execute_single_statement(db.as_ptr(), "PRAGMA user_version;", None, ExecuteRoute::Read) - .unwrap(); + let version = execute_single_statement(db.as_ptr(), "PRAGMA user_version;", None).unwrap(); assert_eq!(version.rows, vec![vec![ColumnValue::Integer(9)]]); } #[test] fn execute_single_statement_rejects_multi_statement_sql() { let db = MemoryDb::open(); - let err = execute_single_statement( - db.as_ptr(), - "SELECT 1; SELECT 2;", - None, - ExecuteRoute::WriteFallback, - ) - .expect_err("multi statement execute should fail"); + let err = execute_single_statement(db.as_ptr(), "SELECT 1; SELECT 2;", None) + .expect_err("multi statement execute should fail"); assert!( err.to_string().contains("single statement"), @@ -1013,13 +529,8 @@ mod tests { #[test] fn execute_single_statement_reports_malformed_sql() { let db = MemoryDb::open(); - let err = execute_single_statement( - db.as_ptr(), - "SELECT FROM", - None, - ExecuteRoute::WriteFallback, - ) - .expect_err("malformed execute should fail"); + let err = execute_single_statement(db.as_ptr(), "SELECT FROM", None) + .expect_err("malformed execute should fail"); assert!( err.to_string().contains("failed to prepare"), diff --git a/engine/packages/depot-client/src/vfs.rs b/engine/packages/depot-client/src/vfs.rs index a63842190c..f6566247b2 100644 --- a/engine/packages/depot-client/src/vfs.rs +++ b/engine/packages/depot-client/src/vfs.rs @@ -4,6 +4,8 @@ use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; use std::ffi::{CStr, CString, c_char, c_int, c_void}; +#[cfg(test)] +use std::future::Future; use std::ptr; use std::slice; use std::sync::Arc; @@ -17,6 +19,8 @@ use parking_lot::{Mutex, RwLock}; use rivet_envoy_client::handle::EnvoyHandle; use rivet_envoy_protocol as protocol; use tokio::runtime::Handle; +#[cfg(test)] +use tokio::runtime::RuntimeFlavor; use crate::optimization_flags::{SqliteOptimizationFlags, sqlite_optimization_flags}; @@ -91,6 +95,40 @@ fn panic_message(payload: &Box) -> String { } } +#[cfg(test)] +fn block_on_runtime(runtime: &Handle, future: F) -> std::result::Result +where + F: Future + Send + 'static, + T: Send + 'static, +{ + if Handle::try_current().is_err() { + return Ok(runtime.block_on(future)); + } + + if runtime.runtime_flavor() == RuntimeFlavor::CurrentThread { + return Err( + "sqlite VFS registration cannot block on a current-thread Tokio runtime".to_string(), + ); + } + + let runtime = runtime.clone(); + // VFS registration is synchronous because SQLite VFS callbacks are synchronous, but native + // actor startup often opens SQLite while already running on a Tokio worker. Blocking the + // current worker with `Handle::block_on` would panic, so only the open-time metadata fetch is + // bridged through a short standalone thread. SQL execution itself stays on the SQLite worker. + std::thread::Builder::new() + .name("sqlite-vfs-runtime-bridge".to_string()) + .spawn(move || runtime.block_on(future)) + .map_err(|err| format!("spawn sqlite VFS runtime bridge: {err}"))? + .join() + .map_err(|panic| { + format!( + "sqlite VFS runtime bridge panicked: {}", + panic_message(&panic) + ) + }) +} + macro_rules! vfs_catch_unwind { ($err_val:expr, $body:expr) => { match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| $body)) { @@ -116,7 +154,7 @@ enum SqliteTransportInner { } impl SqliteTransport { - fn from_envoy(handle: EnvoyHandle) -> Self { + pub(crate) fn from_envoy(handle: EnvoyHandle) -> Self { Self { inner: Arc::new(SqliteTransportInner::Envoy(handle)), } @@ -376,6 +414,11 @@ pub struct SqliteVfsMetricsSnapshot { pub state_update_ns: u64, pub total_ns: u64, pub commit_count: u64, + pub page_cache_entries: u64, + pub page_cache_weighted_size: u64, + pub page_cache_capacity_pages: u64, + pub write_buffer_dirty_pages: u64, + pub db_size_pages: u64, } pub trait SqliteVfsMetrics: Send + Sync { @@ -401,27 +444,21 @@ pub trait SqliteVfsMetrics: Send + Sync { ) { } - fn set_read_pool_active_readers(&self, _readers: u64) {} - - fn set_read_pool_idle_readers(&self, _readers: u64) {} + fn set_worker_queue_depth(&self, _depth: u64) {} - fn observe_read_pool_read_wait(&self, _duration: Duration) {} + fn record_worker_queue_overload(&self) {} - fn observe_read_pool_write_wait(&self, _duration: Duration) {} + fn observe_worker_command_duration(&self, _operation: &'static str, _duration_ns: u64) {} - fn record_read_pool_routed_read_query(&self) {} + fn record_worker_command_error(&self, _operation: &'static str, _code: &'static str) {} - fn record_read_pool_write_fallback_query(&self) {} + fn observe_worker_close_duration(&self, _duration_ns: u64) {} - fn observe_read_pool_manual_transaction(&self, _duration: Duration) {} + fn record_worker_close_timeout(&self) {} - fn record_read_pool_reader_open(&self) {} + fn record_worker_crash(&self) {} - fn record_read_pool_reader_close(&self, _count: u64) {} - - fn record_read_pool_rejected_reader_mutation(&self) {} - - fn record_read_pool_mode_transition(&self, _from: &str, _to: &str) {} + fn record_worker_unclean_close(&self) {} } #[derive(Debug, Clone, Copy, Default)] @@ -945,17 +982,29 @@ impl VfsContext { transport: SqliteTransport, config: VfsConfig, io_methods: sqlite3_io_methods, + initial_main_page: Option>, metrics: Option>, ) -> std::result::Result { let mut state = VfsState::new(&config); + if let Some(page) = initial_main_page { + state.seed_main_page(page); + } #[cfg(test)] if let SqliteTransportInner::Direct(storage) = &*transport.inner { if storage.is_strict_mode() { - if let Some(page) = fetch_initial_main_page(&transport, &runtime, &actor_id)? { + if let Some(page) = + fetch_initial_main_page_for_test(&transport, &runtime, &actor_id)? + { state.seed_main_page(page); } } else { - let snapshot = runtime.block_on(storage.snapshot_pages(&actor_id)); + let storage = Arc::clone(storage); + let actor_id = actor_id.clone(); + let snapshot = + block_on_runtime( + &runtime, + async move { storage.snapshot_pages(&actor_id).await }, + )?; if snapshot.db_size_pages > 0 { state.db_size_pages = snapshot.db_size_pages; state.page_cache.invalidate_all(); @@ -965,10 +1014,6 @@ impl VfsContext { } } } - #[cfg(not(test))] - if let Some(page) = fetch_initial_main_page(&transport, &runtime, &actor_id)? { - state.seed_main_page(page); - } Ok(Self { actor_id, @@ -1044,6 +1089,8 @@ impl VfsContext { } fn sqlite_vfs_metrics(&self) -> SqliteVfsMetricsSnapshot { + let state = self.state.read(); + SqliteVfsMetricsSnapshot { request_build_ns: self.commit_request_build_ns.load(Ordering::Relaxed), serialize_ns: self.commit_serialize_ns.load(Ordering::Relaxed), @@ -1051,6 +1098,11 @@ impl VfsContext { state_update_ns: self.commit_state_update_ns.load(Ordering::Relaxed), total_ns: self.commit_duration_ns_total.load(Ordering::Relaxed), commit_count: self.commit_total.load(Ordering::Relaxed), + page_cache_entries: state.page_cache.entry_count(), + page_cache_weighted_size: state.page_cache.weighted_size(), + page_cache_capacity_pages: self.config.cache_capacity_pages, + write_buffer_dirty_pages: state.write_buffer.dirty.len() as u64, + db_size_pages: state.db_size_pages as u64, } } @@ -1284,6 +1336,9 @@ impl VfsContext { } let get_pages_start = Instant::now(); + // Transport rejection, including envoy shutdown while a VFS callback is + // active, becomes GetPagesError here. The SQLite callback maps that to + // SQLITE_IOERR_* because VFS has no richer async transport error channel. let response = self .runtime .block_on(self.transport.get_pages(protocol::SqliteGetPagesRequest { @@ -1362,6 +1417,9 @@ impl VfsContext { let request_build_ns = request_build_start.elapsed().as_nanos() as u64; let (outcome, transport_metrics) = + // Transport rejection, including envoy shutdown while a VFS callback is + // active, becomes CommitBufferError here. xSync and xClose then surface + // it to SQLite as SQLITE_IOERR_*. match self.block_on_buffered_commit(request.clone(), timeout) { Ok(CommitWait::Completed(outcome)) => outcome, Ok(CommitWait::TimedOut) => return Ok(CommitWait::TimedOut), @@ -1591,17 +1649,26 @@ fn mark_dead_from_fence_commit_error(ctx: &VfsContext, err: &CommitBufferError) } } -fn fetch_initial_main_page( +pub(crate) async fn fetch_initial_main_page_for_registration( transport: &SqliteTransport, - runtime: &Handle, actor_id: &str, ) -> std::result::Result>, String> { - let response = runtime.block_on(transport.get_pages(protocol::SqliteGetPagesRequest { - actor_id: actor_id.to_string(), - pgnos: vec![1], - expected_generation: None, - expected_head_txid: None, - })); + fetch_initial_main_page(transport.clone(), actor_id.to_string()).await +} + +async fn fetch_initial_main_page( + transport: SqliteTransport, + actor_id: String, +) -> std::result::Result>, String> { + let request_actor_id = actor_id.clone(); + let response = transport + .get_pages(protocol::SqliteGetPagesRequest { + actor_id: request_actor_id, + pgnos: vec![1], + expected_generation: None, + expected_head_txid: None, + }) + .await; match response { Ok(protocol::SqliteGetPagesResponse::SqliteGetPagesOk(ok)) => Ok(ok @@ -1627,6 +1694,19 @@ fn fetch_initial_main_page( } } +#[cfg(test)] +fn fetch_initial_main_page_for_test( + transport: &SqliteTransport, + runtime: &Handle, + actor_id: &str, +) -> std::result::Result>, String> { + let transport = transport.clone(); + let actor_id = actor_id.to_string(); + block_on_runtime(runtime, async move { + fetch_initial_main_page(transport, actor_id).await + })? +} + fn is_initial_main_page_missing(message: &str) -> bool { message.contains("sqlite database was not found in this bucket branch") || message.contains("sqlite meta missing for get_pages") @@ -2538,6 +2618,10 @@ impl SqliteVfs { self.ctx.snapshot_preload_hints() } + pub(crate) fn sqlite_vfs_metrics(&self) -> SqliteVfsMetricsSnapshot { + self.ctx.sqlite_vfs_metrics() + } + pub(crate) fn register_with_transport( name: &str, transport: SqliteTransport, @@ -2545,6 +2629,20 @@ impl SqliteVfs { runtime: Handle, config: VfsConfig, metrics: Option>, + ) -> std::result::Result { + Self::register_with_transport_and_initial_page( + name, transport, actor_id, runtime, config, None, metrics, + ) + } + + pub(crate) fn register_with_transport_and_initial_page( + name: &str, + transport: SqliteTransport, + actor_id: String, + runtime: Handle, + config: VfsConfig, + initial_main_page: Option>, + metrics: Option>, ) -> std::result::Result { let mut io_methods: sqlite3_io_methods = unsafe { std::mem::zeroed() }; io_methods.iVersion = 1; @@ -2562,7 +2660,13 @@ impl SqliteVfs { io_methods.xDeviceCharacteristics = Some(io_device_characteristics); let mut ctx = Box::new(VfsContext::new( - actor_id, runtime, transport, config, io_methods, metrics, + actor_id, + runtime, + transport, + config, + io_methods, + initial_main_page, + metrics, )?); let ctx_ptr = (&mut *ctx) as *mut VfsContext; let name_cstring = CString::new(name).map_err(|err| err.to_string())?; diff --git a/engine/packages/depot-client/src/worker.rs b/engine/packages/depot-client/src/worker.rs new file mode 100644 index 0000000000..cd4fa5e10f --- /dev/null +++ b/engine/packages/depot-client/src/worker.rs @@ -0,0 +1,573 @@ +use std::{ + error::Error, + fmt, + sync::{ + Arc, + atomic::{AtomicU8, Ordering}, + }, + thread::JoinHandle, + time::{Duration, Instant}, +}; + +use anyhow::{Context, Result, anyhow}; +use crossbeam_channel::{Receiver, Sender, TrySendError}; +use libsqlite3_sys::{SQLITE_OPEN_CREATE, SQLITE_OPEN_READWRITE}; +use parking_lot::Mutex; +use tokio::sync::{Notify, oneshot}; + +use crate::{ + query::{BindParam, ExecuteResult, QueryResult, exec_statements, execute_single_statement}, + vfs::{ + NativeConnection, NativeVfsHandle, SqliteVfsMetrics, configure_connection_for_database, + open_connection, verify_batch_atomic_writes, + }, +}; + +// Keep the first worker version intentionally fixed-size. A full queue maps to +// actor.overloaded so callers get explicit backpressure instead of hidden work. +pub const SQLITE_WORKER_QUEUE_CAPACITY: usize = 128; +const SQLITE_WORKER_CLOSE_TIMEOUT: Duration = Duration::from_secs(5); + +const STATE_RUNNING: u8 = 0; +const STATE_CLOSING: u8 = 1; +const STATE_CLOSED: u8 = 2; +const STATE_DEAD: u8 = 3; + +#[derive(Clone)] +pub struct SqliteWorkerHandle { + inner: Arc, + sql_tx: Sender, + close_tx: Sender, +} + +struct SqliteWorkerInner { + metrics: Option>, + state: AtomicU8, + closed: Notify, + join: Mutex>>, + ready: Mutex>>>, +} + +enum SqliteCommand { + Execute { + sql: String, + params: Option>, + reply: oneshot::Sender>, + }, + Exec { + sql: String, + reply: oneshot::Sender>, + }, + #[cfg(test)] + Pause { + entered: oneshot::Sender<()>, + resume: oneshot::Receiver<()>, + }, + #[cfg(test)] + Panic, +} + +struct CloseRequest; + +struct WorkerContext { + sql_rx: Receiver, + close_rx: Receiver, + inner: Arc, + vfs: NativeVfsHandle, + file_name: String, + ready_tx: Option>>, +} + +impl SqliteWorkerHandle { + pub fn start( + vfs: NativeVfsHandle, + file_name: String, + metrics: Option>, + ) -> Result { + let (sql_tx, sql_rx) = crossbeam_channel::bounded(SQLITE_WORKER_QUEUE_CAPACITY); + let (close_tx, close_rx) = crossbeam_channel::bounded(1); + let (ready_tx, ready_rx) = oneshot::channel(); + let inner = Arc::new(SqliteWorkerInner { + metrics, + state: AtomicU8::new(STATE_RUNNING), + closed: Notify::new(), + join: Mutex::new(None), + ready: Mutex::new(Some(ready_rx)), + }); + + let thread_inner = Arc::clone(&inner); + let join = std::thread::Builder::new() + .name(format!("sqlite-worker-{file_name}")) + .spawn(move || { + let ctx = WorkerContext { + sql_rx, + close_rx, + inner: Arc::clone(&thread_inner), + vfs, + file_name, + ready_tx: Some(ready_tx), + }; + if let Err(panic) = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| worker_main(ctx))) + { + thread_inner.state.store(STATE_DEAD, Ordering::Release); + if let Some(metrics) = &thread_inner.metrics { + metrics.record_worker_crash(); + } + thread_inner.closed.notify_waiters(); + tracing::error!(message = panic_message(&panic), "sqlite worker panicked"); + } + }) + .context("spawn sqlite worker thread")?; + *inner.join.lock() = Some(join); + + Ok(Self { + inner, + sql_tx, + close_tx, + }) + } + + pub async fn wait_ready(&self) -> Result<()> { + let ready = self.inner.ready.lock().take(); + let Some(ready) = ready else { + return Ok(()); + }; + ready + .await + .map_err(|_| sqlite_worker_dead_error())? + .map_err(|err| anyhow!("failed to initialize sqlite worker: {err}")) + } + + pub async fn exec(&self, sql: String) -> Result { + let (reply, result) = oneshot::channel(); + self.enqueue(SqliteCommand::Exec { sql, reply })?; + result.await.map_err(|_| sqlite_worker_dead_error())? + } + + pub async fn execute( + &self, + sql: String, + params: Option>, + ) -> Result { + let (reply, result) = oneshot::channel(); + self.enqueue(SqliteCommand::Execute { sql, params, reply })?; + result.await.map_err(|_| sqlite_worker_dead_error())? + } + + pub async fn close(&self) -> Result<()> { + let start = Instant::now(); + if self.inner.mark_closing() { + // Close is a control path, not SQL work, so it must bypass the bounded + // SQL queue even when the actor has filled all command slots. + let _ = self.close_tx.try_send(CloseRequest); + } + + let wait_closed = async { + loop { + let closed = self.inner.closed.notified(); + match self.inner.state.load(Ordering::Acquire) { + STATE_CLOSED => return Ok(()), + STATE_DEAD => return Err(sqlite_worker_dead_error()), + STATE_RUNNING | STATE_CLOSING => closed.await, + other => return Err(anyhow!("unknown sqlite worker state {other}")), + } + } + }; + + match tokio::time::timeout(SQLITE_WORKER_CLOSE_TIMEOUT, wait_closed).await { + Ok(result) => result?, + Err(_) => { + if let Some(metrics) = &self.inner.metrics { + metrics.record_worker_close_timeout(); + } + // The worker thread still owns the SQLite connection and VFS handle. + // Reporting timeout here must not drop or unregister the VFS while + // SQLite may still be inside a synchronous VFS callback. + self.join_worker_in_background(start); + return Err(SqliteWorkerCloseTimeoutError.into()); + } + } + if let Some(metrics) = &self.inner.metrics { + metrics.observe_worker_close_duration(start.elapsed().as_nanos() as u64); + } + + self.join_worker().await + } + + pub async fn wait_for_failure(&self) -> bool { + loop { + let closed = self.inner.closed.notified(); + match self.inner.state.load(Ordering::Acquire) { + STATE_DEAD => return true, + STATE_CLOSED => return false, + STATE_RUNNING | STATE_CLOSING => closed.await, + _ => return true, + } + } + } + + fn enqueue(&self, command: SqliteCommand) -> Result<()> { + match self.inner.state.load(Ordering::Acquire) { + STATE_RUNNING => {} + STATE_CLOSING | STATE_CLOSED => return Err(sqlite_closing_error()), + STATE_DEAD => return Err(sqlite_worker_dead_error()), + other => return Err(anyhow!("unknown sqlite worker state {other}")), + } + + match self.sql_tx.try_send(command) { + Ok(()) => { + self.inner.record_queue_depth(self.sql_tx.len() as u64); + Ok(()) + } + Err(TrySendError::Full(_)) => { + if let Some(metrics) = &self.inner.metrics { + metrics.record_worker_queue_overload(); + } + // SQL backpressure is actor backpressure. Waiting for capacity here + // would let a single actor build hidden native SQLite backlog. + Err(SqliteWorkerOverloadedError.into()) + } + Err(TrySendError::Disconnected(_)) => Err(sqlite_worker_dead_error()), + } + } + + #[cfg(test)] + pub(crate) async fn pause_for_test(&self) -> oneshot::Sender<()> { + let (entered_tx, entered_rx) = oneshot::channel(); + let (resume_tx, resume_rx) = oneshot::channel(); + self.enqueue(SqliteCommand::Pause { + entered: entered_tx, + resume: resume_rx, + }) + .expect("test pause should enqueue"); + entered_rx.await.expect("test pause should start"); + resume_tx + } + + #[cfg(test)] + pub(crate) fn is_closing_for_test(&self) -> bool { + matches!( + self.inner.state.load(Ordering::Acquire), + STATE_CLOSING | STATE_CLOSED | STATE_DEAD + ) + } + + #[cfg(test)] + pub(crate) async fn panic_for_test(&self) { + self.enqueue(SqliteCommand::Panic) + .expect("test panic should enqueue"); + assert!(self.wait_for_failure().await); + } + + async fn join_worker(&self) -> Result<()> { + let join = self.inner.join.lock().take(); + let Some(join) = join else { + return Ok(()); + }; + tokio::task::spawn_blocking(move || { + join.join() + .map_err(|panic| anyhow!("sqlite worker panicked: {}", panic_message(&panic))) + }) + .await + .context("join sqlite worker join task")? + } + + fn join_worker_in_background(&self, start: Instant) { + let join = self.inner.join.lock().take(); + let Some(join) = join else { + return; + }; + + let metrics = self.inner.metrics.clone(); + let _ = tokio::task::spawn_blocking(move || { + let result = join.join(); + let duration_ns = start.elapsed().as_nanos() as u64; + + if let Some(metrics) = &metrics { + metrics.observe_worker_close_duration(duration_ns); + } + + match result { + Ok(()) => { + tracing::warn!(duration_ns, "sqlite worker finished after close timeout"); + } + Err(panic) => { + tracing::error!( + duration_ns, + message = panic_message(&panic), + "sqlite worker finished after close timeout with panic", + ); + } + } + }); + } +} + +impl SqliteWorkerInner { + fn mark_closing(&self) -> bool { + self.state + .compare_exchange( + STATE_RUNNING, + STATE_CLOSING, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok() + } + + fn record_queue_depth(&self, depth: u64) { + if let Some(metrics) = &self.metrics { + metrics.set_worker_queue_depth(depth); + } + } +} + +impl Drop for SqliteWorkerInner { + fn drop(&mut self) { + if self.state.load(Ordering::Acquire) == STATE_RUNNING { + if let Some(metrics) = &self.metrics { + metrics.record_worker_unclean_close(); + } + tracing::error!("sqlite worker handle dropped without clean close"); + } + } +} + +fn worker_main(mut ctx: WorkerContext) { + let connection = open_worker_connection(&ctx); + let mut db = match connection { + Ok(db) => { + if let Some(ready_tx) = ctx.ready_tx.take() { + let _ = ready_tx.send(Ok(())); + } + db + } + Err(err) => { + if let Some(ready_tx) = ctx.ready_tx.take() { + let _ = ready_tx.send(Err(err)); + } + fail_queued_sql(&ctx.sql_rx); + ctx.inner.state.store(STATE_DEAD, Ordering::Release); + ctx.inner.closed.notify_waiters(); + return; + } + }; + + loop { + if ctx.close_rx.try_recv().is_ok() + || ctx.inner.state.load(Ordering::Acquire) == STATE_CLOSING + { + // The worker checks close before dispatching queued SQL so shutdown + // cannot be delayed by commands already sitting behind the active call. + fail_queued_sql(&ctx.sql_rx); + break; + } + + crossbeam_channel::select_biased! { + recv(ctx.close_rx) -> close => { + fail_queued_sql(&ctx.sql_rx); + if close.is_err() { + if let Some(metrics) = &ctx.inner.metrics { + metrics.record_worker_unclean_close(); + } + tracing::error!("sqlite worker close channel dropped without clean close"); + } + break; + } + recv(ctx.sql_rx) -> command => { + let Ok(command) = command else { + if let Some(metrics) = &ctx.inner.metrics { + metrics.record_worker_unclean_close(); + } + // A dropped SQL sender without a close request means the owning + // runtime lost the handle without running SQLite shutdown. + tracing::error!("sqlite worker command channel dropped without clean close"); + break; + }; + ctx.inner.record_queue_depth(ctx.sql_rx.len() as u64); + if ctx.inner.state.load(Ordering::Acquire) == STATE_CLOSING { + fail_command(command); + fail_queued_sql(&ctx.sql_rx); + break; + } + run_command(&mut db, command, ctx.inner.metrics.as_deref()); + } + } + } + + drop(db); + ctx.inner.state.store(STATE_CLOSED, Ordering::Release); + ctx.inner.closed.notify_waiters(); +} + +fn open_worker_connection(ctx: &WorkerContext) -> Result { + let connection = open_connection( + ctx.vfs.clone(), + &ctx.file_name, + SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE, + ) + .map_err(anyhow::Error::msg)?; + configure_connection_for_database(connection.as_ptr(), &ctx.vfs, &ctx.file_name) + .map_err(anyhow::Error::msg)?; + verify_batch_atomic_writes(connection.as_ptr(), &ctx.vfs, &ctx.file_name) + .map_err(anyhow::Error::msg)?; + Ok(connection) +} + +fn run_command( + db: &mut NativeConnection, + command: SqliteCommand, + metrics: Option<&dyn SqliteVfsMetrics>, +) { + let start = Instant::now(); + match command { + SqliteCommand::Execute { sql, params, reply } => { + if reply.is_closed() { + return; + } + let result = execute_single_statement(db.as_ptr(), &sql, params.as_deref()); + record_command_metrics(metrics, "execute", &result, start.elapsed()); + let _ = reply.send(result); + } + SqliteCommand::Exec { sql, reply } => { + if reply.is_closed() { + return; + } + let result = exec_statements(db.as_ptr(), &sql); + record_command_metrics(metrics, "exec", &result, start.elapsed()); + let _ = reply.send(result); + } + #[cfg(test)] + SqliteCommand::Pause { entered, resume } => { + let _ = entered.send(()); + let _ = resume.blocking_recv(); + } + #[cfg(test)] + SqliteCommand::Panic => { + panic!("test sqlite worker panic"); + } + } +} + +fn record_command_metrics( + metrics: Option<&dyn SqliteVfsMetrics>, + operation: &'static str, + result: &Result, + duration: Duration, +) { + let Some(metrics) = metrics else { + return; + }; + metrics.observe_worker_command_duration(operation, duration.as_nanos() as u64); + if let Err(error) = result { + metrics.record_worker_command_error(operation, worker_error_code(error)); + } +} + +fn fail_queued_sql(sql_rx: &Receiver) { + for command in sql_rx.try_iter() { + fail_command(command); + } +} + +fn fail_command(command: SqliteCommand) { + // Cancelled requests may sit in the bounded queue until the worker observes + // them. Once observed during shutdown, they fail instead of running. + match command { + SqliteCommand::Execute { reply, .. } => { + let _ = reply.send(Err(sqlite_closing_error())); + } + SqliteCommand::Exec { reply, .. } => { + let _ = reply.send(Err(sqlite_closing_error())); + } + #[cfg(test)] + SqliteCommand::Pause { resume, .. } => { + drop(resume); + } + #[cfg(test)] + SqliteCommand::Panic => {} + } +} + +fn sqlite_closing_error() -> anyhow::Error { + SqliteWorkerClosingError.into() +} + +fn sqlite_worker_dead_error() -> anyhow::Error { + SqliteWorkerDeadError.into() +} + +fn worker_error_code(error: &anyhow::Error) -> &'static str { + if error + .downcast_ref::() + .is_some() + { + "overloaded" + } else if error.downcast_ref::().is_some() { + "closing" + } else if error.downcast_ref::().is_some() { + "dead" + } else if error + .downcast_ref::() + .is_some() + { + "close_timeout" + } else { + "sqlite" + } +} + +#[derive(Debug)] +pub struct SqliteWorkerOverloadedError; + +impl fmt::Display for SqliteWorkerOverloadedError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("actor.overloaded: sqlite worker command queue is full") + } +} + +impl Error for SqliteWorkerOverloadedError {} + +#[derive(Debug)] +pub struct SqliteWorkerClosingError; + +impl fmt::Display for SqliteWorkerClosingError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("sqlite worker is closing") + } +} + +impl Error for SqliteWorkerClosingError {} + +#[derive(Debug)] +pub struct SqliteWorkerDeadError; + +impl fmt::Display for SqliteWorkerDeadError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("sqlite worker is closed") + } +} + +impl Error for SqliteWorkerDeadError {} + +#[derive(Debug)] +pub struct SqliteWorkerCloseTimeoutError; + +impl fmt::Display for SqliteWorkerCloseTimeoutError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("sqlite worker close timed out") + } +} + +impl Error for SqliteWorkerCloseTimeoutError {} + +fn panic_message(payload: &Box) -> String { + if let Some(message) = payload.downcast_ref::<&str>() { + message.to_string() + } else if let Some(message) = payload.downcast_ref::() { + message.clone() + } else { + "unknown panic".to_string() + } +} diff --git a/engine/packages/depot-client/tests/inline/fault/chaos.rs b/engine/packages/depot-client/tests/inline/fault/chaos.rs index 15510bce6c..4544f9ab59 100644 --- a/engine/packages/depot-client/tests/inline/fault/chaos.rs +++ b/engine/packages/depot-client/tests/inline/fault/chaos.rs @@ -10,8 +10,8 @@ use depot::workflows::compaction::ForceCompactionWork; use futures_util::future; use super::{ - scenario::{FaultScenarioCtx, FaultScenarioReplayRecord}, FaultProfile, FaultReplayPhase, FaultScenario, LogicalOp, + scenario::{FaultScenarioCtx, FaultScenarioReplayRecord}, }; #[test] @@ -47,7 +47,9 @@ fn run_chaos_seed(seed: u64, profile: ChaosRunProfile) -> Result<()> { }) .faults(move |faults| { faults - .at(DepotFaultPoint::Commit(fault_plan.commit_pause_point.clone())) + .at(DepotFaultPoint::Commit( + fault_plan.commit_pause_point.clone(), + )) .once() .pause(commit_pause_checkpoint(seed))?; faults @@ -55,15 +57,21 @@ fn run_chaos_seed(seed: u64, profile: ChaosRunProfile) -> Result<()> { .once() .delay(Duration::from_millis(fault_plan.read_delay_ms))?; faults - .at(DepotFaultPoint::HotCompaction(fault_plan.hot_delay_point.clone())) + .at(DepotFaultPoint::HotCompaction( + fault_plan.hot_delay_point.clone(), + )) .once() .delay(Duration::from_millis(fault_plan.hot_delay_ms))?; faults - .at(DepotFaultPoint::ColdCompaction(fault_plan.cold_delay_point.clone())) + .at(DepotFaultPoint::ColdCompaction( + fault_plan.cold_delay_point.clone(), + )) .once() .delay(Duration::from_millis(fault_plan.cold_delay_ms))?; faults - .at(DepotFaultPoint::Reclaim(fault_plan.reclaim_delay_point.clone())) + .at(DepotFaultPoint::Reclaim( + fault_plan.reclaim_delay_point.clone(), + )) .once() .delay(Duration::from_millis(fault_plan.reclaim_delay_ms))?; faults @@ -82,14 +90,16 @@ fn run_chaos_seed(seed: u64, profile: ChaosRunProfile) -> Result<()> { value: vec![0xaa, (seed & 0xff) as u8], }) .await?; - ctx.checkpoint(format!("commit-pause-released-{seed:016x}")).await?; + ctx.checkpoint(format!("commit-pause-released-{seed:016x}")) + .await?; let mut rng = ChaosRng::new(seed ^ 0x9e37_79b9_7f4a_7c15); for index in 0..profile.pre_cold_ops { ctx.exec(random_logical_op(&mut rng, index)).await?; if index % profile.reload_every == 1 { ctx.reload_database().await?; - ctx.checkpoint(format!("reload-{index}-{seed:016x}")).await?; + ctx.checkpoint(format!("reload-{index}-{seed:016x}")) + .await?; } } @@ -99,7 +109,8 @@ fn run_chaos_seed(seed: u64, profile: ChaosRunProfile) -> Result<()> { ctx.exec(random_logical_op(&mut rng, index)).await?; if index % profile.reload_every == profile.reload_every - 1 { ctx.reload_database().await?; - ctx.checkpoint(format!("late-reload-{index}-{seed:016x}")).await?; + ctx.checkpoint(format!("late-reload-{index}-{seed:016x}")) + .await?; } } let restore_point = ctx.create_restore_point().await?; @@ -132,7 +143,8 @@ fn run_chaos_seed(seed: u64, profile: ChaosRunProfile) -> Result<()> { ctx.checkpoint(format!( "after-cold-read-{seed:016x}-elapsed-{}ms", cold_read_elapsed.as_millis() - )).await?; + )) + .await?; ctx.delete_restore_point(restore_point).await?; ctx.exec(LogicalOp::Put { @@ -286,10 +298,7 @@ impl ChaosRunProfile { } } -async fn run_overlapping_hot_compaction( - ctx: &FaultScenarioCtx, - seed: u64, -) -> Result<()> { +async fn run_overlapping_hot_compaction(ctx: &FaultScenarioCtx, seed: u64) -> Result<()> { let checkpoint = format!("chaos-hot-overlap-{seed:016x}"); let pause = ctx.fault_controller().pause_handle(checkpoint.clone()); ctx.fault_controller() @@ -327,7 +336,8 @@ async fn run_overlapping_hot_compaction( elapsed >= Duration::from_millis(1), "seed {seed:016x} hot-overlap completed without measurable paused overlap: elapsed={elapsed:?}", ); - ctx.checkpoint(format!("after-hot-overlap-{seed:016x}")).await + ctx.checkpoint(format!("after-hot-overlap-{seed:016x}")) + .await } fn assert_delay_elapsed( @@ -434,7 +444,7 @@ fn choose_commit_point(rng: &mut ChaosRng) -> CommitFaultPoint { CommitFaultPoint::BeforeDeltaWrites, CommitFaultPoint::BeforeHeadWrite, ][rng.index(3)] - .clone() + .clone() } fn choose_read_point(rng: &mut ChaosRng) -> ReadFaultPoint { @@ -443,7 +453,7 @@ fn choose_read_point(rng: &mut ChaosRng) -> ReadFaultPoint { ReadFaultPoint::ColdRefSelected, ReadFaultPoint::BeforeReturnPages, ][rng.index(3)] - .clone() + .clone() } fn choose_hot_point(rng: &mut ChaosRng) -> HotCompactionFaultPoint { @@ -452,7 +462,7 @@ fn choose_hot_point(rng: &mut ChaosRng) -> HotCompactionFaultPoint { HotCompactionFaultPoint::InstallAfterStagedRead, HotCompactionFaultPoint::InstallBeforeRootUpdate, ][rng.index(3)] - .clone() + .clone() } fn choose_cold_point(rng: &mut ChaosRng) -> ColdCompactionFaultPoint { @@ -461,7 +471,7 @@ fn choose_cold_point(rng: &mut ChaosRng) -> ColdCompactionFaultPoint { ColdCompactionFaultPoint::PublishBeforeColdRefWrite, ColdCompactionFaultPoint::PublishAfterRootUpdate, ][rng.index(3)] - .clone() + .clone() } fn choose_reclaim_point(rng: &mut ChaosRng) -> ReclaimFaultPoint { @@ -470,7 +480,7 @@ fn choose_reclaim_point(rng: &mut ChaosRng) -> ReclaimFaultPoint { ReclaimFaultPoint::PlanAfterSnapshot, ReclaimFaultPoint::BeforeCleanupRows, ][rng.index(3)] - .clone() + .clone() } fn commit_pause_checkpoint(seed: u64) -> String { diff --git a/engine/packages/depot-client/tests/inline/fault/oracle.rs b/engine/packages/depot-client/tests/inline/fault/oracle.rs index 36396e76ca..7d7d8ee5cb 100644 --- a/engine/packages/depot-client/tests/inline/fault/oracle.rs +++ b/engine/packages/depot-client/tests/inline/fault/oracle.rs @@ -4,10 +4,10 @@ use std::ptr; use anyhow::{Context, Result, bail}; use libsqlite3_sys::{ SQLITE_BLOB, SQLITE_FLOAT, SQLITE_INTEGER, SQLITE_NULL, SQLITE_OK, SQLITE_ROW, SQLITE_TEXT, - sqlite3, sqlite3_close, sqlite3_column_blob, sqlite3_column_bytes, sqlite3_column_count, - sqlite3_column_double, sqlite3_column_int64, sqlite3_column_text, sqlite3_column_type, - sqlite3_errmsg, sqlite3_exec, sqlite3_finalize, sqlite3_open, sqlite3_prepare_v2, sqlite3_step, - sqlite3_backup_finish, sqlite3_backup_init, sqlite3_backup_step, + sqlite3, sqlite3_backup_finish, sqlite3_backup_init, sqlite3_backup_step, sqlite3_close, + sqlite3_column_blob, sqlite3_column_bytes, sqlite3_column_count, sqlite3_column_double, + sqlite3_column_int64, sqlite3_column_text, sqlite3_column_type, sqlite3_errmsg, sqlite3_exec, + sqlite3_finalize, sqlite3_open, sqlite3_prepare_v2, sqlite3_step, }; use super::workload::LogicalOp; @@ -276,9 +276,7 @@ fn clone_database(source: *mut sqlite3) -> Result<*mut sqlite3> { bail!("native sqlite oracle clone open failed with code {rc}: {message}"); } - let backup = unsafe { - sqlite3_backup_init(clone, main.as_ptr(), source, main.as_ptr()) - }; + let backup = unsafe { sqlite3_backup_init(clone, main.as_ptr(), source, main.as_ptr()) }; if backup.is_null() { let message = sqlite_error_message(clone); unsafe { @@ -327,7 +325,10 @@ fn query_rows(db: *mut sqlite3, sql: &str) -> Result>> { 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 { - bail!("{sql} prepare failed with code {rc}: {}", sqlite_error_message(db)); + bail!( + "{sql} prepare failed with code {rc}: {}", + sqlite_error_message(db) + ); } if stmt.is_null() { return Ok(Vec::new()); @@ -340,7 +341,10 @@ fn query_rows(db: *mut sqlite3, sql: &str) -> Result>> { SQLITE_ROW => rows.push(read_row(stmt)), libsqlite3_sys::SQLITE_DONE => break, step_rc => { - bail!("{sql} step failed with code {step_rc}: {}", sqlite_error_message(db)); + bail!( + "{sql} step failed with code {step_rc}: {}", + sqlite_error_message(db) + ); } } } @@ -356,7 +360,9 @@ fn query_rows(db: *mut sqlite3, sql: &str) -> Result>> { fn read_row(stmt: *mut libsqlite3_sys::sqlite3_stmt) -> Vec { let column_count = unsafe { sqlite3_column_count(stmt) }; - (0..column_count).map(|index| read_value(stmt, index)).collect() + (0..column_count) + .map(|index| read_value(stmt, index)) + .collect() } fn read_value(stmt: *mut libsqlite3_sys::sqlite3_stmt, index: i32) -> CanonicalValue { @@ -389,10 +395,7 @@ fn read_value(stmt: *mut libsqlite3_sys::sqlite3_stmt, index: i32) -> CanonicalV } fn render_row(row: &[CanonicalValue]) -> String { - row.iter() - .map(render_value) - .collect::>() - .join("|") + row.iter().map(render_value).collect::>().join("|") } fn render_value(value: &CanonicalValue) -> String { @@ -521,7 +524,7 @@ mod tests { key: "ambiguous".to_string(), value: vec![3, 4], }) - .apply(actual)?; + .apply(actual)?; assert_eq!( new_oracle.verify_matches(actual)?, OracleVerification::Ambiguous(AmbiguousOracleOutcome::New) @@ -546,8 +549,12 @@ mod tests { )?; let dump = canonical_dump(oracle.db)?.render(); - let row_a_one = dump.find("row|a|integer:1|text:one").context("missing a row one")?; - let row_a_two = dump.find("row|a|integer:2|text:two").context("missing a row two")?; + let row_a_one = dump + .find("row|a|integer:1|text:one") + .context("missing a row one")?; + let row_a_two = dump + .find("row|a|integer:2|text:two") + .context("missing a row two")?; let row_b_a = dump .find("row|b|text:a|null|integer:-1|float:4000000000000000|null") .context("missing b row a")?; diff --git a/engine/packages/depot-client/tests/inline/fault/scenario.rs b/engine/packages/depot-client/tests/inline/fault/scenario.rs index 385b30cb00..4f22141d5e 100644 --- a/engine/packages/depot-client/tests/inline/fault/scenario.rs +++ b/engine/packages/depot-client/tests/inline/fault/scenario.rs @@ -1,16 +1,14 @@ use std::ffi::{CStr, CString}; use std::future::Future; +use std::path::Path; use std::pin::Pin; use std::ptr; -use std::path::Path; use std::sync::Arc; use anyhow::{Context, Result, bail, ensure}; use depot::{ cold_tier::{ColdTier, FaultyColdTier, FilesystemColdTier}, - fault::{ - DepotFaultCheckpoint, DepotFaultController, DepotFaultReplayEvent, - }, + fault::{DepotFaultCheckpoint, DepotFaultController, DepotFaultReplayEvent}, keys, ltx::{decode_ltx_v3, encode_ltx_v3}, types::{ @@ -23,8 +21,8 @@ use depot::{ test_hooks::{self, WorkflowColdTierGuard, WorkflowFaultControllerGuard}, }, }; -use gas::prelude::{Registry, TestCtx}; use futures_util::TryStreamExt; +use gas::prelude::{Registry, TestCtx}; use libsqlite3_sys::{ SQLITE_BLOB, SQLITE_FLOAT, SQLITE_INTEGER, SQLITE_NULL, SQLITE_OK, SQLITE_ROW, SQLITE_TEXT, sqlite3, sqlite3_column_blob, sqlite3_column_bytes, sqlite3_column_count, @@ -43,15 +41,15 @@ use universaldb::{ utils::IsolationLevel::{Serializable, Snapshot}, }; +use super::super::{ + DirectStorage, DirectStorageStats, NativeDatabase, SqliteTransport, SqliteVfs, VfsConfig, + open_database, +}; use super::oracle::{ AmbiguousOracleOutcome, NativeSqliteOracle, OracleCommitSemantics, OracleVerification, }; use super::verify::DepotInvariantScanner; use super::workload::LogicalOp; -use super::super::{ - DirectStorage, DirectStorageStats, NativeDatabase, SqliteTransport, SqliteVfs, VfsConfig, - open_database, -}; type StageFuture = Pin>>>; type Stage = Box StageFuture>; @@ -348,10 +346,7 @@ impl FaultScenarioCtx { } self.inner.workload.lock().push(op.clone()); - self.inner - .oracle - .lock() - .apply_logical_op(op, semantics) + self.inner.oracle.lock().apply_logical_op(op, semantics) } pub(crate) async fn checkpoint(&self, name: impl Into) -> Result<()> { @@ -480,9 +475,8 @@ impl FaultScenarioCtx { } pub(crate) async fn verify_against_native_oracle(&self) -> Result<()> { - let result = self.with_database_blocking(|db| { - self.inner.oracle.lock().verify_matches(db.as_ptr()) - }); + let result = + self.with_database_blocking(|db| self.inner.oracle.lock().verify_matches(db.as_ptr())); let mut ambiguous_outcome = self.inner.ambiguous_oracle_outcome.lock(); *ambiguous_outcome = match &result { Ok(OracleVerification::Ambiguous(outcome)) => Some(*outcome), @@ -498,8 +492,7 @@ impl FaultScenarioCtx { } Err(err) => format!("{err:#}"), }); - result - .map(|_| ()) + result.map(|_| ()) } pub(crate) async fn verify_depot_invariants(&self) -> Result<()> { @@ -508,8 +501,8 @@ impl FaultScenarioCtx { Some(Arc::clone(&self.inner.verification_cold_tier)), self.inner.actor_id.clone(), ) - .verify() - .await + .verify() + .await } pub(crate) async fn replay_record(&self) -> FaultScenarioReplayRecord { @@ -616,10 +609,7 @@ impl FaultScenarioCtx { .await } - pub(crate) async fn delete_restore_point( - &self, - restore_point: RestorePointId, - ) -> Result<()> { + pub(crate) async fn delete_restore_point(&self, restore_point: RestorePointId) -> Result<()> { self.inner .storage .actor_db(self.inner.actor_id.clone()) @@ -644,18 +634,14 @@ impl FaultScenarioCtx { let state = db._vfs.ctx().state.read(); (1..=state.db_size_pages) .filter(|candidate_pgno| { - *candidate_pgno / depot::keys::SHARD_SIZE - == pgno / depot::keys::SHARD_SIZE + *candidate_pgno / depot::keys::SHARD_SIZE == pgno / depot::keys::SHARD_SIZE }) .map(|candidate_pgno| { - let bytes = state - .page_cache - .get(&candidate_pgno) - .with_context(|| { - format!( - "page {candidate_pgno} should be present in strict VFS cache before cold-ref seed" - ) - })?; + let bytes = state.page_cache.get(&candidate_pgno).with_context(|| { + format!( + "page {candidate_pgno} should be present in strict VFS cache before cold-ref seed" + ) + })?; Ok(DirtyPage { pgno: candidate_pgno, bytes, @@ -740,8 +726,10 @@ impl FaultScenarioCtx { &keys::branch_compaction_cold_shard_key(branch_id, shard_id, head_txid), &encode_cold_shard_ref(reference)?, ); - tx.informal() - .set(&keys::branch_pidx_key(branch_id, pgno), &head_txid.to_be_bytes()); + tx.informal().set( + &keys::branch_pidx_key(branch_id, pgno), + &head_txid.to_be_bytes(), + ); Ok(()) } }) @@ -794,7 +782,11 @@ impl FaultScenarioCtx { } async fn capture_branch_head_before_faults(&self) -> Result<()> { - let (_, head_txid) = self.inner.storage.read_branch_head(&self.inner.actor_id).await?; + let (_, head_txid) = self + .inner + .storage + .read_branch_head(&self.inner.actor_id) + .await?; *self.inner.branch_head_before_faults.lock() = Some(head_txid); Ok(()) } @@ -820,11 +812,9 @@ impl FaultScenarioCtx { .storage .cold_tier() .context("fault scenario cold tier should be configured")?; - *self.inner.workflow_cold_tier_guard.lock() = - Some(test_hooks::install_workflow_cold_tier_for_test( - database_branch_id, - cold_tier, - )); + *self.inner.workflow_cold_tier_guard.lock() = Some( + test_hooks::install_workflow_cold_tier_for_test(database_branch_id, cold_tier), + ); } let manager_workflow_id = DepotCompactionTestDriver::new(&test_ctx) .start_manager(database_branch_id, Some(self.inner.actor_id.clone()), true) @@ -833,10 +823,7 @@ impl FaultScenarioCtx { Ok(manager_workflow_id) } - fn with_database_blocking( - &self, - f: impl FnOnce(&NativeDatabase) -> Result, - ) -> Result { + fn with_database_blocking(&self, f: impl FnOnce(&NativeDatabase) -> Result) -> Result { tokio::task::block_in_place(|| self.with_database(f)) } @@ -856,7 +843,9 @@ impl FaultScenarioCtx { fn build_registry() -> Registry { let mut registry = Registry::new(); registry.register_workflow::().unwrap(); - registry.register_workflow::().unwrap(); + registry + .register_workflow::() + .unwrap(); registry .register_workflow::() .unwrap(); @@ -868,13 +857,11 @@ async fn test_ctx_with_cold_tier(root: &Path) -> Result { let mut test_deps = TestDeps::new().await?; let mut config_root = (**test_deps.config()).clone(); config_root.sqlite = Some(rivet_config::config::Sqlite { - workflow_cold_storage: Some( - rivet_config::config::SqliteWorkflowColdStorage::FileSystem( - rivet_config::config::SqliteWorkflowColdStorageFileSystem { - root: root.display().to_string(), - }, - ), - ), + workflow_cold_storage: Some(rivet_config::config::SqliteWorkflowColdStorage::FileSystem( + rivet_config::config::SqliteWorkflowColdStorageFileSystem { + root: root.display().to_string(), + }, + )), }); test_deps.config = rivet_config::Config::from_root(config_root); TestCtx::new_with_deps(build_registry(), test_deps).await @@ -905,7 +892,10 @@ fn query_rows(db: *mut sqlite3, sql: &str) -> Result>> { 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 { - bail!("{sql} prepare failed with code {rc}: {}", sqlite_error_message(db)); + bail!( + "{sql} prepare failed with code {rc}: {}", + sqlite_error_message(db) + ); } let mut rows = Vec::new(); @@ -917,7 +907,10 @@ fn query_rows(db: *mut sqlite3, sql: &str) -> Result>> { unsafe { sqlite3_finalize(stmt); } - bail!("{sql} step failed with code {step_rc}: {}", sqlite_error_message(db)); + bail!( + "{sql} step failed with code {step_rc}: {}", + sqlite_error_message(db) + ); } } } @@ -950,9 +943,8 @@ fn read_row(stmt: *mut libsqlite3_sys::sqlite3_stmt) -> Vec { if blob.is_null() || len == 0 { String::new() } else { - let bytes = unsafe { - std::slice::from_raw_parts(blob.cast::(), len as usize) - }; + let bytes = + unsafe { std::slice::from_raw_parts(blob.cast::(), len as usize) }; hex_upper(bytes) } } diff --git a/engine/packages/depot-client/tests/inline/fault/simple.rs b/engine/packages/depot-client/tests/inline/fault/simple.rs index 741b716ba1..b58edf361c 100644 --- a/engine/packages/depot-client/tests/inline/fault/simple.rs +++ b/engine/packages/depot-client/tests/inline/fault/simple.rs @@ -54,9 +54,7 @@ fn fault_scenario_runs_setup_workload_reload_and_verify() -> Result<()> { ctx.verify_sqlite_integrity().await?; ctx.verify_against_native_oracle().await?; ctx.verify_depot_invariants().await?; - let rows = ctx - .query("SELECT k, hex(v) FROM kv ORDER BY k;") - .await?; + let rows = ctx.query("SELECT k, hex(v) FROM kv ORDER BY k;").await?; assert_eq!( rows, vec![ @@ -324,7 +322,11 @@ fn simple_failed_cold_publish_preserves_vfs_state() -> Result<()> { final_settle: false, }) .await?; - assert!(result.attempted_job_kinds.contains(&CompactionJobKind::Cold)); + assert!( + result + .attempted_job_kinds + .contains(&CompactionJobKind::Cold) + ); assert!( result .terminal_error @@ -381,7 +383,11 @@ fn simple_workflow_cold_upload_uses_fault_controller_cold_tier() -> Result<()> { final_settle: false, }) .await?; - assert!(result.attempted_job_kinds.contains(&CompactionJobKind::Cold)); + assert!( + result + .attempted_job_kinds + .contains(&CompactionJobKind::Cold) + ); ctx.fault_controller().assert_expected_fired()?; assert_fault_points( &ctx.fault_controller().replay_log(), @@ -442,7 +448,7 @@ fn simple_workflow_cold_object_missing_after_reclaim_recovers_on_reload() -> Res reclaim: true, final_settle: true, }) - .await?; + .await?; assert_eq!(result.requested_work.reclaim, true); assert!(result.terminal_error.is_none()); ctx.checkpoint("after-cold-reclaim").await?; @@ -484,16 +490,22 @@ fn simple_workflow_cold_object_missing_after_reclaim_recovers_on_reload() -> Res &ctx, 1_205, &["after-cold-reclaim"], - &[( - DepotFaultPoint::ColdCompaction(ColdCompactionFaultPoint::UploadAfterPutObject), - FaultBoundary::WorkflowOnly, - ), ( - DepotFaultPoint::Read(ReadFaultPoint::AfterShardBlobLoad), - FaultBoundary::ReadOnly, - ), ( - DepotFaultPoint::ColdTier(ColdTierFaultPoint::GetObject), - FaultBoundary::ReadOnly, - )], + &[ + ( + DepotFaultPoint::ColdCompaction( + ColdCompactionFaultPoint::UploadAfterPutObject, + ), + FaultBoundary::WorkflowOnly, + ), + ( + DepotFaultPoint::Read(ReadFaultPoint::AfterShardBlobLoad), + FaultBoundary::ReadOnly, + ), + ( + DepotFaultPoint::ColdTier(ColdTierFaultPoint::GetObject), + FaultBoundary::ReadOnly, + ), + ], ) .await }) @@ -552,7 +564,11 @@ fn simple_forced_compaction_noops_report_all_requested_work() -> Result<()> { .await?; assert!(settle.terminal_error.is_none()); assert!(settle.attempted_job_kinds.contains(&CompactionJobKind::Hot)); - assert!(settle.attempted_job_kinds.contains(&CompactionJobKind::Cold)); + assert!( + settle + .attempted_job_kinds + .contains(&CompactionJobKind::Cold) + ); let noop = ctx .force_compaction(ForceCompactionWork { hot: true, @@ -571,7 +587,8 @@ fn simple_forced_compaction_noops_report_all_requested_work() -> Result<()> { .contains(&"cold:no-actionable-lag".to_string()) ); assert!( - noop.attempted_job_kinds.contains(&CompactionJobKind::Reclaim) + noop.attempted_job_kinds + .contains(&CompactionJobKind::Reclaim) || noop .skipped_noop_reasons .iter() @@ -822,9 +839,7 @@ fn high_risk_fault_matrix_cases() -> Vec { HighRiskFaultMatrixCase { name: "cold_upload_after_put_object", seed: 1_237, - point: DepotFaultPoint::ColdCompaction( - ColdCompactionFaultPoint::UploadAfterPutObject, - ), + point: DepotFaultPoint::ColdCompaction(ColdCompactionFaultPoint::UploadAfterPutObject), boundary: FaultBoundary::WorkflowOnly, workload: ColdCompaction, value: &[0xc0], @@ -933,10 +948,7 @@ fn run_high_risk_fault_matrix_case(case: HighRiskFaultMatrixCase) -> Result<()> .profile(FaultProfile::Simple) .setup(create_kv_table) .faults(move |faults| { - faults - .at(fault_point) - .once() - .fail(fault_message)?; + faults.at(fault_point).once().fail(fault_message)?; Ok(()) }) .workload(move |ctx| { @@ -998,7 +1010,11 @@ fn run_high_risk_fault_matrix_case(case: HighRiskFaultMatrixCase) -> Result<()> final_settle: false, }) .await?; - assert!(result.attempted_job_kinds.contains(&CompactionJobKind::Cold)); + assert!( + result + .attempted_job_kinds + .contains(&CompactionJobKind::Cold) + ); assert!( result.terminal_error.is_some(), "{} should report a terminal cold compaction error: {result:?}", @@ -1037,7 +1053,11 @@ fn run_high_risk_fault_matrix_case(case: HighRiskFaultMatrixCase) -> Result<()> final_settle: false, }) .await?; - assert!(result.attempted_job_kinds.contains(&CompactionJobKind::Reclaim)); + assert!( + result + .attempted_job_kinds + .contains(&CompactionJobKind::Reclaim) + ); } } @@ -1063,7 +1083,8 @@ fn run_high_risk_fault_matrix_case(case: HighRiskFaultMatrixCase) -> Result<()> | HighRiskFaultMatrixWorkload::HotCompaction | HighRiskFaultMatrixWorkload::ColdCompaction | HighRiskFaultMatrixWorkload::Reclaim => { - assert_kv_rows(&ctx, vec![(verify_case.name, verify_case.expected_hex)]).await?; + assert_kv_rows(&ctx, vec![(verify_case.name, verify_case.expected_hex)]) + .await?; verify_simple_replay( &ctx, verify_case.seed, @@ -1099,9 +1120,7 @@ fn configure_heavy_page_size( } } -async fn assert_shard_boundary_page_count( - ctx: &super::scenario::FaultScenarioCtx, -) -> Result { +async fn assert_shard_boundary_page_count(ctx: &super::scenario::FaultScenarioCtx) -> Result { let pages = page_count(ctx).await?; let target = depot::keys::SHARD_SIZE * 2 + 2; assert!( @@ -1142,7 +1161,10 @@ async fn assert_kv_rows( .into_iter() .map(|(key, value)| vec![key.to_string(), value.to_string()]) .collect::>(); - assert_eq!(ctx.query("SELECT k, hex(v) FROM kv ORDER BY k;").await?, expected); + assert_eq!( + ctx.query("SELECT k, hex(v) FROM kv ORDER BY k;").await?, + expected + ); Ok(()) } diff --git a/engine/packages/depot-client/tests/inline/fault/verify.rs b/engine/packages/depot-client/tests/inline/fault/verify.rs index e46d9e1122..6261930722 100644 --- a/engine/packages/depot-client/tests/inline/fault/verify.rs +++ b/engine/packages/depot-client/tests/inline/fault/verify.rs @@ -109,7 +109,8 @@ impl<'a> InvariantScan<'a> { return Ok(()); }; let rows = self.check_branch_rows(branch_id, head.head_txid).await?; - self.check_pidx(branch_id, head.db_size_pages, &rows).await?; + self.check_pidx(branch_id, head.db_size_pages, &rows) + .await?; self.check_compaction_metadata(branch_id, head.head_txid, &rows) .await?; self.check_restore_points(branch_id).await?; @@ -131,8 +132,12 @@ impl<'a> InvariantScan<'a> { } } (Ok(_), Ok(_)) => {} - (Err(err), _) => self.violate(format!("database pointer key failed to decode: {err:#}")), - (_, Err(err)) => self.violate(format!("database pointer value failed to decode: {err:#}")), + (Err(err), _) => { + self.violate(format!("database pointer key failed to decode: {err:#}")) + } + (_, Err(err)) => { + self.violate(format!("database pointer value failed to decode: {err:#}")) + } } } @@ -165,14 +170,19 @@ impl<'a> InvariantScan<'a> { } } } - Err(err) => self.violate(format!("database branch record failed to decode: {err:#}")), + Err(err) => { + self.violate(format!("database branch record failed to decode: {err:#}")) + } }, None => self.violate("database branch record was missing"), } Ok(()) } - async fn check_live_head(&mut self, branch_id: DatabaseBranchId) -> Result> { + async fn check_live_head( + &mut self, + branch_id: DatabaseBranchId, + ) -> Result> { let Some(value) = get_value(self.tx, &keys::branch_meta_head_key(branch_id)).await? else { self.violate("live branch head row was missing"); return Ok(None); @@ -223,7 +233,10 @@ impl<'a> InvariantScan<'a> { ) -> Result> { let mut commits = BTreeMap::new(); for (key, value) in scan_prefix(self.tx, keys::branch_commit_prefix(branch_id)).await? { - match (decode_branch_commit_txid(branch_id, &key), decode_commit_row(&value)) { + match ( + decode_branch_commit_txid(branch_id, &key), + decode_commit_row(&value), + ) { (Ok(txid), Ok(row)) => { commits.insert(txid, row); } @@ -235,7 +248,9 @@ impl<'a> InvariantScan<'a> { let compacted_through = self.compacted_commit_floor(branch_id, head_txid).await?; for txid in compacted_through.saturating_add(1)..=head_txid { if !commits.contains_key(&txid) { - self.violate(format!("commit rows were not contiguous. Missing txid {txid}")); + self.violate(format!( + "commit rows were not contiguous. Missing txid {txid}" + )); } } for txid in commits.keys().copied() { @@ -251,7 +266,8 @@ impl<'a> InvariantScan<'a> { branch_id: DatabaseBranchId, head_txid: u64, ) -> Result { - let Some(value) = get_value(self.tx, &keys::branch_compaction_root_key(branch_id)).await? else { + let Some(value) = get_value(self.tx, &keys::branch_compaction_root_key(branch_id)).await? + else { return Ok(0); }; let root = match decode_compaction_root(&value) { @@ -279,7 +295,9 @@ impl<'a> InvariantScan<'a> { Ok(chunk_idx) => { chunks.entry(txid).or_default().insert(chunk_idx, value); } - Err(err) => self.violate(format!("delta chunk key failed to decode index: {err:#}")), + Err(err) => { + self.violate(format!("delta chunk key failed to decode index: {err:#}")) + } } } @@ -287,7 +305,9 @@ impl<'a> InvariantScan<'a> { for (txid, chunk_map) in chunks { for expected_idx in 0..u32::try_from(chunk_map.len()).unwrap_or(u32::MAX) { if !chunk_map.contains_key(&expected_idx) { - self.violate(format!("delta txid {txid} was missing chunk {expected_idx}")); + self.violate(format!( + "delta txid {txid} was missing chunk {expected_idx}" + )); } } @@ -315,7 +335,8 @@ impl<'a> InvariantScan<'a> { let mut shards = BTreeMap::new(); let compacted_through = self.compacted_commit_floor(branch_id, head_txid).await?; for (key, value) in scan_prefix(self.tx, keys::branch_shard_prefix(branch_id)).await? { - let Some((shard_id, as_of_txid)) = decode_branch_shard_version_key(branch_id, &key)? else { + let Some((shard_id, as_of_txid)) = decode_branch_shard_version_key(branch_id, &key)? + else { continue; }; match decode_ltx_v3(&value) { @@ -346,7 +367,12 @@ impl<'a> InvariantScan<'a> { ) -> Result> { let mut refs = Vec::new(); let mut seen = BTreeSet::new(); - for (key, value) in scan_prefix(self.tx, keys::branch_compaction_cold_shard_prefix(branch_id)).await? { + for (key, value) in scan_prefix( + self.tx, + keys::branch_compaction_cold_shard_prefix(branch_id), + ) + .await? + { let key_parts = match decode_cold_shard_key(branch_id, &key) { Ok(parts) => parts, Err(err) => { @@ -368,7 +394,9 @@ impl<'a> InvariantScan<'a> { self.violate("duplicate cold shard ref was present"); } if let Some(commit) = commits.get(&reference.as_of_txid) { - if reference.as_of_txid > reference.max_txid || reference.min_txid > reference.max_txid { + if reference.as_of_txid > reference.max_txid + || reference.min_txid > reference.max_txid + { self.violate("cold shard ref txid range was invalid"); } if reference.shard_id > commit.db_size_pages / keys::SHARD_SIZE { @@ -381,10 +409,7 @@ impl<'a> InvariantScan<'a> { )); } let pages = self.check_cold_object(&reference, commits).await?; - refs.push(ColdRefCoverage { - reference, - pages, - }); + refs.push(ColdRefCoverage { reference, pages }); } Ok(refs) } @@ -410,7 +435,12 @@ impl<'a> InvariantScan<'a> { let pages = match decode_ltx_v3(&bytes) { Ok(blob) => { self.check_ltx_pages("cold shard", reference.as_of_txid, &blob, commits); - self.check_ltx_shard_pages("cold shard", reference.shard_id, reference.as_of_txid, &blob); + self.check_ltx_shard_pages( + "cold shard", + reference.shard_id, + reference.as_of_txid, + &blob, + ); Some(blob.pages.iter().map(|page| page.pgno).collect()) } Err(err) => { @@ -443,10 +473,14 @@ impl<'a> InvariantScan<'a> { } }; if pgno == 0 || pgno > db_size_pages { - self.violate(format!("PIDX page {pgno} was outside database size {db_size_pages}")); + self.violate(format!( + "PIDX page {pgno} was outside database size {db_size_pages}" + )); } if !self.page_has_backing(pgno, owner_txid, rows) { - self.violate(format!("PIDX page {pgno} pointed at missing backing txid {owner_txid}")); + self.violate(format!( + "PIDX page {pgno} pointed at missing backing txid {owner_txid}" + )); } } Ok(()) @@ -458,7 +492,9 @@ impl<'a> InvariantScan<'a> { head_txid: u64, rows: &BranchRows, ) -> Result<()> { - if let Some(value) = get_value(self.tx, &keys::branch_compaction_root_key(branch_id)).await? { + if let Some(value) = + get_value(self.tx, &keys::branch_compaction_root_key(branch_id)).await? + { match decode_compaction_root(&value) { Ok(root) => { if root.schema_version == 0 { @@ -483,7 +519,12 @@ impl<'a> InvariantScan<'a> { } } - for (key, value) in scan_prefix(self.tx, keys::branch_compaction_retired_cold_object_prefix(branch_id)).await? { + for (key, value) in scan_prefix( + self.tx, + keys::branch_compaction_retired_cold_object_prefix(branch_id), + ) + .await? + { match decode_retired_cold_object(&value) { Ok(retired) => { let expected_key = keys::branch_compaction_retired_cold_object_key( @@ -501,7 +542,9 @@ impl<'a> InvariantScan<'a> { } } - for (key, value) in scan_prefix(self.tx, keys::branch_pitr_interval_prefix(branch_id)).await? { + for (key, value) in + scan_prefix(self.tx, keys::branch_pitr_interval_prefix(branch_id)).await? + { if let Err(err) = keys::decode_branch_pitr_interval_bucket(branch_id, &key) { self.violate(format!("PITR interval key failed to decode: {err:#}")); continue; @@ -522,13 +565,19 @@ impl<'a> InvariantScan<'a> { } async fn check_restore_points(&mut self, branch_id: DatabaseBranchId) -> Result<()> { - for (_key, value) in scan_prefix(self.tx, keys::restore_point_prefix(&self.database_id)).await? { + for (_key, value) in + scan_prefix(self.tx, keys::restore_point_prefix(&self.database_id)).await? + { match depot::types::decode_restore_point_record(&value) { Ok(record) => { if record.database_branch_id != branch_id { self.violate("restore point referenced a different database branch"); } - let vtx_value = get_value(self.tx, &keys::branch_vtx_key(branch_id, record.versionstamp)).await?; + let vtx_value = get_value( + self.tx, + &keys::branch_vtx_key(branch_id, record.versionstamp), + ) + .await?; if vtx_value.is_none() { self.violate("restore point versionstamp had no VTX row"); } @@ -550,9 +599,12 @@ impl<'a> InvariantScan<'a> { if pin.at_txid > head_txid { self.violate("history pin referenced a txid above branch head"); } - if get_value(self.tx, &keys::branch_vtx_key(branch_id, pin.at_versionstamp)) - .await? - .is_none() + if get_value( + self.tx, + &keys::branch_vtx_key(branch_id, pin.at_versionstamp), + ) + .await? + .is_none() { self.violate("history pin versionstamp had no VTX row"); } @@ -575,7 +627,10 @@ impl<'a> InvariantScan<'a> { for page in <x.pages { if let Some(db_size_pages) = db_size_pages { if page.pgno > db_size_pages { - self.violate(format!("{kind} txid {txid} page {} exceeded database size", page.pgno)); + self.violate(format!( + "{kind} txid {txid} page {} exceeded database size", + page.pgno + )); } } else { self.violate(format!("{kind} txid {txid} had no matching commit row")); @@ -588,14 +643,19 @@ impl<'a> InvariantScan<'a> { self.violate(format!("{kind} txid {txid} had unexpected page size")); } if ltx.header.min_txid > txid || ltx.header.max_txid < txid { - self.violate(format!("{kind} txid {txid} header did not cover its key txid")); + self.violate(format!( + "{kind} txid {txid} header did not cover its key txid" + )); } for page in <x.pages { if page.pgno == 0 { self.violate(format!("{kind} txid {txid} contained page 0")); } if page.bytes.len() != keys::PAGE_SIZE as usize { - self.violate(format!("{kind} txid {txid} page {} had invalid size", page.pgno)); + self.violate(format!( + "{kind} txid {txid} page {} had invalid size", + page.pgno + )); } } } @@ -633,8 +693,7 @@ impl<'a> InvariantScan<'a> { candidate_shard == shard_id && as_of_txid >= owner_txid && shard.get_page(pgno).is_some() - }) - { + }) { return true; } rows.cold_refs.iter().any(|reference| { @@ -878,7 +937,8 @@ fn depot_invariant_scanner_detects_cold_ref_missing_referenced_page() -> Result< .await .expect_err("scanner should reject cold refs missing the PIDX page"); assert!( - err.to_string().contains("PIDX page 1 pointed at missing backing"), + err.to_string() + .contains("PIDX page 1 pointed at missing backing"), "unexpected error: {err:#}" ); Ok(()) diff --git a/engine/packages/depot-client/tests/inline/fault/workload.rs b/engine/packages/depot-client/tests/inline/fault/workload.rs index 3a76bb6a4d..6a939ee1d3 100644 --- a/engine/packages/depot-client/tests/inline/fault/workload.rs +++ b/engine/packages/depot-client/tests/inline/fault/workload.rs @@ -8,9 +8,14 @@ use std::ptr; #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) enum LogicalOp { - Put { key: String, value: Vec }, + Put { + key: String, + value: Vec, + }, #[allow(dead_code)] - Delete { key: String }, + Delete { + key: String, + }, CreateHeavySchema, InsertHeavyBlob { id: i64, @@ -54,8 +59,9 @@ impl LogicalOp { LogicalOp::ExplicitRollbackInsert { id, payload_len } => { explicit_rollback_insert(db, *id, *payload_len) } - LogicalOp::Vacuum => super::super::sqlite_exec(db, "VACUUM;") - .map_err(anyhow::Error::msg), + LogicalOp::Vacuum => { + super::super::sqlite_exec(db, "VACUUM;").map_err(anyhow::Error::msg) + } LogicalOp::Sql(sql) => super::super::sqlite_exec(db, sql).map_err(anyhow::Error::msg), } } @@ -150,7 +156,10 @@ fn prepare(db: *mut sqlite3, sql: &str) -> Result<*mut sqlite3_stmt> { 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 { - bail!("{sql} prepare failed with code {rc}: {}", sqlite_error_message(db)); + bail!( + "{sql} prepare failed with code {rc}: {}", + sqlite_error_message(db) + ); } Ok(stmt) } @@ -201,7 +210,10 @@ fn step_done(db: *mut sqlite3, stmt: *mut sqlite3_stmt) -> Result<()> { sqlite3_finalize(stmt); } if rc != libsqlite3_sys::SQLITE_DONE { - bail!("sqlite step failed with code {rc}: {}", sqlite_error_message(db)); + bail!( + "sqlite step failed with code {rc}: {}", + sqlite_error_message(db) + ); } Ok(()) } diff --git a/engine/packages/depot-client/tests/inline/vfs.rs b/engine/packages/depot-client/tests/inline/vfs.rs index 8b9a5d7b4b..8847ec5214 100644 --- a/engine/packages/depot-client/tests/inline/vfs.rs +++ b/engine/packages/depot-client/tests/inline/vfs.rs @@ -1,3707 +1,4082 @@ - mod vfs_support; - mod fault; +mod fault; +mod vfs_support; + +pub(super) use vfs_support::{ + DirectStorage, DirectStorageStats, DirectTransportHooks, protocol_fetched_page, + sqlite_error_response, storage_dirty_page, +}; + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Barrier, mpsc}; +use std::thread; +use std::time::Duration; + +use depot::cold_tier::FilesystemColdTier; +use parking_lot::Mutex as SyncMutex; +use tempfile::TempDir; +use tokio::runtime::Builder; +use tokio::sync::OnceCell; + +use crate::query::{BindParam, ColumnValue}; +use crate::vfs::SqliteVfsMetrics; + +use super::*; + +static TEST_ID: AtomicU64 = AtomicU64::new(1); + +fn next_test_name(prefix: &str) -> String { + let id = TEST_ID.fetch_add(1, Ordering::Relaxed); + format!("{prefix}-{id}") +} + +struct DirectEngineHarness { + actor_id: String, + db_dir: TempDir, + cold_dir: Option, + storage: OnceCell>, +} + +impl DirectEngineHarness { + fn new() -> Self { + Self { + actor_id: next_test_name("sqlite-direct-actor"), + db_dir: tempfile::tempdir().expect("temp dir should build"), + cold_dir: None, + storage: OnceCell::new(), + } + } + + fn new_with_cold_tier() -> Self { + Self { + actor_id: next_test_name("sqlite-direct-actor"), + db_dir: tempfile::tempdir().expect("temp dir should build"), + cold_dir: Some(tempfile::tempdir().expect("cold temp dir should build")), + storage: OnceCell::new(), + } + } + + async fn open_engine(&self) -> Arc { + // RocksDB enforces one open handle per path, so initialization must be atomic. + let storage = self + .storage + .get_or_init(|| async { + let driver = universaldb::driver::RocksDbDatabaseDriver::new( + self.db_dir.path().to_path_buf(), + ) + .await + .expect("rocksdb driver should build"); + let db = universaldb::Database::new(Arc::new(driver)); + + Arc::new(if let Some(cold_dir) = &self.cold_dir { + DirectStorage::new_with_cold_tier( + db, + Arc::new(FilesystemColdTier::new(cold_dir.path())), + ) + } else { + DirectStorage::new(db) + }) + }) + .await; + Arc::clone(storage) + } - pub(super) use vfs_support::{ - DirectStorage, DirectStorageStats, DirectTransportHooks, protocol_fetched_page, - sqlite_error_response, storage_dirty_page, - }; + fn open_db_on_engine( + &self, + runtime: &tokio::runtime::Runtime, + engine: Arc, + actor_id: &str, + config: VfsConfig, + ) -> NativeDatabase { + let vfs = SqliteVfs::register_with_transport( + &next_test_name("sqlite-direct-vfs"), + SqliteTransport::from_direct(engine), + actor_id.to_string(), + runtime.handle().clone(), + config, + None, + ) + .expect("v2 vfs should register"); - use std::sync::atomic::{AtomicU64, Ordering}; - use std::sync::{Arc, Barrier, mpsc}; - use std::thread; - use std::time::Duration; + open_database(vfs, actor_id).expect("sqlite database should open") + } - use depot::cold_tier::FilesystemColdTier; - use parking_lot::Mutex as SyncMutex; - use tempfile::TempDir; - use tokio::runtime::Builder; - use tokio::sync::OnceCell; + fn open_db(&self, runtime: &tokio::runtime::Runtime) -> NativeDatabase { + let engine = runtime.block_on(self.open_engine()); + self.open_db_on_engine(runtime, engine, &self.actor_id, VfsConfig::default()) + } - use super::*; + fn open_context(&self, runtime: &tokio::runtime::Runtime) -> VfsContext { + let engine = runtime.block_on(self.open_engine()); + VfsContext::new( + self.actor_id.clone(), + runtime.handle().clone(), + SqliteTransport::from_direct(engine), + VfsConfig::default(), + unsafe { std::mem::zeroed() }, + None, + None, + ) + .expect("vfs context should build") + } +} + +fn direct_vfs_ctx(db: &NativeDatabase) -> &VfsContext { + db._vfs.ctx() +} + +fn open_worker_handle( + runtime: &tokio::runtime::Runtime, + harness: &DirectEngineHarness, +) -> crate::database::NativeDatabaseHandle { + open_worker_handle_with_metrics(runtime, harness, None) +} + +fn open_worker_handle_with_metrics( + runtime: &tokio::runtime::Runtime, + harness: &DirectEngineHarness, + metrics: Option>, +) -> crate::database::NativeDatabaseHandle { + let engine = runtime.block_on(harness.open_engine()); + let vfs = Arc::new( + SqliteVfs::register_with_transport( + &next_test_name("sqlite-worker-vfs"), + SqliteTransport::from_direct(engine), + harness.actor_id.clone(), + runtime.handle().clone(), + VfsConfig::default(), + None, + ) + .expect("worker vfs should register"), + ); + crate::database::NativeDatabaseHandle::new_with_metrics(vfs, harness.actor_id.clone(), metrics) + .expect("worker handle should start") +} - static TEST_ID: AtomicU64 = AtomicU64::new(1); +#[derive(Default)] +struct WorkerTestMetrics { + queue_depth: AtomicU64, + overloads: AtomicU64, + command_durations: AtomicU64, + command_errors: AtomicU64, + close_durations: AtomicU64, + crashes: AtomicU64, + unclean_closes: AtomicU64, +} - fn next_test_name(prefix: &str) -> String { - let id = TEST_ID.fetch_add(1, Ordering::Relaxed); - format!("{prefix}-{id}") +impl SqliteVfsMetrics for WorkerTestMetrics { + fn set_worker_queue_depth(&self, depth: u64) { + self.queue_depth.store(depth, Ordering::Release); } - struct DirectEngineHarness { - actor_id: String, - db_dir: TempDir, - cold_dir: Option, - storage: OnceCell>, + fn record_worker_queue_overload(&self) { + self.overloads.fetch_add(1, Ordering::AcqRel); } - impl DirectEngineHarness { - fn new() -> Self { - Self { - actor_id: next_test_name("sqlite-direct-actor"), - db_dir: tempfile::tempdir().expect("temp dir should build"), - cold_dir: None, - storage: OnceCell::new(), - } - } + fn observe_worker_command_duration(&self, _operation: &'static str, _duration_ns: u64) { + self.command_durations.fetch_add(1, Ordering::AcqRel); + } - fn new_with_cold_tier() -> Self { - Self { - actor_id: next_test_name("sqlite-direct-actor"), - db_dir: tempfile::tempdir().expect("temp dir should build"), - cold_dir: Some(tempfile::tempdir().expect("cold temp dir should build")), - storage: OnceCell::new(), - } - } + fn record_worker_command_error(&self, _operation: &'static str, _code: &'static str) { + self.command_errors.fetch_add(1, Ordering::AcqRel); + } - async fn open_engine(&self) -> Arc { - // RocksDB enforces one open handle per path, so initialization must be atomic. - let storage = self - .storage - .get_or_init(|| async { - let driver = universaldb::driver::RocksDbDatabaseDriver::new( - self.db_dir.path().to_path_buf(), - ) - .await - .expect("rocksdb driver should build"); - let db = universaldb::Database::new(Arc::new(driver)); - - Arc::new(if let Some(cold_dir) = &self.cold_dir { - DirectStorage::new_with_cold_tier( - db, - Arc::new(FilesystemColdTier::new(cold_dir.path())), - ) - } else { - DirectStorage::new(db) - }) - }) - .await; - Arc::clone(storage) - } + fn observe_worker_close_duration(&self, _duration_ns: u64) { + self.close_durations.fetch_add(1, Ordering::AcqRel); + } - fn open_db_on_engine( - &self, - runtime: &tokio::runtime::Runtime, - engine: Arc, - actor_id: &str, - config: VfsConfig, - ) -> NativeDatabase { - let vfs = SqliteVfs::register_with_transport( - &next_test_name("sqlite-direct-vfs"), - SqliteTransport::from_direct(engine), - actor_id.to_string(), - runtime.handle().clone(), - config, - None, - ) - .expect("v2 vfs should register"); + fn record_worker_crash(&self) { + self.crashes.fetch_add(1, Ordering::AcqRel); + } - open_database(vfs, actor_id).expect("sqlite database should open") - } + fn record_worker_unclean_close(&self) { + self.unclean_closes.fetch_add(1, Ordering::AcqRel); + } +} - fn open_db(&self, runtime: &tokio::runtime::Runtime) -> NativeDatabase { - let engine = runtime.block_on(self.open_engine()); - self.open_db_on_engine(runtime, engine, &self.actor_id, VfsConfig::default()) - } +async fn wait_worker_queue_depth(metrics: &WorkerTestMetrics, depth: u64) { + tokio::time::timeout(Duration::from_secs(5), async { + loop { + if metrics.queue_depth.load(Ordering::Acquire) >= depth { + return; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("worker queue should reach expected depth"); +} + +async fn wait_worker_closing(db: &crate::database::NativeDatabaseHandle) { + tokio::time::timeout(Duration::from_secs(5), async { + loop { + if db.is_closing_for_test() { + return; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("worker should enter closing state"); +} + +async fn wait_worker_unclean_close(metrics: &WorkerTestMetrics) { + tokio::time::timeout(Duration::from_secs(5), async { + loop { + if metrics.unclean_closes.load(Ordering::Acquire) >= 1 { + return; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("worker should record unclean close"); +} + +#[test] +fn worker_preserves_connection_affine_state() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let db = open_worker_handle(&runtime, &harness); + + runtime.block_on(async { + db.execute( + "CREATE TEMP TABLE temp_items(id INTEGER PRIMARY KEY, label TEXT);".to_owned(), + None, + ) + .await + .expect("temp table should be created"); + db.execute( + "INSERT INTO temp_items(label) VALUES (?);".to_owned(), + Some(vec![BindParam::Text("alpha".to_owned())]), + ) + .await + .expect("insert should succeed"); - fn open_context(&self, runtime: &tokio::runtime::Runtime) -> VfsContext { - let engine = runtime.block_on(self.open_engine()); - VfsContext::new( - self.actor_id.clone(), - runtime.handle().clone(), - SqliteTransport::from_direct(engine), - VfsConfig::default(), - unsafe { std::mem::zeroed() }, + let result = db + .execute( + "SELECT last_insert_rowid(), label FROM temp_items;".to_owned(), None, ) - .expect("vfs context should build") - } - } + .await + .expect("connection-affine query should succeed"); + assert_eq!( + result.rows, + vec![vec![ + ColumnValue::Integer(1), + ColumnValue::Text("alpha".to_owned()), + ]] + ); - fn direct_vfs_ctx(db: &NativeDatabase) -> &VfsContext { - db._vfs.ctx() - } + db.close().await.expect("worker should close"); + }); +} - fn sqlite_query_i64(db: *mut sqlite3, sql: &str) -> std::result::Result { - 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) - )); +#[test] +fn worker_executes_commands_in_send_order() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let db = open_worker_handle(&runtime, &harness); + + runtime.block_on(async { + db.exec("CREATE TABLE items(id INTEGER PRIMARY KEY, label TEXT);".to_owned()) + .await + .expect("table should be created"); + let first = db.execute( + "INSERT INTO items(label) VALUES ('first');".to_owned(), + None, + ); + let second = db.execute( + "INSERT INTO items(label) VALUES ('second');".to_owned(), + None, + ); + let (first, second) = tokio::join!(first, second); + first.expect("first insert should succeed"); + second.expect("second insert should succeed"); + + let result = db + .query("SELECT label FROM items ORDER BY id;".to_owned(), None) + .await + .expect("query should succeed"); + assert_eq!( + result.rows, + vec![ + vec![ColumnValue::Text("first".to_owned())], + vec![ColumnValue::Text("second".to_owned())], + ] + ); + + db.close().await.expect("worker should close"); + }); +} + +#[test] +fn worker_close_rejects_new_work() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let db = open_worker_handle(&runtime, &harness); + + runtime.block_on(async { + db.close().await.expect("worker should close"); + let error = db + .query("SELECT 1;".to_owned(), None) + .await + .expect_err("closed worker should reject work"); + assert!( + error.to_string().contains("sqlite worker is closing") + || error.to_string().contains("sqlite worker is closed"), + "unexpected error: {error}" + ); + }); +} + +#[test] +fn worker_queue_full_returns_actor_overloaded() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let metrics = Arc::new(WorkerTestMetrics::default()); + let db = open_worker_handle_with_metrics( + &runtime, + &harness, + Some(metrics.clone() as Arc), + ); + + runtime.block_on(async { + let resume = db.pause_for_test().await; + let mut pending = Vec::new(); + for _ in 0..crate::worker::SQLITE_WORKER_QUEUE_CAPACITY { + let db = db.clone(); + pending.push(tokio::spawn(async move { + db.query("SELECT 1;".to_owned(), None).await + })); } - if stmt.is_null() { - return Err(format!("`{sql}` returned no statement")); + wait_worker_queue_depth(&metrics, crate::worker::SQLITE_WORKER_QUEUE_CAPACITY as u64).await; + + let error = db + .query("SELECT 2;".to_owned(), None) + .await + .expect_err("full worker queue should reject new work"); + assert!(error.to_string().contains("actor.overloaded")); + assert_eq!(metrics.overloads.load(Ordering::Acquire), 1); + + let _ = resume.send(()); + for task in pending { + task.await + .expect("queued query task should join") + .expect("queued query should run after pause"); + } + db.close().await.expect("worker should close"); + }); +} + +#[test] +fn worker_close_bypasses_full_queue_and_fails_queued_work() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let metrics = Arc::new(WorkerTestMetrics::default()); + let db = open_worker_handle_with_metrics( + &runtime, + &harness, + Some(metrics.clone() as Arc), + ); + + runtime.block_on(async { + db.exec("CREATE TABLE items(id INTEGER PRIMARY KEY, label TEXT);".to_owned()) + .await + .expect("table should be created"); + let resume = db.pause_for_test().await; + let mut pending = Vec::new(); + for idx in 0..crate::worker::SQLITE_WORKER_QUEUE_CAPACITY { + let db = db.clone(); + pending.push(tokio::spawn(async move { + db.execute( + format!("INSERT INTO items(label) VALUES ('queued-{idx}');"), + None, + ) + .await + })); } + wait_worker_queue_depth(&metrics, crate::worker::SQLITE_WORKER_QUEUE_CAPACITY as u64).await; + + let close_db = db.clone(); + let close_task = tokio::spawn(async move { close_db.close().await }); + wait_worker_closing(&db).await; + let error = db + .query("SELECT 1;".to_owned(), None) + .await + .expect_err("closing worker should reject new work"); + assert!(error.to_string().contains("sqlite worker is closing")); + + let _ = resume.send(()); + close_task + .await + .expect("close task should join") + .expect("worker should close"); + for task in pending { + let error = task + .await + .expect("queued insert task should join") + .expect_err("queued insert should fail after close starts"); + assert!(error.to_string().contains("sqlite worker is closing")); + } + }); +} + +#[test] +fn worker_close_is_idempotent_across_clones() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let db = open_worker_handle(&runtime, &harness); + let clone = db.clone(); + + runtime.block_on(async { + let (first, second) = tokio::join!(db.close(), clone.close()); + first.expect("first close should succeed"); + second.expect("second close should succeed"); + }); +} + +#[test] +fn worker_drop_without_close_records_unclean_close() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let metrics = Arc::new(WorkerTestMetrics::default()); + let db = open_worker_handle_with_metrics( + &runtime, + &harness, + Some(metrics.clone() as Arc), + ); + + runtime.block_on(async { + db.query("SELECT 1;".to_owned(), None) + .await + .expect("worker should accept work before drop"); + drop(db); + wait_worker_unclean_close(&metrics).await; + }); +} + +#[test] +fn worker_panic_marks_worker_dead() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let metrics = Arc::new(WorkerTestMetrics::default()); + let db = open_worker_handle_with_metrics( + &runtime, + &harness, + Some(metrics.clone() as Arc), + ); + + runtime.block_on(async { + db.query("SELECT 1;".to_owned(), None) + .await + .expect("worker should accept work before panic"); + db.panic_worker_for_test().await; + + let error = db + .query("SELECT 1;".to_owned(), None) + .await + .expect_err("dead worker should reject work"); + assert!(error.to_string().contains("sqlite worker is closed")); + }); + + assert_eq!(metrics.crashes.load(Ordering::Acquire), 1); +} + +#[test] +fn worker_records_basic_metrics() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let metrics = Arc::new(WorkerTestMetrics::default()); + let db = open_worker_handle_with_metrics( + &runtime, + &harness, + Some(metrics.clone() as Arc), + ); + + runtime.block_on(async { + db.query("SELECT 1;".to_owned(), None) + .await + .expect("query should succeed"); + db.close().await.expect("worker should close"); + }); + + assert!(metrics.command_durations.load(Ordering::Acquire) >= 1); + assert!(metrics.close_durations.load(Ordering::Acquire) >= 1); + assert_eq!(metrics.command_errors.load(Ordering::Acquire), 0); +} + +fn sqlite_query_i64(db: *mut sqlite3, sql: &str) -> std::result::Result { + 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 result = match unsafe { sqlite3_step(stmt) } { - SQLITE_ROW => Ok(unsafe { sqlite3_column_int64(stmt, 0) }), - step_rc => Err(format!( - "`{sql}` step failed with code {step_rc}: {}", - sqlite_error_message(db) - )), - }; - - unsafe { - sqlite3_finalize(stmt); - } + let result = match unsafe { sqlite3_step(stmt) } { + SQLITE_ROW => Ok(unsafe { sqlite3_column_int64(stmt, 0) }), + step_rc => Err(format!( + "`{sql}` step failed with code {step_rc}: {}", + sqlite_error_message(db) + )), + }; - result + unsafe { + sqlite3_finalize(stmt); } - fn sqlite_query_text(db: *mut sqlite3, sql: &str) -> std::result::Result { - 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")); - } + result +} - let result = match unsafe { sqlite3_step(stmt) } { - SQLITE_ROW => { - let text_ptr = unsafe { sqlite3_column_text(stmt, 0) }; - if text_ptr.is_null() { - Ok(String::new()) - } else { - Ok(unsafe { CStr::from_ptr(text_ptr.cast()) } - .to_string_lossy() - .into_owned()) - } - } - step_rc => Err(format!( - "`{sql}` step failed with code {step_rc}: {}", - sqlite_error_message(db) - )), - }; +fn sqlite_query_text(db: *mut sqlite3, sql: &str) -> std::result::Result { + 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")); + } - unsafe { - sqlite3_finalize(stmt); + let result = match unsafe { sqlite3_step(stmt) } { + SQLITE_ROW => { + let text_ptr = unsafe { sqlite3_column_text(stmt, 0) }; + if text_ptr.is_null() { + Ok(String::new()) + } else { + Ok(unsafe { CStr::from_ptr(text_ptr.cast()) } + .to_string_lossy() + .into_owned()) + } } + step_rc => Err(format!( + "`{sql}` step failed with code {step_rc}: {}", + sqlite_error_message(db) + )), + }; - result + unsafe { + sqlite3_finalize(stmt); } - 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()) }; - if rc != SQLITE_OK { - return Err(format!( - "sqlite3_file_control op {op} failed with code {rc}: {}", - sqlite_error_message(db) - )); - } + result +} - Ok(rc) +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()) }; + if rc != SQLITE_OK { + return Err(format!( + "sqlite3_file_control op {op} failed with code {rc}: {}", + sqlite_error_message(db) + )); } - fn direct_runtime() -> tokio::runtime::Runtime { - Builder::new_multi_thread() - .worker_threads(2) - .enable_all() - .build() - .expect("runtime should build") - } + Ok(rc) +} - #[test] - fn predictor_prefers_stride_after_repeated_reads() { - let mut predictor = PrefetchPredictor::default(); - for pgno in [5, 8, 11, 14] { - predictor.record(pgno); - } +fn direct_runtime() -> tokio::runtime::Runtime { + Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build() + .expect("runtime should build") +} - assert_eq!(predictor.multi_predict(14, 3, 30), vec![17, 20, 23]); +#[test] +fn predictor_prefers_stride_after_repeated_reads() { + let mut predictor = PrefetchPredictor::default(); + for pgno in [5, 8, 11, 14] { + predictor.record(pgno); } - #[test] - fn direct_engine_open_engine_is_concurrency_safe() { - let runtime = direct_runtime(); - let handle = runtime.handle().clone(); - let harness = Arc::new(DirectEngineHarness::new()); - let barrier = Arc::new(Barrier::new(8)); - - thread::scope(|scope| { - let mut workers = Vec::new(); - for _ in 0..8 { - let handle = handle.clone(); - let harness = Arc::clone(&harness); - let barrier = Arc::clone(&barrier); - workers.push(scope.spawn(move || { - barrier.wait(); - handle.block_on(harness.open_engine()) - })); - } + assert_eq!(predictor.multi_predict(14, 3, 30), vec![17, 20, 23]); +} - let first = workers - .pop() - .expect("at least one worker should exist") - .join() - .expect("worker should open engine"); - for worker in workers { - let storage = worker.join().expect("worker should open engine"); - assert!( - Arc::ptr_eq(&first, &storage), - "all concurrent callers should share one direct storage", - ); - } - }); - } +#[test] +fn direct_engine_open_engine_is_concurrency_safe() { + let runtime = direct_runtime(); + let handle = runtime.handle().clone(); + let harness = Arc::new(DirectEngineHarness::new()); + let barrier = Arc::new(Barrier::new(8)); - #[test] - fn direct_engine_supports_create_insert_select_and_user_version() { - let runtime = direct_runtime(); - let harness = DirectEngineHarness::new(); - let db = harness.open_db(&runtime); + thread::scope(|scope| { + let mut workers = Vec::new(); + for _ in 0..8 { + let handle = handle.clone(); + let harness = Arc::clone(&harness); + let barrier = Arc::clone(&barrier); + workers.push(scope.spawn(move || { + barrier.wait(); + handle.block_on(harness.open_engine()) + })); + } - assert_eq!( - sqlite_file_control(db.as_ptr(), SQLITE_FCNTL_BEGIN_ATOMIC_WRITE) - .expect("batch atomic begin should succeed"), - SQLITE_OK - ); - assert_eq!( - sqlite_file_control(db.as_ptr(), SQLITE_FCNTL_COMMIT_ATOMIC_WRITE) - .expect("batch atomic commit should succeed"), - SQLITE_OK - ); + let first = workers + .pop() + .expect("at least one worker should exist") + .join() + .expect("worker should open engine"); + for worker in workers { + let storage = worker.join().expect("worker should open engine"); + assert!( + Arc::ptr_eq(&first, &storage), + "all concurrent callers should share one direct storage", + ); + } + }); +} - sqlite_exec( - db.as_ptr(), - "CREATE TABLE items (id INTEGER PRIMARY KEY, value TEXT NOT NULL);", - ) - .expect("create table should succeed"); +#[test] +fn vfs_register_inside_runtime_worker_does_not_block_on_current_thread() { + let runtime = direct_runtime(); + runtime.block_on(async { + let harness = Arc::new(DirectEngineHarness::new()); + let engine = harness.open_engine().await; + engine.enable_strict_mode(); + let actor_id = harness.actor_id.clone(); + let runtime = tokio::runtime::Handle::current(); + + tokio::task::spawn(async move { + let vfs = SqliteVfs::register_with_transport( + &next_test_name("sqlite-runtime-worker-vfs"), + SqliteTransport::from_direct(engine), + actor_id, + runtime, + VfsConfig::default(), + None, + ) + .expect("vfs should register from a runtime worker"); + drop(vfs); + }) + .await + .expect("runtime worker task should finish"); + }); +} + +#[test] +fn direct_engine_supports_create_insert_select_and_user_version() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let db = harness.open_db(&runtime); + + assert_eq!( + sqlite_file_control(db.as_ptr(), SQLITE_FCNTL_BEGIN_ATOMIC_WRITE) + .expect("batch atomic begin should succeed"), + SQLITE_OK + ); + assert_eq!( + sqlite_file_control(db.as_ptr(), SQLITE_FCNTL_COMMIT_ATOMIC_WRITE) + .expect("batch atomic commit should succeed"), + SQLITE_OK + ); + + sqlite_exec( + db.as_ptr(), + "CREATE TABLE items (id INTEGER PRIMARY KEY, value TEXT NOT NULL);", + ) + .expect("create table should succeed"); + sqlite_step_statement( + db.as_ptr(), + "INSERT INTO items (id, value) VALUES (1, 'alpha');", + ) + .expect("insert should succeed"); + sqlite_exec(db.as_ptr(), "PRAGMA user_version = 42;") + .expect("user_version pragma should succeed"); + + assert_eq!( + sqlite_query_text(db.as_ptr(), "SELECT value FROM items WHERE id = 1;") + .expect("select should succeed"), + "alpha" + ); + assert_eq!( + sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM items;").expect("count should succeed"), + 1 + ); + assert_eq!( + sqlite_query_i64(db.as_ptr(), "PRAGMA user_version;") + .expect("user_version read should succeed"), + 42 + ); +} + +#[test] +fn direct_engine_handles_large_rows_and_multi_page_growth() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let db = harness.open_db(&runtime); + + sqlite_exec( + db.as_ptr(), + "CREATE TABLE blobs (id INTEGER PRIMARY KEY, payload BLOB NOT NULL);", + ) + .expect("create table should succeed"); + + for _ in 0..48 { sqlite_step_statement( db.as_ptr(), - "INSERT INTO items (id, value) VALUES (1, 'alpha');", - ) - .expect("insert should succeed"); - sqlite_exec(db.as_ptr(), "PRAGMA user_version = 42;") - .expect("user_version pragma should succeed"); - - assert_eq!( - sqlite_query_text(db.as_ptr(), "SELECT value FROM items WHERE id = 1;") - .expect("select should succeed"), - "alpha" - ); - assert_eq!( - sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM items;") - .expect("count should succeed"), - 1 - ); - assert_eq!( - sqlite_query_i64(db.as_ptr(), "PRAGMA user_version;") - .expect("user_version read should succeed"), - 42 - ); - } - - #[test] - fn direct_engine_handles_large_rows_and_multi_page_growth() { - let runtime = direct_runtime(); - let harness = DirectEngineHarness::new(); + "INSERT INTO blobs (payload) VALUES (randomblob(3500));", + ) + .expect("seed insert should succeed"); + } + sqlite_step_statement( + db.as_ptr(), + "INSERT INTO blobs (payload) VALUES (randomblob(9000));", + ) + .expect("large row insert should succeed"); + + assert_eq!( + sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM blobs;").expect("count should succeed"), + 49 + ); + assert!( + sqlite_query_i64(db.as_ptr(), "PRAGMA page_count;").expect("page_count should succeed") + > 20 + ); + assert!( + sqlite_query_i64(db.as_ptr(), "SELECT max(length(payload)) FROM blobs;") + .expect("max payload length should succeed") + >= 9000 + ); +} + +#[test] +fn direct_engine_persists_data_across_close_and_reopen() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + + { let db = harness.open_db(&runtime); - sqlite_exec( db.as_ptr(), - "CREATE TABLE blobs (id INTEGER PRIMARY KEY, payload BLOB NOT NULL);", + "CREATE TABLE events (id INTEGER PRIMARY KEY, value TEXT NOT NULL);", ) .expect("create table should succeed"); - - for _ in 0..48 { - sqlite_step_statement( - db.as_ptr(), - "INSERT INTO blobs (payload) VALUES (randomblob(3500));", - ) - .expect("seed insert should succeed"); - } sqlite_step_statement( db.as_ptr(), - "INSERT INTO blobs (payload) VALUES (randomblob(9000));", + "INSERT INTO events (id, value) VALUES (1, 'persisted');", ) - .expect("large row insert should succeed"); - - assert_eq!( - sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM blobs;") - .expect("count should succeed"), - 49 - ); - assert!( - sqlite_query_i64(db.as_ptr(), "PRAGMA page_count;").expect("page_count should succeed") - > 20 - ); - assert!( - sqlite_query_i64(db.as_ptr(), "SELECT max(length(payload)) FROM blobs;") - .expect("max payload length should succeed") - >= 9000 - ); - } - - #[test] - fn direct_engine_persists_data_across_close_and_reopen() { - let runtime = direct_runtime(); - let harness = DirectEngineHarness::new(); - - { - let db = harness.open_db(&runtime); - sqlite_exec( - db.as_ptr(), - "CREATE TABLE events (id INTEGER PRIMARY KEY, value TEXT NOT NULL);", - ) - .expect("create table should succeed"); - sqlite_step_statement( - db.as_ptr(), - "INSERT INTO events (id, value) VALUES (1, 'persisted');", - ) - .expect("insert should succeed"); - sqlite_exec(db.as_ptr(), "PRAGMA user_version = 7;") - .expect("user_version write should succeed"); - } - - let reopened = harness.open_db(&runtime); - assert_eq!( - sqlite_query_i64(reopened.as_ptr(), "SELECT COUNT(*) FROM events;") - .expect("count after reopen should succeed"), - 1 - ); - assert_eq!( - sqlite_query_text(reopened.as_ptr(), "SELECT value FROM events WHERE id = 1;") - .expect("value after reopen should succeed"), - "persisted" - ); - assert_eq!( - sqlite_query_i64(reopened.as_ptr(), "PRAGMA user_version;") - .expect("user_version after reopen should succeed"), - 7 - ); - } - - #[test] - fn strict_direct_reopen_ignores_poisoned_mirror_and_reads_depot() { - let runtime = direct_runtime(); - let harness = DirectEngineHarness::new(); - let page_count; - - { - let db = harness.open_db(&runtime); - sqlite_exec(db.as_ptr(), "PRAGMA user_version = 2718;") - .expect("user_version write should succeed"); - page_count = sqlite_query_i64(db.as_ptr(), "PRAGMA page_count;") - .expect("page count should succeed") as u32; - } - - let engine = runtime.block_on(harness.open_engine()); - runtime.block_on(engine.poison_mirror_page( - &harness.actor_id, - 1, - vec![0xdb; 4096], - page_count, - )); - engine.enable_strict_mode(); - runtime.block_on(engine.evict_actor_db(&harness.actor_id)); - - let before = engine.stats(); - let reopened = harness.open_db(&runtime); - assert_eq!( - sqlite_query_i64(reopened.as_ptr(), "PRAGMA user_version;") - .expect("strict reopen should read depot state"), - 2718 - ); - let after = engine.stats(); - assert!(after.depot_get_pages > before.depot_get_pages); - assert_eq!(after.mirror_reads, before.mirror_reads); - assert_eq!(after.mirror_fills, before.mirror_fills); - assert_eq!(after.mirror_seeds, before.mirror_seeds); - } - - #[test] - fn strict_direct_mode_rejects_mirror_fallback_and_seed_paths() { - let runtime = direct_runtime(); - let harness = DirectEngineHarness::new(); - let engine = runtime.block_on(harness.open_engine()); - - runtime - .block_on(engine.apply_commit( - &harness.actor_id, - vec![storage_dirty_page(protocol::SqliteDirtyPage { - pgno: 1, - bytes: empty_db_page(), - })], - 1, - )) - .expect("non-strict mirror seed should succeed"); - - engine.enable_strict_mode(); - runtime.block_on(engine.evict_actor_db(&harness.actor_id)); - let before = engine.stats(); - let err = runtime - .block_on(engine.get_pages(&harness.actor_id, &[1])) - .expect_err("strict mode should not read from the mirror"); - assert!(!err.to_string().is_empty()); - let after = engine.stats(); - assert_eq!(after.mirror_reads, before.mirror_reads); - assert_eq!(after.mirror_fills, before.mirror_fills); - - let err = runtime - .block_on(engine.apply_commit( - &harness.actor_id, - vec![storage_dirty_page(protocol::SqliteDirtyPage { - pgno: 1, - bytes: empty_db_page(), - })], - 1, - )) - .expect_err("strict mode should reject mirror seed"); - assert!(err.to_string().contains("forbids mirror-backed cache seeding")); - } - - #[test] - fn strict_direct_reopen_counts_cold_tier_get_for_cold_covered_page() { - let runtime = direct_runtime(); - let harness = DirectEngineHarness::new_with_cold_tier(); - let page_count; - - { - let db = harness.open_db(&runtime); - sqlite_exec(db.as_ptr(), "PRAGMA user_version = 808;") - .expect("user_version write should succeed"); - page_count = sqlite_query_i64(db.as_ptr(), "PRAGMA page_count;") - .expect("page count should succeed") as u32; - } - - let engine = runtime.block_on(harness.open_engine()); - let page = runtime - .block_on(engine.snapshot_pages(&harness.actor_id)) - .pages - .get(&1) - .cloned() - .expect("page 1 should be present"); - assert_eq!(&page[..16], b"SQLite format 3\0"); - runtime - .block_on(engine.seed_page_as_cold_ref(&harness.actor_id, 1, page)) - .expect("cold ref should seed"); - runtime.block_on(engine.poison_mirror_page( + .expect("insert should succeed"); + sqlite_exec(db.as_ptr(), "PRAGMA user_version = 7;") + .expect("user_version write should succeed"); + } + + let reopened = harness.open_db(&runtime); + assert_eq!( + sqlite_query_i64(reopened.as_ptr(), "SELECT COUNT(*) FROM events;") + .expect("count after reopen should succeed"), + 1 + ); + assert_eq!( + sqlite_query_text(reopened.as_ptr(), "SELECT value FROM events WHERE id = 1;") + .expect("value after reopen should succeed"), + "persisted" + ); + assert_eq!( + sqlite_query_i64(reopened.as_ptr(), "PRAGMA user_version;") + .expect("user_version after reopen should succeed"), + 7 + ); +} + +#[test] +fn strict_direct_reopen_ignores_poisoned_mirror_and_reads_depot() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let page_count; + + { + let db = harness.open_db(&runtime); + sqlite_exec(db.as_ptr(), "PRAGMA user_version = 2718;") + .expect("user_version write should succeed"); + page_count = sqlite_query_i64(db.as_ptr(), "PRAGMA page_count;") + .expect("page count should succeed") as u32; + } + + let engine = runtime.block_on(harness.open_engine()); + runtime.block_on(engine.poison_mirror_page(&harness.actor_id, 1, vec![0xdb; 4096], page_count)); + engine.enable_strict_mode(); + runtime.block_on(engine.evict_actor_db(&harness.actor_id)); + + let before = engine.stats(); + let reopened = harness.open_db(&runtime); + assert_eq!( + sqlite_query_i64(reopened.as_ptr(), "PRAGMA user_version;") + .expect("strict reopen should read depot state"), + 2718 + ); + let after = engine.stats(); + assert!(after.depot_get_pages > before.depot_get_pages); + assert_eq!(after.mirror_reads, before.mirror_reads); + assert_eq!(after.mirror_fills, before.mirror_fills); + assert_eq!(after.mirror_seeds, before.mirror_seeds); +} + +#[test] +fn strict_direct_mode_rejects_mirror_fallback_and_seed_paths() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let engine = runtime.block_on(harness.open_engine()); + + runtime + .block_on(engine.apply_commit( &harness.actor_id, + vec![storage_dirty_page(protocol::SqliteDirtyPage { + pgno: 1, + bytes: empty_db_page(), + })], 1, - vec![0xcd; 4096], - page_count, - )); - engine.enable_strict_mode(); - runtime.block_on(engine.evict_actor_db(&harness.actor_id)); - - let before = engine.stats(); - let reopened = harness.open_db(&runtime); - assert_eq!( - sqlite_query_i64(reopened.as_ptr(), "PRAGMA user_version;") - .expect("strict reopen should read cold-backed state"), - 808 - ); - let after = engine.stats(); - assert!(after.depot_get_pages > before.depot_get_pages); - assert!(after.cold_gets > before.cold_gets); - assert_eq!(after.mirror_reads, before.mirror_reads); - assert_eq!(after.mirror_fills, before.mirror_fills); - assert_eq!(after.mirror_seeds, before.mirror_seeds); - } - - #[test] - fn strict_direct_warmed_shard_cache_does_not_count_as_cold_tier_evidence() { - let runtime = direct_runtime(); - let harness = DirectEngineHarness::new_with_cold_tier(); - let page_count; - - { - let db = harness.open_db(&runtime); - sqlite_exec(db.as_ptr(), "PRAGMA user_version = 909;") - .expect("user_version write should succeed"); - page_count = sqlite_query_i64(db.as_ptr(), "PRAGMA page_count;") - .expect("page count should succeed") as u32; - } - - let engine = runtime.block_on(harness.open_engine()); - let page = runtime - .block_on(engine.snapshot_pages(&harness.actor_id)) - .pages - .get(&1) - .cloned() - .expect("page 1 should be present"); - assert_eq!(&page[..16], b"SQLite format 3\0"); - runtime - .block_on(engine.seed_page_as_cold_ref(&harness.actor_id, 1, page)) - .expect("cold ref should seed"); - runtime.block_on(engine.poison_mirror_page( + )) + .expect("non-strict mirror seed should succeed"); + + engine.enable_strict_mode(); + runtime.block_on(engine.evict_actor_db(&harness.actor_id)); + let before = engine.stats(); + let err = runtime + .block_on(engine.get_pages(&harness.actor_id, &[1])) + .expect_err("strict mode should not read from the mirror"); + assert!(!err.to_string().is_empty()); + let after = engine.stats(); + assert_eq!(after.mirror_reads, before.mirror_reads); + assert_eq!(after.mirror_fills, before.mirror_fills); + + let err = runtime + .block_on(engine.apply_commit( &harness.actor_id, + vec![storage_dirty_page(protocol::SqliteDirtyPage { + pgno: 1, + bytes: empty_db_page(), + })], 1, - vec![0xcd; 4096], - page_count, - )); - engine.enable_strict_mode(); - runtime.block_on(engine.evict_actor_db(&harness.actor_id)); - - let before_warm = engine.stats(); - let cold_page = runtime - .block_on(engine.get_pages(&harness.actor_id, &[1])) - .expect("strict direct read should hit cold tier") - .into_iter() - .find(|page| page.pgno == 1) - .and_then(|page| page.bytes) - .expect("cold-backed page should be present"); - assert_eq!(&cold_page[..16], b"SQLite format 3\0"); - let after_warm = engine.stats(); - assert!(after_warm.cold_gets > before_warm.cold_gets); - - let actor_db = runtime.block_on(engine.actor_db(harness.actor_id.clone())); - runtime.block_on(actor_db.wait_for_shard_cache_fill_idle_for_test()); - runtime.block_on(engine.evict_actor_db(&harness.actor_id)); - - let before = engine.stats(); - let reopened = harness.open_db(&runtime); - assert_eq!( - sqlite_query_i64(reopened.as_ptr(), "PRAGMA user_version;") - .expect("strict reopen should read shard-cache-backed state"), - 909 - ); - let after = engine.stats(); - assert!(after.depot_get_pages > before.depot_get_pages); - assert_eq!(after.cold_gets, before.cold_gets); - assert_eq!(after.mirror_reads, before.mirror_reads); - assert_eq!(after.mirror_fills, before.mirror_fills); - assert_eq!(after.mirror_seeds, before.mirror_seeds); - } - - #[test] - fn direct_engine_handles_aux_files_and_truncate_then_regrow() { - let runtime = direct_runtime(); - let harness = DirectEngineHarness::new(); + )) + .expect_err("strict mode should reject mirror seed"); + assert!( + err.to_string() + .contains("forbids mirror-backed cache seeding") + ); +} + +#[test] +fn strict_direct_reopen_counts_cold_tier_get_for_cold_covered_page() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new_with_cold_tier(); + let page_count; + + { let db = harness.open_db(&runtime); - - sqlite_exec(db.as_ptr(), "PRAGMA temp_store = FILE;") - .expect("temp_store pragma should succeed"); - sqlite_exec( + sqlite_exec(db.as_ptr(), "PRAGMA user_version = 808;") + .expect("user_version write should succeed"); + page_count = sqlite_query_i64(db.as_ptr(), "PRAGMA page_count;") + .expect("page count should succeed") as u32; + } + + let engine = runtime.block_on(harness.open_engine()); + let page = runtime + .block_on(engine.snapshot_pages(&harness.actor_id)) + .pages + .get(&1) + .cloned() + .expect("page 1 should be present"); + assert_eq!(&page[..16], b"SQLite format 3\0"); + runtime + .block_on(engine.seed_page_as_cold_ref(&harness.actor_id, 1, page)) + .expect("cold ref should seed"); + runtime.block_on(engine.poison_mirror_page(&harness.actor_id, 1, vec![0xcd; 4096], page_count)); + engine.enable_strict_mode(); + runtime.block_on(engine.evict_actor_db(&harness.actor_id)); + + let before = engine.stats(); + let reopened = harness.open_db(&runtime); + assert_eq!( + sqlite_query_i64(reopened.as_ptr(), "PRAGMA user_version;") + .expect("strict reopen should read cold-backed state"), + 808 + ); + let after = engine.stats(); + assert!(after.depot_get_pages > before.depot_get_pages); + assert!(after.cold_gets > before.cold_gets); + assert_eq!(after.mirror_reads, before.mirror_reads); + assert_eq!(after.mirror_fills, before.mirror_fills); + assert_eq!(after.mirror_seeds, before.mirror_seeds); +} + +#[test] +fn strict_direct_warmed_shard_cache_does_not_count_as_cold_tier_evidence() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new_with_cold_tier(); + let page_count; + + { + let db = harness.open_db(&runtime); + sqlite_exec(db.as_ptr(), "PRAGMA user_version = 909;") + .expect("user_version write should succeed"); + page_count = sqlite_query_i64(db.as_ptr(), "PRAGMA page_count;") + .expect("page count should succeed") as u32; + } + + let engine = runtime.block_on(harness.open_engine()); + let page = runtime + .block_on(engine.snapshot_pages(&harness.actor_id)) + .pages + .get(&1) + .cloned() + .expect("page 1 should be present"); + assert_eq!(&page[..16], b"SQLite format 3\0"); + runtime + .block_on(engine.seed_page_as_cold_ref(&harness.actor_id, 1, page)) + .expect("cold ref should seed"); + runtime.block_on(engine.poison_mirror_page(&harness.actor_id, 1, vec![0xcd; 4096], page_count)); + engine.enable_strict_mode(); + runtime.block_on(engine.evict_actor_db(&harness.actor_id)); + + let before_warm = engine.stats(); + let cold_page = runtime + .block_on(engine.get_pages(&harness.actor_id, &[1])) + .expect("strict direct read should hit cold tier") + .into_iter() + .find(|page| page.pgno == 1) + .and_then(|page| page.bytes) + .expect("cold-backed page should be present"); + assert_eq!(&cold_page[..16], b"SQLite format 3\0"); + let after_warm = engine.stats(); + assert!(after_warm.cold_gets > before_warm.cold_gets); + + let actor_db = runtime.block_on(engine.actor_db(harness.actor_id.clone())); + runtime.block_on(actor_db.wait_for_shard_cache_fill_idle_for_test()); + runtime.block_on(engine.evict_actor_db(&harness.actor_id)); + + let before = engine.stats(); + let reopened = harness.open_db(&runtime); + assert_eq!( + sqlite_query_i64(reopened.as_ptr(), "PRAGMA user_version;") + .expect("strict reopen should read shard-cache-backed state"), + 909 + ); + let after = engine.stats(); + assert!(after.depot_get_pages > before.depot_get_pages); + assert_eq!(after.cold_gets, before.cold_gets); + assert_eq!(after.mirror_reads, before.mirror_reads); + assert_eq!(after.mirror_fills, before.mirror_fills); + assert_eq!(after.mirror_seeds, before.mirror_seeds); +} + +#[test] +fn direct_engine_handles_aux_files_and_truncate_then_regrow() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let db = harness.open_db(&runtime); + + sqlite_exec(db.as_ptr(), "PRAGMA temp_store = FILE;") + .expect("temp_store pragma should succeed"); + sqlite_exec( + db.as_ptr(), + "CREATE TABLE blobs (id INTEGER PRIMARY KEY, payload BLOB NOT NULL);", + ) + .expect("create table should succeed"); + + for _ in 0..32 { + sqlite_step_statement( db.as_ptr(), - "CREATE TABLE blobs (id INTEGER PRIMARY KEY, payload BLOB NOT NULL);", + "INSERT INTO blobs (payload) VALUES (randomblob(8192));", ) - .expect("create table should succeed"); + .expect("growth insert should succeed"); + } + let grown_pages = sqlite_query_i64(db.as_ptr(), "PRAGMA page_count;") + .expect("grown page_count should succeed"); + assert!(grown_pages > 40); - for _ in 0..32 { - sqlite_step_statement( - db.as_ptr(), - "INSERT INTO blobs (payload) VALUES (randomblob(8192));", - ) - .expect("growth insert should succeed"); - } - let grown_pages = sqlite_query_i64(db.as_ptr(), "PRAGMA page_count;") - .expect("grown page_count should succeed"); - assert!(grown_pages > 40); + sqlite_exec( + db.as_ptr(), + "CREATE TEMP TABLE scratch AS SELECT id FROM blobs ORDER BY id DESC;", + ) + .expect("temp table should succeed"); + assert_eq!( + sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM scratch;") + .expect("temp table count should succeed"), + 32 + ); - sqlite_exec( + sqlite_exec(db.as_ptr(), "DELETE FROM blobs;").expect("delete should succeed"); + sqlite_exec(db.as_ptr(), "VACUUM;").expect("vacuum should succeed"); + let shrunk_pages = sqlite_query_i64(db.as_ptr(), "PRAGMA page_count;") + .expect("shrunk page_count should succeed"); + assert!(shrunk_pages < grown_pages); + + for _ in 0..8 { + sqlite_step_statement( db.as_ptr(), - "CREATE TEMP TABLE scratch AS SELECT id FROM blobs ORDER BY id DESC;", + "INSERT INTO blobs (payload) VALUES (randomblob(8192));", ) - .expect("temp table should succeed"); - assert_eq!( - sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM scratch;") - .expect("temp table count should succeed"), - 32 - ); + .expect("regrow insert should succeed"); + } + let regrown_pages = sqlite_query_i64(db.as_ptr(), "PRAGMA page_count;") + .expect("regrown page_count should succeed"); + assert!(regrown_pages > shrunk_pages); +} - sqlite_exec(db.as_ptr(), "DELETE FROM blobs;").expect("delete should succeed"); - sqlite_exec(db.as_ptr(), "VACUUM;").expect("vacuum should succeed"); - let shrunk_pages = sqlite_query_i64(db.as_ptr(), "PRAGMA page_count;") - .expect("shrunk page_count should succeed"); - assert!(shrunk_pages < grown_pages); +#[test] +fn direct_engine_accepts_actual_nul_text_when_bound_with_explicit_length() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let db = harness.open_db(&runtime); - for _ in 0..8 { - sqlite_step_statement( - db.as_ptr(), - "INSERT INTO blobs (payload) VALUES (randomblob(8192));", - ) - .expect("regrow insert should succeed"); - } - let regrown_pages = sqlite_query_i64(db.as_ptr(), "PRAGMA page_count;") - .expect("regrown page_count should succeed"); - assert!(regrown_pages > shrunk_pages); - } + sqlite_exec( + db.as_ptr(), + "CREATE TABLE nul_texts (payload TEXT PRIMARY KEY, marker INTEGER NOT NULL);", + ) + .expect("create table should succeed"); - #[test] - fn direct_engine_accepts_actual_nul_text_when_bound_with_explicit_length() { - let runtime = direct_runtime(); - let harness = DirectEngineHarness::new(); - let db = harness.open_db(&runtime); + let payload = b"actual\0nul-text"; + sqlite_insert_text_with_int_value( + db.as_ptr(), + "INSERT INTO nul_texts (payload, marker) VALUES (?, ?);", + payload, + 7, + ) + .expect("explicit-length text bind should preserve embedded nul"); - sqlite_exec( + assert_eq!( + sqlite_query_i64_bind_text( db.as_ptr(), - "CREATE TABLE nul_texts (payload TEXT PRIMARY KEY, marker INTEGER NOT NULL);", + "SELECT marker FROM nul_texts WHERE payload = ?;", + payload, ) - .expect("create table should succeed"); - - let payload = b"actual\0nul-text"; + .expect("lookup by embedded-nul text should succeed"), + 7 + ); + assert_eq!( + sqlite_query_text(db.as_ptr(), "SELECT hex(payload) FROM nul_texts;") + .expect("hex query should succeed"), + "61637475616C006E756C2D74657874" + ); +} + +#[test] +fn direct_engine_handles_boundary_primary_keys() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let db = harness.open_db(&runtime); + + sqlite_exec( + db.as_ptr(), + "CREATE TABLE boundary_keys (key_text TEXT PRIMARY KEY, value INTEGER NOT NULL);", + ) + .expect("create table should succeed"); + + let mut keys = vec![ + Vec::from("".as_bytes()), + Vec::from(" ".as_bytes()), + Vec::from("slash/key".as_bytes()), + Vec::from("comma,key".as_bytes()), + Vec::from("percent%key".as_bytes()), + Vec::from("CaseKey".as_bytes()), + Vec::from("casekey".as_bytes()), + vec![b'k'; 2048], + ]; + for i in 0..256 { + keys.push(format!("seq-{i:04}").into_bytes()); + } + + for (index, key) in keys.iter().enumerate() { sqlite_insert_text_with_int_value( db.as_ptr(), - "INSERT INTO nul_texts (payload, marker) VALUES (?, ?);", - payload, - 7, + "INSERT INTO boundary_keys (key_text, value) VALUES (?, ?);", + key, + index as i64, ) - .expect("explicit-length text bind should preserve embedded nul"); + .expect("boundary key insert should succeed"); + } + for (index, key) in keys.iter().enumerate() { assert_eq!( sqlite_query_i64_bind_text( db.as_ptr(), - "SELECT marker FROM nul_texts WHERE payload = ?;", - payload, + "SELECT value FROM boundary_keys WHERE key_text = ?;", + key, ) - .expect("lookup by embedded-nul text should succeed"), - 7 - ); - assert_eq!( - sqlite_query_text(db.as_ptr(), "SELECT hex(payload) FROM nul_texts;") - .expect("hex query should succeed"), - "61637475616C006E756C2D74657874" - ); + .expect("boundary key lookup should succeed"), + index as i64 + ); + } + + assert_eq!( + sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM boundary_keys;") + .expect("count should succeed"), + keys.len() as i64 + ); +} + +#[test] +fn direct_engine_keeps_shadow_checksum_transaction_consistent() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let db = harness.open_db(&runtime); + + sqlite_exec( + db.as_ptr(), + "CREATE TABLE items (id INTEGER PRIMARY KEY, value INTEGER NOT NULL);", + ) + .expect("create items should succeed"); + sqlite_exec( + db.as_ptr(), + "CREATE TABLE shadow_checksums (name TEXT PRIMARY KEY, total INTEGER NOT NULL);", + ) + .expect("create shadow table should succeed"); + + let expected_total = (1..=128).sum::(); + sqlite_exec(db.as_ptr(), "BEGIN").expect("begin should succeed"); + for i in 1..=128 { + sqlite_step_statement( + db.as_ptr(), + &format!("INSERT INTO items (id, value) VALUES ({i}, {i});"), + ) + .expect("item insert should succeed"); + } + sqlite_step_statement( + db.as_ptr(), + &format!("INSERT INTO shadow_checksums (name, total) VALUES ('items', {expected_total});"), + ) + .expect("shadow insert should succeed"); + sqlite_exec(db.as_ptr(), "COMMIT").expect("commit should succeed"); + + assert_eq!( + sqlite_query_i64(db.as_ptr(), "SELECT SUM(value) FROM items;") + .expect("item sum should succeed"), + expected_total + ); + assert_eq!( + sqlite_query_i64( + db.as_ptr(), + "SELECT total FROM shadow_checksums WHERE name = 'items';", + ) + .expect("shadow total should succeed"), + expected_total + ); +} + +#[test] +fn direct_engine_mixed_row_model_preserves_invariants() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let db = harness.open_db(&runtime); + + sqlite_exec( + db.as_ptr(), + "CREATE TABLE items (item_key TEXT PRIMARY KEY, value TEXT NOT NULL, version INTEGER NOT NULL);", + ) + .expect("create table should succeed"); + + for i in 0..64 { + sqlite_step_statement( + db.as_ptr(), + &format!( + "INSERT INTO items (item_key, value, version) VALUES ('item-{i:02}', 'insert-{i}', 1);" + ), + ) + .expect("seed insert should succeed"); } - #[test] - fn direct_engine_handles_boundary_primary_keys() { - let runtime = direct_runtime(); - let harness = DirectEngineHarness::new(); - let db = harness.open_db(&runtime); - - sqlite_exec( + for i in 0..32 { + sqlite_step_statement( db.as_ptr(), - "CREATE TABLE boundary_keys (key_text TEXT PRIMARY KEY, value INTEGER NOT NULL);", + &format!( + "UPDATE items SET value = 'update-{i}', version = version + 1 WHERE item_key = 'item-{i:02}';" + ), ) - .expect("create table should succeed"); + .expect("update should succeed"); + } - let mut keys = vec![ - Vec::from("".as_bytes()), - Vec::from(" ".as_bytes()), - Vec::from("slash/key".as_bytes()), - Vec::from("comma,key".as_bytes()), - Vec::from("percent%key".as_bytes()), - Vec::from("CaseKey".as_bytes()), - Vec::from("casekey".as_bytes()), - vec![b'k'; 2048], - ]; - for i in 0..256 { - keys.push(format!("seq-{i:04}").into_bytes()); - } + for i in 16..24 { + sqlite_step_statement( + db.as_ptr(), + &format!("DELETE FROM items WHERE item_key = 'item-{i:02}';"), + ) + .expect("delete should succeed"); + } - for (index, key) in keys.iter().enumerate() { - sqlite_insert_text_with_int_value( - db.as_ptr(), - "INSERT INTO boundary_keys (key_text, value) VALUES (?, ?);", - key, - index as i64, - ) - .expect("boundary key insert should succeed"); - } - - for (index, key) in keys.iter().enumerate() { - assert_eq!( - sqlite_query_i64_bind_text( - db.as_ptr(), - "SELECT value FROM boundary_keys WHERE key_text = ?;", - key, - ) - .expect("boundary key lookup should succeed"), - index as i64 - ); - } - - assert_eq!( - sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM boundary_keys;") - .expect("count should succeed"), - keys.len() as i64 - ); - } - - #[test] - fn direct_engine_keeps_shadow_checksum_transaction_consistent() { - let runtime = direct_runtime(); - let harness = DirectEngineHarness::new(); - let db = harness.open_db(&runtime); - - sqlite_exec( + for i in 0..16 { + sqlite_step_statement( db.as_ptr(), - "CREATE TABLE items (id INTEGER PRIMARY KEY, value INTEGER NOT NULL);", + &format!( + "INSERT INTO items (item_key, value, version) VALUES ('item-{i:02}', 'upsert-{i}', 99) + ON CONFLICT(item_key) DO UPDATE SET value = excluded.value, version = excluded.version;" + ), ) - .expect("create items should succeed"); - sqlite_exec( + .expect("upsert should succeed"); + } + + for i in 0..1000 { + sqlite_step_statement( db.as_ptr(), - "CREATE TABLE shadow_checksums (name TEXT PRIMARY KEY, total INTEGER NOT NULL);", + &format!( + "UPDATE items SET version = version + 1, value = 'hot-{i}' WHERE item_key = 'item-00';" + ), ) - .expect("create shadow table should succeed"); - - let expected_total = (1..=128).sum::(); - sqlite_exec(db.as_ptr(), "BEGIN").expect("begin should succeed"); - for i in 1..=128 { - sqlite_step_statement( - db.as_ptr(), - &format!("INSERT INTO items (id, value) VALUES ({i}, {i});"), - ) - .expect("item insert should succeed"); - } + .expect("hot-row update should succeed"); + } + + assert_eq!( + sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM items;").expect("count should succeed"), + 56 + ); + assert_eq!( + sqlite_query_i64( + db.as_ptr(), + "SELECT version FROM items WHERE item_key = 'item-00';" + ) + .expect("hot-row version should succeed"), + 1099 + ); + assert_eq!( + sqlite_query_text(db.as_ptr(), "PRAGMA quick_check;").expect("quick_check should succeed"), + "ok" + ); + assert_eq!( + sqlite_query_text(db.as_ptr(), "PRAGMA integrity_check;") + .expect("integrity_check should succeed"), + "ok" + ); +} + +#[test] +fn direct_engine_runs_deterministic_nasty_script() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let db = harness.open_db(&runtime); + + sqlite_exec( + db.as_ptr(), + "CREATE TABLE nasty_edge (id INTEGER PRIMARY KEY, payload BLOB NOT NULL);", + ) + .expect("create nasty_edge should succeed"); + sqlite_exec( + db.as_ptr(), + "CREATE TABLE nasty_counter (id INTEGER PRIMARY KEY, value INTEGER NOT NULL);", + ) + .expect("create nasty_counter should succeed"); + sqlite_exec( + db.as_ptr(), + "CREATE TABLE nasty_rows (n INTEGER PRIMARY KEY, payload BLOB NOT NULL);", + ) + .expect("create nasty_rows should succeed"); + + for i in 0..256 { + let size = 1 + ((131072 - 1) * i / 255); sqlite_step_statement( db.as_ptr(), &format!( - "INSERT INTO shadow_checksums (name, total) VALUES ('items', {expected_total});" + "INSERT OR REPLACE INTO nasty_edge (id, payload) VALUES (1, randomblob({size}));" ), ) - .expect("shadow insert should succeed"); - sqlite_exec(db.as_ptr(), "COMMIT").expect("commit should succeed"); - - assert_eq!( - sqlite_query_i64(db.as_ptr(), "SELECT SUM(value) FROM items;") - .expect("item sum should succeed"), - expected_total - ); - assert_eq!( - sqlite_query_i64( - db.as_ptr(), - "SELECT total FROM shadow_checksums WHERE name = 'items';", - ) - .expect("shadow total should succeed"), - expected_total - ); + .expect("grow-row write should succeed"); } - - #[test] - fn direct_engine_mixed_row_model_preserves_invariants() { - let runtime = direct_runtime(); - let harness = DirectEngineHarness::new(); - let db = harness.open_db(&runtime); - - sqlite_exec( + assert_eq!( + sqlite_query_i64( db.as_ptr(), - "CREATE TABLE items (item_key TEXT PRIMARY KEY, value TEXT NOT NULL, version INTEGER NOT NULL);", + "SELECT length(payload) FROM nasty_edge WHERE id = 1;" ) - .expect("create table should succeed"); - - for i in 0..64 { - sqlite_step_statement( - db.as_ptr(), - &format!( - "INSERT INTO items (item_key, value, version) VALUES ('item-{i:02}', 'insert-{i}', 1);" - ), - ) - .expect("seed insert should succeed"); - } - - for i in 0..32 { - sqlite_step_statement( - db.as_ptr(), - &format!( - "UPDATE items SET value = 'update-{i}', version = version + 1 WHERE item_key = 'item-{i:02}';" - ), - ) - .expect("update should succeed"); - } - - for i in 16..24 { - sqlite_step_statement( - db.as_ptr(), - &format!("DELETE FROM items WHERE item_key = 'item-{i:02}';"), - ) - .expect("delete should succeed"); - } - - for i in 0..16 { - sqlite_step_statement( - db.as_ptr(), - &format!( - "INSERT INTO items (item_key, value, version) VALUES ('item-{i:02}', 'upsert-{i}', 99) - ON CONFLICT(item_key) DO UPDATE SET value = excluded.value, version = excluded.version;" - ), - ) - .expect("upsert should succeed"); - } - - for i in 0..1000 { - sqlite_step_statement( - db.as_ptr(), - &format!( - "UPDATE items SET version = version + 1, value = 'hot-{i}' WHERE item_key = 'item-00';" - ), - ) - .expect("hot-row update should succeed"); - } - - assert_eq!( - sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM items;") - .expect("count should succeed"), - 56 - ); - assert_eq!( - sqlite_query_i64( - db.as_ptr(), - "SELECT version FROM items WHERE item_key = 'item-00';" - ) - .expect("hot-row version should succeed"), - 1099 - ); - assert_eq!( - sqlite_query_text(db.as_ptr(), "PRAGMA quick_check;") - .expect("quick_check should succeed"), - "ok" - ); - assert_eq!( - sqlite_query_text(db.as_ptr(), "PRAGMA integrity_check;") - .expect("integrity_check should succeed"), - "ok" - ); - } + .expect("grown row length should succeed"), + 131072 + ); - #[test] - fn direct_engine_runs_deterministic_nasty_script() { - let runtime = direct_runtime(); - let harness = DirectEngineHarness::new(); - let db = harness.open_db(&runtime); - - sqlite_exec( + sqlite_step_statement( + db.as_ptr(), + "INSERT INTO nasty_counter (id, value) VALUES (1, 0);", + ) + .expect("seed counter should succeed"); + sqlite_exec(db.as_ptr(), "BEGIN").expect("counter begin should succeed"); + for _ in 0..10_000 { + sqlite_step_statement( db.as_ptr(), - "CREATE TABLE nasty_edge (id INTEGER PRIMARY KEY, payload BLOB NOT NULL);", + "UPDATE nasty_counter SET value = value + 1 WHERE id = 1;", ) - .expect("create nasty_edge should succeed"); - sqlite_exec( + .expect("counter update should succeed"); + } + sqlite_exec(db.as_ptr(), "COMMIT").expect("counter commit should succeed"); + assert_eq!( + sqlite_query_i64(db.as_ptr(), "SELECT value FROM nasty_counter WHERE id = 1;") + .expect("counter read should succeed"), + 10_000 + ); + + sqlite_exec( + db.as_ptr(), + "CREATE INDEX idx_nasty_rows_payload ON nasty_rows(payload);", + ) + .expect("create index should succeed"); + sqlite_exec(db.as_ptr(), "BEGIN").expect("bulk begin should succeed"); + for i in 0..10_000 { + sqlite_step_statement( db.as_ptr(), - "CREATE TABLE nasty_counter (id INTEGER PRIMARY KEY, value INTEGER NOT NULL);", + &format!("INSERT INTO nasty_rows (n, payload) VALUES ({i}, randomblob(64));"), ) - .expect("create nasty_counter should succeed"); - sqlite_exec( + .expect("bulk insert should succeed"); + } + sqlite_exec(db.as_ptr(), "DELETE FROM nasty_rows WHERE n % 2 = 0;") + .expect("bulk delete should succeed"); + sqlite_exec(db.as_ptr(), "COMMIT").expect("bulk commit should succeed"); + sqlite_exec(db.as_ptr(), "DROP INDEX idx_nasty_rows_payload;") + .expect("drop index should succeed"); + assert_eq!( + sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM nasty_rows;") + .expect("remaining row count should succeed"), + 5000 + ); + assert_eq!( + sqlite_query_i64( db.as_ptr(), - "CREATE TABLE nasty_rows (n INTEGER PRIMARY KEY, payload BLOB NOT NULL);", + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'idx_nasty_rows_payload';", ) - .expect("create nasty_rows should succeed"); - - for i in 0..256 { - let size = 1 + ((131072 - 1) * i / 255); - sqlite_step_statement( - db.as_ptr(), - &format!( - "INSERT OR REPLACE INTO nasty_edge (id, payload) VALUES (1, randomblob({size}));" - ), - ) - .expect("grow-row write should succeed"); - } - assert_eq!( - sqlite_query_i64( - db.as_ptr(), - "SELECT length(payload) FROM nasty_edge WHERE id = 1;" - ) - .expect("grown row length should succeed"), - 131072 - ); + .expect("index absence should succeed"), + 0 + ); + sqlite_exec(db.as_ptr(), "BEGIN").expect("rollback begin should succeed"); + for i in 0..1000 { sqlite_step_statement( db.as_ptr(), - "INSERT INTO nasty_counter (id, value) VALUES (1, 0);", + &format!( + "INSERT INTO nasty_rows (n, payload) VALUES ({}, randomblob(8));", + 20_000 + i + ), ) - .expect("seed counter should succeed"); - sqlite_exec(db.as_ptr(), "BEGIN").expect("counter begin should succeed"); - for _ in 0..10_000 { - sqlite_step_statement( - db.as_ptr(), - "UPDATE nasty_counter SET value = value + 1 WHERE id = 1;", - ) - .expect("counter update should succeed"); - } - sqlite_exec(db.as_ptr(), "COMMIT").expect("counter commit should succeed"); - assert_eq!( - sqlite_query_i64(db.as_ptr(), "SELECT value FROM nasty_counter WHERE id = 1;") - .expect("counter read should succeed"), - 10_000 - ); - - sqlite_exec( + .expect("rollback insert should succeed"); + } + sqlite_exec(db.as_ptr(), "ROLLBACK").expect("rollback should succeed"); + assert_eq!( + sqlite_query_i64( db.as_ptr(), - "CREATE INDEX idx_nasty_rows_payload ON nasty_rows(payload);", + "SELECT COUNT(*) FROM nasty_rows WHERE n >= 20000;" ) - .expect("create index should succeed"); - sqlite_exec(db.as_ptr(), "BEGIN").expect("bulk begin should succeed"); - for i in 0..10_000 { - sqlite_step_statement( - db.as_ptr(), - &format!("INSERT INTO nasty_rows (n, payload) VALUES ({i}, randomblob(64));"), - ) - .expect("bulk insert should succeed"); - } - sqlite_exec(db.as_ptr(), "DELETE FROM nasty_rows WHERE n % 2 = 0;") - .expect("bulk delete should succeed"); - sqlite_exec(db.as_ptr(), "COMMIT").expect("bulk commit should succeed"); - sqlite_exec(db.as_ptr(), "DROP INDEX idx_nasty_rows_payload;") - .expect("drop index should succeed"); - assert_eq!( - sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM nasty_rows;") - .expect("remaining row count should succeed"), - 5000 - ); - assert_eq!( - sqlite_query_i64( - db.as_ptr(), - "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'idx_nasty_rows_payload';", - ) - .expect("index absence should succeed"), - 0 - ); - - sqlite_exec(db.as_ptr(), "BEGIN").expect("rollback begin should succeed"); - for i in 0..1000 { - sqlite_step_statement( - db.as_ptr(), - &format!( - "INSERT INTO nasty_rows (n, payload) VALUES ({}, randomblob(8));", - 20_000 + i - ), - ) - .expect("rollback insert should succeed"); - } - sqlite_exec(db.as_ptr(), "ROLLBACK").expect("rollback should succeed"); - assert_eq!( - sqlite_query_i64( - db.as_ptr(), - "SELECT COUNT(*) FROM nasty_rows WHERE n >= 20000;" - ) - .expect("rollback count should succeed"), - 0 - ); - } + .expect("rollback count should succeed"), + 0 + ); +} - #[test] - fn direct_engine_handles_page_boundary_payloads_and_text_roundtrip() { - let runtime = direct_runtime(); - let harness = DirectEngineHarness::new(); - let db = harness.open_db(&runtime); +#[test] +fn direct_engine_handles_page_boundary_payloads_and_text_roundtrip() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let db = harness.open_db(&runtime); - sqlite_exec( - db.as_ptr(), - "CREATE TABLE payload_matrix ( + sqlite_exec( + db.as_ptr(), + "CREATE TABLE payload_matrix ( id INTEGER PRIMARY KEY, blob_payload BLOB NOT NULL, unicode_text TEXT NOT NULL, escaped_text TEXT NOT NULL );", - ) - .expect("create table should succeed"); - - let sizes = [ - 1, 4095, 4096, 4097, 8191, 8192, 8193, 32768, 65535, 65536, 98304, 131072, - ]; - for (index, size) in sizes.iter().enumerate() { - let id = index + 1; - let unicode_text = format!("snowman-{id}-こんにちは-ß-🧪"); - let escaped_text = format!("escaped\\\\0-row-{id}"); - sqlite_step_statement( - db.as_ptr(), - &format!( - "INSERT INTO payload_matrix (id, blob_payload, unicode_text, escaped_text) + ) + .expect("create table should succeed"); + + let sizes = [ + 1, 4095, 4096, 4097, 8191, 8192, 8193, 32768, 65535, 65536, 98304, 131072, + ]; + for (index, size) in sizes.iter().enumerate() { + let id = index + 1; + let unicode_text = format!("snowman-{id}-こんにちは-ß-🧪"); + let escaped_text = format!("escaped\\\\0-row-{id}"); + sqlite_step_statement( + db.as_ptr(), + &format!( + "INSERT INTO payload_matrix (id, blob_payload, unicode_text, escaped_text) VALUES ({id}, zeroblob({size}), '{}', '{}');", - unicode_text.replace('\'', "''"), - escaped_text.replace('\'', "''"), - ), - ) - .expect("boundary payload insert should succeed"); - } - - for (index, size) in sizes.iter().enumerate() { - let id = index + 1; - assert_eq!( - sqlite_query_i64( - db.as_ptr(), - &format!("SELECT length(blob_payload) FROM payload_matrix WHERE id = {id};"), - ) - .expect("blob length query should succeed"), - *size as i64 - ); - } + unicode_text.replace('\'', "''"), + escaped_text.replace('\'', "''"), + ), + ) + .expect("boundary payload insert should succeed"); + } + for (index, size) in sizes.iter().enumerate() { + let id = index + 1; assert_eq!( - sqlite_query_text( - db.as_ptr(), - "SELECT unicode_text FROM payload_matrix WHERE id = 4;", - ) - .expect("unicode text should roundtrip"), - "snowman-4-こんにちは-ß-🧪" - ); - assert_eq!( - sqlite_query_text( + sqlite_query_i64( db.as_ptr(), - "SELECT escaped_text FROM payload_matrix WHERE id = 4;", + &format!("SELECT length(blob_payload) FROM payload_matrix WHERE id = {id};"), ) - .expect("escaped nul text should roundtrip"), - "escaped\\\\0-row-4" - ); - assert_eq!( - sqlite_query_text(db.as_ptr(), "PRAGMA quick_check;") - .expect("quick_check should succeed"), - "ok" + .expect("blob length query should succeed"), + *size as i64 ); } - #[test] - fn direct_engine_enforces_constraints_savepoints_and_relational_invariants() { - let runtime = direct_runtime(); - let harness = DirectEngineHarness::new(); - let db = harness.open_db(&runtime); - - sqlite_exec(db.as_ptr(), "PRAGMA foreign_keys = ON;") - .expect("foreign_keys pragma should succeed"); - sqlite_exec( + assert_eq!( + sqlite_query_text( + db.as_ptr(), + "SELECT unicode_text FROM payload_matrix WHERE id = 4;", + ) + .expect("unicode text should roundtrip"), + "snowman-4-こんにちは-ß-🧪" + ); + assert_eq!( + sqlite_query_text( db.as_ptr(), - "CREATE TABLE users ( + "SELECT escaped_text FROM payload_matrix WHERE id = 4;", + ) + .expect("escaped nul text should roundtrip"), + "escaped\\\\0-row-4" + ); + assert_eq!( + sqlite_query_text(db.as_ptr(), "PRAGMA quick_check;").expect("quick_check should succeed"), + "ok" + ); +} + +#[test] +fn direct_engine_enforces_constraints_savepoints_and_relational_invariants() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let db = harness.open_db(&runtime); + + sqlite_exec(db.as_ptr(), "PRAGMA foreign_keys = ON;") + .expect("foreign_keys pragma should succeed"); + sqlite_exec( + db.as_ptr(), + "CREATE TABLE users ( id INTEGER PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT NOT NULL );", - ) - .expect("create users should succeed"); - sqlite_exec( - db.as_ptr(), - "CREATE TABLE inventory ( + ) + .expect("create users should succeed"); + sqlite_exec( + db.as_ptr(), + "CREATE TABLE inventory ( sku TEXT PRIMARY KEY, stock INTEGER NOT NULL CHECK(stock >= 0) );", - ) - .expect("create inventory should succeed"); - sqlite_exec( - db.as_ptr(), - "CREATE TABLE orders ( + ) + .expect("create inventory should succeed"); + sqlite_exec( + db.as_ptr(), + "CREATE TABLE orders ( id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, total_cents INTEGER NOT NULL CHECK(total_cents >= 0), status TEXT NOT NULL );", - ) - .expect("create orders should succeed"); - sqlite_exec( - db.as_ptr(), - "CREATE TABLE order_items ( + ) + .expect("create orders should succeed"); + sqlite_exec( + db.as_ptr(), + "CREATE TABLE order_items ( order_id INTEGER NOT NULL REFERENCES orders(id) ON DELETE CASCADE, sku TEXT NOT NULL REFERENCES inventory(sku), qty INTEGER NOT NULL CHECK(qty > 0), price_cents INTEGER NOT NULL CHECK(price_cents >= 0), PRIMARY KEY(order_id, sku) ) WITHOUT ROWID;", - ) - .expect("create order_items should succeed"); - sqlite_exec( - db.as_ptr(), - "CREATE TABLE payments ( + ) + .expect("create order_items should succeed"); + sqlite_exec( + db.as_ptr(), + "CREATE TABLE payments ( id INTEGER PRIMARY KEY, order_id INTEGER NOT NULL REFERENCES orders(id) ON DELETE CASCADE, amount_cents INTEGER NOT NULL CHECK(amount_cents >= 0), status TEXT NOT NULL );", - ) - .expect("create payments should succeed"); + ) + .expect("create payments should succeed"); + sqlite_exec( + db.as_ptr(), + "INSERT INTO users (id, email, name) VALUES (1, 'alice@example.com', 'Alice');", + ) + .expect("seed user should succeed"); + sqlite_exec( + db.as_ptr(), + "INSERT INTO inventory (sku, stock) VALUES ('sku-red', 10), ('sku-blue', 8);", + ) + .expect("seed inventory should succeed"); + + assert!( sqlite_exec( db.as_ptr(), - "INSERT INTO users (id, email, name) VALUES (1, 'alice@example.com', 'Alice');", + "INSERT INTO users (id, email, name) VALUES (2, 'alice@example.com', 'Again');", ) - .expect("seed user should succeed"); + .expect_err("unique violation should fail") + .contains("UNIQUE constraint failed") + ); + assert!( sqlite_exec( db.as_ptr(), - "INSERT INTO inventory (sku, stock) VALUES ('sku-red', 10), ('sku-blue', 8);", + "INSERT INTO users (id, email, name) VALUES (3, 'null@example.com', NULL);", ) - .expect("seed inventory should succeed"); - - assert!( - sqlite_exec( - db.as_ptr(), - "INSERT INTO users (id, email, name) VALUES (2, 'alice@example.com', 'Again');", - ) - .expect_err("unique violation should fail") - .contains("UNIQUE constraint failed") - ); - assert!( - sqlite_exec( - db.as_ptr(), - "INSERT INTO users (id, email, name) VALUES (3, 'null@example.com', NULL);", - ) - .expect_err("not-null violation should fail") - .contains("NOT NULL constraint failed") - ); - assert!( - sqlite_exec( - db.as_ptr(), - "UPDATE inventory SET stock = -1 WHERE sku = 'sku-red';" - ) - .expect_err("check violation should fail") - .contains("CHECK constraint failed") - ); - assert!( - sqlite_exec( - db.as_ptr(), - "INSERT INTO orders (id, user_id, total_cents, status) VALUES (9, 999, 100, 'pending');", - ) - .expect_err("foreign-key violation should fail") - .contains("FOREIGN KEY constraint failed") - ); - - sqlite_exec(db.as_ptr(), "BEGIN").expect("begin should succeed"); + .expect_err("not-null violation should fail") + .contains("NOT NULL constraint failed") + ); + assert!( sqlite_exec( db.as_ptr(), - "INSERT INTO orders (id, user_id, total_cents, status) VALUES (1, 1, 700, 'paid');", + "UPDATE inventory SET stock = -1 WHERE sku = 'sku-red';" ) - .expect("order insert should succeed"); + .expect_err("check violation should fail") + .contains("CHECK constraint failed") + ); + assert!( sqlite_exec( db.as_ptr(), - "INSERT INTO order_items (order_id, sku, qty, price_cents) - VALUES (1, 'sku-red', 2, 150), (1, 'sku-blue', 1, 400);", + "INSERT INTO orders (id, user_id, total_cents, status) VALUES (9, 999, 100, 'pending');", ) - .expect("order items insert should succeed"); - sqlite_exec( - db.as_ptr(), - "UPDATE inventory + .expect_err("foreign-key violation should fail") + .contains("FOREIGN KEY constraint failed") + ); + + sqlite_exec(db.as_ptr(), "BEGIN").expect("begin should succeed"); + sqlite_exec( + db.as_ptr(), + "INSERT INTO orders (id, user_id, total_cents, status) VALUES (1, 1, 700, 'paid');", + ) + .expect("order insert should succeed"); + sqlite_exec( + db.as_ptr(), + "INSERT INTO order_items (order_id, sku, qty, price_cents) + VALUES (1, 'sku-red', 2, 150), (1, 'sku-blue', 1, 400);", + ) + .expect("order items insert should succeed"); + sqlite_exec( + db.as_ptr(), + "UPDATE inventory SET stock = stock - CASE sku WHEN 'sku-red' THEN 2 WHEN 'sku-blue' THEN 1 ELSE 0 END WHERE sku IN ('sku-red', 'sku-blue');", - ) - .expect("inventory update should succeed"); - sqlite_exec( - db.as_ptr(), - "INSERT INTO payments (id, order_id, amount_cents, status) VALUES (1, 1, 700, 'captured');", - ) - .expect("payment insert should succeed"); - sqlite_exec(db.as_ptr(), "COMMIT").expect("commit should succeed"); - - assert_eq!( - sqlite_query_i64(db.as_ptr(), "SELECT total_cents FROM orders WHERE id = 1;",) - .expect("order total should succeed"), - sqlite_query_i64( - db.as_ptr(), - "SELECT SUM(qty * price_cents) FROM order_items WHERE order_id = 1;", - ) - .expect("item sum should succeed") - ); - assert_eq!( - sqlite_query_i64( - db.as_ptr(), - "SELECT SUM(amount_cents) FROM payments WHERE order_id = 1 AND status = 'captured';", - ) - .expect("captured payment sum should succeed"), - 700 - ); - assert_eq!( - sqlite_query_i64( - db.as_ptr(), - "SELECT SUM(stock) FROM inventory WHERE sku IN ('sku-red', 'sku-blue');", - ) - .expect("inventory sum should succeed"), - 15 - ); - - sqlite_exec(db.as_ptr(), "SAVEPOINT sp_order_rollback;").expect("savepoint should succeed"); - sqlite_exec( - db.as_ptr(), - "INSERT INTO orders (id, user_id, total_cents, status) VALUES (2, 1, 123, 'draft');", - ) - .expect("draft order insert should succeed"); - sqlite_exec( - db.as_ptr(), - "INSERT INTO order_items (order_id, sku, qty, price_cents) VALUES (2, 'sku-red', 1, 123);", - ) - .expect("draft item insert should succeed"); - sqlite_exec(db.as_ptr(), "ROLLBACK TO sp_order_rollback;") - .expect("rollback to savepoint should succeed"); - sqlite_exec(db.as_ptr(), "RELEASE sp_order_rollback;") - .expect("release savepoint should succeed"); - assert_eq!( - sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM orders WHERE id = 2;") - .expect("rolled-back order should be absent"), - 0 - ); - - sqlite_exec(db.as_ptr(), "SAVEPOINT sp_order_release;") - .expect("release savepoint should succeed"); - sqlite_exec( - db.as_ptr(), - "INSERT INTO orders (id, user_id, total_cents, status) VALUES (3, 1, 50, 'pending');", - ) - .expect("released order insert should succeed"); - sqlite_exec(db.as_ptr(), "RELEASE sp_order_release;") - .expect("release savepoint should succeed"); - assert_eq!( - sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM orders WHERE id = 3;") - .expect("released order should exist"), - 1 - ); - - sqlite_exec(db.as_ptr(), "BEGIN").expect("rollback begin should succeed"); - sqlite_exec( - db.as_ptr(), - "INSERT INTO payments (id, order_id, amount_cents, status) VALUES (2, 3, 50, 'captured');", - ) - .expect("rollback payment insert should succeed"); - sqlite_exec(db.as_ptr(), "ROLLBACK").expect("rollback should succeed"); - assert_eq!( - sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM payments WHERE id = 2;") - .expect("rolled-back payment should be absent"), - 0 - ); - - sqlite_exec( - db.as_ptr(), - "INSERT INTO users (id, email, name) VALUES (7, 'idempotent@example.com', 'Replay') + ) + .expect("inventory update should succeed"); + sqlite_exec( + db.as_ptr(), + "INSERT INTO payments (id, order_id, amount_cents, status) VALUES (1, 1, 700, 'captured');", + ) + .expect("payment insert should succeed"); + sqlite_exec(db.as_ptr(), "COMMIT").expect("commit should succeed"); + + assert_eq!( + sqlite_query_i64(db.as_ptr(), "SELECT total_cents FROM orders WHERE id = 1;",) + .expect("order total should succeed"), + sqlite_query_i64( + db.as_ptr(), + "SELECT SUM(qty * price_cents) FROM order_items WHERE order_id = 1;", + ) + .expect("item sum should succeed") + ); + assert_eq!( + sqlite_query_i64( + db.as_ptr(), + "SELECT SUM(amount_cents) FROM payments WHERE order_id = 1 AND status = 'captured';", + ) + .expect("captured payment sum should succeed"), + 700 + ); + assert_eq!( + sqlite_query_i64( + db.as_ptr(), + "SELECT SUM(stock) FROM inventory WHERE sku IN ('sku-red', 'sku-blue');", + ) + .expect("inventory sum should succeed"), + 15 + ); + + sqlite_exec(db.as_ptr(), "SAVEPOINT sp_order_rollback;").expect("savepoint should succeed"); + sqlite_exec( + db.as_ptr(), + "INSERT INTO orders (id, user_id, total_cents, status) VALUES (2, 1, 123, 'draft');", + ) + .expect("draft order insert should succeed"); + sqlite_exec( + db.as_ptr(), + "INSERT INTO order_items (order_id, sku, qty, price_cents) VALUES (2, 'sku-red', 1, 123);", + ) + .expect("draft item insert should succeed"); + sqlite_exec(db.as_ptr(), "ROLLBACK TO sp_order_rollback;") + .expect("rollback to savepoint should succeed"); + sqlite_exec(db.as_ptr(), "RELEASE sp_order_rollback;") + .expect("release savepoint should succeed"); + assert_eq!( + sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM orders WHERE id = 2;") + .expect("rolled-back order should be absent"), + 0 + ); + + sqlite_exec(db.as_ptr(), "SAVEPOINT sp_order_release;") + .expect("release savepoint should succeed"); + sqlite_exec( + db.as_ptr(), + "INSERT INTO orders (id, user_id, total_cents, status) VALUES (3, 1, 50, 'pending');", + ) + .expect("released order insert should succeed"); + sqlite_exec(db.as_ptr(), "RELEASE sp_order_release;") + .expect("release savepoint should succeed"); + assert_eq!( + sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM orders WHERE id = 3;") + .expect("released order should exist"), + 1 + ); + + sqlite_exec(db.as_ptr(), "BEGIN").expect("rollback begin should succeed"); + sqlite_exec( + db.as_ptr(), + "INSERT INTO payments (id, order_id, amount_cents, status) VALUES (2, 3, 50, 'captured');", + ) + .expect("rollback payment insert should succeed"); + sqlite_exec(db.as_ptr(), "ROLLBACK").expect("rollback should succeed"); + assert_eq!( + sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM payments WHERE id = 2;") + .expect("rolled-back payment should be absent"), + 0 + ); + + sqlite_exec( + db.as_ptr(), + "INSERT INTO users (id, email, name) VALUES (7, 'idempotent@example.com', 'Replay') ON CONFLICT(id) DO NOTHING;", - ) - .expect("idempotent insert should succeed"); - sqlite_exec( - db.as_ptr(), - "INSERT INTO users (id, email, name) VALUES (7, 'idempotent@example.com', 'Replay') + ) + .expect("idempotent insert should succeed"); + sqlite_exec( + db.as_ptr(), + "INSERT INTO users (id, email, name) VALUES (7, 'idempotent@example.com', 'Replay') ON CONFLICT(id) DO NOTHING;", - ) - .expect("idempotent replay should succeed"); - assert_eq!( - sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM users WHERE id = 7;") - .expect("idempotent user count should succeed"), - 1 - ); - - sqlite_exec(db.as_ptr(), "DELETE FROM orders WHERE id = 1;") - .expect("cascade delete should succeed"); - assert_eq!( - sqlite_query_i64( - db.as_ptr(), - "SELECT COUNT(*) FROM order_items WHERE order_id = 1;" - ) - .expect("cascaded order items should be removed"), - 0 - ); - assert_eq!( - sqlite_query_i64( - db.as_ptr(), - "SELECT COUNT(*) FROM payments WHERE order_id = 1;" - ) - .expect("cascaded payments should be removed"), - 0 - ); - } - - #[test] - fn direct_engine_handles_schema_churn_index_parity_and_pragmas() { - let runtime = direct_runtime(); - let harness = DirectEngineHarness::new(); - let db = harness.open_db(&runtime); - - assert_eq!( - sqlite_query_text(db.as_ptr(), "PRAGMA journal_mode = DELETE;") - .expect("journal_mode pragma should succeed"), - "delete" - ); - sqlite_exec(db.as_ptr(), "PRAGMA synchronous = NORMAL;") - .expect("synchronous pragma should succeed"); - sqlite_exec(db.as_ptr(), "PRAGMA cache_size = -2000;") - .expect("cache_size pragma should succeed"); - sqlite_exec(db.as_ptr(), "PRAGMA foreign_keys = ON;") - .expect("foreign_keys pragma should succeed"); - sqlite_exec(db.as_ptr(), "PRAGMA auto_vacuum = NONE;") - .expect("auto_vacuum pragma should succeed"); - assert_eq!( - sqlite_query_i64(db.as_ptr(), "PRAGMA cache_size;") - .expect("cache_size read should succeed"), - -2000 - ); - assert_eq!( - sqlite_query_i64(db.as_ptr(), "PRAGMA foreign_keys;") - .expect("foreign_keys read should succeed"), - 1 - ); - - sqlite_exec( - db.as_ptr(), - "CREATE TABLE items ( + ) + .expect("idempotent replay should succeed"); + assert_eq!( + sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM users WHERE id = 7;") + .expect("idempotent user count should succeed"), + 1 + ); + + sqlite_exec(db.as_ptr(), "DELETE FROM orders WHERE id = 1;") + .expect("cascade delete should succeed"); + assert_eq!( + sqlite_query_i64( + db.as_ptr(), + "SELECT COUNT(*) FROM order_items WHERE order_id = 1;" + ) + .expect("cascaded order items should be removed"), + 0 + ); + assert_eq!( + sqlite_query_i64( + db.as_ptr(), + "SELECT COUNT(*) FROM payments WHERE order_id = 1;" + ) + .expect("cascaded payments should be removed"), + 0 + ); +} + +#[test] +fn direct_engine_handles_schema_churn_index_parity_and_pragmas() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let db = harness.open_db(&runtime); + + assert_eq!( + sqlite_query_text(db.as_ptr(), "PRAGMA journal_mode = DELETE;") + .expect("journal_mode pragma should succeed"), + "delete" + ); + sqlite_exec(db.as_ptr(), "PRAGMA synchronous = NORMAL;") + .expect("synchronous pragma should succeed"); + sqlite_exec(db.as_ptr(), "PRAGMA cache_size = -2000;") + .expect("cache_size pragma should succeed"); + sqlite_exec(db.as_ptr(), "PRAGMA foreign_keys = ON;") + .expect("foreign_keys pragma should succeed"); + sqlite_exec(db.as_ptr(), "PRAGMA auto_vacuum = NONE;") + .expect("auto_vacuum pragma should succeed"); + assert_eq!( + sqlite_query_i64(db.as_ptr(), "PRAGMA cache_size;") + .expect("cache_size read should succeed"), + -2000 + ); + assert_eq!( + sqlite_query_i64(db.as_ptr(), "PRAGMA foreign_keys;") + .expect("foreign_keys read should succeed"), + 1 + ); + + sqlite_exec( + db.as_ptr(), + "CREATE TABLE items ( id INTEGER PRIMARY KEY, bucket INTEGER NOT NULL, key_text TEXT NOT NULL, value INTEGER NOT NULL );", - ) - .expect("create items should succeed"); - sqlite_exec( - db.as_ptr(), - "CREATE TABLE item_ops ( + ) + .expect("create items should succeed"); + sqlite_exec( + db.as_ptr(), + "CREATE TABLE item_ops ( seq INTEGER PRIMARY KEY AUTOINCREMENT, kind TEXT NOT NULL, item_id INTEGER NOT NULL );", - ) - .expect("create item_ops should succeed"); - sqlite_exec( - db.as_ptr(), - "CREATE TABLE ledger ( + ) + .expect("create item_ops should succeed"); + sqlite_exec( + db.as_ptr(), + "CREATE TABLE ledger ( account_id TEXT NOT NULL, entry_id INTEGER NOT NULL, amount INTEGER NOT NULL, PRIMARY KEY(account_id, entry_id) ) WITHOUT ROWID;", - ) - .expect("create ledger should succeed"); - sqlite_exec( - db.as_ptr(), - "CREATE VIEW active_items AS + ) + .expect("create ledger should succeed"); + sqlite_exec( + db.as_ptr(), + "CREATE VIEW active_items AS SELECT id, bucket, key_text, value FROM items WHERE value >= 0;", - ) - .expect("create view should succeed"); - sqlite_exec( - db.as_ptr(), - "CREATE TRIGGER items_ai AFTER INSERT ON items + ) + .expect("create view should succeed"); + sqlite_exec( + db.as_ptr(), + "CREATE TRIGGER items_ai AFTER INSERT ON items BEGIN INSERT INTO item_ops (kind, item_id) VALUES ('insert', NEW.id); END;", - ) - .expect("create insert trigger should succeed"); - sqlite_exec( - db.as_ptr(), - "CREATE TRIGGER items_ad AFTER DELETE ON items + ) + .expect("create insert trigger should succeed"); + sqlite_exec( + db.as_ptr(), + "CREATE TRIGGER items_ad AFTER DELETE ON items BEGIN INSERT INTO item_ops (kind, item_id) VALUES ('delete', OLD.id); END;", - ) - .expect("create delete trigger should succeed"); - sqlite_exec( + ) + .expect("create delete trigger should succeed"); + sqlite_exec( + db.as_ptr(), + "ALTER TABLE items ADD COLUMN tag TEXT NOT NULL DEFAULT 'base';", + ) + .expect("alter table should succeed"); + sqlite_exec( + db.as_ptr(), + "CREATE INDEX idx_items_bucket_key ON items(bucket, key_text);", + ) + .expect("create compound index should succeed"); + + sqlite_exec(db.as_ptr(), "BEGIN").expect("begin should succeed"); + for i in 0..128 { + sqlite_step_statement( db.as_ptr(), - "ALTER TABLE items ADD COLUMN tag TEXT NOT NULL DEFAULT 'base';", + &format!( + "INSERT INTO items (id, bucket, key_text, value, tag) + VALUES ({i}, {}, 'k-{i:03}', {}, 'tag-{}');", + i % 8, + i * 2, + i % 5, + ), ) - .expect("alter table should succeed"); - sqlite_exec( + .expect("items insert should succeed"); + sqlite_step_statement( db.as_ptr(), - "CREATE INDEX idx_items_bucket_key ON items(bucket, key_text);", + &format!( + "INSERT INTO ledger (account_id, entry_id, amount) VALUES ('acct-{}', {i}, {});", + i % 4, + (i as i64) - 32, + ), ) - .expect("create compound index should succeed"); - - sqlite_exec(db.as_ptr(), "BEGIN").expect("begin should succeed"); - for i in 0..128 { - sqlite_step_statement( - db.as_ptr(), - &format!( - "INSERT INTO items (id, bucket, key_text, value, tag) - VALUES ({i}, {}, 'k-{i:03}', {}, 'tag-{}');", - i % 8, - i * 2, - i % 5, - ), - ) - .expect("items insert should succeed"); - sqlite_step_statement( - db.as_ptr(), - &format!( - "INSERT INTO ledger (account_id, entry_id, amount) VALUES ('acct-{}', {i}, {});", - i % 4, - (i as i64) - 32, - ), - ) - .expect("ledger insert should succeed"); - } - sqlite_exec(db.as_ptr(), "COMMIT").expect("commit should succeed"); - sqlite_exec(db.as_ptr(), "DELETE FROM items WHERE id % 9 = 0;") - .expect("delete should succeed"); + .expect("ledger insert should succeed"); + } + sqlite_exec(db.as_ptr(), "COMMIT").expect("commit should succeed"); + sqlite_exec(db.as_ptr(), "DELETE FROM items WHERE id % 9 = 0;").expect("delete should succeed"); - assert_eq!( - sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM active_items;") - .expect("view query should succeed"), - sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM items WHERE value >= 0;") - .expect("table query should succeed") - ); - assert_eq!( - sqlite_query_i64( - db.as_ptr(), - "SELECT + assert_eq!( + sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM active_items;") + .expect("view query should succeed"), + sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM items WHERE value >= 0;") + .expect("table query should succeed") + ); + assert_eq!( + sqlite_query_i64( + db.as_ptr(), + "SELECT SUM(CASE WHEN kind = 'insert' THEN 1 ELSE 0 END) - SUM(CASE WHEN kind = 'delete' THEN 1 ELSE 0 END) FROM item_ops;", - ) - .expect("op log delta should succeed"), - sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM items;") - .expect("live item count should succeed") - ); - assert_eq!( - sqlite_query_i64( - db.as_ptr(), - "SELECT COUNT(*) FROM items WHERE bucket = 3 AND key_text >= 'k-040';", - ) - .expect("indexed scan should succeed"), - sqlite_query_i64( - db.as_ptr(), - "SELECT COUNT(*) FROM items NOT INDEXED WHERE bucket = 3 AND key_text >= 'k-040';", - ) - .expect("not-indexed scan should succeed") - ); - assert_eq!( - sqlite_query_i64( - db.as_ptr(), - "SELECT COUNT(*) FROM ledger WHERE account_id = 'acct-2';" - ) - .expect("without-rowid query should succeed"), - 32 - ); + ) + .expect("op log delta should succeed"), + sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM items;") + .expect("live item count should succeed") + ); + assert_eq!( + sqlite_query_i64( + db.as_ptr(), + "SELECT COUNT(*) FROM items WHERE bucket = 3 AND key_text >= 'k-040';", + ) + .expect("indexed scan should succeed"), + sqlite_query_i64( + db.as_ptr(), + "SELECT COUNT(*) FROM items NOT INDEXED WHERE bucket = 3 AND key_text >= 'k-040';", + ) + .expect("not-indexed scan should succeed") + ); + assert_eq!( + sqlite_query_i64( + db.as_ptr(), + "SELECT COUNT(*) FROM ledger WHERE account_id = 'acct-2';" + ) + .expect("without-rowid query should succeed"), + 32 + ); - sqlite_exec(db.as_ptr(), "DROP INDEX idx_items_bucket_key;") - .expect("drop index should succeed"); - assert_eq!( - sqlite_query_i64( - db.as_ptr(), - "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'idx_items_bucket_key';", - ) - .expect("dropped index should be absent"), - 0 - ); - } + sqlite_exec(db.as_ptr(), "DROP INDEX idx_items_bucket_key;") + .expect("drop index should succeed"); + assert_eq!( + sqlite_query_i64( + db.as_ptr(), + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'idx_items_bucket_key';", + ) + .expect("dropped index should be absent"), + 0 + ); +} - #[test] - fn direct_engine_handles_prepared_statement_churn() { - let runtime = direct_runtime(); - let harness = DirectEngineHarness::new(); - let db = harness.open_db(&runtime); +#[test] +fn direct_engine_handles_prepared_statement_churn() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let db = harness.open_db(&runtime); - sqlite_exec( - db.as_ptr(), - "CREATE TABLE prepared_items ( + sqlite_exec( + db.as_ptr(), + "CREATE TABLE prepared_items ( id INTEGER PRIMARY KEY, value TEXT NOT NULL, counter INTEGER NOT NULL );", - ) - .expect("create table should succeed"); - - for i in 1..=128 { - let sql = format!( - "INSERT INTO prepared_items (id, value, counter) VALUES ({i}, 'seed-{i}', 0); -- unique-sql-{i}" - ); - let stmt = sqlite_prepare_statement(db.as_ptr(), &sql) - .expect("unique prepared statement should prepare"); - sqlite_step_prepared(db.as_ptr(), stmt, &sql) - .expect("unique prepared statement should execute"); - unsafe { - sqlite3_finalize(stmt); - } - } + ) + .expect("create table should succeed"); - let update_sql = "UPDATE prepared_items SET counter = counter + ?, value = ? WHERE id = ?;"; - let stmt = sqlite_prepare_statement(db.as_ptr(), update_sql) - .expect("reused prepared statement should prepare"); - for i in 0..4000 { - sqlite_reset_prepared(stmt, update_sql).expect("statement reset should succeed"); - sqlite_clear_bindings(stmt, update_sql).expect("binding clear should succeed"); - sqlite_bind_i64(db.as_ptr(), stmt, 1, 1, update_sql) - .expect("increment bind should succeed"); - let value = format!("value-{i}"); - sqlite_bind_text_bytes(db.as_ptr(), stmt, 2, value.as_bytes(), update_sql) - .expect("text bind should succeed"); - sqlite_bind_i64(db.as_ptr(), stmt, 3, (i % 128 + 1) as i64, update_sql) - .expect("id bind should succeed"); - sqlite_step_prepared(db.as_ptr(), stmt, update_sql) - .expect("reused prepared statement should execute"); - } + for i in 1..=128 { + let sql = format!( + "INSERT INTO prepared_items (id, value, counter) VALUES ({i}, 'seed-{i}', 0); -- unique-sql-{i}" + ); + let stmt = sqlite_prepare_statement(db.as_ptr(), &sql) + .expect("unique prepared statement should prepare"); + sqlite_step_prepared(db.as_ptr(), stmt, &sql) + .expect("unique prepared statement should execute"); unsafe { sqlite3_finalize(stmt); } - - assert_eq!( - sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM prepared_items;") - .expect("row count should succeed"), - 128 - ); - assert_eq!( - sqlite_query_i64(db.as_ptr(), "SELECT SUM(counter) FROM prepared_items;") - .expect("counter sum should succeed"), - 4000 - ); - assert_eq!( - sqlite_query_text( - db.as_ptr(), - "SELECT value FROM prepared_items WHERE id = 1;", - ) - .expect("final prepared value should succeed"), - "value-3968" - ); } - #[test] - fn direct_engine_preserves_transaction_balance_and_fragmentation_invariants() { - let runtime = direct_runtime(); - let harness = DirectEngineHarness::new(); - let db = harness.open_db(&runtime); - - sqlite_exec( - db.as_ptr(), - "CREATE TABLE accounts ( + let update_sql = "UPDATE prepared_items SET counter = counter + ?, value = ? WHERE id = ?;"; + let stmt = sqlite_prepare_statement(db.as_ptr(), update_sql) + .expect("reused prepared statement should prepare"); + for i in 0..4000 { + sqlite_reset_prepared(stmt, update_sql).expect("statement reset should succeed"); + sqlite_clear_bindings(stmt, update_sql).expect("binding clear should succeed"); + sqlite_bind_i64(db.as_ptr(), stmt, 1, 1, update_sql) + .expect("increment bind should succeed"); + let value = format!("value-{i}"); + sqlite_bind_text_bytes(db.as_ptr(), stmt, 2, value.as_bytes(), update_sql) + .expect("text bind should succeed"); + sqlite_bind_i64(db.as_ptr(), stmt, 3, (i % 128 + 1) as i64, update_sql) + .expect("id bind should succeed"); + sqlite_step_prepared(db.as_ptr(), stmt, update_sql) + .expect("reused prepared statement should execute"); + } + unsafe { + sqlite3_finalize(stmt); + } + + assert_eq!( + sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM prepared_items;") + .expect("row count should succeed"), + 128 + ); + assert_eq!( + sqlite_query_i64(db.as_ptr(), "SELECT SUM(counter) FROM prepared_items;") + .expect("counter sum should succeed"), + 4000 + ); + assert_eq!( + sqlite_query_text( + db.as_ptr(), + "SELECT value FROM prepared_items WHERE id = 1;", + ) + .expect("final prepared value should succeed"), + "value-3968" + ); +} + +#[test] +fn direct_engine_preserves_transaction_balance_and_fragmentation_invariants() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let db = harness.open_db(&runtime); + + sqlite_exec( + db.as_ptr(), + "CREATE TABLE accounts ( id INTEGER PRIMARY KEY, balance INTEGER NOT NULL );", - ) - .expect("create accounts should succeed"); - sqlite_exec( - db.as_ptr(), - "CREATE TABLE transfer_log ( + ) + .expect("create accounts should succeed"); + sqlite_exec( + db.as_ptr(), + "CREATE TABLE transfer_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, from_id INTEGER NOT NULL, to_id INTEGER NOT NULL, amount INTEGER NOT NULL );", - ) - .expect("create transfer_log should succeed"); - sqlite_exec( - db.as_ptr(), - "CREATE TABLE frag ( + ) + .expect("create transfer_log should succeed"); + sqlite_exec( + db.as_ptr(), + "CREATE TABLE frag ( id INTEGER PRIMARY KEY, payload BLOB NOT NULL );", - ) - .expect("create frag should succeed"); - - for id in 1..=8 { - sqlite_exec( - db.as_ptr(), - &format!("INSERT INTO accounts (id, balance) VALUES ({id}, 1000);"), - ) - .expect("seed account should succeed"); - } + ) + .expect("create frag should succeed"); - sqlite_exec(db.as_ptr(), "BEGIN").expect("transfer begin should succeed"); - for step in 0..500 { - let from_id = step % 8 + 1; - let to_id = (step * 5 + 3) % 8 + 1; - let amount = step % 17 + 1; - sqlite_exec( - db.as_ptr(), - &format!("UPDATE accounts SET balance = balance - {amount} WHERE id = {from_id};"), - ) - .expect("debit should succeed"); - sqlite_exec( - db.as_ptr(), - &format!("UPDATE accounts SET balance = balance + {amount} WHERE id = {to_id};"), - ) - .expect("credit should succeed"); - sqlite_exec( - db.as_ptr(), - &format!( - "INSERT INTO transfer_log (from_id, to_id, amount) VALUES ({from_id}, {to_id}, {amount});" - ), - ) - .expect("transfer log insert should succeed"); - } - sqlite_exec(db.as_ptr(), "COMMIT").expect("transfer commit should succeed"); + for id in 1..=8 { + sqlite_exec( + db.as_ptr(), + &format!("INSERT INTO accounts (id, balance) VALUES ({id}, 1000);"), + ) + .expect("seed account should succeed"); + } - sqlite_exec(db.as_ptr(), "BEGIN").expect("rollback begin should succeed"); + sqlite_exec(db.as_ptr(), "BEGIN").expect("transfer begin should succeed"); + for step in 0..500 { + let from_id = step % 8 + 1; + let to_id = (step * 5 + 3) % 8 + 1; + let amount = step % 17 + 1; sqlite_exec( db.as_ptr(), - "UPDATE accounts SET balance = balance - 777 WHERE id = 1;", + &format!("UPDATE accounts SET balance = balance - {amount} WHERE id = {from_id};"), ) - .expect("rollback debit should succeed"); + .expect("debit should succeed"); sqlite_exec( db.as_ptr(), - "UPDATE accounts SET balance = balance + 777 WHERE id = 2;", + &format!("UPDATE accounts SET balance = balance + {amount} WHERE id = {to_id};"), ) - .expect("rollback credit should succeed"); + .expect("credit should succeed"); sqlite_exec( db.as_ptr(), - "INSERT INTO transfer_log (from_id, to_id, amount) VALUES (1, 2, 777);", + &format!( + "INSERT INTO transfer_log (from_id, to_id, amount) VALUES ({from_id}, {to_id}, {amount});" + ), ) - .expect("rollback transfer log insert should succeed"); - sqlite_exec(db.as_ptr(), "ROLLBACK").expect("rollback should succeed"); + .expect("transfer log insert should succeed"); + } + sqlite_exec(db.as_ptr(), "COMMIT").expect("transfer commit should succeed"); + + sqlite_exec(db.as_ptr(), "BEGIN").expect("rollback begin should succeed"); + sqlite_exec( + db.as_ptr(), + "UPDATE accounts SET balance = balance - 777 WHERE id = 1;", + ) + .expect("rollback debit should succeed"); + sqlite_exec( + db.as_ptr(), + "UPDATE accounts SET balance = balance + 777 WHERE id = 2;", + ) + .expect("rollback credit should succeed"); + sqlite_exec( + db.as_ptr(), + "INSERT INTO transfer_log (from_id, to_id, amount) VALUES (1, 2, 777);", + ) + .expect("rollback transfer log insert should succeed"); + sqlite_exec(db.as_ptr(), "ROLLBACK").expect("rollback should succeed"); + + for id in 0..512 { + let size = ((id * 541) % 16384) + 1; + sqlite_exec( + db.as_ptr(), + &format!("INSERT INTO frag (id, payload) VALUES ({id}, randomblob({size}));"), + ) + .expect("fragmentation insert should succeed"); + } + for id in (0..512).filter(|id| (id * 17 + 11) % 5 == 0) { + sqlite_exec(db.as_ptr(), &format!("DELETE FROM frag WHERE id = {id};")) + .expect("fragmentation delete should succeed"); + } + for id in (0..512).filter(|id| id % 3 == 0) { + let shrink_size = ((id * 13) % 256) + 1; + sqlite_exec( + db.as_ptr(), + &format!("UPDATE frag SET payload = randomblob({shrink_size}) WHERE id = {id};"), + ) + .expect("fragmentation shrink should succeed"); + } + for id in (0..512).filter(|id| id % 7 == 0) { + let grow_size = 16384 + ((id * 97) % 8192); + sqlite_exec( + db.as_ptr(), + &format!("UPDATE frag SET payload = randomblob({grow_size}) WHERE id = {id};"), + ) + .expect("fragmentation grow should succeed"); + } + sqlite_exec(db.as_ptr(), "VACUUM;").expect("vacuum should succeed"); + + assert_eq!( + sqlite_query_i64(db.as_ptr(), "SELECT SUM(balance) FROM accounts;") + .expect("balance sum should succeed"), + 8000 + ); + assert_eq!( + sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM transfer_log;") + .expect("transfer log count should succeed"), + 500 + ); + assert_eq!( + sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM frag;") + .expect("frag count should succeed"), + 410 + ); + assert!( + sqlite_query_i64(db.as_ptr(), "SELECT SUM(length(payload)) FROM frag;") + .expect("frag payload sum should succeed") + > 0 + ); + assert_eq!( + sqlite_query_text(db.as_ptr(), "PRAGMA integrity_check;") + .expect("integrity_check should succeed"), + "ok" + ); +} + +#[test] +fn direct_engine_batch_atomic_probe_runs_on_open() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let db = harness.open_db(&runtime); + + assert!( + db._vfs.commit_atomic_count() > 0, + "open_database should run the sqlite batch-atomic probe", + ); +} + +#[test] +fn direct_engine_marks_vfs_dead_after_transport_errors() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let engine = runtime.block_on(harness.open_engine()); + let transport = SqliteTransport::from_direct(engine); + let hooks = transport + .direct_hooks() + .expect("direct transport should expose test hooks"); + let vfs = SqliteVfs::register_with_transport( + &next_test_name("sqlite-direct-vfs"), + transport, + harness.actor_id.clone(), + runtime.handle().clone(), + VfsConfig::default(), + None, + ) + .expect("v2 vfs should register"); + let db = open_database(vfs, &harness.actor_id).expect("sqlite database should open"); + + hooks.fail_next_commit("InjectedTransportError: commit transport dropped"); + let err = sqlite_exec( + db.as_ptr(), + "CREATE TABLE broken (id INTEGER PRIMARY KEY, value TEXT NOT NULL);", + ) + .expect_err("failing transport commit should surface as an IO error"); + assert!( + err.contains("I/O") || err.contains("disk I/O"), + "sqlite should surface transport failure as an IO error: {err}", + ); + assert!( + direct_vfs_ctx(&db).is_dead(), + "transport error should kill the v2 VFS" + ); + assert_eq!( + db.take_last_kv_error().as_deref(), + Some("InjectedTransportError: commit transport dropped"), + ); + assert!( + sqlite_query_i64(db.as_ptr(), "PRAGMA page_count;").is_err(), + "subsequent reads should fail once the VFS is dead", + ); +} + +#[test] +fn flush_dirty_pages_marks_vfs_dead_after_transport_error() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let engine = runtime.block_on(harness.open_engine()); + let transport = SqliteTransport::from_direct(engine); + let hooks = transport + .direct_hooks() + .expect("direct transport should expose test hooks"); + let vfs = SqliteVfs::register_with_transport( + &next_test_name("sqlite-direct-vfs"), + transport, + harness.actor_id.clone(), + runtime.handle().clone(), + VfsConfig::default(), + None, + ) + .expect("v2 vfs should register"); + let db = open_database(vfs, &harness.actor_id).expect("sqlite database should open"); + let ctx = direct_vfs_ctx(&db); + + { + let mut state = ctx.state.write(); + state.write_buffer.dirty.insert(1, vec![0x7a; 4096]); + state.db_size_pages = 1; + } + + hooks.fail_next_commit("InjectedTransportError: flush transport dropped"); + let err = ctx + .flush_dirty_pages() + .expect_err("transport failure should bubble out of flush_dirty_pages"); + + assert!( + matches!(err, CommitBufferError::Other(ref message) if message.contains("InjectedTransportError")), + "flush failure should surface as a transport error: {err:?}", + ); + assert!( + ctx.is_dead(), + "flush transport failure should poison the VFS" + ); + assert_eq!( + db.take_last_kv_error().as_deref(), + Some("InjectedTransportError: flush transport dropped"), + ); +} + +#[test] +fn commit_atomic_write_marks_vfs_dead_after_transport_error() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let engine = runtime.block_on(harness.open_engine()); + let transport = SqliteTransport::from_direct(engine); + let hooks = transport + .direct_hooks() + .expect("direct transport should expose test hooks"); + let vfs = SqliteVfs::register_with_transport( + &next_test_name("sqlite-direct-vfs"), + transport, + harness.actor_id.clone(), + runtime.handle().clone(), + VfsConfig::default(), + None, + ) + .expect("v2 vfs should register"); + let db = open_database(vfs, &harness.actor_id).expect("sqlite database should open"); + let ctx = direct_vfs_ctx(&db); + + { + let mut state = ctx.state.write(); + state.write_buffer.in_atomic_write = true; + state.write_buffer.saved_db_size = state.db_size_pages; + state.write_buffer.dirty.insert(1, vec![0x5c; 4096]); + state.db_size_pages = 1; + } + + hooks.fail_next_commit("InjectedTransportError: atomic transport dropped"); + let err = ctx + .commit_atomic_write() + .expect_err("transport failure should bubble out of commit_atomic_write"); + + assert!( + matches!(err, CommitBufferError::Other(ref message) if message.contains("InjectedTransportError")), + "atomic-write failure should surface as a transport error: {err:?}", + ); + assert!( + ctx.is_dead(), + "commit_atomic_write transport failure should poison the VFS", + ); + assert_eq!( + db.take_last_kv_error().as_deref(), + Some("InjectedTransportError: atomic transport dropped"), + ); +} + +#[test] +fn commit_atomic_write_clears_last_error_on_success() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let engine = runtime.block_on(harness.open_engine()); + let transport = SqliteTransport::from_direct(engine); + let vfs = SqliteVfs::register_with_transport( + &next_test_name("sqlite-direct-vfs"), + transport, + harness.actor_id.clone(), + runtime.handle().clone(), + VfsConfig::default(), + None, + ) + .expect("v2 vfs should register"); + let db = open_database(vfs, &harness.actor_id).expect("sqlite database should open"); + let ctx = direct_vfs_ctx(&db); + + // An empty dirty buffer short-circuits before the success branch runs. + { + let mut state = ctx.state.write(); + state.write_buffer.in_atomic_write = true; + state.write_buffer.saved_db_size = state.db_size_pages; + state.write_buffer.dirty.insert(1, vec![0xa3; 4096]); + state.db_size_pages = 1; + } + + ctx.commit_atomic_write() + .expect("commit_atomic_write should succeed against the direct engine"); + + assert!( + !ctx.is_dead(), + "successful commit_atomic_write must not poison the VFS", + ); + let last_err = db.take_last_kv_error(); + assert!( + last_err.is_none(), + "successful commit_atomic_write must leave last_kv_error unset; got {last_err:?}", + ); +} + +#[test] +fn concurrent_reader_during_commit_atomic_observes_consistent_snapshot() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let engine = runtime.block_on(harness.open_engine()); + let transport = SqliteTransport::from_direct(engine); + let hooks = transport + .direct_hooks() + .expect("direct transport should expose test hooks"); + let ctx = VfsContext::new( + harness.actor_id.clone(), + runtime.handle().clone(), + transport, + VfsConfig::default(), + unsafe { std::mem::zeroed() }, + None, + None, + ) + .expect("vfs context should build"); + + let before_page_1 = vec![0x11; 4096]; + let before_page_2 = vec![0x22; 4096]; + let after_page_1 = vec![0xaa; 4096]; + let after_page_2 = vec![0xbb; 4096]; + { + let mut state = ctx.state.write(); + state.db_size_pages = 2; + state.page_cache.insert(1, before_page_1.clone()); + state.page_cache.insert(2, before_page_2.clone()); + state.write_buffer.in_atomic_write = true; + state.write_buffer.saved_db_size = state.db_size_pages; + state.write_buffer.dirty.insert(1, after_page_1.clone()); + state.write_buffer.dirty.insert(2, after_page_2.clone()); + } + + let pause = hooks.pause_next_commit(); + let (read_rc, observed) = thread::scope(|scope| { + let writer = scope.spawn(|| { + ctx.commit_atomic_write() + .expect("commit_atomic_write should finish after the pause is released"); + }); + pause.wait_until_reached(); + + let reader = scope.spawn(|| { + let mut file = VfsFile { + base: unsafe { std::mem::zeroed() }, + ctx: &ctx, + aux: ptr::null_mut(), + }; + let mut buf = vec![0; 8192]; + let rc = unsafe { + io_read( + (&mut file as *mut VfsFile).cast::(), + buf.as_mut_ptr().cast(), + buf.len() as c_int, + 0, + ) + }; + (rc, buf) + }); + let read = reader.join().expect("reader thread should not panic"); + pause.resume(); + writer.join().expect("writer thread should not panic"); + read + }); + + assert_eq!(read_rc, SQLITE_OK); + let before = [before_page_1, before_page_2].concat(); + let after = [after_page_1, after_page_2].concat(); + assert!( + observed == before || observed == after, + "concurrent xRead during commit_atomic_write saw a torn page snapshot", + ); + let resolved = ctx + .resolve_pages(&[1, 2], false) + .expect("post-commit pages should resolve"); + assert_eq!( + resolved.get(&1).and_then(Option::as_deref), + Some(&after[..4096]), + ); + assert_eq!( + resolved.get(&2).and_then(Option::as_deref), + Some(&after[4096..]), + ); +} + +#[test] +fn vfs_registration_is_removed_after_registration_panic() { + let vfs_name = next_test_name("panic-leak-vfs"); + let c_vfs_name = CString::new(vfs_name).expect("vfs name should not contain NULs"); + + let panic_result = std::panic::catch_unwind(|| { + let mut vfs: sqlite3_vfs = unsafe { std::mem::zeroed() }; + vfs.iVersion = 1; + vfs.szOsFile = std::mem::size_of::() as c_int; + vfs.mxPathname = MAX_PATHNAME; + vfs.zName = c_vfs_name.as_ptr(); + + let _registration = SqliteVfsRegistration::register(vfs).expect("vfs should register"); + let registered = unsafe { sqlite3_vfs_find(c_vfs_name.as_ptr()) }; + assert!(!registered.is_null(), "registered vfs should be findable"); + + panic!("simulate panic after sqlite3_vfs_register"); + }); + + assert!(panic_result.is_err(), "test panic should be captured"); + let registered = unsafe { sqlite3_vfs_find(c_vfs_name.as_ptr()) }; + assert!( + registered.is_null(), + "panicked registration should be unregistered during unwind", + ); +} + +#[test] +fn vfs_delete_main_db_resets_in_memory_state() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let vfs_name = next_test_name("sqlite-direct-vfs"); + let engine = runtime.block_on(harness.open_engine()); + let transport = SqliteTransport::from_direct(engine); + let vfs = SqliteVfs::register_with_transport( + &vfs_name, + transport, + harness.actor_id.clone(), + runtime.handle().clone(), + VfsConfig::default(), + None, + ) + .expect("v2 vfs should register"); + let db = open_database(vfs, &harness.actor_id).expect("sqlite database should open"); + let ctx = direct_vfs_ctx(&db); + + sqlite_exec( + db.as_ptr(), + "CREATE TABLE doomed (id INTEGER PRIMARY KEY, value TEXT NOT NULL);", + ) + .expect("create table should succeed"); + sqlite_exec( + db.as_ptr(), + "INSERT INTO doomed (id, value) VALUES (1, 'gone');", + ) + .expect("insert should succeed"); + + assert!( + ctx.state.read().db_size_pages > 0, + "db should have pages before delete", + ); + + let c_vfs_name = CString::new(vfs_name).expect("vfs name should not contain NULs"); + let c_actor_path = + CString::new(harness.actor_id.as_str()).expect("actor id should not contain NULs"); + let rc = unsafe { + let p_vfs = sqlite3_vfs_find(c_vfs_name.as_ptr()); + assert!(!p_vfs.is_null(), "registered vfs should be findable"); + let x_delete = (*p_vfs).xDelete.expect("vfs must define xDelete"); + x_delete(p_vfs, c_actor_path.as_ptr(), 0) + }; - for id in 0..512 { - let size = ((id * 541) % 16384) + 1; - sqlite_exec( - db.as_ptr(), - &format!("INSERT INTO frag (id, payload) VALUES ({id}, randomblob({size}));"), - ) - .expect("fragmentation insert should succeed"); - } - for id in (0..512).filter(|id| (id * 17 + 11) % 5 == 0) { - sqlite_exec(db.as_ptr(), &format!("DELETE FROM frag WHERE id = {id};")) - .expect("fragmentation delete should succeed"); - } - for id in (0..512).filter(|id| id % 3 == 0) { - let shrink_size = ((id * 13) % 256) + 1; - sqlite_exec( + assert_eq!(rc, SQLITE_IOERR_DELETE); + assert_eq!( + db.take_last_kv_error().as_deref(), + Some("main database deletion is unsupported"), + ); +} + +#[test] +fn direct_engine_handles_multithreaded_statement_churn() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + // Forced-sync: this test shares one SQLite handle across std::thread workers. + let db = Arc::new(SyncMutex::new(harness.open_db(&runtime))); + + { + let db = db.lock(); + sqlite_exec( + db.as_ptr(), + "CREATE TABLE items (id INTEGER PRIMARY KEY AUTOINCREMENT, value TEXT NOT NULL);", + ) + .expect("create table should succeed"); + } + + let mut workers = Vec::new(); + for worker_id in 0..4 { + let db = Arc::clone(&db); + workers.push(thread::spawn(move || { + for idx in 0..40 { + let db = db.lock(); + sqlite_step_statement( + db.as_ptr(), + &format!("INSERT INTO items (value) VALUES ('worker-{worker_id}-row-{idx}');"), + ) + .expect("threaded insert should succeed"); + } + })); + } + for worker in workers { + worker.join().expect("worker thread should finish"); + } + + let db = db.lock(); + assert_eq!( + sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM items;") + .expect("threaded row count should succeed"), + 160 + ); +} + +#[test] +fn direct_engine_isolates_two_actors_on_one_shared_engine() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let engine = runtime.block_on(harness.open_engine()); + let actor_a = next_test_name("sqlite-actor-a"); + let actor_b = next_test_name("sqlite-actor-b"); + let db_a = harness.open_db_on_engine( + &runtime, + Arc::clone(&engine), + &actor_a, + VfsConfig::default(), + ); + let db_b = harness.open_db_on_engine(&runtime, engine, &actor_b, VfsConfig::default()); + + sqlite_exec( + db_a.as_ptr(), + "CREATE TABLE items (id INTEGER PRIMARY KEY, value TEXT NOT NULL);", + ) + .expect("actor A create table should succeed"); + sqlite_exec( + db_b.as_ptr(), + "CREATE TABLE items (id INTEGER PRIMARY KEY, value TEXT NOT NULL);", + ) + .expect("actor B create table should succeed"); + sqlite_step_statement( + db_a.as_ptr(), + "INSERT INTO items (id, value) VALUES (1, 'alpha');", + ) + .expect("actor A insert should succeed"); + sqlite_step_statement( + db_b.as_ptr(), + "INSERT INTO items (id, value) VALUES (1, 'beta');", + ) + .expect("actor B insert should succeed"); + + assert_eq!( + sqlite_query_text(db_a.as_ptr(), "SELECT value FROM items WHERE id = 1;") + .expect("actor A select should succeed"), + "alpha" + ); + assert_eq!( + sqlite_query_text(db_b.as_ptr(), "SELECT value FROM items WHERE id = 1;") + .expect("actor B select should succeed"), + "beta" + ); +} + +#[test] +fn direct_engine_hot_row_updates_survive_reopen() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + + { + let db = harness.open_db(&runtime); + sqlite_exec( + db.as_ptr(), + "CREATE TABLE counters (id INTEGER PRIMARY KEY, value TEXT NOT NULL);", + ) + .expect("create table should succeed"); + sqlite_step_statement( + db.as_ptr(), + "INSERT INTO counters (id, value) VALUES (1, 'v-0');", + ) + .expect("seed row should succeed"); + for i in 1..=150 { + sqlite_step_statement( db.as_ptr(), - &format!("UPDATE frag SET payload = randomblob({shrink_size}) WHERE id = {id};"), + &format!("UPDATE counters SET value = 'v-{i}' WHERE id = 1;"), ) - .expect("fragmentation shrink should succeed"); + .expect("hot-row update should succeed"); } - for id in (0..512).filter(|id| id % 7 == 0) { - let grow_size = 16384 + ((id * 97) % 8192); + } + + let reopened = harness.open_db(&runtime); + assert_eq!( + sqlite_query_text( + reopened.as_ptr(), + "SELECT value FROM counters WHERE id = 1;" + ) + .expect("final value should survive reopen"), + "v-150" + ); +} + +#[test] +fn direct_engine_repeated_close_reopen_cycles_preserve_state() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let engine = runtime.block_on(harness.open_engine()); + let actor_id = &harness.actor_id; + + for cycle in 0..20 { + let db = + harness.open_db_on_engine(&runtime, engine.clone(), actor_id, VfsConfig::default()); + sqlite_exec( + db.as_ptr(), + "CREATE TABLE IF NOT EXISTS reopen_cycles ( + id INTEGER PRIMARY KEY, + cycle INTEGER NOT NULL, + value INTEGER NOT NULL + );", + ) + .expect("create table should succeed"); + + let start = cycle * 25; + for id in start..start + 25 { sqlite_exec( db.as_ptr(), - &format!("UPDATE frag SET payload = randomblob({grow_size}) WHERE id = {id};"), + &format!( + "INSERT INTO reopen_cycles (id, cycle, value) VALUES ({id}, {cycle}, {}) + ON CONFLICT(id) DO UPDATE SET cycle = excluded.cycle, value = excluded.value;", + id * 3 + ), ) - .expect("fragmentation grow should succeed"); + .expect("insert across reopen cycle should succeed"); } - sqlite_exec(db.as_ptr(), "VACUUM;").expect("vacuum should succeed"); assert_eq!( - sqlite_query_i64(db.as_ptr(), "SELECT SUM(balance) FROM accounts;") - .expect("balance sum should succeed"), - 8000 - ); - assert_eq!( - sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM transfer_log;") - .expect("transfer log count should succeed"), - 500 - ); - assert_eq!( - sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM frag;") - .expect("frag count should succeed"), - 410 - ); - assert!( - sqlite_query_i64(db.as_ptr(), "SELECT SUM(length(payload)) FROM frag;") - .expect("frag payload sum should succeed") - > 0 - ); - assert_eq!( - sqlite_query_text(db.as_ptr(), "PRAGMA integrity_check;") - .expect("integrity_check should succeed"), - "ok" + sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM reopen_cycles;") + .expect("count during reopen cycle should succeed"), + ((cycle + 1) * 25) as i64 ); } - #[test] - fn direct_engine_batch_atomic_probe_runs_on_open() { - let runtime = direct_runtime(); - let harness = DirectEngineHarness::new(); - let db = harness.open_db(&runtime); - - assert!( - db._vfs.commit_atomic_count() > 0, - "open_database should run the sqlite batch-atomic probe", - ); - } + let reopened = + harness.open_db_on_engine(&runtime, engine.clone(), actor_id, VfsConfig::default()); + assert_eq!( + sqlite_query_i64(reopened.as_ptr(), "SELECT COUNT(*) FROM reopen_cycles;") + .expect("final reopen count should succeed"), + 500 + ); + assert_eq!( + sqlite_query_i64(reopened.as_ptr(), "SELECT SUM(value) FROM reopen_cycles;") + .expect("final reopen sum should succeed"), + (0..500).map(|id| (id * 3) as i64).sum::() + ); + assert_eq!( + sqlite_query_text(reopened.as_ptr(), "PRAGMA integrity_check;") + .expect("integrity_check after reopen loop should succeed"), + "ok" + ); +} - #[test] - fn direct_engine_marks_vfs_dead_after_transport_errors() { - let runtime = direct_runtime(); - let harness = DirectEngineHarness::new(); - let engine = runtime.block_on(harness.open_engine()); - let transport = SqliteTransport::from_direct(engine); - let hooks = transport - .direct_hooks() - .expect("direct transport should expose test hooks"); - let vfs = SqliteVfs::register_with_transport( - &next_test_name("sqlite-direct-vfs"), - transport, - harness.actor_id.clone(), - runtime.handle().clone(), - VfsConfig::default(), - None, - ) - .expect("v2 vfs should register"); - let db = open_database(vfs, &harness.actor_id).expect("sqlite database should open"); +#[test] +fn direct_engine_preserves_mixed_workload_across_sleep_wake() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); - hooks.fail_next_commit("InjectedTransportError: commit transport dropped"); - let err = sqlite_exec( + { + let db = harness.open_db(&runtime); + sqlite_exec( db.as_ptr(), - "CREATE TABLE broken (id INTEGER PRIMARY KEY, value TEXT NOT NULL);", - ) - .expect_err("failing transport commit should surface as an IO error"); - assert!( - err.contains("I/O") || err.contains("disk I/O"), - "sqlite should surface transport failure as an IO error: {err}", - ); - assert!( - direct_vfs_ctx(&db).is_dead(), - "transport error should kill the v2 VFS" - ); - assert_eq!( - db.take_last_kv_error().as_deref(), - Some("InjectedTransportError: commit transport dropped"), - ); - assert!( - sqlite_query_i64(db.as_ptr(), "PRAGMA page_count;").is_err(), - "subsequent reads should fail once the VFS is dead", - ); - } - - #[test] - fn flush_dirty_pages_marks_vfs_dead_after_transport_error() { - let runtime = direct_runtime(); - let harness = DirectEngineHarness::new(); - let engine = runtime.block_on(harness.open_engine()); - let transport = SqliteTransport::from_direct(engine); - let hooks = transport - .direct_hooks() - .expect("direct transport should expose test hooks"); - let vfs = SqliteVfs::register_with_transport( - &next_test_name("sqlite-direct-vfs"), - transport, - harness.actor_id.clone(), - runtime.handle().clone(), - VfsConfig::default(), - None, - ) - .expect("v2 vfs should register"); - let db = open_database(vfs, &harness.actor_id).expect("sqlite database should open"); - let ctx = direct_vfs_ctx(&db); - - { - let mut state = ctx.state.write(); - state.write_buffer.dirty.insert(1, vec![0x7a; 4096]); - state.db_size_pages = 1; - } - - hooks.fail_next_commit("InjectedTransportError: flush transport dropped"); - let err = ctx - .flush_dirty_pages() - .expect_err("transport failure should bubble out of flush_dirty_pages"); - - assert!( - matches!(err, CommitBufferError::Other(ref message) if message.contains("InjectedTransportError")), - "flush failure should surface as a transport error: {err:?}", - ); - assert!( - ctx.is_dead(), - "flush transport failure should poison the VFS" - ); - assert_eq!( - db.take_last_kv_error().as_deref(), - Some("InjectedTransportError: flush transport dropped"), - ); - } - - #[test] - fn commit_atomic_write_marks_vfs_dead_after_transport_error() { - let runtime = direct_runtime(); - let harness = DirectEngineHarness::new(); - let engine = runtime.block_on(harness.open_engine()); - let transport = SqliteTransport::from_direct(engine); - let hooks = transport - .direct_hooks() - .expect("direct transport should expose test hooks"); - let vfs = SqliteVfs::register_with_transport( - &next_test_name("sqlite-direct-vfs"), - transport, - harness.actor_id.clone(), - runtime.handle().clone(), - VfsConfig::default(), - None, - ) - .expect("v2 vfs should register"); - let db = open_database(vfs, &harness.actor_id).expect("sqlite database should open"); - let ctx = direct_vfs_ctx(&db); - - { - let mut state = ctx.state.write(); - state.write_buffer.in_atomic_write = true; - state.write_buffer.saved_db_size = state.db_size_pages; - state.write_buffer.dirty.insert(1, vec![0x5c; 4096]); - state.db_size_pages = 1; - } - - hooks.fail_next_commit("InjectedTransportError: atomic transport dropped"); - let err = ctx - .commit_atomic_write() - .expect_err("transport failure should bubble out of commit_atomic_write"); - - assert!( - matches!(err, CommitBufferError::Other(ref message) if message.contains("InjectedTransportError")), - "atomic-write failure should surface as a transport error: {err:?}", - ); - assert!( - ctx.is_dead(), - "commit_atomic_write transport failure should poison the VFS", - ); - assert_eq!( - db.take_last_kv_error().as_deref(), - Some("InjectedTransportError: atomic transport dropped"), - ); - } - - #[test] - fn commit_atomic_write_clears_last_error_on_success() { - let runtime = direct_runtime(); - let harness = DirectEngineHarness::new(); - let engine = runtime.block_on(harness.open_engine()); - let transport = SqliteTransport::from_direct(engine); - let vfs = SqliteVfs::register_with_transport( - &next_test_name("sqlite-direct-vfs"), - transport, - harness.actor_id.clone(), - runtime.handle().clone(), - VfsConfig::default(), - None, - ) - .expect("v2 vfs should register"); - let db = open_database(vfs, &harness.actor_id).expect("sqlite database should open"); - let ctx = direct_vfs_ctx(&db); - - // An empty dirty buffer short-circuits before the success branch runs. - { - let mut state = ctx.state.write(); - state.write_buffer.in_atomic_write = true; - state.write_buffer.saved_db_size = state.db_size_pages; - state.write_buffer.dirty.insert(1, vec![0xa3; 4096]); - state.db_size_pages = 1; - } - - ctx.commit_atomic_write() - .expect("commit_atomic_write should succeed against the direct engine"); - - assert!( - !ctx.is_dead(), - "successful commit_atomic_write must not poison the VFS", - ); - let last_err = db.take_last_kv_error(); - assert!( - last_err.is_none(), - "successful commit_atomic_write must leave last_kv_error unset; got {last_err:?}", - ); - } - - #[test] - fn concurrent_reader_during_commit_atomic_observes_consistent_snapshot() { - let runtime = direct_runtime(); - let harness = DirectEngineHarness::new(); - let engine = runtime.block_on(harness.open_engine()); - let transport = SqliteTransport::from_direct(engine); - let hooks = transport - .direct_hooks() - .expect("direct transport should expose test hooks"); - let ctx = VfsContext::new( - harness.actor_id.clone(), - runtime.handle().clone(), - transport, - VfsConfig::default(), - unsafe { std::mem::zeroed() }, - None, - ) - .expect("vfs context should build"); - - let before_page_1 = vec![0x11; 4096]; - let before_page_2 = vec![0x22; 4096]; - let after_page_1 = vec![0xaa; 4096]; - let after_page_2 = vec![0xbb; 4096]; - { - let mut state = ctx.state.write(); - state.db_size_pages = 2; - state.page_cache.insert(1, before_page_1.clone()); - state.page_cache.insert(2, before_page_2.clone()); - state.write_buffer.in_atomic_write = true; - state.write_buffer.saved_db_size = state.db_size_pages; - state.write_buffer.dirty.insert(1, after_page_1.clone()); - state.write_buffer.dirty.insert(2, after_page_2.clone()); - } - - let pause = hooks.pause_next_commit(); - let (read_rc, observed) = thread::scope(|scope| { - let writer = scope.spawn(|| { - ctx.commit_atomic_write() - .expect("commit_atomic_write should finish after the pause is released"); - }); - pause.wait_until_reached(); - - let reader = scope.spawn(|| { - let mut file = VfsFile { - base: unsafe { std::mem::zeroed() }, - ctx: &ctx, - aux: ptr::null_mut(), - }; - let mut buf = vec![0; 8192]; - let rc = unsafe { - io_read( - (&mut file as *mut VfsFile).cast::(), - buf.as_mut_ptr().cast(), - buf.len() as c_int, - 0, - ) - }; - (rc, buf) - }); - let read = reader.join().expect("reader thread should not panic"); - pause.resume(); - writer.join().expect("writer thread should not panic"); - read - }); - - assert_eq!(read_rc, SQLITE_OK); - let before = [before_page_1, before_page_2].concat(); - let after = [after_page_1, after_page_2].concat(); - assert!( - observed == before || observed == after, - "concurrent xRead during commit_atomic_write saw a torn page snapshot", - ); - let resolved = ctx - .resolve_pages(&[1, 2], false) - .expect("post-commit pages should resolve"); - assert_eq!( - resolved.get(&1).and_then(Option::as_deref), - Some(&after[..4096]), - ); - assert_eq!( - resolved.get(&2).and_then(Option::as_deref), - Some(&after[4096..]), - ); - } - - #[test] - fn vfs_registration_is_removed_after_registration_panic() { - let vfs_name = next_test_name("panic-leak-vfs"); - let c_vfs_name = CString::new(vfs_name).expect("vfs name should not contain NULs"); - - let panic_result = std::panic::catch_unwind(|| { - let mut vfs: sqlite3_vfs = unsafe { std::mem::zeroed() }; - vfs.iVersion = 1; - vfs.szOsFile = std::mem::size_of::() as c_int; - vfs.mxPathname = MAX_PATHNAME; - vfs.zName = c_vfs_name.as_ptr(); - - let _registration = - SqliteVfsRegistration::register(vfs).expect("vfs should register"); - let registered = unsafe { sqlite3_vfs_find(c_vfs_name.as_ptr()) }; - assert!(!registered.is_null(), "registered vfs should be findable"); - - panic!("simulate panic after sqlite3_vfs_register"); - }); - - assert!(panic_result.is_err(), "test panic should be captured"); - let registered = unsafe { sqlite3_vfs_find(c_vfs_name.as_ptr()) }; - assert!( - registered.is_null(), - "panicked registration should be unregistered during unwind", - ); - } - - #[test] - fn vfs_delete_main_db_resets_in_memory_state() { - let runtime = direct_runtime(); - let harness = DirectEngineHarness::new(); - let vfs_name = next_test_name("sqlite-direct-vfs"); - let engine = runtime.block_on(harness.open_engine()); - let transport = SqliteTransport::from_direct(engine); - let vfs = SqliteVfs::register_with_transport( - &vfs_name, - transport, - harness.actor_id.clone(), - runtime.handle().clone(), - VfsConfig::default(), - None, - ) - .expect("v2 vfs should register"); - let db = open_database(vfs, &harness.actor_id).expect("sqlite database should open"); - let ctx = direct_vfs_ctx(&db); - - sqlite_exec( - db.as_ptr(), - "CREATE TABLE doomed (id INTEGER PRIMARY KEY, value TEXT NOT NULL);", - ) - .expect("create table should succeed"); - sqlite_exec( - db.as_ptr(), - "INSERT INTO doomed (id, value) VALUES (1, 'gone');", - ) - .expect("insert should succeed"); - - assert!( - ctx.state.read().db_size_pages > 0, - "db should have pages before delete", - ); - - let c_vfs_name = CString::new(vfs_name).expect("vfs name should not contain NULs"); - let c_actor_path = - CString::new(harness.actor_id.as_str()).expect("actor id should not contain NULs"); - let rc = unsafe { - let p_vfs = sqlite3_vfs_find(c_vfs_name.as_ptr()); - assert!(!p_vfs.is_null(), "registered vfs should be findable"); - let x_delete = (*p_vfs).xDelete.expect("vfs must define xDelete"); - x_delete(p_vfs, c_actor_path.as_ptr(), 0) - }; - - assert_eq!(rc, SQLITE_IOERR_DELETE); - assert_eq!( - db.take_last_kv_error().as_deref(), - Some("main database deletion is unsupported"), - ); - } - - #[test] - fn direct_engine_handles_multithreaded_statement_churn() { - let runtime = direct_runtime(); - let harness = DirectEngineHarness::new(); - // Forced-sync: this test shares one SQLite handle across std::thread workers. - let db = Arc::new(SyncMutex::new(harness.open_db(&runtime))); - - { - let db = db.lock(); - sqlite_exec( - db.as_ptr(), - "CREATE TABLE items (id INTEGER PRIMARY KEY AUTOINCREMENT, value TEXT NOT NULL);", - ) - .expect("create table should succeed"); - } - - let mut workers = Vec::new(); - for worker_id in 0..4 { - let db = Arc::clone(&db); - workers.push(thread::spawn(move || { - for idx in 0..40 { - let db = db.lock(); - sqlite_step_statement( - db.as_ptr(), - &format!( - "INSERT INTO items (value) VALUES ('worker-{worker_id}-row-{idx}');" - ), - ) - .expect("threaded insert should succeed"); - } - })); - } - for worker in workers { - worker.join().expect("worker thread should finish"); - } - - let db = db.lock(); - assert_eq!( - sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM items;") - .expect("threaded row count should succeed"), - 160 - ); - } - - #[test] - fn direct_engine_isolates_two_actors_on_one_shared_engine() { - let runtime = direct_runtime(); - let harness = DirectEngineHarness::new(); - let engine = runtime.block_on(harness.open_engine()); - let actor_a = next_test_name("sqlite-actor-a"); - let actor_b = next_test_name("sqlite-actor-b"); - let db_a = harness.open_db_on_engine( - &runtime, - Arc::clone(&engine), - &actor_a, - VfsConfig::default(), - ); - let db_b = harness.open_db_on_engine(&runtime, engine, &actor_b, VfsConfig::default()); - - sqlite_exec( - db_a.as_ptr(), - "CREATE TABLE items (id INTEGER PRIMARY KEY, value TEXT NOT NULL);", - ) - .expect("actor A create table should succeed"); - sqlite_exec( - db_b.as_ptr(), - "CREATE TABLE items (id INTEGER PRIMARY KEY, value TEXT NOT NULL);", - ) - .expect("actor B create table should succeed"); - sqlite_step_statement( - db_a.as_ptr(), - "INSERT INTO items (id, value) VALUES (1, 'alpha');", - ) - .expect("actor A insert should succeed"); - sqlite_step_statement( - db_b.as_ptr(), - "INSERT INTO items (id, value) VALUES (1, 'beta');", - ) - .expect("actor B insert should succeed"); - - assert_eq!( - sqlite_query_text(db_a.as_ptr(), "SELECT value FROM items WHERE id = 1;") - .expect("actor A select should succeed"), - "alpha" - ); - assert_eq!( - sqlite_query_text(db_b.as_ptr(), "SELECT value FROM items WHERE id = 1;") - .expect("actor B select should succeed"), - "beta" - ); - } - - #[test] - fn direct_engine_hot_row_updates_survive_reopen() { - let runtime = direct_runtime(); - let harness = DirectEngineHarness::new(); - - { - let db = harness.open_db(&runtime); - sqlite_exec( - db.as_ptr(), - "CREATE TABLE counters (id INTEGER PRIMARY KEY, value TEXT NOT NULL);", - ) - .expect("create table should succeed"); - sqlite_step_statement( - db.as_ptr(), - "INSERT INTO counters (id, value) VALUES (1, 'v-0');", - ) - .expect("seed row should succeed"); - for i in 1..=150 { - sqlite_step_statement( - db.as_ptr(), - &format!("UPDATE counters SET value = 'v-{i}' WHERE id = 1;"), - ) - .expect("hot-row update should succeed"); - } - } - - let reopened = harness.open_db(&runtime); - assert_eq!( - sqlite_query_text( - reopened.as_ptr(), - "SELECT value FROM counters WHERE id = 1;" - ) - .expect("final value should survive reopen"), - "v-150" - ); - } - - #[test] - fn direct_engine_repeated_close_reopen_cycles_preserve_state() { - let runtime = direct_runtime(); - let harness = DirectEngineHarness::new(); - let engine = runtime.block_on(harness.open_engine()); - let actor_id = &harness.actor_id; - - for cycle in 0..20 { - let db = - harness.open_db_on_engine(&runtime, engine.clone(), actor_id, VfsConfig::default()); - sqlite_exec( - db.as_ptr(), - "CREATE TABLE IF NOT EXISTS reopen_cycles ( - id INTEGER PRIMARY KEY, - cycle INTEGER NOT NULL, - value INTEGER NOT NULL - );", - ) - .expect("create table should succeed"); - - let start = cycle * 25; - for id in start..start + 25 { - sqlite_exec( - db.as_ptr(), - &format!( - "INSERT INTO reopen_cycles (id, cycle, value) VALUES ({id}, {cycle}, {}) - ON CONFLICT(id) DO UPDATE SET cycle = excluded.cycle, value = excluded.value;", - id * 3 - ), - ) - .expect("insert across reopen cycle should succeed"); - } - - assert_eq!( - sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM reopen_cycles;") - .expect("count during reopen cycle should succeed"), - ((cycle + 1) * 25) as i64 - ); - } - - let reopened = - harness.open_db_on_engine(&runtime, engine.clone(), actor_id, VfsConfig::default()); - assert_eq!( - sqlite_query_i64(reopened.as_ptr(), "SELECT COUNT(*) FROM reopen_cycles;") - .expect("final reopen count should succeed"), - 500 - ); - assert_eq!( - sqlite_query_i64(reopened.as_ptr(), "SELECT SUM(value) FROM reopen_cycles;") - .expect("final reopen sum should succeed"), - (0..500).map(|id| (id * 3) as i64).sum::() - ); - assert_eq!( - sqlite_query_text(reopened.as_ptr(), "PRAGMA integrity_check;") - .expect("integrity_check after reopen loop should succeed"), - "ok" - ); - } - - #[test] - fn direct_engine_preserves_mixed_workload_across_sleep_wake() { - let runtime = direct_runtime(); - let harness = DirectEngineHarness::new(); - - { - let db = harness.open_db(&runtime); - sqlite_exec( - db.as_ptr(), - "CREATE TABLE items (id INTEGER PRIMARY KEY, value TEXT NOT NULL, status TEXT NOT NULL);", - ) - .expect("create table should succeed"); - for id in 1..=50 { - sqlite_step_statement( - db.as_ptr(), - &format!( - "INSERT INTO items (id, value, status) VALUES ({id}, 'item-{id}', 'new');" - ), - ) - .expect("seed insert should succeed"); - } - for id in 1..=20 { - sqlite_step_statement( - db.as_ptr(), - &format!( - "UPDATE items SET status = 'updated', value = 'item-{id}-updated' WHERE id = {id};" - ), - ) - .expect("update should succeed"); - } - for id in 41..=50 { - sqlite_step_statement(db.as_ptr(), &format!("DELETE FROM items WHERE id = {id};")) - .expect("delete should succeed"); - } - sqlite_step_statement( - db.as_ptr(), - "INSERT INTO items (id, value, status) VALUES (1000, 'disconnect-write', 'new');", - ) - .expect("disconnect-style write before close should succeed"); - } - - let reopened = harness.open_db(&runtime); - assert_eq!( - sqlite_query_i64(reopened.as_ptr(), "SELECT COUNT(*) FROM items;") - .expect("row count after reopen should succeed"), - 41 - ); - assert_eq!( - sqlite_query_i64( - reopened.as_ptr(), - "SELECT COUNT(*) FROM items WHERE status = 'updated';", - ) - .expect("updated row count should succeed"), - 20 - ); - assert_eq!( - sqlite_query_text( - reopened.as_ptr(), - "SELECT value FROM items WHERE id = 1000;", - ) - .expect("disconnect write should survive reopen"), - "disconnect-write" - ); - } - - #[test] - fn direct_engine_reopens_cleanly_after_failed_migration() { - let runtime = direct_runtime(); - let harness = DirectEngineHarness::new(); - - { - let db = harness.open_db(&runtime); - sqlite_exec( - db.as_ptr(), - "CREATE TABLE items (id INTEGER PRIMARY KEY, value TEXT NOT NULL);", - ) - .expect("create table should succeed"); - sqlite_exec(db.as_ptr(), "ALTER TABLE items ADD COLUMN;") - .expect_err("broken migration should fail"); - } - - let reopened = harness.open_db(&runtime); - sqlite_step_statement( - reopened.as_ptr(), - "INSERT INTO items (id, value) VALUES (1, 'still-alive');", - ) - .expect("reopened database should still accept writes after migration failure"); - assert_eq!( - sqlite_query_text(reopened.as_ptr(), "SELECT value FROM items WHERE id = 1;") - .expect("select after reopen should succeed"), - "still-alive" - ); - } - - #[test] - fn direct_engine_fresh_reopen_recovers_after_poisoned_handle() { - let runtime = direct_runtime(); - let harness = DirectEngineHarness::new(); - let engine = runtime.block_on(harness.open_engine()); - let transport = SqliteTransport::from_direct(engine.clone()); - let hooks = transport - .direct_hooks() - .expect("direct transport should expose test hooks"); - let vfs = SqliteVfs::register_with_transport( - &next_test_name("sqlite-direct-vfs"), - transport, - harness.actor_id.clone(), - runtime.handle().clone(), - VfsConfig::default(), - None, - ) - .expect("v2 vfs should register"); - let db = open_database(vfs, &harness.actor_id).expect("sqlite database should open"); - - sqlite_exec( - db.as_ptr(), - "CREATE TABLE stable_rows (id INTEGER PRIMARY KEY, value TEXT NOT NULL);", - ) - .expect("create table should succeed"); - sqlite_exec( - db.as_ptr(), - "INSERT INTO stable_rows (id, value) VALUES (1, 'committed-before-failure');", - ) - .expect("seed write should succeed"); - - hooks.fail_next_commit("InjectedTransportError: reopen recovery transport dropped"); - let err = sqlite_exec( - db.as_ptr(), - "INSERT INTO stable_rows (id, value) VALUES (2, 'should-not-commit');", - ) - .expect_err("failing transport commit should surface as an IO error"); - assert!( - err.contains("I/O") || err.contains("disk I/O"), - "sqlite should surface transport failure as an IO error: {err}", - ); - assert!( - direct_vfs_ctx(&db).is_dead(), - "transport error should kill the live VFS", - ); - - drop(db); - - let reopened = harness.open_db_on_engine( - &runtime, - engine.clone(), - &harness.actor_id, - VfsConfig::default(), - ); - assert_eq!( - sqlite_query_i64(reopened.as_ptr(), "SELECT COUNT(*) FROM stable_rows;") - .expect("reopened count should succeed"), - 1 - ); - assert_eq!( - sqlite_query_text( - reopened.as_ptr(), - "SELECT value FROM stable_rows WHERE id = 1;" - ) - .expect("committed row should survive reopen"), - "committed-before-failure" - ); - assert_eq!( - sqlite_query_i64( - reopened.as_ptr(), - "SELECT COUNT(*) FROM stable_rows WHERE id = 2;", - ) - .expect("failed row should stay absent"), - 0 - ); - - sqlite_exec( - reopened.as_ptr(), - "INSERT INTO stable_rows (id, value) VALUES (3, 'after-reopen');", - ) - .expect("fresh reopen should accept new writes"); - assert_eq!( - sqlite_query_i64(reopened.as_ptr(), "SELECT COUNT(*) FROM stable_rows;") - .expect("final count should succeed"), - 2 - ); - } - - #[test] - fn direct_engine_crash_with_dirty_buffer_recovers_last_commit() { - let runtime = direct_runtime(); - let harness = DirectEngineHarness::new(); - let engine = runtime.block_on(harness.open_engine()); - let transport = SqliteTransport::from_direct(engine.clone()); - let hooks = transport - .direct_hooks() - .expect("direct transport should expose test hooks"); - let vfs = SqliteVfs::register_with_transport( - &next_test_name("sqlite-direct-vfs"), - transport, - harness.actor_id.clone(), - runtime.handle().clone(), - VfsConfig::default(), - None, - ) - .expect("v2 vfs should register"); - let db = open_database(vfs, &harness.actor_id).expect("sqlite database should open"); - - sqlite_exec( - db.as_ptr(), - "CREATE TABLE crash_rows (id INTEGER PRIMARY KEY, value TEXT NOT NULL);", - ) - .expect("create table should succeed"); - sqlite_exec( - db.as_ptr(), - "INSERT INTO crash_rows (id, value) VALUES (1, 'durable-before-crash');", - ) - .expect("seed write should succeed"); - - let ctx = direct_vfs_ctx(&db); - { - let mut state = ctx.state.write(); - state.write_buffer.in_atomic_write = true; - state.write_buffer.saved_db_size = state.db_size_pages; - state.write_buffer.dirty.insert(1, empty_db_page()); - state.db_size_pages = 1; - } - hooks.fail_next_commit("InjectedTransportError: crash before dirty buffer commit ack"); - drop(db); - - let reopened = harness.open_db_on_engine( - &runtime, - engine.clone(), - &harness.actor_id, - VfsConfig::default(), - ); - assert_eq!( - sqlite_query_i64(reopened.as_ptr(), "SELECT COUNT(*) FROM crash_rows;") - .expect("reopened database should keep the last successful commit"), - 1 - ); - assert_eq!( - sqlite_query_text( - reopened.as_ptr(), - "SELECT value FROM crash_rows WHERE id = 1;" - ) - .expect("committed row should survive dirty-buffer crash"), - "durable-before-crash" - ); - } - - #[test] - fn direct_engine_aux_open_failure_surfaces_without_poisoning_main_db() { - let runtime = direct_runtime(); - let harness = DirectEngineHarness::new(); - let db = harness.open_db(&runtime); - let ctx = direct_vfs_ctx(&db); - - sqlite_exec( - db.as_ptr(), - "CREATE TABLE items (id INTEGER PRIMARY KEY, value TEXT NOT NULL);", - ) - .expect("create table should succeed"); - sqlite_exec( - db.as_ptr(), - "INSERT INTO items (id, value) VALUES (1, 'still-works');", - ) - .expect("seed write should succeed"); - - ctx.fail_next_aux_open("InjectedAuxOpenError: attached db open failed"); - let err = sqlite_exec(db.as_ptr(), "ATTACH 'scratch-aux.db' AS scratch;") - .expect_err("attach should surface aux open failure"); - assert!( - err.contains("open") || err.contains("I/O") || err.contains("disk I/O"), - "sqlite should surface aux open failure: {err}", - ); - assert_eq!( - db.take_last_kv_error().as_deref(), - Some("InjectedAuxOpenError: attached db open failed"), - ); - assert!( - !ctx.is_dead(), - "aux open failure should not poison the main db handle", - ); - assert_eq!( - sqlite_query_text(db.as_ptr(), "SELECT value FROM items WHERE id = 1;") - .expect("main db should remain queryable"), - "still-works" - ); - } - - #[test] - fn vfs_delete_surfaces_aux_delete_failure() { - let runtime = direct_runtime(); - let harness = DirectEngineHarness::new(); - let db = harness.open_db(&runtime); - let ctx = direct_vfs_ctx(&db); - - ctx.open_aux_file("actor-journal"); - ctx.fail_next_aux_delete("InjectedAuxDeleteError: delete failed"); - let path = CString::new("actor-journal").expect("cstring should build"); - - let rc = unsafe { vfs_delete(db._vfs.vfs_ptr(), path.as_ptr(), 0) }; - assert_eq!(rc, SQLITE_IOERR_DELETE); - assert_eq!( - db.take_last_kv_error().as_deref(), - Some("InjectedAuxDeleteError: delete failed"), - ); - assert!( - ctx.aux_file_exists("actor-journal"), - "failed delete should leave aux state intact", - ); - } - - #[test] - fn direct_engine_commits_trigger_workflow_compaction_wake() { - let runtime = direct_runtime(); - let harness = DirectEngineHarness::new(); - let engine = runtime.block_on(harness.open_engine()); - let db = harness.open_db_on_engine( - &runtime, - Arc::clone(&engine), - &harness.actor_id, - VfsConfig::default(), - ); - sqlite_exec( - db.as_ptr(), - "CREATE TABLE items (id INTEGER PRIMARY KEY, value TEXT NOT NULL);", - ) - .expect("create table should succeed"); - for id in 1..=40 { - sqlite_step_statement( - db.as_ptr(), - &format!("INSERT INTO items (id, value) VALUES ({id}, 'row-{id}');"), - ) - .expect("seed insert should succeed"); - } - - assert_eq!( - sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM items;") - .expect("final row count should succeed"), - 40 - ); - let signals = engine.compaction_signals(); - assert!( - !signals.is_empty(), - "VFS commits should wake workflow compaction once hot lag is actionable", - ); - assert!( - signals - .iter() - .any(|signal| signal.observed_head_txid >= 32), - "workflow wake should observe the actionable hot-lag txid: {signals:?}", - ); - } - - #[test] - fn native_database_drop_times_out_pending_commit() { - let runtime = direct_runtime(); - let harness = DirectEngineHarness::new(); - let engine = runtime.block_on(harness.open_engine()); - let transport = SqliteTransport::from_direct(engine); - let hooks = transport - .direct_hooks() - .expect("direct transport should expose test hooks"); - let vfs = SqliteVfs::register_with_transport( - &next_test_name("sqlite-direct-vfs"), - transport, - harness.actor_id.clone(), - runtime.handle().clone(), - VfsConfig::default(), - None, - ) - .expect("vfs should register"); - let db = open_database(vfs, &harness.actor_id).expect("db should open"); - let commit_count_before_drop = hooks.commit_requests().len(); - { - let ctx = db._vfs.ctx(); - let mut state = ctx.state.write(); - state.db_size_pages = 1; - state.write_buffer.dirty.insert(1, empty_db_page()); - } - hooks.hang_next_commit(); - - let (finished_tx, finished_rx) = mpsc::channel(); - let drop_thread = thread::spawn(move || { - drop(db); - finished_tx - .send(()) - .expect("drop completion should be reported"); - }); - - finished_rx - .recv_timeout(Duration::from_secs(2)) - .expect("drop should not block forever on a pending commit"); - drop_thread.join().expect("drop thread should finish"); - assert_eq!(hooks.commit_requests().len(), commit_count_before_drop + 1); - } - - #[test] - fn open_database_supports_empty_db_schema_setup() { - let runtime = direct_runtime(); - let harness = DirectEngineHarness::new(); - let db = harness.open_db(&runtime); - - sqlite_exec( - db.as_ptr(), - "CREATE TABLE test (id INTEGER PRIMARY KEY, value TEXT NOT NULL);", - ) - .expect("schema setup should succeed"); - } - - #[test] - fn open_database_supports_insert_after_pragma_migration() { - let runtime = direct_runtime(); - let harness = DirectEngineHarness::new(); - let db = harness.open_db(&runtime); - - sqlite_exec( - db.as_ptr(), - "CREATE TABLE items (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL);", - ) - .expect("create table should succeed"); - sqlite_exec( - db.as_ptr(), - "ALTER TABLE items ADD COLUMN status TEXT NOT NULL DEFAULT 'active';", - ) - .expect("alter table should succeed"); - sqlite_exec(db.as_ptr(), "PRAGMA user_version = 2;").expect("pragma should succeed"); - sqlite_step_statement( - db.as_ptr(), - "INSERT INTO items (name) VALUES ('test-item');", - ) - .expect("insert after pragma migration should succeed"); - } - - #[test] - fn open_database_supports_explicit_status_insert_after_pragma_migration() { - let runtime = direct_runtime(); - let harness = DirectEngineHarness::new(); - let db = harness.open_db(&runtime); - - sqlite_exec( - db.as_ptr(), - "CREATE TABLE items (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL);", - ) - .expect("create table should succeed"); - sqlite_exec( - db.as_ptr(), - "ALTER TABLE items ADD COLUMN status TEXT NOT NULL DEFAULT 'active';", - ) - .expect("alter table should succeed"); - sqlite_exec(db.as_ptr(), "PRAGMA user_version = 2;").expect("pragma should succeed"); - sqlite_step_statement( - db.as_ptr(), - "INSERT INTO items (name, status) VALUES ('done-item', 'completed');", - ) - .expect("explicit status insert should succeed"); - } - - #[test] - fn open_database_supports_hot_row_update_churn() { - let runtime = direct_runtime(); - let harness = DirectEngineHarness::new(); - let db = harness.open_db(&runtime); - - sqlite_exec( - db.as_ptr(), - "CREATE TABLE test_data (id INTEGER PRIMARY KEY AUTOINCREMENT, value TEXT NOT NULL, payload TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL);", - ) - .expect("create table should succeed"); - for i in 0..10 { - sqlite_step_statement( - db.as_ptr(), - &format!( - "INSERT INTO test_data (value, payload, created_at) VALUES ('init-{i}', '', 1);" - ), - ) - .expect("seed insert should succeed"); - } - for i in 0..240 { - let row_id = i % 10 + 1; - sqlite_step_statement( - db.as_ptr(), - &format!("UPDATE test_data SET value = 'v-{i}' WHERE id = {row_id};"), - ) - .expect("hot-row update should succeed"); - } - } - - #[test] - fn open_database_supports_cross_thread_exec_sequence() { - let runtime = direct_runtime(); - let harness = DirectEngineHarness::new(); - // Forced-sync: this test moves one SQLite handle between std::thread workers. - let db = Arc::new(SyncMutex::new(harness.open_db(&runtime))); - - { - let db = db.clone(); - thread::spawn(move || { - let db = db.lock(); - sqlite_exec( - db.as_ptr(), - "CREATE TABLE items (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL);", - ) - .expect("create table should succeed"); - sqlite_exec( - db.as_ptr(), - "ALTER TABLE items ADD COLUMN status TEXT NOT NULL DEFAULT 'active';", - ) - .expect("alter table should succeed"); - sqlite_exec(db.as_ptr(), "PRAGMA user_version = 2;") - .expect("pragma should succeed"); - }) - .join() - .expect("migration thread should finish"); - } - - thread::spawn(move || { - let db = db.lock(); - sqlite_step_statement( - db.as_ptr(), - "INSERT INTO items (name) VALUES ('test-item');", - ) - .expect("cross-thread insert should succeed"); - }) - .join() - .expect("insert thread should finish"); - } - - #[test] - fn aux_files_are_shared_by_path_until_deleted() { - let runtime = direct_runtime(); - let harness = DirectEngineHarness::new(); - let ctx = harness.open_context(&runtime); - - let first = ctx.open_aux_file("actor-journal"); - first.bytes.lock().extend_from_slice(&[1, 2, 3, 4]); - let second = ctx.open_aux_file("actor-journal"); - assert_eq!(*second.bytes.lock(), vec![1, 2, 3, 4]); - assert!(ctx.aux_file_exists("actor-journal")); - - ctx.delete_aux_file("actor-journal"); - assert!(!ctx.aux_file_exists("actor-journal")); - assert!(ctx.open_aux_file("actor-journal").bytes.lock().is_empty()); - } - - #[test] - fn concurrent_aux_file_opens_share_single_state() { - let runtime = direct_runtime(); - let harness = DirectEngineHarness::new(); - let ctx = Arc::new(harness.open_context(&runtime)); - let barrier = Arc::new(Barrier::new(2)); - - let first = { - let ctx = ctx.clone(); - let barrier = barrier.clone(); - thread::spawn(move || { - barrier.wait(); - ctx.open_aux_file("actor-journal") - }) - }; - let second = { - let ctx = ctx.clone(); - let barrier = barrier.clone(); - thread::spawn(move || { - barrier.wait(); - ctx.open_aux_file("actor-journal") - }) - }; - - let first = first.join().expect("first open should complete"); - let second = second.join().expect("second open should complete"); - assert!(Arc::ptr_eq(&first, &second)); - assert_eq!(ctx.aux_files.read().len(), 1); - } - - #[test] - fn truncate_main_file_discards_pages_beyond_eof() { - let runtime = direct_runtime(); - let harness = DirectEngineHarness::new(); - let ctx = harness.open_context(&runtime); - { - let mut state = ctx.state.write(); - state.write_buffer.dirty.insert(3, vec![3; 4096]); - state.write_buffer.dirty.insert(4, vec![4; 4096]); - } - - ctx.truncate_main_file(2 * 4096); - - let state = ctx.state.read(); - assert_eq!(state.db_size_pages, 2); - assert!(!state.write_buffer.dirty.contains_key(&3)); - assert!(!state.write_buffer.dirty.contains_key(&4)); - assert!(state.page_cache.get(&4).is_none()); - } - - #[test] - fn resolve_pages_surfaces_read_path_error_response() { - let runtime = direct_runtime(); - let harness = DirectEngineHarness::new(); - let ctx = harness.open_context(&runtime); - ctx.transport - .direct_hooks() - .expect("direct transport should expose test hooks") - .fail_next_get_pages("InjectedGetPagesError: read path dropped"); - - let err = ctx - .resolve_pages(&[2], false) - .expect_err("read-path error response should surface"); - assert!(matches!( - err, - GetPagesError::Other(ref message) - if message.contains("InjectedGetPagesError") - )); - } - - #[test] - fn commit_buffered_pages_uses_fast_path() { - let runtime = direct_runtime(); - let harness = DirectEngineHarness::new(); - let engine = runtime.block_on(harness.open_engine()); - let transport = SqliteTransport::from_direct(engine); - let hooks = transport - .direct_hooks() - .expect("direct transport should expose test hooks"); - - let outcome = runtime - .block_on(commit_buffered_pages( - &transport, - BufferedCommitRequest { - actor_id: harness.actor_id.clone(), - new_db_size_pages: 1, - dirty_pages: vec![protocol::SqliteDirtyPage { - pgno: 1, - bytes: empty_db_page(), - }], - }, - )) - .expect("fast-path commit should succeed"); - let (outcome, metrics) = outcome; - - assert_eq!(outcome.path, CommitPath::Fast); - assert_eq!(outcome.db_size_pages, 1); - assert!(metrics.serialize_ns > 0); - assert!(metrics.transport_ns > 0); - assert_eq!(hooks.commit_requests().len(), 1); - } - - #[test] - fn vfs_records_commit_phase_durations() { - let runtime = direct_runtime(); - let harness = DirectEngineHarness::new(); - let db = harness.open_db(&runtime); - let ctx = direct_vfs_ctx(&db); - - sqlite_exec( - db.as_ptr(), - "CREATE TABLE metrics_test (id INTEGER PRIMARY KEY, value TEXT NOT NULL);", - ) - .expect("create table should succeed"); - - let relaxed = std::sync::atomic::Ordering::Relaxed; - ctx.commit_request_build_ns.store(0, relaxed); - ctx.commit_serialize_ns.store(0, relaxed); - ctx.commit_transport_ns.store(0, relaxed); - ctx.commit_state_update_ns.store(0, relaxed); - ctx.commit_duration_ns_total.store(0, relaxed); - ctx.commit_total.store(0, relaxed); - - sqlite_exec( - db.as_ptr(), - "INSERT INTO metrics_test (id, value) VALUES (1, 'hello');", - ) - .expect("insert should succeed"); - - let metrics = db.sqlite_vfs_metrics(); - assert_eq!(metrics.commit_count, 1); - assert!(metrics.request_build_ns > 0); - assert!(metrics.serialize_ns > 0); - assert!(metrics.transport_ns > 0); - assert!(metrics.state_update_ns > 0); - assert!(metrics.total_ns >= metrics.request_build_ns); - assert!(metrics.request_build_ns + metrics.transport_ns + metrics.state_update_ns > 0); - } - - #[test] - fn profile_large_tx_insert_5mb() { - // 5MB = 1280 rows x 4KB blobs in one transaction - let runtime = direct_runtime(); - let harness = DirectEngineHarness::new(); - let db = harness.open_db(&runtime); - let ctx = direct_vfs_ctx(&db); - - sqlite_exec( - db.as_ptr(), - "CREATE TABLE bench (id INTEGER PRIMARY KEY, payload BLOB NOT NULL);", + "CREATE TABLE items (id INTEGER PRIMARY KEY, value TEXT NOT NULL, status TEXT NOT NULL);", ) .expect("create table should succeed"); - - let relaxed = std::sync::atomic::Ordering::Relaxed; - ctx.resolve_pages_total.store(0, relaxed); - ctx.resolve_pages_cache_hits.store(0, relaxed); - ctx.resolve_pages_fetches.store(0, relaxed); - ctx.pages_fetched_total.store(0, relaxed); - ctx.prefetch_pages_total.store(0, relaxed); - ctx.commit_total.store(0, relaxed); - - let start = std::time::Instant::now(); - sqlite_exec(db.as_ptr(), "BEGIN;").expect("begin"); - for i in 0..1280 { + for id in 1..=50 { sqlite_step_statement( db.as_ptr(), &format!( - "INSERT INTO bench (id, payload) VALUES ({}, randomblob(4096));", - i + "INSERT INTO items (id, value, status) VALUES ({id}, 'item-{id}', 'new');" ), - ) - .expect("insert should succeed"); - } - sqlite_exec(db.as_ptr(), "COMMIT;").expect("commit"); - let elapsed = start.elapsed(); - - let resolve_total = ctx.resolve_pages_total.load(relaxed); - let cache_hits = ctx.resolve_pages_cache_hits.load(relaxed); - let fetches = ctx.resolve_pages_fetches.load(relaxed); - let pages_fetched = ctx.pages_fetched_total.load(relaxed); - let prefetch = ctx.prefetch_pages_total.load(relaxed); - let commits = ctx.commit_total.load(relaxed); - - eprintln!("=== 5MB INSERT PROFILE (1280 rows x 4KB) ==="); - eprintln!(" wall clock: {:?}", elapsed); - eprintln!(" resolve_pages calls: {}", resolve_total); - eprintln!(" cache hits (pages): {}", cache_hits); - eprintln!(" engine fetches: {}", fetches); - eprintln!(" pages fetched total: {}", pages_fetched); - eprintln!(" prefetch pages: {}", prefetch); - eprintln!(" commits: {}", commits); - eprintln!("============================================"); - - // In a single transaction, all 1280 row writes are to new pages. - // Only the single commit at the end should hit the engine. - assert_eq!( - fetches, 0, - "expected 0 engine fetches during 5MB insert transaction" - ); - assert_eq!( - commits, 1, - "expected exactly 1 commit for transactional insert" - ); - - let count = sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM bench;") - .expect("count should succeed"); - assert_eq!(count, 1280); - } - - #[test] - fn profile_hot_row_updates() { - // 100 updates to the same row - this is the autocommit case - let runtime = direct_runtime(); - let harness = DirectEngineHarness::new(); - let db = harness.open_db(&runtime); - let ctx = direct_vfs_ctx(&db); - - sqlite_exec( - db.as_ptr(), - "CREATE TABLE counter (id INTEGER PRIMARY KEY, value INTEGER NOT NULL);", - ) - .expect("create"); - sqlite_exec(db.as_ptr(), "INSERT INTO counter VALUES (1, 0);").expect("insert"); - - let relaxed = std::sync::atomic::Ordering::Relaxed; - ctx.resolve_pages_total.store(0, relaxed); - ctx.resolve_pages_cache_hits.store(0, relaxed); - ctx.resolve_pages_fetches.store(0, relaxed); - ctx.pages_fetched_total.store(0, relaxed); - ctx.prefetch_pages_total.store(0, relaxed); - ctx.commit_total.store(0, relaxed); - - let start = std::time::Instant::now(); - for _ in 0..100 { - sqlite_exec( - db.as_ptr(), - "UPDATE counter SET value = value + 1 WHERE id = 1;", - ) - .expect("update"); - } - let elapsed = start.elapsed(); - - let fetches = ctx.resolve_pages_fetches.load(relaxed); - let commits = ctx.commit_total.load(relaxed); - - eprintln!("=== 100 HOT ROW UPDATES (autocommit) ==="); - eprintln!(" wall clock: {:?}", elapsed); - eprintln!( - " resolve_pages calls: {}", - ctx.resolve_pages_total.load(relaxed) - ); - eprintln!( - " cache hits (pages): {}", - ctx.resolve_pages_cache_hits.load(relaxed) - ); - eprintln!(" engine fetches: {}", fetches); - eprintln!( - " pages fetched total: {}", - ctx.pages_fetched_total.load(relaxed) - ); - eprintln!( - " prefetch pages: {}", - ctx.prefetch_pages_total.load(relaxed) - ); - eprintln!(" commits: {}", commits); - eprintln!("========================================="); - - // Hot row updates: each update modifies the same page. Pages already - // in write_buffer or cache should not need re-fetching. With the - // counter's page(s) already warm, subsequent updates should be - // 100% cache hits (0 fetches). Autocommit means 100 separate commits. - assert_eq!( - fetches, 0, - "expected 0 engine fetches for 100 hot row updates" - ); - assert_eq!( - commits, 100, - "expected 100 commits (autocommit per statement)" - ); - } - - #[test] - fn profile_large_tx_insert_1mb_preloaded() { - // Same as the 1MB test but preload all pages first to see commit-only cost - let runtime = direct_runtime(); - let harness = DirectEngineHarness::new(); - let engine = runtime.block_on(harness.open_engine()); - let actor_id = &harness.actor_id; - - // First pass: create and populate the table to generate pages - let db1 = - harness.open_db_on_engine(&runtime, engine.clone(), actor_id, VfsConfig::default()); - sqlite_exec( - db1.as_ptr(), - "CREATE TABLE bench (id INTEGER PRIMARY KEY, payload BLOB NOT NULL);", - ) - .expect("create table should succeed"); - sqlite_exec(db1.as_ptr(), "BEGIN;").expect("begin"); - for i in 0..256 { - sqlite_step_statement( - db1.as_ptr(), - &format!( - "INSERT INTO bench (id, payload) VALUES ({}, randomblob(4096));", - i - ), - ) - .expect("insert should succeed"); - } - sqlite_exec(db1.as_ptr(), "COMMIT;").expect("commit"); - drop(db1); - - // Second pass: reopen with warm cache (takeover preloads page 1, rest from reads) - let db2 = - harness.open_db_on_engine(&runtime, engine.clone(), actor_id, VfsConfig::default()); - let ctx = direct_vfs_ctx(&db2); - - // Warm the cache by reading everything - sqlite_exec(db2.as_ptr(), "SELECT COUNT(*) FROM bench;").expect("count"); - - // Reset counters - let relaxed = std::sync::atomic::Ordering::Relaxed; - ctx.resolve_pages_total.store(0, relaxed); - ctx.resolve_pages_cache_hits.store(0, relaxed); - ctx.resolve_pages_fetches.store(0, relaxed); - ctx.pages_fetched_total.store(0, relaxed); - ctx.prefetch_pages_total.store(0, relaxed); - ctx.commit_total.store(0, relaxed); - - let start = std::time::Instant::now(); - sqlite_exec(db2.as_ptr(), "BEGIN;").expect("begin"); - for i in 256..512 { - sqlite_step_statement( - db2.as_ptr(), - &format!( - "INSERT INTO bench (id, payload) VALUES ({}, randomblob(4096));", - i - ), - ) - .expect("insert should succeed"); - } - sqlite_exec(db2.as_ptr(), "COMMIT;").expect("commit"); - let elapsed = start.elapsed(); - - let resolve_total = ctx.resolve_pages_total.load(relaxed); - let cache_hits = ctx.resolve_pages_cache_hits.load(relaxed); - let fetches = ctx.resolve_pages_fetches.load(relaxed); - let pages_fetched = ctx.pages_fetched_total.load(relaxed); - let prefetch = ctx.prefetch_pages_total.load(relaxed); - let commits = ctx.commit_total.load(relaxed); - - eprintln!("=== 1MB INSERT PROFILE (WARM CACHE) ==="); - eprintln!(" wall clock: {:?}", elapsed); - eprintln!(" resolve_pages calls: {}", resolve_total); - eprintln!(" cache hits (pages): {}", cache_hits); - eprintln!(" engine fetches: {}", fetches); - eprintln!(" pages fetched total: {}", pages_fetched); - eprintln!(" prefetch pages: {}", prefetch); - eprintln!(" commits: {}", commits); - eprintln!("========================================"); - - // Second 256-row transaction into the already-populated table. - // All new pages are beyond db_size_pages, so no engine fetches. - assert_eq!( - fetches, 0, - "expected 0 engine fetches during warm 1MB insert" - ); - assert_eq!( - commits, 1, - "expected exactly 1 commit for transactional insert" - ); - - let count = sqlite_query_i64(db2.as_ptr(), "SELECT COUNT(*) FROM bench;") - .expect("count should succeed"); - assert_eq!(count, 512); - } - - #[test] - fn profile_large_tx_insert_1mb() { - let runtime = direct_runtime(); - let harness = DirectEngineHarness::new(); - let db = harness.open_db(&runtime); - let ctx = direct_vfs_ctx(&db); - - sqlite_exec( - db.as_ptr(), - "CREATE TABLE bench (id INTEGER PRIMARY KEY, payload BLOB NOT NULL);", - ) - .expect("create table should succeed"); - - // Reset counters after schema setup - ctx.resolve_pages_total - .store(0, std::sync::atomic::Ordering::Relaxed); - ctx.resolve_pages_cache_hits - .store(0, std::sync::atomic::Ordering::Relaxed); - ctx.resolve_pages_fetches - .store(0, std::sync::atomic::Ordering::Relaxed); - ctx.pages_fetched_total - .store(0, std::sync::atomic::Ordering::Relaxed); - ctx.prefetch_pages_total - .store(0, std::sync::atomic::Ordering::Relaxed); - ctx.commit_total - .store(0, std::sync::atomic::Ordering::Relaxed); - - let start = std::time::Instant::now(); - - sqlite_exec(db.as_ptr(), "BEGIN;").expect("begin should succeed"); - for i in 0..256 { + ) + .expect("seed insert should succeed"); + } + for id in 1..=20 { sqlite_step_statement( db.as_ptr(), &format!( - "INSERT INTO bench (id, payload) VALUES ({}, randomblob(4096));", - i + "UPDATE items SET status = 'updated', value = 'item-{id}-updated' WHERE id = {id};" ), ) - .expect("insert should succeed"); + .expect("update should succeed"); } - sqlite_exec(db.as_ptr(), "COMMIT;").expect("commit should succeed"); - - let elapsed = start.elapsed(); - let relaxed = std::sync::atomic::Ordering::Relaxed; - - let resolve_total = ctx.resolve_pages_total.load(relaxed); - let cache_hits = ctx.resolve_pages_cache_hits.load(relaxed); - let fetches = ctx.resolve_pages_fetches.load(relaxed); - let pages_fetched = ctx.pages_fetched_total.load(relaxed); - let prefetch = ctx.prefetch_pages_total.load(relaxed); - let commits = ctx.commit_total.load(relaxed); - - eprintln!("=== 1MB INSERT PROFILE (256 rows x 4KB) ==="); - eprintln!(" wall clock: {:?}", elapsed); - eprintln!(" resolve_pages calls: {}", resolve_total); - eprintln!(" cache hits (pages): {}", cache_hits); - eprintln!(" engine fetches: {}", fetches); - eprintln!(" pages fetched total: {}", pages_fetched); - eprintln!(" prefetch pages: {}", prefetch); - eprintln!(" commits: {}", commits); - eprintln!("============================================"); - - // Assert expected zero-fetch behavior: in a single transaction, - // all writes are to new pages, so no engine fetches should happen. - // Only the single commit at the end should hit the engine. - assert_eq!( - fetches, 0, - "expected 0 engine fetches during 1MB insert transaction" - ); - assert_eq!( - commits, 1, - "expected exactly 1 commit for transactional insert" - ); - - let count = sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM bench;") - .expect("count should succeed"); - assert_eq!(count, 256); + for id in 41..=50 { + sqlite_step_statement(db.as_ptr(), &format!("DELETE FROM items WHERE id = {id};")) + .expect("delete should succeed"); + } + sqlite_step_statement( + db.as_ptr(), + "INSERT INTO items (id, value, status) VALUES (1000, 'disconnect-write', 'new');", + ) + .expect("disconnect-style write before close should succeed"); } - // Regression test for fence mismatch during rapid autocommit inserts. - // Each autocommit INSERT is its own transaction. This test drives many - // sequential commits through the VFS and verifies they all succeed. - #[test] - fn autocommit_inserts_maintain_head_txid_consistency() { - let runtime = direct_runtime(); - let harness = DirectEngineHarness::new(); - let db = harness.open_db(&runtime); - let ctx = direct_vfs_ctx(&db); + let reopened = harness.open_db(&runtime); + assert_eq!( + sqlite_query_i64(reopened.as_ptr(), "SELECT COUNT(*) FROM items;") + .expect("row count after reopen should succeed"), + 41 + ); + assert_eq!( + sqlite_query_i64( + reopened.as_ptr(), + "SELECT COUNT(*) FROM items WHERE status = 'updated';", + ) + .expect("updated row count should succeed"), + 20 + ); + assert_eq!( + sqlite_query_text( + reopened.as_ptr(), + "SELECT value FROM items WHERE id = 1000;", + ) + .expect("disconnect write should survive reopen"), + "disconnect-write" + ); +} +#[test] +fn direct_engine_reopens_cleanly_after_failed_migration() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + + { + let db = harness.open_db(&runtime); sqlite_exec( db.as_ptr(), - "CREATE TABLE t (id INTEGER PRIMARY KEY, v INTEGER NOT NULL);", + "CREATE TABLE items (id INTEGER PRIMARY KEY, value TEXT NOT NULL);", + ) + .expect("create table should succeed"); + sqlite_exec(db.as_ptr(), "ALTER TABLE items ADD COLUMN;") + .expect_err("broken migration should fail"); + } + + let reopened = harness.open_db(&runtime); + sqlite_step_statement( + reopened.as_ptr(), + "INSERT INTO items (id, value) VALUES (1, 'still-alive');", + ) + .expect("reopened database should still accept writes after migration failure"); + assert_eq!( + sqlite_query_text(reopened.as_ptr(), "SELECT value FROM items WHERE id = 1;") + .expect("select after reopen should succeed"), + "still-alive" + ); +} + +#[test] +fn direct_engine_fresh_reopen_recovers_after_poisoned_handle() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let engine = runtime.block_on(harness.open_engine()); + let transport = SqliteTransport::from_direct(engine.clone()); + let hooks = transport + .direct_hooks() + .expect("direct transport should expose test hooks"); + let vfs = SqliteVfs::register_with_transport( + &next_test_name("sqlite-direct-vfs"), + transport, + harness.actor_id.clone(), + runtime.handle().clone(), + VfsConfig::default(), + None, + ) + .expect("v2 vfs should register"); + let db = open_database(vfs, &harness.actor_id).expect("sqlite database should open"); + + sqlite_exec( + db.as_ptr(), + "CREATE TABLE stable_rows (id INTEGER PRIMARY KEY, value TEXT NOT NULL);", + ) + .expect("create table should succeed"); + sqlite_exec( + db.as_ptr(), + "INSERT INTO stable_rows (id, value) VALUES (1, 'committed-before-failure');", + ) + .expect("seed write should succeed"); + + hooks.fail_next_commit("InjectedTransportError: reopen recovery transport dropped"); + let err = sqlite_exec( + db.as_ptr(), + "INSERT INTO stable_rows (id, value) VALUES (2, 'should-not-commit');", + ) + .expect_err("failing transport commit should surface as an IO error"); + assert!( + err.contains("I/O") || err.contains("disk I/O"), + "sqlite should surface transport failure as an IO error: {err}", + ); + assert!( + direct_vfs_ctx(&db).is_dead(), + "transport error should kill the live VFS", + ); + + drop(db); + + let reopened = harness.open_db_on_engine( + &runtime, + engine.clone(), + &harness.actor_id, + VfsConfig::default(), + ); + assert_eq!( + sqlite_query_i64(reopened.as_ptr(), "SELECT COUNT(*) FROM stable_rows;") + .expect("reopened count should succeed"), + 1 + ); + assert_eq!( + sqlite_query_text( + reopened.as_ptr(), + "SELECT value FROM stable_rows WHERE id = 1;" + ) + .expect("committed row should survive reopen"), + "committed-before-failure" + ); + assert_eq!( + sqlite_query_i64( + reopened.as_ptr(), + "SELECT COUNT(*) FROM stable_rows WHERE id = 2;", + ) + .expect("failed row should stay absent"), + 0 + ); + + sqlite_exec( + reopened.as_ptr(), + "INSERT INTO stable_rows (id, value) VALUES (3, 'after-reopen');", + ) + .expect("fresh reopen should accept new writes"); + assert_eq!( + sqlite_query_i64(reopened.as_ptr(), "SELECT COUNT(*) FROM stable_rows;") + .expect("final count should succeed"), + 2 + ); +} + +#[test] +fn direct_engine_crash_with_dirty_buffer_recovers_last_commit() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let engine = runtime.block_on(harness.open_engine()); + let transport = SqliteTransport::from_direct(engine.clone()); + let hooks = transport + .direct_hooks() + .expect("direct transport should expose test hooks"); + let vfs = SqliteVfs::register_with_transport( + &next_test_name("sqlite-direct-vfs"), + transport, + harness.actor_id.clone(), + runtime.handle().clone(), + VfsConfig::default(), + None, + ) + .expect("v2 vfs should register"); + let db = open_database(vfs, &harness.actor_id).expect("sqlite database should open"); + + sqlite_exec( + db.as_ptr(), + "CREATE TABLE crash_rows (id INTEGER PRIMARY KEY, value TEXT NOT NULL);", + ) + .expect("create table should succeed"); + sqlite_exec( + db.as_ptr(), + "INSERT INTO crash_rows (id, value) VALUES (1, 'durable-before-crash');", + ) + .expect("seed write should succeed"); + + let ctx = direct_vfs_ctx(&db); + { + let mut state = ctx.state.write(); + state.write_buffer.in_atomic_write = true; + state.write_buffer.saved_db_size = state.db_size_pages; + state.write_buffer.dirty.insert(1, empty_db_page()); + state.db_size_pages = 1; + } + hooks.fail_next_commit("InjectedTransportError: crash before dirty buffer commit ack"); + drop(db); + + let reopened = harness.open_db_on_engine( + &runtime, + engine.clone(), + &harness.actor_id, + VfsConfig::default(), + ); + assert_eq!( + sqlite_query_i64(reopened.as_ptr(), "SELECT COUNT(*) FROM crash_rows;") + .expect("reopened database should keep the last successful commit"), + 1 + ); + assert_eq!( + sqlite_query_text( + reopened.as_ptr(), + "SELECT value FROM crash_rows WHERE id = 1;" + ) + .expect("committed row should survive dirty-buffer crash"), + "durable-before-crash" + ); +} + +#[test] +fn direct_engine_aux_open_failure_surfaces_without_poisoning_main_db() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let db = harness.open_db(&runtime); + let ctx = direct_vfs_ctx(&db); + + sqlite_exec( + db.as_ptr(), + "CREATE TABLE items (id INTEGER PRIMARY KEY, value TEXT NOT NULL);", + ) + .expect("create table should succeed"); + sqlite_exec( + db.as_ptr(), + "INSERT INTO items (id, value) VALUES (1, 'still-works');", + ) + .expect("seed write should succeed"); + + ctx.fail_next_aux_open("InjectedAuxOpenError: attached db open failed"); + let err = sqlite_exec(db.as_ptr(), "ATTACH 'scratch-aux.db' AS scratch;") + .expect_err("attach should surface aux open failure"); + assert!( + err.contains("open") || err.contains("I/O") || err.contains("disk I/O"), + "sqlite should surface aux open failure: {err}", + ); + assert_eq!( + db.take_last_kv_error().as_deref(), + Some("InjectedAuxOpenError: attached db open failed"), + ); + assert!( + !ctx.is_dead(), + "aux open failure should not poison the main db handle", + ); + assert_eq!( + sqlite_query_text(db.as_ptr(), "SELECT value FROM items WHERE id = 1;") + .expect("main db should remain queryable"), + "still-works" + ); +} + +#[test] +fn vfs_delete_surfaces_aux_delete_failure() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let db = harness.open_db(&runtime); + let ctx = direct_vfs_ctx(&db); + + ctx.open_aux_file("actor-journal"); + ctx.fail_next_aux_delete("InjectedAuxDeleteError: delete failed"); + let path = CString::new("actor-journal").expect("cstring should build"); + + let rc = unsafe { vfs_delete(db._vfs.vfs_ptr(), path.as_ptr(), 0) }; + assert_eq!(rc, SQLITE_IOERR_DELETE); + assert_eq!( + db.take_last_kv_error().as_deref(), + Some("InjectedAuxDeleteError: delete failed"), + ); + assert!( + ctx.aux_file_exists("actor-journal"), + "failed delete should leave aux state intact", + ); +} + +#[test] +fn direct_engine_commits_trigger_workflow_compaction_wake() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let engine = runtime.block_on(harness.open_engine()); + let db = harness.open_db_on_engine( + &runtime, + Arc::clone(&engine), + &harness.actor_id, + VfsConfig::default(), + ); + sqlite_exec( + db.as_ptr(), + "CREATE TABLE items (id INTEGER PRIMARY KEY, value TEXT NOT NULL);", + ) + .expect("create table should succeed"); + for id in 1..=40 { + sqlite_step_statement( + db.as_ptr(), + &format!("INSERT INTO items (id, value) VALUES ({id}, 'row-{id}');"), + ) + .expect("seed insert should succeed"); + } + + assert_eq!( + sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM items;") + .expect("final row count should succeed"), + 40 + ); + let signals = engine.compaction_signals(); + assert!( + !signals.is_empty(), + "VFS commits should wake workflow compaction once hot lag is actionable", + ); + assert!( + signals.iter().any(|signal| signal.observed_head_txid >= 32), + "workflow wake should observe the actionable hot-lag txid: {signals:?}", + ); +} + +#[test] +fn native_database_drop_times_out_pending_commit() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let engine = runtime.block_on(harness.open_engine()); + let transport = SqliteTransport::from_direct(engine); + let hooks = transport + .direct_hooks() + .expect("direct transport should expose test hooks"); + let vfs = SqliteVfs::register_with_transport( + &next_test_name("sqlite-direct-vfs"), + transport, + harness.actor_id.clone(), + runtime.handle().clone(), + VfsConfig::default(), + None, + ) + .expect("vfs should register"); + let db = open_database(vfs, &harness.actor_id).expect("db should open"); + let commit_count_before_drop = hooks.commit_requests().len(); + { + let ctx = db._vfs.ctx(); + let mut state = ctx.state.write(); + state.db_size_pages = 1; + state.write_buffer.dirty.insert(1, empty_db_page()); + } + hooks.hang_next_commit(); + + let (finished_tx, finished_rx) = mpsc::channel(); + let drop_thread = thread::spawn(move || { + drop(db); + finished_tx + .send(()) + .expect("drop completion should be reported"); + }); + + finished_rx + .recv_timeout(Duration::from_secs(2)) + .expect("drop should not block forever on a pending commit"); + drop_thread.join().expect("drop thread should finish"); + assert_eq!(hooks.commit_requests().len(), commit_count_before_drop + 1); +} + +#[test] +fn open_database_supports_empty_db_schema_setup() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let db = harness.open_db(&runtime); + + sqlite_exec( + db.as_ptr(), + "CREATE TABLE test (id INTEGER PRIMARY KEY, value TEXT NOT NULL);", + ) + .expect("schema setup should succeed"); +} + +#[test] +fn open_database_supports_insert_after_pragma_migration() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let db = harness.open_db(&runtime); + + sqlite_exec( + db.as_ptr(), + "CREATE TABLE items (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL);", + ) + .expect("create table should succeed"); + sqlite_exec( + db.as_ptr(), + "ALTER TABLE items ADD COLUMN status TEXT NOT NULL DEFAULT 'active';", + ) + .expect("alter table should succeed"); + sqlite_exec(db.as_ptr(), "PRAGMA user_version = 2;").expect("pragma should succeed"); + sqlite_step_statement( + db.as_ptr(), + "INSERT INTO items (name) VALUES ('test-item');", + ) + .expect("insert after pragma migration should succeed"); +} + +#[test] +fn open_database_supports_explicit_status_insert_after_pragma_migration() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let db = harness.open_db(&runtime); + + sqlite_exec( + db.as_ptr(), + "CREATE TABLE items (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL);", + ) + .expect("create table should succeed"); + sqlite_exec( + db.as_ptr(), + "ALTER TABLE items ADD COLUMN status TEXT NOT NULL DEFAULT 'active';", + ) + .expect("alter table should succeed"); + sqlite_exec(db.as_ptr(), "PRAGMA user_version = 2;").expect("pragma should succeed"); + sqlite_step_statement( + db.as_ptr(), + "INSERT INTO items (name, status) VALUES ('done-item', 'completed');", + ) + .expect("explicit status insert should succeed"); +} + +#[test] +fn open_database_supports_hot_row_update_churn() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let db = harness.open_db(&runtime); + + sqlite_exec( + db.as_ptr(), + "CREATE TABLE test_data (id INTEGER PRIMARY KEY AUTOINCREMENT, value TEXT NOT NULL, payload TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL);", ) .expect("create table should succeed"); + for i in 0..10 { + sqlite_step_statement( + db.as_ptr(), + &format!( + "INSERT INTO test_data (value, payload, created_at) VALUES ('init-{i}', '', 1);" + ), + ) + .expect("seed insert should succeed"); + } + for i in 0..240 { + let row_id = i % 10 + 1; + sqlite_step_statement( + db.as_ptr(), + &format!("UPDATE test_data SET value = 'v-{i}' WHERE id = {row_id};"), + ) + .expect("hot-row update should succeed"); + } +} - let relaxed = std::sync::atomic::Ordering::Relaxed; - ctx.commit_total.store(0, relaxed); +#[test] +fn open_database_supports_cross_thread_exec_sequence() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + // Forced-sync: this test moves one SQLite handle between std::thread workers. + let db = Arc::new(SyncMutex::new(harness.open_db(&runtime))); - // 100 sequential autocommit inserts. If fence mismatch is the bug, - // this will fail partway through with "commit head_txid X did not - // match current head_txid X-1". - for i in 0..100 { + { + let db = db.clone(); + thread::spawn(move || { + let db = db.lock(); sqlite_exec( db.as_ptr(), - &format!("INSERT INTO t (id, v) VALUES ({i}, {});", i * 2), + "CREATE TABLE items (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL);", ) - .expect("autocommit insert should not fence-mismatch"); - } - - let commits = ctx.commit_total.load(relaxed); - // Each autocommit INSERT = 1 commit. CREATE TABLE was 1 more. - // We reset commit_total after CREATE, so expect 100. - assert_eq!(commits, 100, "expected exactly 100 commits"); - - let count = - sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM t;").expect("count should succeed"); - assert_eq!(count, 100); - - // Verify the sum to make sure data is correct and not corrupted - let sum = - sqlite_query_i64(db.as_ptr(), "SELECT SUM(v) FROM t;").expect("sum should succeed"); - assert_eq!(sum, (0..100).map(|i| i * 2).sum::()); - } - - // Regression test: 5 actors run 200 autocommits each on the same engine. - // Compaction is triggered via the mpsc channel after each commit, so this - // also exercises the commit-vs-compaction race that caused fence rewinds - // before the tx_get_value_serializable fix. - #[test] - fn stress_concurrent_multi_actor_autocommits() { - let runtime = direct_runtime(); - let harness = DirectEngineHarness::new(); - let engine = runtime.block_on(harness.open_engine()); - - let mut dbs = Vec::new(); - for i in 0..5 { - let actor_id = format!("{}-stress-{}", harness.actor_id, i); - let db = harness.open_db_on_engine( - &runtime, - engine.clone(), - &actor_id, - VfsConfig::default(), - ); + .expect("create table should succeed"); sqlite_exec( db.as_ptr(), - "CREATE TABLE t (id INTEGER PRIMARY KEY, v INTEGER NOT NULL);", + "ALTER TABLE items ADD COLUMN status TEXT NOT NULL DEFAULT 'active';", ) - .expect("create"); - dbs.push(db); - } + .expect("alter table should succeed"); + sqlite_exec(db.as_ptr(), "PRAGMA user_version = 2;").expect("pragma should succeed"); + }) + .join() + .expect("migration thread should finish"); + } - // Interleave 200 autocommit inserts across all 5 actors - for i in 0..200 { - for db in &dbs { - sqlite_exec( - db.as_ptr(), - &format!("INSERT INTO t (id, v) VALUES ({i}, {i});"), - ) - .expect("insert"); - } - } + thread::spawn(move || { + let db = db.lock(); + sqlite_step_statement( + db.as_ptr(), + "INSERT INTO items (name) VALUES ('test-item');", + ) + .expect("cross-thread insert should succeed"); + }) + .join() + .expect("insert thread should finish"); +} + +#[test] +fn aux_files_are_shared_by_path_until_deleted() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let ctx = harness.open_context(&runtime); + + let first = ctx.open_aux_file("actor-journal"); + first.bytes.lock().extend_from_slice(&[1, 2, 3, 4]); + let second = ctx.open_aux_file("actor-journal"); + assert_eq!(*second.bytes.lock(), vec![1, 2, 3, 4]); + assert!(ctx.aux_file_exists("actor-journal")); + + ctx.delete_aux_file("actor-journal"); + assert!(!ctx.aux_file_exists("actor-journal")); + assert!(ctx.open_aux_file("actor-journal").bytes.lock().is_empty()); +} + +#[test] +fn concurrent_aux_file_opens_share_single_state() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let ctx = Arc::new(harness.open_context(&runtime)); + let barrier = Arc::new(Barrier::new(2)); + + let first = { + let ctx = ctx.clone(); + let barrier = barrier.clone(); + thread::spawn(move || { + barrier.wait(); + ctx.open_aux_file("actor-journal") + }) + }; + let second = { + let ctx = ctx.clone(); + let barrier = barrier.clone(); + thread::spawn(move || { + barrier.wait(); + ctx.open_aux_file("actor-journal") + }) + }; - for db in &dbs { - let count = sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM t;").expect("count"); - assert_eq!(count, 200); - } + let first = first.join().expect("first open should complete"); + let second = second.join().expect("second open should complete"); + assert!(Arc::ptr_eq(&first, &second)); + assert_eq!(ctx.aux_files.read().len(), 1); +} + +#[test] +fn truncate_main_file_discards_pages_beyond_eof() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let ctx = harness.open_context(&runtime); + { + let mut state = ctx.state.write(); + state.write_buffer.dirty.insert(3, vec![3; 4096]); + state.write_buffer.dirty.insert(4, vec![4; 4096]); + } + + ctx.truncate_main_file(2 * 4096); + + let state = ctx.state.read(); + assert_eq!(state.db_size_pages, 2); + assert!(!state.write_buffer.dirty.contains_key(&3)); + assert!(!state.write_buffer.dirty.contains_key(&4)); + assert!(state.page_cache.get(&4).is_none()); +} + +#[test] +fn resolve_pages_surfaces_read_path_error_response() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let ctx = harness.open_context(&runtime); + ctx.transport + .direct_hooks() + .expect("direct transport should expose test hooks") + .fail_next_get_pages("InjectedGetPagesError: read path dropped"); + + let err = ctx + .resolve_pages(&[2], false) + .expect_err("read-path error response should surface"); + assert!(matches!( + err, + GetPagesError::Other(ref message) + if message.contains("InjectedGetPagesError") + )); +} + +#[test] +fn commit_buffered_pages_uses_fast_path() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let engine = runtime.block_on(harness.open_engine()); + let transport = SqliteTransport::from_direct(engine); + let hooks = transport + .direct_hooks() + .expect("direct transport should expose test hooks"); + + let outcome = runtime + .block_on(commit_buffered_pages( + &transport, + BufferedCommitRequest { + actor_id: harness.actor_id.clone(), + new_db_size_pages: 1, + dirty_pages: vec![protocol::SqliteDirtyPage { + pgno: 1, + bytes: empty_db_page(), + }], + }, + )) + .expect("fast-path commit should succeed"); + let (outcome, metrics) = outcome; + + assert_eq!(outcome.path, CommitPath::Fast); + assert_eq!(outcome.db_size_pages, 1); + assert!(metrics.serialize_ns > 0); + assert!(metrics.transport_ns > 0); + assert_eq!(hooks.commit_requests().len(), 1); +} + +#[test] +fn vfs_records_commit_phase_durations() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let db = harness.open_db(&runtime); + let ctx = direct_vfs_ctx(&db); + + sqlite_exec( + db.as_ptr(), + "CREATE TABLE metrics_test (id INTEGER PRIMARY KEY, value TEXT NOT NULL);", + ) + .expect("create table should succeed"); + + let relaxed = std::sync::atomic::Ordering::Relaxed; + ctx.commit_request_build_ns.store(0, relaxed); + ctx.commit_serialize_ns.store(0, relaxed); + ctx.commit_transport_ns.store(0, relaxed); + ctx.commit_state_update_ns.store(0, relaxed); + ctx.commit_duration_ns_total.store(0, relaxed); + ctx.commit_total.store(0, relaxed); + + sqlite_exec( + db.as_ptr(), + "INSERT INTO metrics_test (id, value) VALUES (1, 'hello');", + ) + .expect("insert should succeed"); + + let metrics = db.sqlite_vfs_metrics(); + assert_eq!(metrics.commit_count, 1); + assert!(metrics.request_build_ns > 0); + assert!(metrics.serialize_ns > 0); + assert!(metrics.transport_ns > 0); + assert!(metrics.state_update_ns > 0); + assert!(metrics.total_ns >= metrics.request_build_ns); + assert!(metrics.request_build_ns + metrics.transport_ns + metrics.state_update_ns > 0); +} + +#[test] +fn profile_large_tx_insert_5mb() { + // 5MB = 1280 rows x 4KB blobs in one transaction + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let db = harness.open_db(&runtime); + let ctx = direct_vfs_ctx(&db); + + sqlite_exec( + db.as_ptr(), + "CREATE TABLE bench (id INTEGER PRIMARY KEY, payload BLOB NOT NULL);", + ) + .expect("create table should succeed"); + + let relaxed = std::sync::atomic::Ordering::Relaxed; + ctx.resolve_pages_total.store(0, relaxed); + ctx.resolve_pages_cache_hits.store(0, relaxed); + ctx.resolve_pages_fetches.store(0, relaxed); + ctx.pages_fetched_total.store(0, relaxed); + ctx.prefetch_pages_total.store(0, relaxed); + ctx.commit_total.store(0, relaxed); + + let start = std::time::Instant::now(); + sqlite_exec(db.as_ptr(), "BEGIN;").expect("begin"); + for i in 0..1280 { + sqlite_step_statement( + db.as_ptr(), + &format!( + "INSERT INTO bench (id, payload) VALUES ({}, randomblob(4096));", + i + ), + ) + .expect("insert should succeed"); + } + sqlite_exec(db.as_ptr(), "COMMIT;").expect("commit"); + let elapsed = start.elapsed(); + + let resolve_total = ctx.resolve_pages_total.load(relaxed); + let cache_hits = ctx.resolve_pages_cache_hits.load(relaxed); + let fetches = ctx.resolve_pages_fetches.load(relaxed); + let pages_fetched = ctx.pages_fetched_total.load(relaxed); + let prefetch = ctx.prefetch_pages_total.load(relaxed); + let commits = ctx.commit_total.load(relaxed); + + eprintln!("=== 5MB INSERT PROFILE (1280 rows x 4KB) ==="); + eprintln!(" wall clock: {:?}", elapsed); + eprintln!(" resolve_pages calls: {}", resolve_total); + eprintln!(" cache hits (pages): {}", cache_hits); + eprintln!(" engine fetches: {}", fetches); + eprintln!(" pages fetched total: {}", pages_fetched); + eprintln!(" prefetch pages: {}", prefetch); + eprintln!(" commits: {}", commits); + eprintln!("============================================"); + + // In a single transaction, all 1280 row writes are to new pages. + // Only the single commit at the end should hit the engine. + assert_eq!( + fetches, 0, + "expected 0 engine fetches during 5MB insert transaction" + ); + assert_eq!( + commits, 1, + "expected exactly 1 commit for transactional insert" + ); + + let count = + sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM bench;").expect("count should succeed"); + assert_eq!(count, 1280); +} + +#[test] +fn profile_hot_row_updates() { + // 100 updates to the same row - this is the autocommit case + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let db = harness.open_db(&runtime); + let ctx = direct_vfs_ctx(&db); + + sqlite_exec( + db.as_ptr(), + "CREATE TABLE counter (id INTEGER PRIMARY KEY, value INTEGER NOT NULL);", + ) + .expect("create"); + sqlite_exec(db.as_ptr(), "INSERT INTO counter VALUES (1, 0);").expect("insert"); + + let relaxed = std::sync::atomic::Ordering::Relaxed; + ctx.resolve_pages_total.store(0, relaxed); + ctx.resolve_pages_cache_hits.store(0, relaxed); + ctx.resolve_pages_fetches.store(0, relaxed); + ctx.pages_fetched_total.store(0, relaxed); + ctx.prefetch_pages_total.store(0, relaxed); + ctx.commit_total.store(0, relaxed); + + let start = std::time::Instant::now(); + for _ in 0..100 { + sqlite_exec( + db.as_ptr(), + "UPDATE counter SET value = value + 1 WHERE id = 1;", + ) + .expect("update"); + } + let elapsed = start.elapsed(); + + let fetches = ctx.resolve_pages_fetches.load(relaxed); + let commits = ctx.commit_total.load(relaxed); + + eprintln!("=== 100 HOT ROW UPDATES (autocommit) ==="); + eprintln!(" wall clock: {:?}", elapsed); + eprintln!( + " resolve_pages calls: {}", + ctx.resolve_pages_total.load(relaxed) + ); + eprintln!( + " cache hits (pages): {}", + ctx.resolve_pages_cache_hits.load(relaxed) + ); + eprintln!(" engine fetches: {}", fetches); + eprintln!( + " pages fetched total: {}", + ctx.pages_fetched_total.load(relaxed) + ); + eprintln!( + " prefetch pages: {}", + ctx.prefetch_pages_total.load(relaxed) + ); + eprintln!(" commits: {}", commits); + eprintln!("========================================="); + + // Hot row updates: each update modifies the same page. Pages already + // in write_buffer or cache should not need re-fetching. With the + // counter's page(s) already warm, subsequent updates should be + // 100% cache hits (0 fetches). Autocommit means 100 separate commits. + assert_eq!( + fetches, 0, + "expected 0 engine fetches for 100 hot row updates" + ); + assert_eq!( + commits, 100, + "expected 100 commits (autocommit per statement)" + ); +} + +#[test] +fn profile_large_tx_insert_1mb_preloaded() { + // Same as the 1MB test but preload all pages first to see commit-only cost + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let engine = runtime.block_on(harness.open_engine()); + let actor_id = &harness.actor_id; + + // First pass: create and populate the table to generate pages + let db1 = harness.open_db_on_engine(&runtime, engine.clone(), actor_id, VfsConfig::default()); + sqlite_exec( + db1.as_ptr(), + "CREATE TABLE bench (id INTEGER PRIMARY KEY, payload BLOB NOT NULL);", + ) + .expect("create table should succeed"); + sqlite_exec(db1.as_ptr(), "BEGIN;").expect("begin"); + for i in 0..256 { + sqlite_step_statement( + db1.as_ptr(), + &format!( + "INSERT INTO bench (id, payload) VALUES ({}, randomblob(4096));", + i + ), + ) + .expect("insert should succeed"); } + sqlite_exec(db1.as_ptr(), "COMMIT;").expect("commit"); + drop(db1); - // Regression test: two actors run autocommits concurrently on the same - // direct storage. If compaction cross-contaminates actors or races on - // shared state, we'd see fence mismatches. - #[test] - fn concurrent_multi_actor_autocommits() { - let runtime = direct_runtime(); - let harness = DirectEngineHarness::new(); - let engine = runtime.block_on(harness.open_engine()); + // Second pass: reopen with warm cache (takeover preloads page 1, rest from reads) + let db2 = harness.open_db_on_engine(&runtime, engine.clone(), actor_id, VfsConfig::default()); + let ctx = direct_vfs_ctx(&db2); - let actor_a = format!("{}-a", harness.actor_id); - let actor_b = format!("{}-b", harness.actor_id); + // Warm the cache by reading everything + sqlite_exec(db2.as_ptr(), "SELECT COUNT(*) FROM bench;").expect("count"); - let db_a = - harness.open_db_on_engine(&runtime, engine.clone(), &actor_a, VfsConfig::default()); - let db_b = - harness.open_db_on_engine(&runtime, engine.clone(), &actor_b, VfsConfig::default()); + // Reset counters + let relaxed = std::sync::atomic::Ordering::Relaxed; + ctx.resolve_pages_total.store(0, relaxed); + ctx.resolve_pages_cache_hits.store(0, relaxed); + ctx.resolve_pages_fetches.store(0, relaxed); + ctx.pages_fetched_total.store(0, relaxed); + ctx.prefetch_pages_total.store(0, relaxed); + ctx.commit_total.store(0, relaxed); - sqlite_exec( - db_a.as_ptr(), - "CREATE TABLE t (id INTEGER PRIMARY KEY, v INTEGER NOT NULL);", + let start = std::time::Instant::now(); + sqlite_exec(db2.as_ptr(), "BEGIN;").expect("begin"); + for i in 256..512 { + sqlite_step_statement( + db2.as_ptr(), + &format!( + "INSERT INTO bench (id, payload) VALUES ({}, randomblob(4096));", + i + ), + ) + .expect("insert should succeed"); + } + sqlite_exec(db2.as_ptr(), "COMMIT;").expect("commit"); + let elapsed = start.elapsed(); + + let resolve_total = ctx.resolve_pages_total.load(relaxed); + let cache_hits = ctx.resolve_pages_cache_hits.load(relaxed); + let fetches = ctx.resolve_pages_fetches.load(relaxed); + let pages_fetched = ctx.pages_fetched_total.load(relaxed); + let prefetch = ctx.prefetch_pages_total.load(relaxed); + let commits = ctx.commit_total.load(relaxed); + + eprintln!("=== 1MB INSERT PROFILE (WARM CACHE) ==="); + eprintln!(" wall clock: {:?}", elapsed); + eprintln!(" resolve_pages calls: {}", resolve_total); + eprintln!(" cache hits (pages): {}", cache_hits); + eprintln!(" engine fetches: {}", fetches); + eprintln!(" pages fetched total: {}", pages_fetched); + eprintln!(" prefetch pages: {}", prefetch); + eprintln!(" commits: {}", commits); + eprintln!("========================================"); + + // Second 256-row transaction into the already-populated table. + // All new pages are beyond db_size_pages, so no engine fetches. + assert_eq!( + fetches, 0, + "expected 0 engine fetches during warm 1MB insert" + ); + assert_eq!( + commits, 1, + "expected exactly 1 commit for transactional insert" + ); + + let count = sqlite_query_i64(db2.as_ptr(), "SELECT COUNT(*) FROM bench;") + .expect("count should succeed"); + assert_eq!(count, 512); +} + +#[test] +fn profile_large_tx_insert_1mb() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let db = harness.open_db(&runtime); + let ctx = direct_vfs_ctx(&db); + + sqlite_exec( + db.as_ptr(), + "CREATE TABLE bench (id INTEGER PRIMARY KEY, payload BLOB NOT NULL);", + ) + .expect("create table should succeed"); + + // Reset counters after schema setup + ctx.resolve_pages_total + .store(0, std::sync::atomic::Ordering::Relaxed); + ctx.resolve_pages_cache_hits + .store(0, std::sync::atomic::Ordering::Relaxed); + ctx.resolve_pages_fetches + .store(0, std::sync::atomic::Ordering::Relaxed); + ctx.pages_fetched_total + .store(0, std::sync::atomic::Ordering::Relaxed); + ctx.prefetch_pages_total + .store(0, std::sync::atomic::Ordering::Relaxed); + ctx.commit_total + .store(0, std::sync::atomic::Ordering::Relaxed); + + let start = std::time::Instant::now(); + + sqlite_exec(db.as_ptr(), "BEGIN;").expect("begin should succeed"); + for i in 0..256 { + sqlite_step_statement( + db.as_ptr(), + &format!( + "INSERT INTO bench (id, payload) VALUES ({}, randomblob(4096));", + i + ), ) - .expect("create a"); + .expect("insert should succeed"); + } + sqlite_exec(db.as_ptr(), "COMMIT;").expect("commit should succeed"); + + let elapsed = start.elapsed(); + let relaxed = std::sync::atomic::Ordering::Relaxed; + + let resolve_total = ctx.resolve_pages_total.load(relaxed); + let cache_hits = ctx.resolve_pages_cache_hits.load(relaxed); + let fetches = ctx.resolve_pages_fetches.load(relaxed); + let pages_fetched = ctx.pages_fetched_total.load(relaxed); + let prefetch = ctx.prefetch_pages_total.load(relaxed); + let commits = ctx.commit_total.load(relaxed); + + eprintln!("=== 1MB INSERT PROFILE (256 rows x 4KB) ==="); + eprintln!(" wall clock: {:?}", elapsed); + eprintln!(" resolve_pages calls: {}", resolve_total); + eprintln!(" cache hits (pages): {}", cache_hits); + eprintln!(" engine fetches: {}", fetches); + eprintln!(" pages fetched total: {}", pages_fetched); + eprintln!(" prefetch pages: {}", prefetch); + eprintln!(" commits: {}", commits); + eprintln!("============================================"); + + // Assert expected zero-fetch behavior: in a single transaction, + // all writes are to new pages, so no engine fetches should happen. + // Only the single commit at the end should hit the engine. + assert_eq!( + fetches, 0, + "expected 0 engine fetches during 1MB insert transaction" + ); + assert_eq!( + commits, 1, + "expected exactly 1 commit for transactional insert" + ); + + let count = + sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM bench;").expect("count should succeed"); + assert_eq!(count, 256); +} + +// Regression test for fence mismatch during rapid autocommit inserts. +// Each autocommit INSERT is its own transaction. This test drives many +// sequential commits through the VFS and verifies they all succeed. +#[test] +fn autocommit_inserts_maintain_head_txid_consistency() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let db = harness.open_db(&runtime); + let ctx = direct_vfs_ctx(&db); + + sqlite_exec( + db.as_ptr(), + "CREATE TABLE t (id INTEGER PRIMARY KEY, v INTEGER NOT NULL);", + ) + .expect("create table should succeed"); + + let relaxed = std::sync::atomic::Ordering::Relaxed; + ctx.commit_total.store(0, relaxed); + + // 100 sequential autocommit inserts. If fence mismatch is the bug, + // this will fail partway through with "commit head_txid X did not + // match current head_txid X-1". + for i in 0..100 { + sqlite_exec( + db.as_ptr(), + &format!("INSERT INTO t (id, v) VALUES ({i}, {});", i * 2), + ) + .expect("autocommit insert should not fence-mismatch"); + } + + let commits = ctx.commit_total.load(relaxed); + // Each autocommit INSERT = 1 commit. CREATE TABLE was 1 more. + // We reset commit_total after CREATE, so expect 100. + assert_eq!(commits, 100, "expected exactly 100 commits"); + + let count = + sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM t;").expect("count should succeed"); + assert_eq!(count, 100); + + // Verify the sum to make sure data is correct and not corrupted + let sum = sqlite_query_i64(db.as_ptr(), "SELECT SUM(v) FROM t;").expect("sum should succeed"); + assert_eq!(sum, (0..100).map(|i| i * 2).sum::()); +} + +// Regression test: 5 actors run 200 autocommits each on the same engine. +// Compaction is triggered via the mpsc channel after each commit, so this +// also exercises the commit-vs-compaction race that caused fence rewinds +// before the tx_get_value_serializable fix. +#[test] +fn stress_concurrent_multi_actor_autocommits() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let engine = runtime.block_on(harness.open_engine()); + + let mut dbs = Vec::new(); + for i in 0..5 { + let actor_id = format!("{}-stress-{}", harness.actor_id, i); + let db = + harness.open_db_on_engine(&runtime, engine.clone(), &actor_id, VfsConfig::default()); sqlite_exec( - db_b.as_ptr(), + db.as_ptr(), "CREATE TABLE t (id INTEGER PRIMARY KEY, v INTEGER NOT NULL);", ) - .expect("create b"); + .expect("create"); + dbs.push(db); + } - // Run 100 autocommits on each actor, interleaved. - for i in 0..100 { - sqlite_exec( - db_a.as_ptr(), - &format!("INSERT INTO t (id, v) VALUES ({i}, {i});"), - ) - .expect("insert a"); + // Interleave 200 autocommit inserts across all 5 actors + for i in 0..200 { + for db in &dbs { sqlite_exec( - db_b.as_ptr(), + db.as_ptr(), &format!("INSERT INTO t (id, v) VALUES ({i}, {i});"), ) - .expect("insert b"); + .expect("insert"); } + } - let count_a = sqlite_query_i64(db_a.as_ptr(), "SELECT COUNT(*) FROM t;").expect("count a"); - assert_eq!(count_a, 100); - let count_b = sqlite_query_i64(db_b.as_ptr(), "SELECT COUNT(*) FROM t;").expect("count b"); - assert_eq!(count_b, 100); + for db in &dbs { + let count = sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM t;").expect("count"); + assert_eq!(count, 200); } +} - // Same as above but across a close/reopen cycle to exercise takeover. - #[test] - fn autocommit_survives_close_reopen() { - let runtime = direct_runtime(); - let harness = DirectEngineHarness::new(); - let engine = runtime.block_on(harness.open_engine()); - let actor_id = &harness.actor_id; +// Regression test: two actors run autocommits concurrently on the same +// direct storage. If compaction cross-contaminates actors or races on +// shared state, we'd see fence mismatches. +#[test] +fn concurrent_multi_actor_autocommits() { + 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(), actor_id, VfsConfig::default()); - sqlite_exec( - db.as_ptr(), - "CREATE TABLE t (id INTEGER PRIMARY KEY, v INTEGER NOT NULL);", - ) - .expect("create table"); - for i in 0..50 { - sqlite_exec( - db.as_ptr(), - &format!("INSERT INTO t (id, v) VALUES ({i}, {});", i), - ) - .expect("insert"); - } - } + let actor_a = format!("{}-a", harness.actor_id); + let actor_b = format!("{}-b", harness.actor_id); - // Reopen (triggers takeover which bumps generation) - let db2 = - harness.open_db_on_engine(&runtime, engine.clone(), actor_id, VfsConfig::default()); - for i in 50..100 { - sqlite_exec( - db2.as_ptr(), - &format!("INSERT INTO t (id, v) VALUES ({i}, {});", i), - ) - .expect("insert after reopen"); - } + let db_a = harness.open_db_on_engine(&runtime, engine.clone(), &actor_a, VfsConfig::default()); + let db_b = harness.open_db_on_engine(&runtime, engine.clone(), &actor_b, VfsConfig::default()); + + sqlite_exec( + db_a.as_ptr(), + "CREATE TABLE t (id INTEGER PRIMARY KEY, v INTEGER NOT NULL);", + ) + .expect("create a"); + sqlite_exec( + db_b.as_ptr(), + "CREATE TABLE t (id INTEGER PRIMARY KEY, v INTEGER NOT NULL);", + ) + .expect("create b"); - let count = sqlite_query_i64(db2.as_ptr(), "SELECT COUNT(*) FROM t;") - .expect("count should succeed"); - assert_eq!(count, 100); + // Run 100 autocommits on each actor, interleaved. + for i in 0..100 { + sqlite_exec( + db_a.as_ptr(), + &format!("INSERT INTO t (id, v) VALUES ({i}, {i});"), + ) + .expect("insert a"); + sqlite_exec( + db_b.as_ptr(), + &format!("INSERT INTO t (id, v) VALUES ({i}, {i});"), + ) + .expect("insert b"); } - // Bench-parity tests. Each mirrors a workload in - // examples/kitchen-sink/src/actors/testing/test-sqlite-bench.ts so - // storage-layer regressions surface here without needing the full stack. + let count_a = sqlite_query_i64(db_a.as_ptr(), "SELECT COUNT(*) FROM t;").expect("count a"); + assert_eq!(count_a, 100); + let count_b = sqlite_query_i64(db_b.as_ptr(), "SELECT COUNT(*) FROM t;").expect("count b"); + assert_eq!(count_b, 100); +} - fn open_bench_db(runtime: &tokio::runtime::Runtime) -> NativeDatabase { - let harness = DirectEngineHarness::new(); - harness.open_db(runtime) - } +// Same as above but across a close/reopen cycle to exercise takeover. +#[test] +fn autocommit_survives_close_reopen() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let engine = runtime.block_on(harness.open_engine()); + let actor_id = &harness.actor_id; - #[test] - fn bench_insert_tx_x10000() { - let runtime = direct_runtime(); - let db = open_bench_db(&runtime); + { + let db = + harness.open_db_on_engine(&runtime, engine.clone(), actor_id, VfsConfig::default()); sqlite_exec( db.as_ptr(), - "CREATE TABLE t (id INTEGER PRIMARY KEY, v INTEGER);", + "CREATE TABLE t (id INTEGER PRIMARY KEY, v INTEGER NOT NULL);", ) - .unwrap(); - - sqlite_exec(db.as_ptr(), "BEGIN").unwrap(); - for i in 0..10_000 { + .expect("create table"); + for i in 0..50 { sqlite_exec( db.as_ptr(), - &format!("INSERT INTO t (id, v) VALUES ({i}, {i});"), + &format!("INSERT INTO t (id, v) VALUES ({i}, {});", i), ) - .unwrap(); + .expect("insert"); } - sqlite_exec(db.as_ptr(), "COMMIT").unwrap(); - - assert_eq!( - sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM t;").unwrap(), - 10_000 - ); } - #[test] - fn bench_large_tx_insert_500kb() { - large_tx_insert(500 * 1024); + // Reopen (triggers takeover which bumps generation) + let db2 = harness.open_db_on_engine(&runtime, engine.clone(), actor_id, VfsConfig::default()); + for i in 50..100 { + sqlite_exec( + db2.as_ptr(), + &format!("INSERT INTO t (id, v) VALUES ({i}, {});", i), + ) + .expect("insert after reopen"); } - #[test] - fn bench_large_tx_insert_10mb() { - large_tx_insert(10 * 1024 * 1024); - } + let count = + sqlite_query_i64(db2.as_ptr(), "SELECT COUNT(*) FROM t;").expect("count should succeed"); + assert_eq!(count, 100); +} - #[test] - fn bench_large_tx_insert_50mb() { - // 50MB exercises the slow-path stage/finalize chunking that has - // historically hit decode errors under certain transports. - large_tx_insert(50 * 1024 * 1024); - } +// Bench-parity tests. Each mirrors a workload in +// examples/kitchen-sink/src/actors/testing/test-sqlite-bench.ts so +// storage-layer regressions surface here without needing the full stack. - #[test] - fn bench_large_tx_insert_100mb() { - large_tx_insert(100 * 1024 * 1024); - } +fn open_bench_db(runtime: &tokio::runtime::Runtime) -> NativeDatabase { + let harness = DirectEngineHarness::new(); + harness.open_db(runtime) +} + +#[test] +fn bench_insert_tx_x10000() { + let runtime = direct_runtime(); + let db = open_bench_db(&runtime); + sqlite_exec( + db.as_ptr(), + "CREATE TABLE t (id INTEGER PRIMARY KEY, v INTEGER);", + ) + .unwrap(); - fn large_tx_insert(target_bytes: usize) { - let runtime = direct_runtime(); - let db = open_bench_db(&runtime); + sqlite_exec(db.as_ptr(), "BEGIN").unwrap(); + for i in 0..10_000 { sqlite_exec( db.as_ptr(), - "CREATE TABLE large_tx (id INTEGER PRIMARY KEY AUTOINCREMENT, payload BLOB NOT NULL);", + &format!("INSERT INTO t (id, v) VALUES ({i}, {i});"), ) .unwrap(); - - let row_size = 4 * 1024; - let rows = (target_bytes + row_size - 1) / row_size; + } + sqlite_exec(db.as_ptr(), "COMMIT").unwrap(); + + assert_eq!( + sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM t;").unwrap(), + 10_000 + ); +} + +#[test] +fn bench_large_tx_insert_500kb() { + large_tx_insert(500 * 1024); +} + +#[test] +fn bench_large_tx_insert_10mb() { + large_tx_insert(10 * 1024 * 1024); +} + +#[test] +fn bench_large_tx_insert_50mb() { + // 50MB exercises the slow-path stage/finalize chunking that has + // historically hit decode errors under certain transports. + large_tx_insert(50 * 1024 * 1024); +} + +#[test] +fn bench_large_tx_insert_100mb() { + large_tx_insert(100 * 1024 * 1024); +} + +fn large_tx_insert(target_bytes: usize) { + let runtime = direct_runtime(); + let db = open_bench_db(&runtime); + sqlite_exec( + db.as_ptr(), + "CREATE TABLE large_tx (id INTEGER PRIMARY KEY AUTOINCREMENT, payload BLOB NOT NULL);", + ) + .unwrap(); + + let row_size = 4 * 1024; + let rows = (target_bytes + row_size - 1) / row_size; + sqlite_exec(db.as_ptr(), "BEGIN").unwrap(); + for _ in 0..rows { + sqlite_exec( + db.as_ptr(), + &format!("INSERT INTO large_tx (payload) VALUES (randomblob({row_size}));"), + ) + .unwrap(); + } + if let Err(err) = sqlite_exec(db.as_ptr(), "COMMIT") { + let vfs_err = direct_vfs_ctx(&db).clone_last_error(); + panic!( + "COMMIT failed for {} MiB: sqlite={}, vfs_last_error={:?}", + target_bytes / (1024 * 1024), + err, + vfs_err, + ); + } + + assert_eq!( + sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM large_tx;").unwrap(), + rows as i64 + ); +} + +#[test] +fn bench_churn_insert_delete_10x1000() { + // Tests freelist reuse / space reclamation under heavy churn. + let runtime = direct_runtime(); + let db = open_bench_db(&runtime); + sqlite_exec( + db.as_ptr(), + "CREATE TABLE churn (id INTEGER PRIMARY KEY AUTOINCREMENT, payload BLOB NOT NULL);", + ) + .unwrap(); + for _ in 0..10 { sqlite_exec(db.as_ptr(), "BEGIN").unwrap(); - for _ in 0..rows { + for _ in 0..1000 { sqlite_exec( db.as_ptr(), - &format!("INSERT INTO large_tx (payload) VALUES (randomblob({row_size}));"), + "INSERT INTO churn (payload) VALUES (randomblob(1024));", ) .unwrap(); } - if let Err(err) = sqlite_exec(db.as_ptr(), "COMMIT") { - let vfs_err = direct_vfs_ctx(&db).clone_last_error(); - panic!( - "COMMIT failed for {} MiB: sqlite={}, vfs_last_error={:?}", - target_bytes / (1024 * 1024), - err, - vfs_err, - ); - } - - assert_eq!( - sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM large_tx;").unwrap(), - rows as i64 - ); + sqlite_exec(db.as_ptr(), "DELETE FROM churn;").unwrap(); + sqlite_exec(db.as_ptr(), "COMMIT").unwrap(); } + assert_eq!( + sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM churn;").unwrap(), + 0 + ); +} - #[test] - fn bench_churn_insert_delete_10x1000() { - // Tests freelist reuse / space reclamation under heavy churn. - let runtime = direct_runtime(); - let db = open_bench_db(&runtime); +#[test] +fn bench_mixed_oltp_large() { + let runtime = direct_runtime(); + let db = open_bench_db(&runtime); + sqlite_exec( + db.as_ptr(), + "CREATE TABLE mixed (id INTEGER PRIMARY KEY, v INTEGER NOT NULL, data BLOB NOT NULL);", + ) + .unwrap(); + + sqlite_exec(db.as_ptr(), "BEGIN").unwrap(); + for i in 0..500 { sqlite_exec( db.as_ptr(), - "CREATE TABLE churn (id INTEGER PRIMARY KEY AUTOINCREMENT, payload BLOB NOT NULL);", + &format!( + "INSERT INTO mixed (id, v, data) VALUES ({i}, {}, randomblob(1024));", + i * 2 + ), ) .unwrap(); - for _ in 0..10 { - sqlite_exec(db.as_ptr(), "BEGIN").unwrap(); - for _ in 0..1000 { - sqlite_exec( - db.as_ptr(), - "INSERT INTO churn (payload) VALUES (randomblob(1024));", - ) - .unwrap(); - } - sqlite_exec(db.as_ptr(), "DELETE FROM churn;").unwrap(); - sqlite_exec(db.as_ptr(), "COMMIT").unwrap(); - } - assert_eq!( - sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM churn;").unwrap(), - 0 - ); } + sqlite_exec(db.as_ptr(), "COMMIT").unwrap(); - #[test] - fn bench_mixed_oltp_large() { - let runtime = direct_runtime(); - let db = open_bench_db(&runtime); + sqlite_exec(db.as_ptr(), "BEGIN").unwrap(); + for i in 0..500 { sqlite_exec( db.as_ptr(), - "CREATE TABLE mixed (id INTEGER PRIMARY KEY, v INTEGER NOT NULL, data BLOB NOT NULL);", + &format!( + "INSERT INTO mixed (id, v, data) VALUES ({}, {}, randomblob(1024));", + 500 + i, + i * 3 + ), ) .unwrap(); - - sqlite_exec(db.as_ptr(), "BEGIN").unwrap(); - for i in 0..500 { + sqlite_exec( + db.as_ptr(), + &format!("UPDATE mixed SET v = v + 1 WHERE id = {i};"), + ) + .unwrap(); + if i % 5 == 0 && i >= 50 { sqlite_exec( db.as_ptr(), - &format!( - "INSERT INTO mixed (id, v, data) VALUES ({i}, {}, randomblob(1024));", - i * 2 - ), + &format!("DELETE FROM mixed WHERE id = {};", i - 50), ) .unwrap(); } - sqlite_exec(db.as_ptr(), "COMMIT").unwrap(); + } + sqlite_exec(db.as_ptr(), "COMMIT").unwrap(); - sqlite_exec(db.as_ptr(), "BEGIN").unwrap(); - for i in 0..500 { - sqlite_exec( - db.as_ptr(), - &format!( - "INSERT INTO mixed (id, v, data) VALUES ({}, {}, randomblob(1024));", - 500 + i, - i * 3 - ), - ) - .unwrap(); - sqlite_exec( - db.as_ptr(), - &format!("UPDATE mixed SET v = v + 1 WHERE id = {i};"), - ) - .unwrap(); - if i % 5 == 0 && i >= 50 { - sqlite_exec( - db.as_ptr(), - &format!("DELETE FROM mixed WHERE id = {};", i - 50), - ) - .unwrap(); - } - } - sqlite_exec(db.as_ptr(), "COMMIT").unwrap(); + let count = sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM mixed;").unwrap(); + assert!(count > 900 && count < 1000); +} - let count = sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM mixed;").unwrap(); - assert!(count > 900 && count < 1000); +#[test] +fn bench_bulk_update_1000_rows() { + let runtime = direct_runtime(); + let db = open_bench_db(&runtime); + sqlite_exec( + db.as_ptr(), + "CREATE TABLE bulk (id INTEGER PRIMARY KEY, v INTEGER);", + ) + .unwrap(); + sqlite_exec(db.as_ptr(), "BEGIN").unwrap(); + for i in 0..1000 { + sqlite_exec( + db.as_ptr(), + &format!("INSERT INTO bulk (id, v) VALUES ({i}, {i});"), + ) + .unwrap(); } + sqlite_exec(db.as_ptr(), "COMMIT").unwrap(); - #[test] - fn bench_bulk_update_1000_rows() { - let runtime = direct_runtime(); - let db = open_bench_db(&runtime); + sqlite_exec(db.as_ptr(), "BEGIN").unwrap(); + for i in 0..1000 { sqlite_exec( db.as_ptr(), - "CREATE TABLE bulk (id INTEGER PRIMARY KEY, v INTEGER);", + &format!("UPDATE bulk SET v = v + 1 WHERE id = {i};"), ) .unwrap(); + } + sqlite_exec(db.as_ptr(), "COMMIT").unwrap(); + + assert_eq!( + sqlite_query_i64(db.as_ptr(), "SELECT SUM(v) FROM bulk;").unwrap(), + (0..1000).map(|i| i + 1).sum::() + ); +} + +#[test] +fn bench_truncate_and_regrow() { + let runtime = direct_runtime(); + let db = open_bench_db(&runtime); + sqlite_exec( + db.as_ptr(), + "CREATE TABLE regrow (id INTEGER PRIMARY KEY AUTOINCREMENT, payload BLOB NOT NULL);", + ) + .unwrap(); + for _ in 0..2 { sqlite_exec(db.as_ptr(), "BEGIN").unwrap(); - for i in 0..1000 { - sqlite_exec( - db.as_ptr(), - &format!("INSERT INTO bulk (id, v) VALUES ({i}, {i});"), - ) - .unwrap(); - } - sqlite_exec(db.as_ptr(), "COMMIT").unwrap(); - - sqlite_exec(db.as_ptr(), "BEGIN").unwrap(); - for i in 0..1000 { + for _ in 0..500 { sqlite_exec( db.as_ptr(), - &format!("UPDATE bulk SET v = v + 1 WHERE id = {i};"), + "INSERT INTO regrow (payload) VALUES (randomblob(1024));", ) .unwrap(); } sqlite_exec(db.as_ptr(), "COMMIT").unwrap(); - - assert_eq!( - sqlite_query_i64(db.as_ptr(), "SELECT SUM(v) FROM bulk;").unwrap(), - (0..1000).map(|i| i + 1).sum::() - ); + sqlite_exec(db.as_ptr(), "DELETE FROM regrow;").unwrap(); } + assert_eq!( + sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM regrow;").unwrap(), + 0 + ); +} - #[test] - fn bench_truncate_and_regrow() { - let runtime = direct_runtime(); - let db = open_bench_db(&runtime); +#[test] +fn bench_many_small_tables() { + let runtime = direct_runtime(); + let db = open_bench_db(&runtime); + sqlite_exec(db.as_ptr(), "BEGIN").unwrap(); + for i in 0..50 { sqlite_exec( db.as_ptr(), - "CREATE TABLE regrow (id INTEGER PRIMARY KEY AUTOINCREMENT, payload BLOB NOT NULL);", + &format!("CREATE TABLE t_{i} (id INTEGER PRIMARY KEY, v INTEGER);"), ) .unwrap(); - for _ in 0..2 { - sqlite_exec(db.as_ptr(), "BEGIN").unwrap(); - for _ in 0..500 { - sqlite_exec( - db.as_ptr(), - "INSERT INTO regrow (payload) VALUES (randomblob(1024));", - ) - .unwrap(); - } - sqlite_exec(db.as_ptr(), "COMMIT").unwrap(); - sqlite_exec(db.as_ptr(), "DELETE FROM regrow;").unwrap(); - } - assert_eq!( - sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM regrow;").unwrap(), - 0 - ); - } - - #[test] - fn bench_many_small_tables() { - let runtime = direct_runtime(); - let db = open_bench_db(&runtime); - sqlite_exec(db.as_ptr(), "BEGIN").unwrap(); - for i in 0..50 { + for j in 0..10 { sqlite_exec( db.as_ptr(), - &format!("CREATE TABLE t_{i} (id INTEGER PRIMARY KEY, v INTEGER);"), + &format!("INSERT INTO t_{i} (id, v) VALUES ({j}, {});", i * j), ) .unwrap(); - for j in 0..10 { - sqlite_exec( - db.as_ptr(), - &format!("INSERT INTO t_{i} (id, v) VALUES ({j}, {});", i * j), - ) - .unwrap(); - } } - sqlite_exec(db.as_ptr(), "COMMIT").unwrap(); - - let total: i64 = (0..50) - .map(|i| { - sqlite_query_i64(db.as_ptr(), &format!("SELECT COUNT(*) FROM t_{i};")).unwrap() - }) - .sum(); - assert_eq!(total, 500); } + sqlite_exec(db.as_ptr(), "COMMIT").unwrap(); - #[test] - fn bench_index_creation_on_10k_rows() { - let runtime = direct_runtime(); - let db = open_bench_db(&runtime); - sqlite_exec( + let total: i64 = (0..50) + .map(|i| sqlite_query_i64(db.as_ptr(), &format!("SELECT COUNT(*) FROM t_{i};")).unwrap()) + .sum(); + assert_eq!(total, 500); +} + +#[test] +fn bench_index_creation_on_10k_rows() { + let runtime = direct_runtime(); + let db = open_bench_db(&runtime); + sqlite_exec( db.as_ptr(), "CREATE TABLE idx_test (id INTEGER PRIMARY KEY AUTOINCREMENT, k TEXT NOT NULL, v INTEGER NOT NULL);", ) .unwrap(); + sqlite_exec(db.as_ptr(), "BEGIN").unwrap(); + for i in 0..10_000 { + sqlite_exec( + db.as_ptr(), + &format!( + "INSERT INTO idx_test (k, v) VALUES ('key-{}-{i}', {i});", + i % 1000 + ), + ) + .unwrap(); + } + sqlite_exec(db.as_ptr(), "COMMIT").unwrap(); + + sqlite_exec(db.as_ptr(), "CREATE INDEX idx_test_k ON idx_test(k);").unwrap(); + + assert_eq!( + sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM idx_test;").unwrap(), + 10_000 + ); +} + +#[test] +fn bench_growing_aggregation() { + let runtime = direct_runtime(); + let db = open_bench_db(&runtime); + sqlite_exec( + db.as_ptr(), + "CREATE TABLE agg (id INTEGER PRIMARY KEY AUTOINCREMENT, v INTEGER NOT NULL);", + ) + .unwrap(); + + let batches = 20; + let per_batch = 100; + for batch in 0..batches { sqlite_exec(db.as_ptr(), "BEGIN").unwrap(); - for i in 0..10_000 { + for i in 0..per_batch { sqlite_exec( db.as_ptr(), - &format!( - "INSERT INTO idx_test (k, v) VALUES ('key-{}-{i}', {i});", - i % 1000 - ), + &format!("INSERT INTO agg (v) VALUES ({});", batch * per_batch + i), ) .unwrap(); } sqlite_exec(db.as_ptr(), "COMMIT").unwrap(); - - sqlite_exec(db.as_ptr(), "CREATE INDEX idx_test_k ON idx_test(k);").unwrap(); - + let expected_sum: i64 = (0..(batch + 1) * per_batch).map(|i| i as i64).sum(); assert_eq!( - sqlite_query_i64(db.as_ptr(), "SELECT COUNT(*) FROM idx_test;").unwrap(), - 10_000 + sqlite_query_i64(db.as_ptr(), "SELECT SUM(v) FROM agg;").unwrap(), + expected_sum ); } - - #[test] - fn bench_growing_aggregation() { - let runtime = direct_runtime(); - let db = open_bench_db(&runtime); - sqlite_exec( - db.as_ptr(), - "CREATE TABLE agg (id INTEGER PRIMARY KEY AUTOINCREMENT, v INTEGER NOT NULL);", - ) - .unwrap(); - - let batches = 20; - let per_batch = 100; - for batch in 0..batches { - sqlite_exec(db.as_ptr(), "BEGIN").unwrap(); - for i in 0..per_batch { - sqlite_exec( - db.as_ptr(), - &format!("INSERT INTO agg (v) VALUES ({});", batch * per_batch + i), - ) - .unwrap(); - } - sqlite_exec(db.as_ptr(), "COMMIT").unwrap(); - let expected_sum: i64 = (0..(batch + 1) * per_batch).map(|i| i as i64).sum(); - assert_eq!( - sqlite_query_i64(db.as_ptr(), "SELECT SUM(v) FROM agg;").unwrap(), - expected_sum - ); - } - } +} diff --git a/engine/packages/depot-client/tests/inline/vfs_support.rs b/engine/packages/depot-client/tests/inline/vfs_support.rs index f690769c5c..969d090545 100644 --- a/engine/packages/depot-client/tests/inline/vfs_support.rs +++ b/engine/packages/depot-client/tests/inline/vfs_support.rs @@ -1,7 +1,8 @@ use std::collections::BTreeMap; use std::sync::{ - Arc, mpsc, + Arc, atomic::{AtomicBool, AtomicU64, Ordering}, + mpsc, }; use anyhow::{Context, Result, bail}; @@ -9,11 +10,11 @@ use async_trait::async_trait; use depot::{ cold_tier::{ColdTier, ColdTierObjectMetadata}, conveyer::{Db, db::CompactionSignaler}, - fault::DepotFaultController, error::SqliteStorageError, + fault::DepotFaultController, keys::{ - SHARD_SIZE, branch_compaction_cold_shard_key, branch_delta_chunk_key, - branch_compaction_root_key, branch_meta_head_key, branch_pidx_key, branch_shard_key, + SHARD_SIZE, branch_compaction_cold_shard_key, branch_compaction_root_key, + branch_delta_chunk_key, branch_meta_head_key, branch_pidx_key, branch_shard_key, }, ltx::{LtxHeader, encode_ltx_v3}, types::{ @@ -108,26 +109,28 @@ impl DirectStorage { Ok(()) }) }); - Arc::new(if let Some(fault_controller) = self.fault_controller.clone() { - Db::new_with_compaction_signaler_and_fault_controller_for_test( - Arc::clone(&self.db), - Id::nil(), - actor_id, - self.node_id, - cold_tier, - compaction_signaler, - fault_controller, - ) - } else { - Db::new_with_compaction_signaler( - Arc::clone(&self.db), - Id::nil(), - actor_id, - self.node_id, - cold_tier, - compaction_signaler, - ) - }) + Arc::new( + if let Some(fault_controller) = self.fault_controller.clone() { + Db::new_with_compaction_signaler_and_fault_controller_for_test( + Arc::clone(&self.db), + Id::nil(), + actor_id, + self.node_id, + cold_tier, + compaction_signaler, + fault_controller, + ) + } else { + Db::new_with_compaction_signaler( + Arc::clone(&self.db), + Id::nil(), + actor_id, + self.node_id, + cold_tier, + compaction_signaler, + ) + }, + ) }) .get() .clone() @@ -195,7 +198,8 @@ impl DirectStorage { if dirty_pages.is_empty() { dirty_pages.push(DirtyPage { pgno, bytes }); } - self.seed_pages_as_cold_ref(actor_id, pgno, dirty_pages).await + self.seed_pages_as_cold_ref(actor_id, pgno, dirty_pages) + .await } pub(crate) async fn seed_pages_as_cold_ref( @@ -216,10 +220,7 @@ impl DirectStorage { if dirty_pages.is_empty() { bail!("cold-ref seed requires at least one page"); } - let object_bytes = encode_ltx_v3( - LtxHeader::delta(head_txid, 1, 1_000), - &dirty_pages, - )?; + let object_bytes = encode_ltx_v3(LtxHeader::delta(head_txid, 1, 1_000), &dirty_pages)?; let digest = Sha256::digest(&object_bytes); let mut content_hash = [0_u8; 32]; content_hash.copy_from_slice(&digest); @@ -357,7 +358,9 @@ impl DirectStorage { let mirror_pages = self.read_mirror(actor_id, pgnos).await; for page in mirror_pages { if page.bytes.is_some() - || by_pgno.get(&page.pgno).is_none_or(|existing| existing.bytes.is_none()) + || by_pgno + .get(&page.pgno) + .is_none_or(|existing| existing.bytes.is_none()) { by_pgno.insert(page.pgno, page); } @@ -373,11 +376,7 @@ impl DirectStorage { .collect()) } - async fn read_mirror( - &self, - actor_id: &str, - pgnos: &[u32], - ) -> Vec { + async fn read_mirror(&self, actor_id: &str, pgnos: &[u32]) -> Vec { self.counters.mirror_reads.fetch_add(1, Ordering::SeqCst); let mirror = self.page_mirror(actor_id.to_string()).await; let mirror = mirror.lock(); @@ -545,9 +544,7 @@ pub(crate) struct DirectCommitPause { impl DirectCommitPause { pub(crate) fn wait_until_reached(&self) { - self.reached - .recv() - .expect("commit pause should be reached"); + self.reached.recv().expect("commit pause should be reached"); } pub(crate) fn resume(self) { @@ -560,7 +557,9 @@ struct DirectCommitGate { resume: mpsc::Receiver<()>, } -pub(crate) fn protocol_fetched_page(page: depot::types::FetchedPage) -> protocol::SqliteFetchedPage { +pub(crate) fn protocol_fetched_page( + page: depot::types::FetchedPage, +) -> protocol::SqliteFetchedPage { protocol::SqliteFetchedPage { pgno: page.pgno, bytes: page.bytes, diff --git a/engine/packages/depot-client/tests/query_text_nul.rs b/engine/packages/depot-client/tests/query_text_nul.rs index 3b95b353c0..dffa1bcd71 100644 --- a/engine/packages/depot-client/tests/query_text_nul.rs +++ b/engine/packages/depot-client/tests/query_text_nul.rs @@ -1,10 +1,10 @@ use std::ffi::CString; use std::ptr; -use libsqlite3_sys::{SQLITE_OK, sqlite3, sqlite3_close, sqlite3_open}; use depot_client::query::{ BindParam, ColumnValue, exec_statements, execute_statement, query_statement, }; +use libsqlite3_sys::{SQLITE_OK, sqlite3, sqlite3_close, sqlite3_open}; struct MemoryDb(*mut sqlite3); diff --git a/engine/packages/depot-client/tests/statement_classification.rs b/engine/packages/depot-client/tests/statement_classification.rs deleted file mode 100644 index cac434378f..0000000000 --- a/engine/packages/depot-client/tests/statement_classification.rs +++ /dev/null @@ -1,198 +0,0 @@ -use std::ffi::CString; -use std::ptr; - -use libsqlite3_sys::{SQLITE_OK, sqlite3, sqlite3_close, sqlite3_open}; -use depot_client::query::{ - ExecuteRoute, StatementAuthorizerActionKind, classify_statement, exec_statements, - execute_single_statement, install_reader_authorizer, -}; - -struct MemoryDb(*mut sqlite3); - -impl MemoryDb { - fn open() -> Self { - let name = CString::new(":memory:").unwrap(); - let mut db = ptr::null_mut(); - let rc = unsafe { sqlite3_open(name.as_ptr(), &mut db) }; - assert_eq!(rc, SQLITE_OK); - Self(db) - } - - fn as_ptr(&self) -> *mut sqlite3 { - self.0 - } -} - -impl Drop for MemoryDb { - fn drop(&mut self) { - unsafe { - sqlite3_close(self.0); - } - } -} - -#[test] -fn select_is_reader_eligible() { - let db = MemoryDb::open(); - let classification = classify_statement(db.as_ptr(), "SELECT 1 AS value").unwrap(); - - assert!(classification.has_statement); - assert!(classification.sqlite_readonly); - assert!(!classification.has_trailing_sql); - assert!(classification.reader_eligible()); - assert!( - classification - .authorizer - .actions - .iter() - .any(|action| action.kind == StatementAuthorizerActionKind::Select) - ); -} - -#[test] -fn readonly_pragma_is_reader_eligible_and_captures_pragma_usage() { - let db = MemoryDb::open(); - let classification = classify_statement(db.as_ptr(), "PRAGMA user_version").unwrap(); - - assert!(classification.sqlite_readonly); - assert!(classification.reader_eligible()); - assert!(classification.authorizer.pragma_usage); -} - -#[test] -fn readonly_schema_pragma_with_argument_is_allowed_on_reader() { - let db = MemoryDb::open(); - exec_statements( - db.as_ptr(), - "CREATE TABLE items(id INTEGER PRIMARY KEY, label TEXT);", - ) - .unwrap(); - - let classification = classify_statement(db.as_ptr(), "PRAGMA table_info(items)").unwrap(); - - assert!(classification.sqlite_readonly); - assert!(classification.reader_eligible()); - assert!(classification.authorizer.pragma_usage); - install_reader_authorizer(db.as_ptr()).unwrap(); - let result = - execute_single_statement(db.as_ptr(), "PRAGMA table_info(items)", None, ExecuteRoute::Read) - .unwrap(); - assert!(result.columns.iter().any(|column| column == "name")); - assert_eq!(result.rows.len(), 2); -} - -#[test] -fn mutating_pragma_is_not_reader_eligible() { - let db = MemoryDb::open(); - let classification = classify_statement(db.as_ptr(), "PRAGMA user_version = 7").unwrap(); - - assert!(!classification.sqlite_readonly); - assert!(!classification.reader_eligible()); - assert!(classification.authorizer.pragma_usage); -} - -#[test] -fn insert_returning_is_a_write_operation() { - let db = MemoryDb::open(); - exec_statements( - db.as_ptr(), - "CREATE TABLE items(id INTEGER PRIMARY KEY, label TEXT);", - ) - .unwrap(); - - let classification = classify_statement( - db.as_ptr(), - "INSERT INTO items(label) VALUES ('alpha') RETURNING id", - ) - .unwrap(); - - assert!(!classification.sqlite_readonly); - assert!(!classification.reader_eligible()); - assert!(classification.authorizer.write_operations); - assert!( - classification - .authorizer - .actions - .iter() - .any(|action| action.kind == StatementAuthorizerActionKind::Insert) - ); -} - -#[test] -fn cte_insert_returning_is_a_write_operation() { - let db = MemoryDb::open(); - exec_statements(db.as_ptr(), "CREATE TABLE items(value INTEGER);").unwrap(); - - let classification = classify_statement( - db.as_ptr(), - "WITH source(value) AS (VALUES (1)) INSERT INTO items(value) SELECT value FROM source RETURNING value", - ) - .unwrap(); - - assert!(!classification.sqlite_readonly); - assert!(!classification.reader_eligible()); - assert!(classification.authorizer.write_operations); -} - -#[test] -fn vacuum_is_not_reader_eligible() { - let db = MemoryDb::open(); - let classification = classify_statement(db.as_ptr(), "VACUUM").unwrap(); - - assert!(!classification.sqlite_readonly); - assert!(!classification.reader_eligible()); -} - -#[test] -fn attach_is_not_reader_eligible_and_captures_attach() { - let db = MemoryDb::open(); - let classification = - classify_statement(db.as_ptr(), "ATTACH DATABASE ':memory:' AS attached").unwrap(); - - assert!(!classification.reader_eligible()); - assert!(classification.authorizer.attach); -} - -#[test] -fn begin_is_not_reader_eligible_and_captures_transaction_control() { - let db = MemoryDb::open(); - let classification = classify_statement(db.as_ptr(), "BEGIN").unwrap(); - - assert!(classification.sqlite_readonly); - assert!(!classification.reader_eligible()); - assert!(classification.authorizer.transaction_control); - assert!( - classification - .authorizer - .actions - .iter() - .any(|action| action.kind == StatementAuthorizerActionKind::Transaction) - ); -} - -#[test] -fn savepoint_is_not_reader_eligible_and_captures_transaction_control() { - let db = MemoryDb::open(); - let classification = classify_statement(db.as_ptr(), "SAVEPOINT manual").unwrap(); - - assert!(classification.sqlite_readonly); - assert!(!classification.reader_eligible()); - assert!(classification.authorizer.transaction_control); - assert!( - classification - .authorizer - .actions - .iter() - .any(|action| action.kind == StatementAuthorizerActionKind::Savepoint) - ); -} - -#[test] -fn multi_statement_sql_is_not_reader_eligible() { - let db = MemoryDb::open(); - let classification = classify_statement(db.as_ptr(), "SELECT 1; SELECT 2").unwrap(); - - assert!(classification.sqlite_readonly); - assert!(classification.has_trailing_sql); - assert!(!classification.reader_eligible()); -} diff --git a/engine/packages/depot/src/burst_mode.rs b/engine/packages/depot/src/burst_mode.rs index 73a9cf4fdf..b3103d93ba 100644 --- a/engine/packages/depot/src/burst_mode.rs +++ b/engine/packages/depot/src/burst_mode.rs @@ -49,7 +49,10 @@ pub async fn read_branch_signal( .transpose() .context("decode sqlite burst-mode compaction root")?; - Ok(read_branch_signal_for_head(head_txid, compaction_root.as_ref())) + Ok(read_branch_signal_for_head( + head_txid, + compaction_root.as_ref(), + )) } pub fn read_branch_signal_for_head( diff --git a/engine/packages/depot/src/cold_tier/faulty.rs b/engine/packages/depot/src/cold_tier/faulty.rs index 98d184c3f6..668dc571f4 100644 --- a/engine/packages/depot/src/cold_tier/faulty.rs +++ b/engine/packages/depot/src/cold_tier/faulty.rs @@ -6,8 +6,7 @@ use std::time::Duration; #[cfg(feature = "test-faults")] use crate::fault::{ - ColdTierFaultPoint, DepotFaultAction, DepotFaultContext, DepotFaultController, - DepotFaultPoint, + ColdTierFaultPoint, DepotFaultAction, DepotFaultContext, DepotFaultController, DepotFaultPoint, }; use crate::metrics; @@ -127,10 +126,7 @@ impl FaultyColdTier { return Ok(None); }; let Some(fired) = controller - .maybe_fire( - DepotFaultPoint::ColdTier(point), - DepotFaultContext::new(), - ) + .maybe_fire(DepotFaultPoint::ColdTier(point), DepotFaultContext::new()) .await? else { return Ok(None); @@ -179,15 +175,14 @@ where .await?, Some(DepotFaultAction::DropArtifact) ); - self.inner.put_object(key, bytes).await - .and_then(|()| { - #[cfg(feature = "test-faults")] - if drop_ack { - anyhow::bail!("injected cold-tier put acknowledgement drop for {key}"); - } - - Ok(()) - }) + self.inner.put_object(key, bytes).await.and_then(|()| { + #[cfg(feature = "test-faults")] + if drop_ack { + anyhow::bail!("injected cold-tier put acknowledgement drop for {key}"); + } + + Ok(()) + }) } async fn get_object(&self, key: &str) -> Result>> { diff --git a/engine/packages/depot/src/cold_tier/filesystem.rs b/engine/packages/depot/src/cold_tier/filesystem.rs index fce0f0f712..5bad198cf8 100644 --- a/engine/packages/depot/src/cold_tier/filesystem.rs +++ b/engine/packages/depot/src/cold_tier/filesystem.rs @@ -50,7 +50,8 @@ impl FilesystemColdTier { Ok(entries) => entries, Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue, Err(err) => { - return Err(err).with_context(|| format!("list cold-tier dir {}", dir.display())); + return Err(err) + .with_context(|| format!("list cold-tier dir {}", dir.display())); } }; @@ -59,10 +60,9 @@ impl FilesystemColdTier { .await .with_context(|| format!("list cold-tier dir {}", dir.display()))? { - let metadata = entry - .metadata() - .await - .with_context(|| format!("read cold-tier metadata {}", entry.path().display()))?; + let metadata = entry.metadata().await.with_context(|| { + format!("read cold-tier metadata {}", entry.path().display()) + })?; if metadata.is_dir() { pending.push(entry.path()); @@ -120,7 +120,9 @@ impl ColdTier for FilesystemColdTier { match tokio::fs::read(&path).await { Ok(bytes) => Ok(Some(bytes)), Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(err) => Err(err).with_context(|| format!("read cold-tier object {}", path.display())), + Err(err) => { + Err(err).with_context(|| format!("read cold-tier object {}", path.display())) + } } } diff --git a/engine/packages/depot/src/compaction/companion.rs b/engine/packages/depot/src/compaction/companion.rs index 067acabc14..d93835b62a 100644 --- a/engine/packages/depot/src/compaction/companion.rs +++ b/engine/packages/depot/src/compaction/companion.rs @@ -34,8 +34,7 @@ async fn run_hot_companion_loop( continue; } - handle_hot_companion_signal(ctx, state, database_branch_id, signal) - .await?; + handle_hot_companion_signal(ctx, state, database_branch_id, signal).await?; } Ok(companion_loop_after_signals(state)) @@ -59,8 +58,7 @@ async fn run_cold_companion_loop( continue; } - handle_cold_companion_signal(ctx, state, database_branch_id, signal) - .await?; + handle_cold_companion_signal(ctx, state, database_branch_id, signal).await?; } Ok(companion_loop_after_signals(state)) @@ -84,8 +82,7 @@ async fn run_reclaim_companion_loop( continue; } - handle_reclaim_companion_signal(ctx, state, database_branch_id, signal) - .await?; + handle_reclaim_companion_signal(ctx, state, database_branch_id, signal).await?; } Ok(companion_loop_after_signals(state)) @@ -422,10 +419,7 @@ fn record_companion_job( }); } -fn record_companion_stop_signal( - state: &mut CompanionWorkflowState, - signal: DestroyDatabaseBranch, -) { +fn record_companion_stop_signal(state: &mut CompanionWorkflowState, signal: DestroyDatabaseBranch) { record_companion_stop( state, signal.lifecycle_generation, diff --git a/engine/packages/depot/src/compaction/shared.rs b/engine/packages/depot/src/compaction/shared.rs index 279f7a68cd..e4d9096a3c 100644 --- a/engine/packages/depot/src/compaction/shared.rs +++ b/engine/packages/depot/src/compaction/shared.rs @@ -38,19 +38,23 @@ pub(crate) async fn read_manager_fdb_snapshot( .map(decode_db_head) .transpose() .context("decode sqlite head for compaction manager")?; - let root = tx_get_value(tx, &keys::branch_compaction_root_key(branch_id), Serializable) - .await? - .as_deref() - .map(decode_compaction_root) - .transpose() - .context("decode sqlite compaction root for manager refresh")? - .unwrap_or(CompactionRoot { - schema_version: 1, - manifest_generation: 0, - hot_watermark_txid: 0, - cold_watermark_txid: 0, - cold_watermark_versionstamp: [0; 16], - }); + let root = tx_get_value( + tx, + &keys::branch_compaction_root_key(branch_id), + Serializable, + ) + .await? + .as_deref() + .map(decode_compaction_root) + .transpose() + .context("decode sqlite compaction root for manager refresh")? + .unwrap_or(CompactionRoot { + schema_version: 1, + manifest_generation: 0, + hot_watermark_txid: 0, + cold_watermark_txid: 0, + cold_watermark_versionstamp: [0; 16], + }); let dirty_key = keys::sqlite_cmp_dirty_key(branch_id); let dirty_bytes = tx_get_value(tx, &dirty_key, Serializable).await?; let dirty = dirty_bytes @@ -64,9 +68,16 @@ pub(crate) async fn read_manager_fdb_snapshot( let pitr_policy = read_effective_pitr_policy_for_branch(tx, branch_record.as_ref()).await?; let shard_cache_policy = read_effective_shard_cache_policy_for_branch(tx, branch_record.as_ref()).await?; - let hot_inputs = - read_hot_input_snapshot(tx, branch_id, head.as_ref(), &root, Snapshot, pitr_policy, now_ms) - .await?; + let hot_inputs = read_hot_input_snapshot( + tx, + branch_id, + head.as_ref(), + &root, + Snapshot, + pitr_policy, + now_ms, + ) + .await?; let cold_inputs = if cold_storage_enabled { read_cold_input_snapshot(tx, branch_id, &root, Snapshot).await? } else { @@ -84,12 +95,12 @@ pub(crate) async fn read_manager_fdb_snapshot( now_ms, ) .await?; - let hot_lag = head - .as_ref() - .map_or(0, |head| head.head_txid.saturating_sub(root.hot_watermark_txid)); - let cold_lag = head - .as_ref() - .map_or(0, |head| head.head_txid.saturating_sub(root.cold_watermark_txid)); + let hot_lag = head.as_ref().map_or(0, |head| { + head.head_txid.saturating_sub(root.hot_watermark_txid) + }); + let cold_lag = head.as_ref().map_or(0, |head| { + head.head_txid.saturating_sub(root.cold_watermark_txid) + }); let has_actionable_lag = hot_lag >= quota::COMPACTION_DELTA_THRESHOLD || (cold_lag >= HOT_BURST_COLD_LAG_THRESHOLD_TXIDS && !cold_inputs.shard_blobs.is_empty()) || reclaim_coverage_is_complete(&reclaim_inputs); @@ -123,8 +134,12 @@ pub(crate) async fn resolve_bucket_fork_pins( branch_id: DatabaseBranchId, db_pins: &mut Vec, ) -> Result { - let catalog_rows = - tx_scan_prefix_values(tx, &keys::bucket_catalog_by_db_prefix(branch_id), Serializable).await?; + let catalog_rows = tx_scan_prefix_values( + tx, + &keys::bucket_catalog_by_db_prefix(branch_id), + Serializable, + ) + .await?; if catalog_rows.len() >= CMP_FDB_BATCH_MAX_KEYS { tracing::warn!( ?branch_id, @@ -171,9 +186,12 @@ pub(crate) async fn resolve_bucket_catalog_forks( continue; } - let child_rows = - tx_scan_prefix_values(tx, &keys::bucket_child_prefix(source_bucket_branch_id), Serializable) - .await?; + let child_rows = tx_scan_prefix_values( + tx, + &keys::bucket_child_prefix(source_bucket_branch_id), + Serializable, + ) + .await?; inspected_rows = inspected_rows.saturating_add(child_rows.len()); if inspected_rows >= CMP_FDB_BATCH_MAX_KEYS { tracing::warn!( @@ -285,8 +303,7 @@ pub(crate) async fn materialize_bucket_fork_pin( at_txid, commit.wall_clock_ms, )?; - db_pins - .retain(|pin| pin.owner_bucket_branch_id != Some(fork_fact.target_bucket_branch_id)); + db_pins.retain(|pin| pin.owner_bucket_branch_id != Some(fork_fact.target_bucket_branch_id)); db_pins.push(DbHistoryPin { at_versionstamp, at_txid, @@ -363,13 +380,17 @@ pub(crate) async fn read_effective_shard_cache_policy_for_branch( return Ok(policy); } - tx_get_value(tx, &keys::bucket_policy_shard_cache_key(bucket_id), Serializable) - .await? - .as_deref() - .map(decode_shard_cache_policy) - .transpose() - .context("decode sqlite bucket shard cache policy for compaction manager") - .map(|policy| policy.unwrap_or_default()) + tx_get_value( + tx, + &keys::bucket_policy_shard_cache_key(bucket_id), + Serializable, + ) + .await? + .as_deref() + .map(decode_shard_cache_policy) + .transpose() + .context("decode sqlite bucket shard cache policy for compaction manager") + .map(|policy| policy.unwrap_or_default()) } pub(crate) async fn resolve_policy_scope_for_branch( @@ -379,11 +400,12 @@ pub(crate) async fn resolve_policy_scope_for_branch( for (key, value) in tx_scan_prefix_values(tx, &keys::database_pointer_cur_prefix(), Serializable).await? { - let Ok((bucket_branch_id, database_id)) = keys::decode_database_pointer_cur_key(&key) else { + let Ok((bucket_branch_id, database_id)) = keys::decode_database_pointer_cur_key(&key) + else { continue; }; - let pointer = - decode_database_pointer(&value).context("decode sqlite database pointer for PITR policy")?; + let pointer = decode_database_pointer(&value) + .context("decode sqlite database pointer for PITR policy")?; if pointer.current_branch != branch_id { continue; } @@ -434,8 +456,8 @@ pub(crate) async fn resolve_bucket_id_for_root_branch( let Ok(bucket_id) = keys::decode_bucket_pointer_cur_bucket_id(&key) else { continue; }; - let pointer = - decode_bucket_pointer(&value).context("decode sqlite bucket pointer for PITR policy")?; + let pointer = decode_bucket_pointer(&value) + .context("decode sqlite bucket pointer for PITR policy")?; if pointer.current_branch == root_bucket_branch_id { return Ok(Some(bucket_id)); } @@ -475,7 +497,8 @@ pub(crate) async fn latest_commit_at_or_before_versionstamp( let Some((txid, versionstamp)) = selected else { return Ok(None); }; - let Some(commit_bytes) = tx_get_value(tx, &keys::branch_commit_key(branch_id, txid), Serializable).await? + let Some(commit_bytes) = + tx_get_value(tx, &keys::branch_commit_key(branch_id, txid), Serializable).await? else { return Ok(None); }; @@ -602,8 +625,14 @@ pub(crate) fn select_pitr_interval_coverage( commits: &[(u64, CommitRow)], now_ms: i64, ) -> Result> { - ensure!(policy.interval_ms > 0, "sqlite PITR interval policy must be positive"); - ensure!(policy.retention_ms > 0, "sqlite PITR retention policy must be positive"); + ensure!( + policy.interval_ms > 0, + "sqlite PITR interval policy must be positive" + ); + ensure!( + policy.retention_ms > 0, + "sqlite PITR retention policy must be positive" + ); let retention_floor_ms = now_ms.saturating_sub(policy.retention_ms); let mut selected_by_bucket = BTreeMap::::new(); @@ -611,7 +640,8 @@ pub(crate) fn select_pitr_interval_coverage( if commit.wall_clock_ms < retention_floor_ms || commit.wall_clock_ms > now_ms { continue; } - let bucket_start_ms = commit.wall_clock_ms.div_euclid(policy.interval_ms) * policy.interval_ms; + let bucket_start_ms = + commit.wall_clock_ms.div_euclid(policy.interval_ms) * policy.interval_ms; let coverage = PitrIntervalCoverage { txid: *txid, versionstamp: commit.versionstamp, @@ -761,8 +791,10 @@ pub(crate) async fn read_reclaim_input_snapshot( Ok(snapshot) } -type PitrIntervalReclaimRows = - (Vec, Vec<(i64, Vec, Vec, PitrIntervalCoverage)>); +type PitrIntervalReclaimRows = ( + Vec, + Vec<(i64, Vec, Vec, PitrIntervalCoverage)>, +); pub(crate) async fn read_pitr_interval_reclaim_rows( tx: &universaldb::Transaction, @@ -773,9 +805,12 @@ pub(crate) async fn read_pitr_interval_reclaim_rows( let mut retained = Vec::new(); let mut expired = Vec::new(); - for (key, value) in - tx_scan_prefix_values(tx, &keys::branch_pitr_interval_prefix(branch_id), isolation_level) - .await? + for (key, value) in tx_scan_prefix_values( + tx, + &keys::branch_pitr_interval_prefix(branch_id), + isolation_level, + ) + .await? { let bucket_start_ms = keys::decode_branch_pitr_interval_bucket(branch_id, &key)?; let coverage = decode_pitr_interval_coverage(&value) @@ -801,9 +836,12 @@ pub(crate) async fn read_reclaim_cold_object_refs( ) -> Result> { let mut refs = Vec::new(); - for (_, value) in - tx_scan_prefix_values(tx, &keys::branch_compaction_cold_shard_prefix(branch_id), isolation_level) - .await? + for (_, value) in tx_scan_prefix_values( + tx, + &keys::branch_compaction_cold_shard_prefix(branch_id), + isolation_level, + ) + .await? { let cold_ref = decode_cold_shard_ref(&value).context("decode sqlite cold shard ref for reclaim")?; @@ -945,7 +983,8 @@ pub(crate) async fn read_cold_input_snapshot( if txid < min_txid || txid > max_txid { continue; } - let commit = decode_commit_row(&value).context("decode sqlite commit row for cold planning")?; + let commit = + decode_commit_row(&value).context("decode sqlite commit row for cold planning")?; if snapshot.commits.is_empty() { snapshot.min_versionstamp = commit.versionstamp; snapshot.max_versionstamp = commit.versionstamp; @@ -1074,7 +1113,9 @@ pub(crate) fn plan_hot_job( if head.head_txid <= snapshot.root.hot_watermark_txid { return None; } - let hot_lag = head.head_txid.saturating_sub(snapshot.root.hot_watermark_txid); + let hot_lag = head + .head_txid + .saturating_sub(snapshot.root.hot_watermark_txid); let coverage_txids = selected_hot_coverage_txids( &snapshot.root, head, @@ -1175,9 +1216,11 @@ pub(crate) fn plan_reclaim_job( && reclaim_coverage_is_complete(&snapshot.reclaim_inputs); let has_cold_reclaim = !snapshot.reclaim_inputs.cold_object_refs.is_empty(); let has_shard_cache_eviction = !snapshot.reclaim_inputs.shard_cache_evictions.is_empty(); - let has_interval_cleanup = !snapshot.reclaim_inputs.expired_pitr_interval_rows.is_empty(); - if !has_hot_reclaim && !has_cold_reclaim && !has_shard_cache_eviction && !has_interval_cleanup - { + let has_interval_cleanup = !snapshot + .reclaim_inputs + .expired_pitr_interval_rows + .is_empty(); + if !has_hot_reclaim && !has_cold_reclaim && !has_shard_cache_eviction && !has_interval_cleanup { return None; } @@ -1236,7 +1279,11 @@ pub(crate) fn reclaim_noop_reason(snapshot: &ManagerFdbSnapshot) -> &'static str let has_cold_inputs = !snapshot.reclaim_inputs.cold_object_refs.is_empty(); let has_cache_evictions = !snapshot.reclaim_inputs.shard_cache_evictions.is_empty(); if !has_hot_inputs && !has_cold_inputs && !has_cache_evictions { - if snapshot.reclaim_inputs.expired_pitr_interval_rows.is_empty() { + if snapshot + .reclaim_inputs + .expired_pitr_interval_rows + .is_empty() + { return "reclaim:no-actionable-work"; } return "reclaim:expired-pitr-intervals"; @@ -1272,8 +1319,14 @@ pub(crate) fn fingerprint_hot_inputs( update_fingerprint(&mut fingerprint, &selection.bucket_start_ms.to_be_bytes()); update_fingerprint(&mut fingerprint, &selection.coverage.txid.to_be_bytes()); update_fingerprint(&mut fingerprint, &selection.coverage.versionstamp); - update_fingerprint(&mut fingerprint, &selection.coverage.wall_clock_ms.to_be_bytes()); - update_fingerprint(&mut fingerprint, &selection.coverage.expires_at_ms.to_be_bytes()); + update_fingerprint( + &mut fingerprint, + &selection.coverage.wall_clock_ms.to_be_bytes(), + ); + update_fingerprint( + &mut fingerprint, + &selection.coverage.expires_at_ms.to_be_bytes(), + ); } for (txid, commit) in &hot_inputs.commits { update_fingerprint(&mut fingerprint, &txid.to_be_bytes()); @@ -1308,7 +1361,10 @@ pub(crate) fn fingerprint_reclaim_inputs( } for cold_object in &reclaim_inputs.cold_object_refs { update_fingerprint(&mut fingerprint, cold_object.object_key.as_bytes()); - update_fingerprint(&mut fingerprint, &cold_object.object_generation_id.as_bytes()); + update_fingerprint( + &mut fingerprint, + &cold_object.object_generation_id.as_bytes(), + ); update_fingerprint(&mut fingerprint, &cold_object.content_hash); update_fingerprint( &mut fingerprint, @@ -1318,9 +1374,18 @@ pub(crate) fn fingerprint_reclaim_inputs( update_fingerprint(&mut fingerprint, &cold_object.as_of_txid.to_be_bytes()); } for candidate in &reclaim_inputs.shard_cache_evictions { - update_fingerprint(&mut fingerprint, &candidate.reference.shard_id.to_be_bytes()); - update_fingerprint(&mut fingerprint, &candidate.reference.as_of_txid.to_be_bytes()); - update_fingerprint(&mut fingerprint, &candidate.reference.size_bytes.to_be_bytes()); + update_fingerprint( + &mut fingerprint, + &candidate.reference.shard_id.to_be_bytes(), + ); + update_fingerprint( + &mut fingerprint, + &candidate.reference.as_of_txid.to_be_bytes(), + ); + update_fingerprint( + &mut fingerprint, + &candidate.reference.size_bytes.to_be_bytes(), + ); update_fingerprint(&mut fingerprint, &candidate.reference.content_hash); update_fingerprint(&mut fingerprint, &candidate.shard_key); update_fingerprint(&mut fingerprint, &candidate.shard_bytes); @@ -1368,8 +1433,14 @@ pub(crate) fn fingerprint_repair_reclaim_range( for staged in &input_range.staged_hot_shards { update_fingerprint(&mut fingerprint, &staged.job_id.as_bytes()); update_fingerprint(&mut fingerprint, &staged.output_ref.shard_id.to_be_bytes()); - update_fingerprint(&mut fingerprint, &staged.output_ref.as_of_txid.to_be_bytes()); - update_fingerprint(&mut fingerprint, &staged.output_ref.size_bytes.to_be_bytes()); + update_fingerprint( + &mut fingerprint, + &staged.output_ref.as_of_txid.to_be_bytes(), + ); + update_fingerprint( + &mut fingerprint, + &staged.output_ref.size_bytes.to_be_bytes(), + ); update_fingerprint(&mut fingerprint, &staged.output_ref.content_hash); } for cold_ref in &input_range.orphan_cold_objects { @@ -1486,10 +1557,7 @@ pub(crate) fn decode_hot_delta_chunks( chunks_by_txid .into_iter() .map(|(txid, chunks)| { - let bytes = chunks - .into_values() - .flatten() - .collect::>(); + let bytes = chunks.into_values().flatten().collect::>(); let decoded = decode_ltx_v3(&bytes).with_context(|| format!("decode hot delta {txid}"))?; @@ -1518,7 +1586,10 @@ pub(crate) fn collect_hot_pages_by_shard( let mut pages_by_shard = BTreeMap::)>>::new(); for (pgno, bytes) in pages_by_number { - pages_by_shard.entry(pgno / keys::SHARD_SIZE).or_default().push((pgno, bytes)); + pages_by_shard + .entry(pgno / keys::SHARD_SIZE) + .or_default() + .push((pgno, bytes)); } Ok(pages_by_shard) } diff --git a/engine/packages/depot/src/compaction/test_driver.rs b/engine/packages/depot/src/compaction/test_driver.rs index cb65259d91..9b7f0c61d5 100644 --- a/engine/packages/depot/src/compaction/test_driver.rs +++ b/engine/packages/depot/src/compaction/test_driver.rs @@ -82,7 +82,8 @@ impl<'a> DepotCompactionTestDriver<'a> { .context("force compaction signal should target manager workflow")?; self.wait_for_signal_ack(signal_id).await?; - self.wait_for_force_result(manager_workflow_id, request_id).await + self.wait_for_force_result(manager_workflow_id, request_id) + .await } async fn wait_for_signal_ack(&self, signal_id: Id) -> Result<()> { diff --git a/engine/packages/depot/src/compaction/test_hooks.rs b/engine/packages/depot/src/compaction/test_hooks.rs index e1b28675d0..a99698d3fb 100644 --- a/engine/packages/depot/src/compaction/test_hooks.rs +++ b/engine/packages/depot/src/compaction/test_hooks.rs @@ -31,8 +31,11 @@ pub fn pause_after_hot_stage( ) -> (PauseGuard, Arc, Arc) { let reached = Arc::new(Notify::new()); let release = Arc::new(Notify::new()); - *PAUSE_AFTER_HOT_STAGE.lock() = - Some((database_branch_id, Arc::clone(&reached), Arc::clone(&release))); + *PAUSE_AFTER_HOT_STAGE.lock() = Some(( + database_branch_id, + Arc::clone(&reached), + Arc::clone(&release), + )); ( PauseGuard { @@ -211,9 +214,7 @@ impl Drop for WorkflowFaultControllerGuard { #[cfg(feature = "test-faults")] impl Drop for WorkflowColdTierGuard { fn drop(&mut self) { - crate::compaction::shared::clear_workflow_test_cold_tier_for_test( - self.database_branch_id, - ); + crate::compaction::shared::clear_workflow_test_cold_tier_for_test(self.database_branch_id); } } diff --git a/engine/packages/depot/src/compaction/types.rs b/engine/packages/depot/src/compaction/types.rs index dc81de7692..fe61e80690 100644 --- a/engine/packages/depot/src/compaction/types.rs +++ b/engine/packages/depot/src/compaction/types.rs @@ -18,30 +18,28 @@ pub(crate) use universaldb::{ }; pub(crate) use crate::{ - CMP_COLD_OBJECT_DELETE_GRACE_MS, CMP_FDB_BATCH_MAX_KEYS, CMP_FDB_BATCH_MAX_VALUE_BYTES, - CMP_S3_DELETE_MAX_OBJECTS, CMP_S3_UPLOAD_LIMIT_BYTES, CMP_S3_UPLOAD_MAX_OBJECTS, - MAX_BUCKET_DEPTH, - ACCESS_TOUCH_THROTTLE_MS, - HOT_BURST_COLD_LAG_THRESHOLD_TXIDS, + ACCESS_TOUCH_THROTTLE_MS, CMP_COLD_OBJECT_DELETE_GRACE_MS, CMP_FDB_BATCH_MAX_KEYS, + CMP_FDB_BATCH_MAX_VALUE_BYTES, CMP_S3_DELETE_MAX_OBJECTS, CMP_S3_UPLOAD_LIMIT_BYTES, + CMP_S3_UPLOAD_MAX_OBJECTS, HOT_BURST_COLD_LAG_THRESHOLD_TXIDS, MAX_BUCKET_DEPTH, + cold_tier::{ColdTier, cold_tier_from_config}, conveyer::{ - history_pin, keys, quota, + history_pin, keys, ltx::{DecodedLtx, LtxHeader, decode_ltx_v3, encode_ltx_v3}, + quota, types::{ - BranchState, BucketCatalogDbFact, BucketForkFact, BucketId, ColdShardRef, - CommitRow, CompactionRoot, DBHead, DatabaseBranchId, DatabaseBranchRecord, - DbHistoryPin, DirtyPage, PitrIntervalCoverage, PitrPolicy, RetiredColdObject, - RetiredColdObjectDeleteState, SqliteCmpDirty, decode_bucket_catalog_db_fact, - decode_bucket_fork_fact, decode_bucket_pointer, decode_cold_shard_ref, - decode_commit_row, decode_compaction_root, decode_database_branch_record, - decode_database_pointer, decode_db_head, decode_pitr_policy, - decode_shard_cache_policy, - decode_pitr_interval_coverage, decode_retired_cold_object, decode_sqlite_cmp_dirty, - encode_cold_shard_ref, encode_compaction_root, encode_pitr_interval_coverage, - encode_retired_cold_object, ShardCachePolicy, + BranchState, BucketCatalogDbFact, BucketForkFact, BucketId, ColdShardRef, CommitRow, + CompactionRoot, DBHead, DatabaseBranchId, DatabaseBranchRecord, DbHistoryPin, + DirtyPage, PitrIntervalCoverage, PitrPolicy, RetiredColdObject, + RetiredColdObjectDeleteState, ShardCachePolicy, SqliteCmpDirty, + decode_bucket_catalog_db_fact, decode_bucket_fork_fact, decode_bucket_pointer, + decode_cold_shard_ref, decode_commit_row, decode_compaction_root, + decode_database_branch_record, decode_database_pointer, decode_db_head, + decode_pitr_interval_coverage, decode_pitr_policy, decode_retired_cold_object, + decode_shard_cache_policy, decode_sqlite_cmp_dirty, encode_cold_shard_ref, + encode_compaction_root, encode_pitr_interval_coverage, encode_retired_cold_object, }, udb, }, - cold_tier::{ColdTier, cold_tier_from_config}, }; pub const DATABASE_BRANCH_ID_TAG: &str = "database_branch_id"; @@ -629,7 +627,9 @@ pub enum BranchStopState { requested_at_ms: i64, reason: ManagerStopReason, }, - Stopped { stopped_at_ms: i64 }, + Stopped { + stopped_at_ms: i64, + }, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/engine/packages/depot/src/conveyer/branch.rs b/engine/packages/depot/src/conveyer/branch.rs index c91297ff2f..69ca5de49f 100644 --- a/engine/packages/depot/src/conveyer/branch.rs +++ b/engine/packages/depot/src/conveyer/branch.rs @@ -4,16 +4,14 @@ mod lifecycle; mod resolve; mod shared; -pub(crate) use catalog::{ - write_bucket_catalog_marker, write_bucket_catalog_marker_with_root, -}; pub use catalog::list_databases; -pub use fork::{derive_branch_at, derive_bucket_branch_at, fork_database, fork_bucket}; +pub(crate) use catalog::{write_bucket_catalog_marker, write_bucket_catalog_marker_with_root}; +pub use fork::{derive_branch_at, derive_bucket_branch_at, fork_bucket, fork_database}; pub(crate) use lifecycle::rollback_database_to_target_tx; -pub use lifecycle::{delete_database, rollback_database, rollback_bucket}; +pub use lifecycle::{delete_database, rollback_bucket, rollback_database}; pub use resolve::{ - BucketBranchResolution, resolve_database_branch, resolve_database_branch_in_bucket, - resolve_database_pointer, resolve_bucket_branch, resolve_or_allocate_root_bucket_branch, - write_root_bucket_metadata, + BucketBranchResolution, resolve_bucket_branch, resolve_database_branch, + resolve_database_branch_in_bucket, resolve_database_pointer, + resolve_or_allocate_root_bucket_branch, write_root_bucket_metadata, }; pub(super) use shared::read_database_branch_record; diff --git a/engine/packages/depot/src/conveyer/branch/catalog.rs b/engine/packages/depot/src/conveyer/branch/catalog.rs index 40acdb2778..068b25620d 100644 --- a/engine/packages/depot/src/conveyer/branch/catalog.rs +++ b/engine/packages/depot/src/conveyer/branch/catalog.rs @@ -5,19 +5,17 @@ use universaldb::{options::MutationType, utils::IsolationLevel::Serializable}; use super::{ resolve::resolve_bucket_branch, - shared::{ - decode_versionstamp_value, read_bucket_branch_record, tx_scan_prefix_values, - }, + shared::{decode_versionstamp_value, read_bucket_branch_record, tx_scan_prefix_values}, }; use crate::conveyer::{ constants::MAX_BUCKET_DEPTH, error::SqliteStorageError, - keys, udb, + keys, types::{ - DatabaseBranchId, BucketBranchId, BucketCatalogDbFact, BucketForkFact, - BucketId, decode_bucket_catalog_db_fact, encode_bucket_catalog_db_fact, - encode_bucket_fork_fact, + BucketBranchId, BucketCatalogDbFact, BucketForkFact, BucketId, DatabaseBranchId, + decode_bucket_catalog_db_fact, encode_bucket_catalog_db_fact, encode_bucket_fork_fact, }, + udb, }; pub async fn list_databases( @@ -25,9 +23,7 @@ pub async fn list_databases( bucket: BucketId, ) -> Result> { udb.run(move |tx| async move { - let Some(bucket_branch_id) = - resolve_bucket_branch(&tx, bucket, Serializable).await? - else { + let Some(bucket_branch_id) = resolve_bucket_branch(&tx, bucket, Serializable).await? else { return Ok(Vec::new()); }; @@ -125,8 +121,8 @@ pub(crate) fn write_bucket_catalog_marker_with_root( catalog_versionstamp: *catalog_versionstamp, tombstone_versionstamp: None, }; - let encoded_fact = encode_bucket_catalog_db_fact(fact) - .context("encode sqlite bucket catalog proof fact")?; + let encoded_fact = + encode_bucket_catalog_db_fact(fact).context("encode sqlite bucket catalog proof fact")?; tx.informal().atomic_op( &keys::bucket_catalog_by_db_key(database_id, bucket_branch_id), &udb::append_versionstamp_offset(encoded_fact, catalog_versionstamp) @@ -146,7 +142,10 @@ pub(super) async fn write_bucket_catalog_tombstone_marker( let root_bucket_branch_id = read_bucket_root_branch_id(tx, bucket_branch_id).await?; let existing_fact = tx .informal() - .get(&keys::bucket_catalog_by_db_key(database_id, bucket_branch_id), Serializable) + .get( + &keys::bucket_catalog_by_db_key(database_id, bucket_branch_id), + Serializable, + ) .await? .as_deref() .map(|bytes| decode_bucket_catalog_db_fact(bytes)) @@ -179,8 +178,7 @@ pub(super) async fn write_bucket_fork_facts( target_bucket_branch_id: BucketBranchId, fork_versionstamp: [u8; 16], ) -> Result<()> { - let root_bucket_branch_id = - read_bucket_root_branch_id(tx, source_bucket_branch_id).await?; + let root_bucket_branch_id = read_bucket_root_branch_id(tx, source_bucket_branch_id).await?; let fact = BucketForkFact { source_bucket_branch_id, target_bucket_branch_id, @@ -230,10 +228,7 @@ async fn read_bucket_root_branch_id( Err(SqliteStorageError::BucketForkChainTooDeep.into()) } -fn bump_bucket_proof_epoch( - tx: &universaldb::Transaction, - root_bucket_branch_id: BucketBranchId, -) { +fn bump_bucket_proof_epoch(tx: &universaldb::Transaction, root_bucket_branch_id: BucketBranchId) { tx.informal().atomic_op( &keys::bucket_proof_epoch_key(root_bucket_branch_id), &1_i64.to_le_bytes(), diff --git a/engine/packages/depot/src/conveyer/branch/fork.rs b/engine/packages/depot/src/conveyer/branch/fork.rs index e885138309..902c654356 100644 --- a/engine/packages/depot/src/conveyer/branch/fork.rs +++ b/engine/packages/depot/src/conveyer/branch/fork.rs @@ -3,23 +3,24 @@ use universaldb::{options::MutationType, utils::IsolationLevel::Serializable}; use super::{ catalog::{write_bucket_catalog_marker, write_bucket_fork_facts}, - resolve::{resolve_database_branch_in_bucket, resolve_bucket_branch}, + resolve::{resolve_bucket_branch, resolve_database_branch_in_bucket}, shared::{ - lookup_txid_at_versionstamp, now_ms, read_commit_row, read_database_branch_record, - read_bucket_branch_record, read_versionstamp_pin, + lookup_txid_at_versionstamp, now_ms, read_bucket_branch_record, read_commit_row, + read_database_branch_record, read_versionstamp_pin, }, }; use crate::conveyer::{ - constants::{MAX_FORK_DEPTH, MAX_BUCKET_DEPTH}, + constants::{MAX_BUCKET_DEPTH, MAX_FORK_DEPTH}, error::SqliteStorageError, - history_pin, keys, restore_point, udb, + history_pin, keys, restore_point, types::{ - RestorePointRef, BranchState, DBHead, DatabaseBranchId, DatabasePointer, BucketBranchId, - DatabaseBranchRecord, BucketBranchRecord, BucketId, BucketPointer, - ResolvedRestoreTarget, ResolvedVersionstamp, SnapshotSelector, - encode_database_branch_record, encode_database_pointer, encode_db_head, - encode_bucket_branch_record, encode_bucket_pointer, + BranchState, BucketBranchId, BucketBranchRecord, BucketId, BucketPointer, DBHead, + DatabaseBranchId, DatabaseBranchRecord, DatabasePointer, ResolvedRestoreTarget, + ResolvedVersionstamp, RestorePointRef, SnapshotSelector, encode_bucket_branch_record, + encode_bucket_pointer, encode_database_branch_record, encode_database_pointer, + encode_db_head, }, + udb, }; use crate::gc; @@ -56,8 +57,13 @@ where let target = match target.into() { DatabaseForkTarget::Resolved(at) => ResolvedForkTarget::CurrentSourceBranch(at), DatabaseForkTarget::Selector(selector) => { - let target = - restore_point::resolve_restore_target(udb, source_bucket, source_database_id.clone(), selector).await?; + let target = restore_point::resolve_restore_target( + udb, + source_bucket, + source_database_id.clone(), + selector, + ) + .await?; ResolvedForkTarget::ResolvedTarget(target) } }; @@ -71,14 +77,12 @@ where let target = target_for_tx.clone(); async move { - let source_bucket_branch = - resolve_bucket_branch(&tx, source_bucket, Serializable) - .await? - .ok_or(SqliteStorageError::DatabaseNotFound)?; - let target_bucket_branch = - resolve_bucket_branch(&tx, target_bucket, Serializable) - .await? - .ok_or(SqliteStorageError::DatabaseNotFound)?; + let source_bucket_branch = resolve_bucket_branch(&tx, source_bucket, Serializable) + .await? + .ok_or(SqliteStorageError::DatabaseNotFound)?; + let target_bucket_branch = resolve_bucket_branch(&tx, target_bucket, Serializable) + .await? + .ok_or(SqliteStorageError::DatabaseNotFound)?; let (source_database_branch, at_versionstamp, restore_point) = match target { ResolvedForkTarget::CurrentSourceBranch(at) => { let source_database_branch = resolve_database_branch_in_bucket( @@ -91,9 +95,11 @@ where .ok_or(SqliteStorageError::DatabaseNotFound)?; (source_database_branch, at.versionstamp, at.restore_point) } - ResolvedForkTarget::ResolvedTarget(target) => { - (target.database_branch_id, target.versionstamp, target.restore_point) - } + ResolvedForkTarget::ResolvedTarget(target) => ( + target.database_branch_id, + target.versionstamp, + target.restore_point, + ), }; derive_branch_at( @@ -110,8 +116,8 @@ where current_branch: new_database_branch_id, last_swapped_at_ms: now_ms()?, }; - let encoded_pointer = - encode_database_pointer(pointer).context("encode sqlite fork database pointer")?; + let encoded_pointer = encode_database_pointer(pointer) + .context("encode sqlite fork database pointer")?; tx.informal().set( &keys::database_pointer_cur_key(target_bucket_branch, &new_database_id), &encoded_pointer, @@ -153,10 +159,9 @@ pub async fn fork_bucket( let at = at_for_tx.clone(); async move { - let source_bucket_branch = - resolve_bucket_branch(&tx, source_bucket, Serializable) - .await? - .ok_or(SqliteStorageError::DatabaseNotFound)?; + let source_bucket_branch = resolve_bucket_branch(&tx, source_bucket, Serializable) + .await? + .ok_or(SqliteStorageError::DatabaseNotFound)?; derive_bucket_branch_at( &tx, @@ -173,8 +178,10 @@ pub async fn fork_bucket( }; let encoded_pointer = encode_bucket_pointer(pointer).context("encode sqlite fork bucket pointer")?; - tx.informal() - .set(&keys::bucket_pointer_cur_key(new_bucket_id), &encoded_pointer); + tx.informal().set( + &keys::bucket_pointer_cur_key(new_bucket_id), + &encoded_pointer, + ); Ok(()) } @@ -198,7 +205,8 @@ pub async fn derive_branch_at( return Err(SqliteStorageError::ForkChainTooDeep.into()); } - let restore_point_pin = read_versionstamp_pin(tx, &keys::branches_restore_point_pin_key(source_branch_id)).await?; + let restore_point_pin = + read_versionstamp_pin(tx, &keys::branches_restore_point_pin_key(source_branch_id)).await?; if restore_point_pin > at_versionstamp { return Err(SqliteStorageError::ForkOutOfRetention.into()); } @@ -254,8 +262,8 @@ pub async fn derive_branch_at( state: BranchState::Live, lifecycle_generation: 0, }; - let encoded_record = - encode_database_branch_record(new_record).context("encode sqlite derived database branch record")?; + let encoded_record = encode_database_branch_record(new_record) + .context("encode sqlite derived database branch record")?; tx.informal() .set(&keys::branches_list_key(new_branch_id), &encoded_record); tx.informal().atomic_op( @@ -297,8 +305,11 @@ pub async fn derive_bucket_branch_at( return Err(SqliteStorageError::BucketForkChainTooDeep.into()); } - let restore_point_pin = - read_versionstamp_pin(tx, &keys::bucket_branches_restore_point_pin_key(source_branch_id)).await?; + let restore_point_pin = read_versionstamp_pin( + tx, + &keys::bucket_branches_restore_point_pin_key(source_branch_id), + ) + .await?; if restore_point_pin > at_versionstamp { return Err(SqliteStorageError::ForkOutOfRetention.into()); } @@ -315,8 +326,10 @@ pub async fn derive_bucket_branch_at( }; let encoded_record = encode_bucket_branch_record(new_record) .context("encode sqlite derived bucket branch record")?; - tx.informal() - .set(&keys::bucket_branches_list_key(new_branch_id), &encoded_record); + tx.informal().set( + &keys::bucket_branches_list_key(new_branch_id), + &encoded_record, + ); tx.informal().atomic_op( &keys::bucket_branches_refcount_key(source_branch_id), &1_i64.to_le_bytes(), diff --git a/engine/packages/depot/src/conveyer/branch/lifecycle.rs b/engine/packages/depot/src/conveyer/branch/lifecycle.rs index 188570e4ce..7bfbb8dad4 100644 --- a/engine/packages/depot/src/conveyer/branch/lifecycle.rs +++ b/engine/packages/depot/src/conveyer/branch/lifecycle.rs @@ -7,17 +7,17 @@ use super::{ write_bucket_catalog_tombstone_marker, }, fork::{derive_branch_at, derive_bucket_branch_at}, - resolve::{resolve_database_pointer, resolve_bucket_branch}, - shared::{now_ms, read_database_branch_record, read_bucket_branch_record}, + resolve::{resolve_bucket_branch, resolve_database_pointer}, + shared::{now_ms, read_bucket_branch_record, read_database_branch_record}, }; use crate::conveyer::{ error::SqliteStorageError, keys, types::{ - BranchState, DatabaseBranchId, DatabasePointer, DatabaseBranchRecord, BucketBranchId, - BucketBranchRecord, BucketId, BucketPointer, ResolvedRestoreTarget, ResolvedVersionstamp, - decode_bucket_pointer, encode_database_branch_record, encode_database_pointer, - encode_bucket_branch_record, encode_bucket_pointer, + BranchState, BucketBranchId, BucketBranchRecord, BucketId, BucketPointer, DatabaseBranchId, + DatabaseBranchRecord, DatabasePointer, ResolvedRestoreTarget, ResolvedVersionstamp, + decode_bucket_pointer, encode_bucket_branch_record, encode_bucket_pointer, + encode_database_branch_record, encode_database_pointer, }, }; @@ -106,8 +106,8 @@ pub async fn rollback_bucket( current_branch: rolled_branch_id, last_swapped_at_ms: now_ms, }; - let encoded_pointer = - encode_bucket_pointer(new_ptr).context("encode sqlite rollback bucket pointer")?; + let encoded_pointer = encode_bucket_pointer(new_ptr) + .context("encode sqlite rollback bucket pointer")?; tx.informal() .set(&keys::bucket_pointer_cur_key(bucket), &encoded_pointer); @@ -137,18 +137,13 @@ pub async fn rollback_database( let at = at.clone(); async move { - let bucket_branch = - resolve_bucket_branch(&tx, bucket, Serializable) + let bucket_branch = resolve_bucket_branch(&tx, bucket, Serializable) + .await? + .ok_or(SqliteStorageError::DatabaseNotFound)?; + let cur_ptr = + resolve_database_pointer(&tx, bucket_branch, &database_id, Serializable) .await? .ok_or(SqliteStorageError::DatabaseNotFound)?; - let cur_ptr = resolve_database_pointer( - &tx, - bucket_branch, - &database_id, - Serializable, - ) - .await? - .ok_or(SqliteStorageError::DatabaseNotFound)?; let cur_record = read_database_branch_record(&tx, cur_ptr.current_branch).await?; derive_branch_at( @@ -180,8 +175,8 @@ pub async fn rollback_database( current_branch: rolled_branch_id, last_swapped_at_ms: now_ms, }; - let encoded_pointer = - encode_database_pointer(new_ptr).context("encode sqlite rollback database pointer")?; + let encoded_pointer = encode_database_pointer(new_ptr) + .context("encode sqlite rollback database pointer")?; tx.informal().set( &keys::database_pointer_cur_key(bucket_branch, &database_id), &encoded_pointer, @@ -256,8 +251,8 @@ async fn freeze_database_branch( ) -> Result<()> { record.state = BranchState::Frozen; let branch_id = record.branch_id; - let encoded_record = - encode_database_branch_record(record).context("encode frozen sqlite database branch record")?; + let encoded_record = encode_database_branch_record(record) + .context("encode frozen sqlite database branch record")?; tx.informal() .set(&keys::branches_list_key(branch_id), &encoded_record); diff --git a/engine/packages/depot/src/conveyer/branch/resolve.rs b/engine/packages/depot/src/conveyer/branch/resolve.rs index 8d4738f4bd..bcfc886012 100644 --- a/engine/packages/depot/src/conveyer/branch/resolve.rs +++ b/engine/packages/depot/src/conveyer/branch/resolve.rs @@ -1,18 +1,16 @@ use anyhow::{Context, Result}; -use universaldb::{ - options::MutationType, - utils::IsolationLevel, -}; +use universaldb::{options::MutationType, utils::IsolationLevel}; use crate::conveyer::{ constants::MAX_BUCKET_DEPTH, error::SqliteStorageError, - keys, udb, + keys, types::{ - BranchState, DatabaseBranchId, DatabasePointer, BucketBranchId, BucketBranchRecord, - BucketId, BucketPointer, decode_database_pointer, decode_bucket_branch_record, - decode_bucket_pointer, encode_bucket_branch_record, encode_bucket_pointer, + BranchState, BucketBranchId, BucketBranchRecord, BucketId, BucketPointer, DatabaseBranchId, + DatabasePointer, decode_bucket_branch_record, decode_bucket_pointer, + decode_database_pointer, encode_bucket_branch_record, encode_bucket_pointer, }, + udb, }; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -93,8 +91,7 @@ pub fn write_root_bucket_metadata( current_branch: branch_id, last_swapped_at_ms: now_ms, }; - let encoded_pointer = - encode_bucket_pointer(pointer).context("encode sqlite bucket pointer")?; + let encoded_pointer = encode_bucket_pointer(pointer).context("encode sqlite bucket pointer")?; tx.informal() .set(&keys::bucket_pointer_cur_key(bucket_id), &encoded_pointer); @@ -107,8 +104,7 @@ pub async fn resolve_database_branch( database_id: &str, isolation_level: IsolationLevel, ) -> Result> { - let Some(bucket_branch_id) = - resolve_bucket_branch(tx, bucket_id, isolation_level).await? + let Some(bucket_branch_id) = resolve_bucket_branch(tx, bucket_id, isolation_level).await? else { return resolve_database_branch_in_bucket( tx, @@ -120,7 +116,8 @@ pub async fn resolve_database_branch( }; if let Some(branch_id) = - resolve_database_branch_in_bucket(tx, bucket_branch_id, database_id, isolation_level).await? + resolve_database_branch_in_bucket(tx, bucket_branch_id, database_id, isolation_level) + .await? { return Ok(Some(branch_id)); } @@ -134,9 +131,11 @@ pub async fn resolve_database_branch_in_bucket( database_id: &str, isolation_level: IsolationLevel, ) -> Result> { - Ok(resolve_database_pointer(tx, bucket_branch_id, database_id, isolation_level) - .await? - .map(|pointer| pointer.current_branch)) + Ok( + resolve_database_pointer(tx, bucket_branch_id, database_id, isolation_level) + .await? + .map(|pointer| pointer.current_branch), + ) } pub async fn resolve_database_pointer( @@ -156,7 +155,8 @@ pub async fn resolve_database_pointer( ) .await? { - let pointer = decode_database_pointer(&pointer_bytes).context("decode sqlite database pointer")?; + let pointer = decode_database_pointer(&pointer_bytes) + .context("decode sqlite database pointer")?; return Ok(Some(pointer)); } @@ -178,7 +178,10 @@ pub async fn resolve_database_pointer( let Some(record_bytes) = tx .informal() - .get(&keys::bucket_branches_list_key(current_branch_id), isolation_level) + .get( + &keys::bucket_branches_list_key(current_branch_id), + isolation_level, + ) .await? else { return Ok(None); diff --git a/engine/packages/depot/src/conveyer/branch/shared.rs b/engine/packages/depot/src/conveyer/branch/shared.rs index 89fe1b5fa5..a1e2e5b496 100644 --- a/engine/packages/depot/src/conveyer/branch/shared.rs +++ b/engine/packages/depot/src/conveyer/branch/shared.rs @@ -2,19 +2,14 @@ use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::{Context, Result}; use futures_util::TryStreamExt; -use universaldb::{ - RangeOption, - options::StreamingMode, - utils::IsolationLevel::Serializable, -}; +use universaldb::{RangeOption, options::StreamingMode, utils::IsolationLevel::Serializable}; use crate::conveyer::{ error::SqliteStorageError, keys, types::{ - CommitRow, DatabaseBranchId, DatabaseBranchRecord, BucketBranchId, - BucketBranchRecord, decode_commit_row, decode_database_branch_record, - decode_bucket_branch_record, + BucketBranchId, BucketBranchRecord, CommitRow, DatabaseBranchId, DatabaseBranchRecord, + decode_bucket_branch_record, decode_commit_row, decode_database_branch_record, }, }; diff --git a/engine/packages/depot/src/conveyer/commit/apply.rs b/engine/packages/depot/src/conveyer/commit/apply.rs index aa52ec8bb7..a4132023af 100644 --- a/engine/packages/depot/src/conveyer/commit/apply.rs +++ b/engine/packages/depot/src/conveyer/commit/apply.rs @@ -6,29 +6,29 @@ use universaldb::{ utils::IsolationLevel::{Serializable, Snapshot}, }; +#[cfg(feature = "test-faults")] +use crate::fault::{CommitFaultPoint, DepotFaultContext, DepotFaultController, DepotFaultPoint}; use crate::{ burst_mode, conveyer::{ - Db, - branch, - db::{BranchAncestry, CacheSnapshot, load_branch_ancestry, touch_access_if_bucket_advanced}, + Db, branch, + db::{ + BranchAncestry, CacheSnapshot, load_branch_ancestry, touch_access_if_bucket_advanced, + }, error::SqliteStorageError, keys, ltx::{LtxHeader, encode_ltx_v3}, - metrics, quota, udb, + metrics, page_index::DeltaPageIndex, + quota, types::{ - BranchState, CommitRow, DBHead, DatabaseBranchId, DirtyPage, - decode_compaction_root, decode_database_branch_record, decode_db_head, - encode_commit_row, encode_db_head, + BranchState, CommitRow, DBHead, DatabaseBranchId, DirtyPage, decode_compaction_root, + decode_database_branch_record, decode_db_head, encode_commit_row, encode_db_head, }, + udb, }, workflows::compaction::DeltasAvailable, }; -#[cfg(feature = "test-faults")] -use crate::fault::{ - CommitFaultPoint, DepotFaultContext, DepotFaultController, DepotFaultPoint, -}; use super::{ branch_init::{resolve_or_allocate_branch, write_root_branch_metadata}, @@ -72,8 +72,9 @@ impl Db { let cached_ancestry = cached_snapshot .as_ref() .map(|snapshot| snapshot.ancestors.clone()); - let cached_access_bucket = - cached_snapshot.as_ref().and_then(|snapshot| snapshot.last_access_bucket); + let cached_access_bucket = cached_snapshot + .as_ref() + .and_then(|snapshot| snapshot.last_access_bucket); let last_deltas_available_at_ms = *self.last_deltas_available_at_ms.read().await; let cache_was_warm = cached_snapshot .as_ref() @@ -109,16 +110,13 @@ impl Db { ) .await?; if !branch_resolution.database_initialized { - let branch_record = tx_get_value( - &tx, - &keys::branches_list_key(branch_id), - Serializable, - ) - .await? - .as_deref() - .map(decode_database_branch_record) - .transpose() - .context("decode sqlite database branch record for commit")?; + let branch_record = + tx_get_value(&tx, &keys::branches_list_key(branch_id), Serializable) + .await? + .as_deref() + .map(decode_database_branch_record) + .transpose() + .context("decode sqlite database branch record for commit")?; if !branch_record .as_ref() .is_some_and(|record| record.state == BranchState::Live) @@ -139,7 +137,9 @@ impl Db { let head_at_fork_key = keys::branch_meta_head_at_fork_key(branch_id); let branch_cache_matches = cached_branch_id == Some(branch_id); let (head_bytes, head_at_fork_bytes, storage_used) = - if let (true, Some(storage_used)) = (branch_cache_matches, cached_storage_used) { + if let (true, Some(storage_used)) = + (branch_cache_matches, cached_storage_used) + { ( tx_get_value(&tx, &head_key, Serializable).await?, tx_get_value(&tx, &head_at_fork_key, Serializable).await?, @@ -148,7 +148,8 @@ impl Db { } else { let quota_fut = quota::read_branch(&tx, branch_id); let head_fut = tx_get_value(&tx, &head_key, Serializable); - let head_at_fork_fut = tx_get_value(&tx, &head_at_fork_key, Serializable); + let head_at_fork_fut = + tx_get_value(&tx, &head_at_fork_key, Serializable); let (head_bytes, head_at_fork_bytes, storage_used) = tokio::try_join!(head_fut, head_at_fork_fut, quota_fut)?; (head_bytes, head_at_fork_bytes, storage_used) @@ -167,18 +168,16 @@ impl Db { Some(branch_id), ) .await?; - let compaction_root = tx_get_value( - &tx, - &keys::branch_compaction_root_key(branch_id), - Snapshot, - ) - .await? - .as_deref() - .map(decode_compaction_root) - .transpose() - .context("decode sqlite compaction root for dirty admission")?; - let previous_db_size_pages = - previous_head.as_ref().map_or(db_size_pages, |head| head.db_size_pages); + let compaction_root = + tx_get_value(&tx, &keys::branch_compaction_root_key(branch_id), Snapshot) + .await? + .as_deref() + .map(decode_compaction_root) + .transpose() + .context("decode sqlite compaction root for dirty admission")?; + let previous_db_size_pages = previous_head + .as_ref() + .map_or(db_size_pages, |head| head.db_size_pages); let txid = match previous_head.as_ref() { Some(head) => head .head_txid @@ -187,9 +186,13 @@ impl Db { None => 1, }; - let truncate_cleanup = - collect_truncate_cleanup(&tx, branch_id, previous_db_size_pages, db_size_pages) - .await?; + let truncate_cleanup = collect_truncate_cleanup( + &tx, + branch_id, + previous_db_size_pages, + db_size_pages, + ) + .await?; test_hooks::maybe_pause_after_truncate_cleanup(&database_id).await; #[cfg(feature = "test-faults")] maybe_fire_commit_fault( @@ -200,11 +203,9 @@ impl Db { ) .await?; - let encoded_delta = encode_ltx_v3( - LtxHeader::delta(txid, db_size_pages, now_ms), - &dirty_pages, - ) - .context("encode commit delta")?; + let encoded_delta = + encode_ltx_v3(LtxHeader::delta(txid, db_size_pages, now_ms), &dirty_pages) + .context("encode commit delta")?; let delta_chunks = encoded_delta .chunks(DELTA_CHUNK_BYTES) .enumerate() @@ -276,7 +277,10 @@ impl Db { + dirty_pgnos .iter() .map(|pgno| { - tracked_entry_size(&keys::branch_pidx_key(branch_id, *pgno), &txid_bytes) + tracked_entry_size( + &keys::branch_pidx_key(branch_id, *pgno), + &txid_bytes, + ) }) .sum::>()?; let removed_bytes = head_bytes @@ -437,7 +441,8 @@ impl Db { *self.storage_used.write().await = Some(result.storage_used); self.commit_bytes_since_rollup.fetch_add( - u64::try_from(result.added_bytes).context("commit added bytes should be non-negative")?, + u64::try_from(result.added_bytes) + .context("commit added bytes should be non-negative")?, std::sync::atomic::Ordering::Relaxed, ); diff --git a/engine/packages/depot/src/conveyer/commit/branch_init.rs b/engine/packages/depot/src/conveyer/commit/branch_init.rs index e63954e7b2..7c9dd15ad1 100644 --- a/engine/packages/depot/src/conveyer/commit/branch_init.rs +++ b/engine/packages/depot/src/conveyer/commit/branch_init.rs @@ -1,16 +1,13 @@ use anyhow::{Context, Result}; -use universaldb::{ - options::MutationType, - utils::IsolationLevel::Serializable, -}; +use universaldb::{options::MutationType, utils::IsolationLevel::Serializable}; use crate::conveyer::{ - branch, - keys, udb, + branch, keys, types::{ - BranchState, DatabaseBranchId, DatabaseBranchRecord, DatabasePointer, BucketBranchId, - BucketId, encode_database_branch_record, encode_database_pointer, + BranchState, BucketBranchId, BucketId, DatabaseBranchId, DatabaseBranchRecord, + DatabasePointer, encode_database_branch_record, encode_database_pointer, }, + udb, }; pub(super) struct BranchResolution { @@ -68,8 +65,8 @@ pub(super) async fn write_root_branch_metadata( state: BranchState::Live, lifecycle_generation: 0, }; - let encoded_record = - encode_database_branch_record(record).context("encode sqlite root database branch record")?; + let encoded_record = encode_database_branch_record(record) + .context("encode sqlite root database branch record")?; let versionstamped_record = udb::append_versionstamp_offset(encoded_record, root_versionstamp) .context("prepare versionstamped sqlite root database branch record")?; tx.informal().atomic_op( @@ -99,7 +96,8 @@ pub(super) async fn write_root_branch_metadata( current_branch: branch_id, last_swapped_at_ms: now_ms, }; - let encoded_pointer = encode_database_pointer(pointer).context("encode sqlite database pointer")?; + let encoded_pointer = + encode_database_pointer(pointer).context("encode sqlite database pointer")?; tx.informal().set( &keys::database_pointer_cur_key(bucket_branch, database_id), &encoded_pointer, diff --git a/engine/packages/depot/src/conveyer/commit/dirty.rs b/engine/packages/depot/src/conveyer/commit/dirty.rs index fd7600025c..f5d1cd520a 100644 --- a/engine/packages/depot/src/conveyer/commit/dirty.rs +++ b/engine/packages/depot/src/conveyer/commit/dirty.rs @@ -4,11 +4,12 @@ use universaldb::utils::IsolationLevel::Serializable; use crate::{ HOT_BURST_COLD_LAG_THRESHOLD_TXIDS, conveyer::{ - keys, quota, udb, + keys, quota, types::{ CompactionRoot, DatabaseBranchId, SqliteCmpDirty, decode_compaction_root, decode_db_head, decode_sqlite_cmp_dirty, encode_sqlite_cmp_dirty, }, + udb, }, workflows::compaction::DeltasAvailable, }; @@ -65,13 +66,13 @@ fn has_actionable_lag( fallback_cold_watermark_txid: u64, ) -> bool { let hot_watermark_txid = compaction_root.map_or(0, |root| root.hot_watermark_txid); - let cold_watermark_txid = - compaction_root.map_or(fallback_cold_watermark_txid, |root| root.cold_watermark_txid); + let cold_watermark_txid = compaction_root.map_or(fallback_cold_watermark_txid, |root| { + root.cold_watermark_txid + }); let hot_lag = head_txid.saturating_sub(hot_watermark_txid); let cold_lag = head_txid.saturating_sub(cold_watermark_txid); - hot_lag >= quota::COMPACTION_DELTA_THRESHOLD - || cold_lag >= HOT_BURST_COLD_LAG_THRESHOLD_TXIDS + hot_lag >= quota::COMPACTION_DELTA_THRESHOLD || cold_lag >= HOT_BURST_COLD_LAG_THRESHOLD_TXIDS } pub async fn clear_sqlite_cmp_dirty_if_observed_idle( @@ -113,15 +114,19 @@ async fn branch_has_actionable_lag( .transpose() .context("decode sqlite db head for dirty clear")? .map_or(0, |head| head.head_txid); - let compaction_root = tx_get_value(tx, &keys::branch_compaction_root_key(branch_id), Serializable) - .await? - .as_deref() - .map(decode_compaction_root) - .transpose() - .context("decode sqlite compaction root for dirty clear")? - .context( - "sqlite compaction root missing for dirty clear; \ + let compaction_root = tx_get_value( + tx, + &keys::branch_compaction_root_key(branch_id), + Serializable, + ) + .await? + .as_deref() + .map(decode_compaction_root) + .transpose() + .context("decode sqlite compaction root for dirty clear")? + .context( + "sqlite compaction root missing for dirty clear; \ workflow manager must publish CMP/root before clearing dirty marker", - )?; + )?; Ok(has_actionable_lag(head_txid, Some(&compaction_root), 0)) } diff --git a/engine/packages/depot/src/conveyer/commit/helpers.rs b/engine/packages/depot/src/conveyer/commit/helpers.rs index 4897d46213..36e9b20649 100644 --- a/engine/packages/depot/src/conveyer/commit/helpers.rs +++ b/engine/packages/depot/src/conveyer/commit/helpers.rs @@ -1,10 +1,6 @@ use anyhow::{Context, Error, Result}; use futures_util::TryStreamExt; -use universaldb::{ - RangeOption, - options::StreamingMode, - utils::IsolationLevel::Snapshot, -}; +use universaldb::{RangeOption, options::StreamingMode, utils::IsolationLevel::Snapshot}; use crate::conveyer::{keys, types::DatabaseBranchId}; diff --git a/engine/packages/depot/src/conveyer/commit/test_hooks.rs b/engine/packages/depot/src/conveyer/commit/test_hooks.rs index 299c0b0bf1..cc88370178 100644 --- a/engine/packages/depot/src/conveyer/commit/test_hooks.rs +++ b/engine/packages/depot/src/conveyer/commit/test_hooks.rs @@ -19,8 +19,11 @@ pub struct PauseGuard { pub fn pause_after_truncate_cleanup(database_id: &str) -> (PauseGuard, Arc, Arc) { let reached = Arc::new(Notify::new()); let release = Arc::new(Notify::new()); - *PAUSE_AFTER_TRUNCATE_CLEANUP.lock() = - Some((database_id.to_string(), Arc::clone(&reached), Arc::clone(&release))); + *PAUSE_AFTER_TRUNCATE_CLEANUP.lock() = Some(( + database_id.to_string(), + Arc::clone(&reached), + Arc::clone(&release), + )); ( PauseGuard { diff --git a/engine/packages/depot/src/conveyer/commit/truncate.rs b/engine/packages/depot/src/conveyer/commit/truncate.rs index f20c2325b7..389be2d883 100644 --- a/engine/packages/depot/src/conveyer/commit/truncate.rs +++ b/engine/packages/depot/src/conveyer/commit/truncate.rs @@ -1,8 +1,5 @@ use anyhow::{Context, Result}; -use universaldb::{ - error::DatabaseError, - utils::IsolationLevel::Serializable, -}; +use universaldb::{error::DatabaseError, utils::IsolationLevel::Serializable}; use crate::conveyer::{ keys::{self, SHARD_SIZE}, @@ -79,8 +76,7 @@ pub(super) async fn fence_truncate_cleanup_row( tx: &universaldb::Transaction, row: &ObservedCleanupRow, ) -> Result<()> { - let current = tx_get_value(tx, &row.key, Serializable) - .await?; + let current = tx_get_value(tx, &row.key, Serializable).await?; if current.as_deref() != Some(row.value.as_slice()) { return Err(DatabaseError::NotCommitted.into()); } diff --git a/engine/packages/depot/src/conveyer/db.rs b/engine/packages/depot/src/conveyer/db.rs index 74da079cf5..424988c8dd 100644 --- a/engine/packages/depot/src/conveyer/db.rs +++ b/engine/packages/depot/src/conveyer/db.rs @@ -13,9 +13,9 @@ use rivet_pools::NodeId; use tokio::sync::RwLock; use universaldb::Database; -use crate::{cold_tier::ColdTier, workflows::compaction::DeltasAvailable}; #[cfg(feature = "test-faults")] use crate::fault::DepotFaultController; +use crate::{cold_tier::ColdTier, workflows::compaction::DeltasAvailable}; use super::{ branch, @@ -23,7 +23,7 @@ use super::{ error::SqliteStorageError, keys, read::cache_fill::{ShardCacheFillOptions, ShardCacheFillQueue}, - types::{DatabaseBranchId, ColdManifestChunk, BucketId}, + types::{BucketId, ColdManifestChunk, DatabaseBranchId}, }; const COLD_MANIFEST_CACHE_BRANCHES: usize = 16; @@ -49,11 +49,7 @@ impl ColdManifestCache { Some(manifest) } - pub(super) fn insert( - &mut self, - branch_id: DatabaseBranchId, - manifest: CachedColdManifest, - ) { + pub(super) fn insert(&mut self, branch_id: DatabaseBranchId, manifest: CachedColdManifest) { self.entries.insert(branch_id, manifest); self.touch(branch_id); @@ -129,12 +125,7 @@ pub struct Db { } impl Db { - pub fn new( - udb: Arc, - bucket_id: Id, - database_id: String, - node_id: NodeId, - ) -> Self { + pub fn new(udb: Arc, bucket_id: Id, database_id: String, node_id: NodeId) -> Self { Self::new_inner( udb, bucket_id, @@ -354,7 +345,12 @@ impl Db { #[cfg(debug_assertions)] pub async fn branch_cache_snapshot_for_test( &self, - ) -> Option<(DatabaseBranchId, DatabaseBranchId, Option, Vec<(u32, u64)>)> { + ) -> Option<( + DatabaseBranchId, + DatabaseBranchId, + Option, + Vec<(u32, u64)>, + )> { let snapshot = self.cache_snapshot.read().await.clone()?; Some(( snapshot.branch_id, @@ -395,7 +391,10 @@ pub(super) async fn touch_access_if_bucket_advanced( let bucket_key = keys::branch_manifest_last_access_bucket_key(branch_id); let stored_bucket = tx .informal() - .get(&bucket_key, universaldb::utils::IsolationLevel::Serializable) + .get( + &bucket_key, + universaldb::utils::IsolationLevel::Serializable, + ) .await? .map(|bytes| decode_i64_le(&bytes)) .transpose() @@ -404,8 +403,10 @@ pub(super) async fn touch_access_if_bucket_advanced( return Ok(Some(bucket)); } - tx.informal() - .set(&keys::branch_manifest_last_access_ts_ms_key(branch_id), &now_ms.to_le_bytes()); + tx.informal().set( + &keys::branch_manifest_last_access_ts_ms_key(branch_id), + &now_ms.to_le_bytes(), + ); tx.informal().set(&bucket_key, &bucket.to_le_bytes()); Ok(Some(bucket)) diff --git a/engine/packages/depot/src/conveyer/debug.rs b/engine/packages/depot/src/conveyer/debug.rs index 70c05ca806..bd3b8c40da 100644 --- a/engine/packages/depot/src/conveyer/debug.rs +++ b/engine/packages/depot/src/conveyer/debug.rs @@ -11,17 +11,19 @@ use universaldb::{ }; use crate::{ - gc, conveyer::{ - Db, db::load_branch_ancestry, branch, keys, + Db, branch, + db::load_branch_ancestry, + keys, ltx::{DecodedLtx, decode_ltx_v3}, types::{ - DatabaseBranchId, RestorePointIndexEntry, ColdManifestChunk, ColdManifestIndex, - CommitRow, FetchedPage, LayerEntry, LayerKind, SQLITE_STORAGE_COLD_SCHEMA_VERSION, + ColdManifestChunk, ColdManifestIndex, CommitRow, DatabaseBranchId, FetchedPage, + LayerEntry, LayerKind, RestorePointIndexEntry, SQLITE_STORAGE_COLD_SCHEMA_VERSION, decode_cold_manifest_chunk, decode_cold_manifest_index, decode_commit_row, decode_restore_point_record, }, }, + gc, }; #[derive(Debug, Clone, PartialEq, Eq)] @@ -54,9 +56,7 @@ struct DebugReadSource { max_txid: u64, } -pub async fn dump_database_ancestry( - db: &Db, -) -> Result)>> { +pub async fn dump_database_ancestry(db: &Db) -> Result)>> { let branch_id = resolve_current_branch(db).await?; let ancestry = db .udb @@ -87,14 +87,15 @@ pub async fn dump_branch_pins(db: &Db) -> Result { pub async fn list_restore_points(db: &Db) -> Result> { let database_id = db.database_id.clone(); - db - .udb + db.udb .run(move |tx| { let database_id = database_id.clone(); async move { let mut entries = Vec::new(); - for (_key, value) in scan_prefix(&tx, &keys::restore_point_prefix(&database_id)).await? { + for (_key, value) in + scan_prefix(&tx, &keys::restore_point_prefix(&database_id)).await? + { let record = decode_restore_point_record(&value) .context("decode sqlite restore point record for debug listing")?; entries.push(RestorePointIndexEntry { @@ -105,10 +106,7 @@ pub async fn list_restore_points(db: &Db) -> Result> created_at_ms: record.created_at_ms, }); } - entries.sort_by(|a, b| { - a.restore_point_id - .cmp(&b.restore_point_id) - }); + entries.sort_by(|a, b| a.restore_point_id.cmp(&b.restore_point_id)); Ok(entries) } @@ -145,17 +143,13 @@ pub async fn dump_cold_manifest(db: &Db) -> Result { let Some(chunk_bytes) = cold_tier .get_object(&chunk_ref.object_key) .await - .with_context(|| { - format!("get sqlite cold manifest chunk {}", chunk_ref.object_key) - })? + .with_context(|| format!("get sqlite cold manifest chunk {}", chunk_ref.object_key))? else { continue; }; - chunks.push( - decode_cold_manifest_chunk(&chunk_bytes).with_context(|| { - format!("decode sqlite cold manifest chunk {}", chunk_ref.object_key) - })?, - ); + chunks.push(decode_cold_manifest_chunk(&chunk_bytes).with_context(|| { + format!("decode sqlite cold manifest chunk {}", chunk_ref.object_key) + })?); } Ok(ColdManifest { @@ -189,7 +183,8 @@ pub async fn read_at(db: &Db, versionstamp: [u8; 16]) -> Result { { continue; } - let Some(txid) = lookup_vtx_txid(&tx, ancestor.branch_id, versionstamp).await? else { + let Some(txid) = lookup_vtx_txid(&tx, ancestor.branch_id, versionstamp).await? + else { continue; }; target = Some((idx, *ancestor, txid)); @@ -261,8 +256,7 @@ struct DebugReadPlan { async fn resolve_current_branch(db: &Db) -> Result { let bucket_id = db.sqlite_bucket_id(); let database_id = db.database_id.clone(); - db - .udb + db.udb .run(move |tx| { let database_id = database_id.clone(); @@ -286,8 +280,12 @@ async fn load_pages_from_hot_tier( for source in sources { for (_txid, blob) in tx_load_delta_blobs(tx, *source).await? { - let decoded = decode_ltx_v3(&blob) - .with_context(|| format!("decode sqlite debug delta for branch {:?}", source.branch_id))?; + let decoded = decode_ltx_v3(&blob).with_context(|| { + format!( + "decode sqlite debug delta for branch {:?}", + source.branch_id + ) + })?; for pgno in 1..=db_size_pages { if pages.get(&pgno).is_some_and(Option::is_some) { continue; @@ -373,8 +371,9 @@ async fn fill_cold_pages( .context("sqlite debug cold layer should be loaded before decode")?; decoded_objects.insert( object_key.clone(), - decode_ltx_v3(bytes) - .with_context(|| format!("decode sqlite debug cold layer {object_key}"))?, + decode_ltx_v3(bytes).with_context(|| { + format!("decode sqlite debug cold layer {object_key}") + })?, ); } if let Some(bytes) = decoded_objects @@ -459,7 +458,8 @@ async fn lookup_vtx_txid( branch_id: DatabaseBranchId, versionstamp: [u8; 16], ) -> Result> { - let Some(bytes) = tx_get_value(tx, &keys::branch_vtx_key(branch_id, versionstamp)).await? else { + let Some(bytes) = tx_get_value(tx, &keys::branch_vtx_key(branch_id, versionstamp)).await? + else { return Ok(None); }; let bytes: [u8; std::mem::size_of::()] = bytes @@ -470,15 +470,8 @@ async fn lookup_vtx_txid( Ok(Some(u64::from_be_bytes(bytes))) } -async fn tx_get_value( - tx: &universaldb::Transaction, - key: &[u8], -) -> Result>> { - Ok(tx - .informal() - .get(key, Snapshot) - .await? - .map(Vec::::from)) +async fn tx_get_value(tx: &universaldb::Transaction, key: &[u8]) -> Result>> { + Ok(tx.informal().get(key, Snapshot).await?.map(Vec::::from)) } async fn scan_prefix( @@ -566,7 +559,10 @@ async fn tx_load_latest_shard_blob( } fn cold_manifest_index_object_key(branch_id: DatabaseBranchId) -> String { - format!("db/{}/cold_manifest/index.bare", branch_id.as_uuid().simple()) + format!( + "db/{}/cold_manifest/index.bare", + branch_id.as_uuid().simple() + ) } fn layer_kind_rank(kind: LayerKind) -> u8 { diff --git a/engine/packages/depot/src/conveyer/error.rs b/engine/packages/depot/src/conveyer/error.rs index 21b52cf346..837c178265 100644 --- a/engine/packages/depot/src/conveyer/error.rs +++ b/engine/packages/depot/src/conveyer/error.rs @@ -43,16 +43,10 @@ pub enum SqliteStorageError { #[error("invalid_v1_migration_state", "Invalid SQLite v1 migration state.")] InvalidV1MigrationState, - #[error( - "fork_chain_too_deep", - "Database branch fork chain is too deep." - )] + #[error("fork_chain_too_deep", "Database branch fork chain is too deep.")] ForkChainTooDeep, - #[error( - "bucket_fork_chain_too_deep", - "Bucket branch fork chain is too deep." - )] + #[error("bucket_fork_chain_too_deep", "Bucket branch fork chain is too deep.")] BucketForkChainTooDeep, #[error( @@ -67,10 +61,7 @@ pub enum SqliteStorageError { )] RestoreTargetExpired, - #[error( - "restore_point_not_found", - "Restore point was not found." - )] + #[error("restore_point_not_found", "Restore point was not found.")] RestorePointNotFound, #[error( @@ -106,16 +97,10 @@ pub enum SqliteStorageError { )] ShardCacheCorrupt { shard_id: u32, as_of_txid: u64 }, - #[error( - "too_many_pins", - "Bucket has too many restore_points." - )] + #[error("too_many_pins", "Bucket has too many restore_points.")] TooManyPins, - #[error( - "too_many_restore_points", - "Bucket has too many restore points." - )] + #[error("too_many_restore_points", "Bucket has too many restore points.")] TooManyRestorePoints, #[error( @@ -167,7 +152,10 @@ impl fmt::Display for SqliteStorageError { write!(f, "sqlite bucket branch fork chain is too deep") } SqliteStorageError::ForkOutOfRetention => { - write!(f, "cannot fork from a point that has fallen out of retention") + write!( + f, + "cannot fork from a point that has fallen out of retention" + ) } SqliteStorageError::RestoreTargetExpired => { write!(f, "sqlite restore point history is no longer retained") @@ -176,7 +164,10 @@ impl fmt::Display for SqliteStorageError { write!(f, "sqlite restore point was not found") } SqliteStorageError::BranchNotReachable => { - write!(f, "sqlite restore point branch is not reachable from this database branch chain") + write!( + f, + "sqlite restore point branch is not reachable from this database branch chain" + ) } SqliteStorageError::BranchNotWritable => { write!(f, "sqlite database branch is not writable") @@ -207,7 +198,10 @@ impl fmt::Display for SqliteStorageError { field, value, } => { - write!(f, "sqlite policy value is invalid for {policy}.{field}: {value}") + write!( + f, + "sqlite policy value is invalid for {policy}.{field}: {value}" + ) } SqliteStorageError::DatabaseNotFound => { write!(f, "sqlite database was not found in this bucket branch") diff --git a/engine/packages/depot/src/conveyer/history_pin.rs b/engine/packages/depot/src/conveyer/history_pin.rs index e16ed161e0..2dc620505a 100644 --- a/engine/packages/depot/src/conveyer/history_pin.rs +++ b/engine/packages/depot/src/conveyer/history_pin.rs @@ -5,7 +5,7 @@ use universaldb::{RangeOption, options::StreamingMode, utils::IsolationLevel}; use super::{ keys, types::{ - RestorePointId, DatabaseBranchId, DbHistoryPin, DbHistoryPinKind, BucketBranchId, + BucketBranchId, DatabaseBranchId, DbHistoryPin, DbHistoryPinKind, RestorePointId, decode_db_history_pin, encode_db_history_pin, }, }; @@ -107,7 +107,8 @@ pub fn write_db_history_pin( pin: DbHistoryPin, ) -> Result<()> { let encoded = encode_db_history_pin(pin).context("encode sqlite db history pin")?; - tx.informal().set(&keys::db_pin_key(branch_id, pin_id), &encoded); + tx.informal() + .set(&keys::db_pin_key(branch_id, pin_id), &encoded); Ok(()) } diff --git a/engine/packages/depot/src/conveyer/keys.rs b/engine/packages/depot/src/conveyer/keys.rs index 33f873d4df..0be98baedd 100644 --- a/engine/packages/depot/src/conveyer/keys.rs +++ b/engine/packages/depot/src/conveyer/keys.rs @@ -4,7 +4,7 @@ use anyhow::{Context, Result, bail, ensure}; use gas::prelude::Id; use universaldb::utils::end_of_key_range; -use super::types::{DatabaseBranchId, BucketBranchId, BucketId}; +use super::types::{BucketBranchId, BucketId, DatabaseBranchId}; pub const SQLITE_SUBSPACE_PREFIX: u8 = 0x02; pub const DBPTR_PARTITION: u8 = 0x10; @@ -179,7 +179,10 @@ pub fn database_range(database_id: &str) -> (Vec, Vec) { } pub fn database_pointer_cur_key(bucket_branch_id: BucketBranchId, database_id: &str) -> Vec { - with_suffix(database_pointer_base(bucket_branch_id, database_id), CUR_PATH) + with_suffix( + database_pointer_base(bucket_branch_id, database_id), + CUR_PATH, + ) } pub fn database_pointer_cur_prefix() -> Vec { @@ -228,7 +231,10 @@ pub fn database_pointer_history_prefix( bucket_branch_id: BucketBranchId, database_id: &str, ) -> Vec { - with_suffix(database_pointer_base(bucket_branch_id, database_id), HISTORY_PATH) + with_suffix( + database_pointer_base(bucket_branch_id, database_id), + HISTORY_PATH, + ) } pub fn bucket_pointer_cur_key(bucket_id: BucketId) -> Vec { @@ -260,11 +266,7 @@ pub fn decode_bucket_pointer_cur_bucket_id(key: &[u8]) -> Result { Ok(BucketId::from_uuid(uuid)) } -pub fn bucket_pointer_history_key( - bucket_id: BucketId, - ts_ms: i64, - nonce: u32, -) -> Vec { +pub fn bucket_pointer_history_key(bucket_id: BucketId, ts_ms: i64, nonce: u32) -> Vec { let mut key = bucket_pointer_history_prefix(bucket_id); append_ts_nonce(&mut key, ts_ms, nonce); key @@ -336,7 +338,10 @@ pub fn bucket_branches_database_name_tombstone_key( branch_id: BucketBranchId, database_id: &str, ) -> Vec { - let mut key = with_suffix(bucket_branch_record_base(branch_id), DATABASE_TOMBSTONES_PATH); + let mut key = with_suffix( + bucket_branch_record_base(branch_id), + DATABASE_TOMBSTONES_PATH, + ); append_database_id(&mut key, database_id); key } @@ -345,13 +350,19 @@ pub fn bucket_branches_database_tombstone_key( branch_id: BucketBranchId, database_id: DatabaseBranchId, ) -> Vec { - let mut key = with_suffix(bucket_branch_record_base(branch_id), DATABASE_TOMBSTONES_PATH); + let mut key = with_suffix( + bucket_branch_record_base(branch_id), + DATABASE_TOMBSTONES_PATH, + ); append_uuid(&mut key, database_id.as_uuid()); key } pub fn bucket_branches_database_tombstone_prefix(branch_id: BucketBranchId) -> Vec { - with_suffix(bucket_branch_record_base(branch_id), DATABASE_TOMBSTONES_PATH) + with_suffix( + bucket_branch_record_base(branch_id), + DATABASE_TOMBSTONES_PATH, + ) } pub fn decode_bucket_branches_database_tombstone_id( @@ -444,19 +455,31 @@ pub fn branch_meta_cold_lease_key(branch_id: DatabaseBranchId) -> Vec { } pub fn branch_manifest_cold_drained_txid_key(branch_id: DatabaseBranchId) -> Vec { - with_suffix(database_branch_base(branch_id), MANIFEST_COLD_DRAINED_TXID_PATH) + with_suffix( + database_branch_base(branch_id), + MANIFEST_COLD_DRAINED_TXID_PATH, + ) } pub fn branch_manifest_last_hot_pass_txid_key(branch_id: DatabaseBranchId) -> Vec { - with_suffix(database_branch_base(branch_id), MANIFEST_LAST_HOT_PASS_TXID_PATH) + with_suffix( + database_branch_base(branch_id), + MANIFEST_LAST_HOT_PASS_TXID_PATH, + ) } pub fn branch_manifest_last_access_ts_ms_key(branch_id: DatabaseBranchId) -> Vec { - with_suffix(database_branch_base(branch_id), MANIFEST_LAST_ACCESS_TS_MS_PATH) + with_suffix( + database_branch_base(branch_id), + MANIFEST_LAST_ACCESS_TS_MS_PATH, + ) } pub fn branch_manifest_last_access_bucket_key(branch_id: DatabaseBranchId) -> Vec { - with_suffix(database_branch_base(branch_id), MANIFEST_LAST_ACCESS_BUCKET_PATH) + with_suffix( + database_branch_base(branch_id), + MANIFEST_LAST_ACCESS_BUCKET_PATH, + ) } pub fn branch_compaction_root_key(branch_id: DatabaseBranchId) -> Vec { @@ -468,7 +491,10 @@ pub fn branch_compaction_cold_shard_prefix(branch_id: DatabaseBranchId) -> Vec Vec { - with_suffix(database_branch_base(branch_id), CMP_RETIRED_COLD_OBJECT_PATH) + with_suffix( + database_branch_base(branch_id), + CMP_RETIRED_COLD_OBJECT_PATH, + ) } pub fn branch_compaction_stage_prefix(branch_id: DatabaseBranchId) -> Vec { @@ -499,7 +525,10 @@ pub fn branch_compaction_retired_cold_object_key( branch_id: DatabaseBranchId, object_key_hash: [u8; 32], ) -> Vec { - let mut key = with_suffix(database_branch_base(branch_id), CMP_RETIRED_COLD_OBJECT_PATH); + let mut key = with_suffix( + database_branch_base(branch_id), + CMP_RETIRED_COLD_OBJECT_PATH, + ); key.extend_from_slice(&object_key_hash); key } @@ -569,10 +598,7 @@ pub fn branch_pitr_interval_prefix(branch_id: DatabaseBranchId) -> Vec { with_suffix(database_branch_base(branch_id), PITR_INTERVAL_PATH) } -pub fn decode_branch_pitr_interval_bucket( - branch_id: DatabaseBranchId, - key: &[u8], -) -> Result { +pub fn decode_branch_pitr_interval_bucket(branch_id: DatabaseBranchId, key: &[u8]) -> Result { let prefix = branch_pitr_interval_prefix(branch_id); let suffix = key .strip_prefix(prefix.as_slice()) @@ -584,11 +610,9 @@ pub fn decode_branch_pitr_interval_bucket( std::mem::size_of::() ); - Ok(i64::from_be_bytes( - suffix - .try_into() - .context("branch PITR interval suffix should decode as i64")?, - )) + Ok(i64::from_be_bytes(suffix.try_into().context( + "branch PITR interval suffix should decode as i64", + )?)) } pub fn branch_pidx_key(branch_id: DatabaseBranchId, pgno: u32) -> Vec { @@ -661,11 +685,9 @@ pub fn decode_branch_delta_chunk_idx( std::mem::size_of::() ); - Ok(u32::from_be_bytes( - suffix - .try_into() - .context("branch delta chunk suffix should decode as u32")?, - )) + Ok(u32::from_be_bytes(suffix.try_into().context( + "branch delta chunk suffix should decode as u32", + )?)) } pub fn branch_shard_prefix(branch_id: DatabaseBranchId) -> Vec { @@ -720,15 +742,24 @@ pub fn decode_ctr_eviction_index_key(key: &[u8]) -> Result<(i64, DatabaseBranchI let bucket_bytes: [u8; std::mem::size_of::()] = suffix[..8] .try_into() .context("decode eviction index bucket")?; - ensure!(suffix[8] == b'/', "eviction index key missing branch separator"); + ensure!( + suffix[8] == b'/', + "eviction index key missing branch separator" + ); let branch_id = uuid::Uuid::from_slice(&suffix[9..]).context("decode eviction index branch id")?; - Ok((i64::from_be_bytes(bucket_bytes), DatabaseBranchId::from_uuid(branch_id))) + Ok(( + i64::from_be_bytes(bucket_bytes), + DatabaseBranchId::from_uuid(branch_id), + )) } pub fn restore_point_prefix(database_id: &str) -> Vec { - let mut key = with_suffix(partition_prefix(RESTORE_POINT_PARTITION), RESTORE_POINT_PATH); + let mut key = with_suffix( + partition_prefix(RESTORE_POINT_PARTITION), + RESTORE_POINT_PATH, + ); append_database_id(&mut key, database_id); key.push(b'/'); key @@ -782,7 +813,10 @@ pub fn bucket_fork_pin_key( } pub fn bucket_fork_pin_prefix(source_bucket_branch_id: BucketBranchId) -> Vec { - let mut key = with_suffix(partition_prefix(BUCKET_FORK_PIN_PARTITION), BUCKET_FORK_PIN_PATH); + let mut key = with_suffix( + partition_prefix(BUCKET_FORK_PIN_PARTITION), + BUCKET_FORK_PIN_PATH, + ); append_uuid(&mut key, source_bucket_branch_id.as_uuid()); key.push(b'/'); key @@ -817,14 +851,20 @@ pub fn bucket_catalog_by_db_key( } pub fn bucket_catalog_by_db_prefix(database_branch_id: DatabaseBranchId) -> Vec { - let mut key = with_suffix(partition_prefix(BUCKET_CATALOG_BY_DB_PARTITION), BUCKET_CATALOG_BY_DB_PATH); + let mut key = with_suffix( + partition_prefix(BUCKET_CATALOG_BY_DB_PARTITION), + BUCKET_CATALOG_BY_DB_PATH, + ); append_uuid(&mut key, database_branch_id.as_uuid()); key.push(b'/'); key } pub fn bucket_proof_epoch_key(root_bucket_branch_id: BucketBranchId) -> Vec { - let mut key = with_suffix(partition_prefix(BUCKET_PROOF_EPOCH_PARTITION), BUCKET_PROOF_EPOCH_PATH); + let mut key = with_suffix( + partition_prefix(BUCKET_PROOF_EPOCH_PARTITION), + BUCKET_PROOF_EPOCH_PATH, + ); append_uuid(&mut key, root_bucket_branch_id.as_uuid()); key } diff --git a/engine/packages/depot/src/conveyer/mod.rs b/engine/packages/depot/src/conveyer/mod.rs index 11dfac17df..1b311493cb 100644 --- a/engine/packages/depot/src/conveyer/mod.rs +++ b/engine/packages/depot/src/conveyer/mod.rs @@ -1,8 +1,7 @@ -pub mod db; -pub mod restore_point; pub mod branch; pub mod commit; pub mod constants; +pub mod db; #[cfg(debug_assertions)] pub mod debug; pub mod error; @@ -15,6 +14,7 @@ pub mod pitr_interval; pub mod policy; pub mod quota; pub mod read; +pub mod restore_point; pub mod types; pub mod udb; diff --git a/engine/packages/depot/src/conveyer/pitr_interval.rs b/engine/packages/depot/src/conveyer/pitr_interval.rs index ed4f0cc0dc..665fcd6eb9 100644 --- a/engine/packages/depot/src/conveyer/pitr_interval.rs +++ b/engine/packages/depot/src/conveyer/pitr_interval.rs @@ -1,10 +1,6 @@ use anyhow::{Context, Result}; use futures_util::TryStreamExt; -use universaldb::{ - RangeOption, - options::StreamingMode, - utils::IsolationLevel, -}; +use universaldb::{RangeOption, options::StreamingMode, utils::IsolationLevel}; use super::{ keys, @@ -22,8 +18,10 @@ pub fn write_pitr_interval_coverage( ) -> Result<()> { let encoded = encode_pitr_interval_coverage(coverage).context("encode sqlite PITR interval coverage")?; - tx.informal() - .set(&keys::branch_pitr_interval_key(branch_id, bucket_start_ms), &encoded); + tx.informal().set( + &keys::branch_pitr_interval_key(branch_id, bucket_start_ms), + &encoded, + ); Ok(()) } @@ -49,8 +47,12 @@ pub async fn scan_pitr_interval_coverage( branch_id: DatabaseBranchId, isolation_level: IsolationLevel, ) -> Result> { - let rows = read_prefix_values(tx, &keys::branch_pitr_interval_prefix(branch_id), isolation_level) - .await?; + let rows = read_prefix_values( + tx, + &keys::branch_pitr_interval_prefix(branch_id), + isolation_level, + ) + .await?; rows.into_iter() .map(|(key, value)| { @@ -70,12 +72,9 @@ pub async fn read_latest_pitr_interval_coverage_at_or_before( ) -> Result> { let rows = scan_pitr_interval_coverage(tx, branch_id, isolation_level).await?; - Ok(rows - .into_iter() - .rev() - .find(|(bucket_start_ms, coverage)| { - *bucket_start_ms <= timestamp_ms && coverage.wall_clock_ms <= timestamp_ms - })) + Ok(rows.into_iter().rev().find(|(bucket_start_ms, coverage)| { + *bucket_start_ms <= timestamp_ms && coverage.wall_clock_ms <= timestamp_ms + })) } async fn read_prefix_values( diff --git a/engine/packages/depot/src/conveyer/policy.rs b/engine/packages/depot/src/conveyer/policy.rs index 48241cfd5a..7015066648 100644 --- a/engine/packages/depot/src/conveyer/policy.rs +++ b/engine/packages/depot/src/conveyer/policy.rs @@ -5,8 +5,8 @@ use super::{ error::SqliteStorageError, keys, types::{ - BucketId, PitrPolicy, ShardCachePolicy, decode_pitr_policy, - decode_shard_cache_policy, encode_pitr_policy, encode_shard_cache_policy, + BucketId, PitrPolicy, ShardCachePolicy, decode_pitr_policy, decode_shard_cache_policy, + encode_pitr_policy, encode_shard_cache_policy, }, }; @@ -113,7 +113,11 @@ pub async fn get_database_shard_cache_policy_override( bucket_id: BucketId, database_id: &str, ) -> Result> { - read_shard_cache_policy(udb, keys::database_shard_cache_policy_key(bucket_id, database_id)).await + read_shard_cache_policy( + udb, + keys::database_shard_cache_policy_key(bucket_id, database_id), + ) + .await } pub async fn clear_database_shard_cache_policy_override( @@ -121,7 +125,11 @@ pub async fn clear_database_shard_cache_policy_override( bucket_id: BucketId, database_id: &str, ) -> Result<()> { - clear_policy_value(udb, keys::database_shard_cache_policy_key(bucket_id, database_id)).await + clear_policy_value( + udb, + keys::database_shard_cache_policy_key(bucket_id, database_id), + ) + .await } pub async fn get_effective_shard_cache_policy( @@ -189,10 +197,7 @@ async fn clear_policy_value(udb: &universaldb::Database, key: Vec) -> Result .await } -async fn read_pitr_policy( - udb: &universaldb::Database, - key: Vec, -) -> Result> { +async fn read_pitr_policy(udb: &universaldb::Database, key: Vec) -> Result> { udb.run(move |tx| { let key = key.clone(); diff --git a/engine/packages/depot/src/conveyer/quota.rs b/engine/packages/depot/src/conveyer/quota.rs index 4b17d3dc19..c7d8a4445d 100644 --- a/engine/packages/depot/src/conveyer/quota.rs +++ b/engine/packages/depot/src/conveyer/quota.rs @@ -5,7 +5,7 @@ use crate::conveyer::{ branch, error::SqliteStorageError, keys, - types::{DatabaseBranchId, BucketId}, + types::{BucketId, DatabaseBranchId}, }; pub const SQLITE_MAX_STORAGE_BYTES: i64 = 10 * 1024 * 1024 * 1024; @@ -21,7 +21,11 @@ pub fn atomic_add(tx: &universaldb::Transaction, database_id: &str, delta_bytes: ); } -pub fn atomic_add_branch(tx: &universaldb::Transaction, branch_id: DatabaseBranchId, delta_bytes: i64) { +pub fn atomic_add_branch( + tx: &universaldb::Transaction, + branch_id: DatabaseBranchId, + delta_bytes: i64, +) { tx.informal().atomic_op( &keys::branch_meta_quota_key(branch_id), &delta_bytes.to_le_bytes(), @@ -38,10 +42,9 @@ pub async fn read_in_bucket( bucket_id: BucketId, database_id: &str, ) -> Result { - if let Some(branch_id) = - branch::resolve_database_branch(tx, bucket_id, database_id, Snapshot) - .await - .context("resolve sqlite database branch for quota read")? + if let Some(branch_id) = branch::resolve_database_branch(tx, bucket_id, database_id, Snapshot) + .await + .context("resolve sqlite database branch for quota read")? { return read_branch(tx, branch_id).await; } @@ -54,9 +57,8 @@ pub async fn read_in_bucket( return Ok(0); }; - let bytes: [u8; std::mem::size_of::()] = Vec::from(value) - .try_into() - .map_err(|value: Vec| { + let bytes: [u8; std::mem::size_of::()] = + Vec::from(value).try_into().map_err(|value: Vec| { Error::msg(format!( "sqlite quota counter had {} bytes, expected {}", value.len(), @@ -67,7 +69,10 @@ pub async fn read_in_bucket( Ok(i64::from_le_bytes(bytes)) } -pub async fn read_branch(tx: &universaldb::Transaction, branch_id: DatabaseBranchId) -> Result { +pub async fn read_branch( + tx: &universaldb::Transaction, + branch_id: DatabaseBranchId, +) -> Result { let Some(value) = tx .informal() .get(&keys::branch_meta_quota_key(branch_id), Snapshot) @@ -76,9 +81,8 @@ pub async fn read_branch(tx: &universaldb::Transaction, branch_id: DatabaseBranc return Ok(0); }; - let bytes: [u8; std::mem::size_of::()] = Vec::from(value) - .try_into() - .map_err(|value: Vec| { + let bytes: [u8; std::mem::size_of::()] = + Vec::from(value).try_into().map_err(|value: Vec| { Error::msg(format!( "sqlite branch quota counter had {} bytes, expected {}", value.len(), diff --git a/engine/packages/depot/src/conveyer/read.rs b/engine/packages/depot/src/conveyer/read.rs index 54e5fb4e22..06be00493e 100644 --- a/engine/packages/depot/src/conveyer/read.rs +++ b/engine/packages/depot/src/conveyer/read.rs @@ -10,12 +10,12 @@ mod tx; use std::collections::{BTreeMap, BTreeSet}; -use anyhow::{Context, Result, ensure}; #[cfg(feature = "test-faults")] use crate::fault::{ - DepotFaultAction, DepotFaultContext, DepotFaultController, DepotFaultFired, - DepotFaultPoint, ReadFaultPoint, + DepotFaultAction, DepotFaultContext, DepotFaultController, DepotFaultFired, DepotFaultPoint, + ReadFaultPoint, }; +use anyhow::{Context, Result, ensure}; use crate::conveyer::{ Db, @@ -59,8 +59,7 @@ impl Db { let cached_ancestry = cached_snapshot .as_ref() .map(|snapshot| snapshot.ancestors.clone()); - let cached_access_bucket = - cached_snapshot.and_then(|snapshot| snapshot.last_access_bucket); + let cached_access_bucket = cached_snapshot.and_then(|snapshot| snapshot.last_access_bucket); let database_id = self.database_id.clone(); let bucket_id = self.sqlite_bucket_id(); @@ -143,8 +142,7 @@ impl Db { (cache_source, cached_pidx.as_ref()) { for (pgno, txid) in cached_pidx { - if let Some(txid) = - txid.filter(|txid| *txid <= cache_source.max_txid()) + if let Some(txid) = txid.filter(|txid| *txid <= cache_source.max_txid()) { pidx_by_pgno.insert( *pgno, @@ -158,7 +156,9 @@ impl Db { } else { let StorageScope::Branch(plan) = &scope; for source in &plan.sources { - let rows = tx_scan_prefix_values(&tx, &source.pidx_prefix(&database_id)).await?; + let rows = + tx_scan_prefix_values(&tx, &source.pidx_prefix(&database_id)) + .await?; let mut decoded_rows = Vec::new(); for (key, value) in rows { let pgno = source.decode_pidx_pgno(&database_id, &key)?; @@ -213,16 +213,15 @@ impl Db { for pgno in &pgnos_in_range { let mut cold_candidates = Vec::new(); - let preferred_delta = pidx_by_pgno - .get(pgno) - .copied() - .map(|page_ref| { - ( - page_ref.source.delta_chunk_prefix(&database_id, page_ref.txid), - page_ref.source, - page_ref.txid, - ) - }); + let preferred_delta = pidx_by_pgno.get(pgno).copied().map(|page_ref| { + ( + page_ref + .source + .delta_chunk_prefix(&database_id, page_ref.txid), + page_ref.source, + page_ref.txid, + ) + }); if preferred_delta .as_ref() @@ -267,12 +266,14 @@ impl Db { missing_delta_prefixes.insert(delta_prefix.clone()); stale_pidx_pgnos.insert(*pgno); let ReadSource::Branch(source) = *delta_source; - cold_candidates.push(ColdLayerCandidate { - branch_id: source.branch_id, - owner_txid: *delta_txid, - shard_id: pgno / SHARD_SIZE, - } - .into()); + cold_candidates.push( + ColdLayerCandidate { + branch_id: source.branch_id, + owner_txid: *delta_txid, + shard_id: pgno / SHARD_SIZE, + } + .into(), + ); } } @@ -310,26 +311,18 @@ impl Db { shard_sources.insert(shard_id, source); } - if let Some((source_key, blob)) = shard_sources - .get(&shard_id) - .cloned() - .flatten() + if let Some((source_key, blob)) = + shard_sources.get(&shard_id).cloned().flatten() { if !source_blobs.contains_key(&source_key) { source_blobs.insert(source_key.clone(), blob); } page_sources.insert(*pgno, source_key); - shard_cache_read_outcomes - .insert(*pgno, ShardCacheReadOutcome::FdbHit); + shard_cache_read_outcomes.insert(*pgno, ShardCacheReadOutcome::FdbHit); touched_cache_backed_page = true; } else { if let Some(reference) = - tx_load_latest_compaction_cold_ref( - &tx, - &scope, - shard_id, - ) - .await? + tx_load_latest_compaction_cold_ref(&tx, &scope, shard_id).await? { #[cfg(feature = "test-faults")] let drop_ref = matches!( @@ -446,10 +439,14 @@ impl Db { .get(source_key) .and_then(|decoded: &DecodedLtx| decoded.get_page(pgno)) .map(ToOwned::to_owned); - if bytes.is_none() && source_key.starts_with(&keys::branch_delta_prefix(tx_result.branch_id)) { + if bytes.is_none() + && source_key.starts_with(&keys::branch_delta_prefix(tx_result.branch_id)) + { stale_pidx_pgnos.insert(pgno); } - bytes.get_or_insert_with(|| vec![0; PAGE_SIZE as usize]).clone() + bytes + .get_or_insert_with(|| vec![0; PAGE_SIZE as usize]) + .clone() } else { if stale_pidx_pgnos.contains(&pgno) { return Err(SqliteStorageError::ShardCoverageMissing { pgno }.into()); diff --git a/engine/packages/depot/src/conveyer/read/cache.rs b/engine/packages/depot/src/conveyer/read/cache.rs index f2a356588d..d7e8c7d9ce 100644 --- a/engine/packages/depot/src/conveyer/read/cache.rs +++ b/engine/packages/depot/src/conveyer/read/cache.rs @@ -5,10 +5,7 @@ use std::{ use anyhow::{Context, Result}; -use crate::conveyer::{ - page_index::DeltaPageIndex, - types::DatabaseBranchId, -}; +use crate::conveyer::{page_index::DeltaPageIndex, types::DatabaseBranchId}; use super::plan::{ReadSource, StorageScope}; @@ -53,10 +50,7 @@ pub(super) fn store_loaded_pidx_rows( } } -pub(super) fn clear_stale_pidx_rows( - cache: &DeltaPageIndex, - stale_pidx_pgnos: BTreeSet, -) { +pub(super) fn clear_stale_pidx_rows(cache: &DeltaPageIndex, stale_pidx_pgnos: BTreeSet) { for pgno in stale_pidx_pgnos { cache.remove(pgno); } diff --git a/engine/packages/depot/src/conveyer/read/cache_fill.rs b/engine/packages/depot/src/conveyer/read/cache_fill.rs index 0b8446ac11..73824362a6 100644 --- a/engine/packages/depot/src/conveyer/read/cache_fill.rs +++ b/engine/packages/depot/src/conveyer/read/cache_fill.rs @@ -1,8 +1,6 @@ -use std::{ - sync::{ - Arc, - atomic::{AtomicUsize, Ordering}, - }, +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, }; use anyhow::{Context, Result}; @@ -17,8 +15,7 @@ use universaldb::{Database, utils::IsolationLevel::Serializable}; use crate::conveyer::{ error::SqliteStorageError, - keys, - metrics, + keys, metrics, types::{ColdShardRef, DatabaseBranchId, encode_cold_shard_ref}, }; @@ -230,7 +227,9 @@ impl ShardCacheFillQueue { return; } let Ok(handle) = tokio::runtime::Handle::try_current() else { - tracing::warn!("sqlite shard cache fill workers could not start without a tokio runtime"); + tracing::warn!( + "sqlite shard cache fill workers could not start without a tokio runtime" + ); return; }; diff --git a/engine/packages/depot/src/conveyer/read/cold.rs b/engine/packages/depot/src/conveyer/read/cold.rs index 5a64f49938..05e5525875 100644 --- a/engine/packages/depot/src/conveyer/read/cold.rs +++ b/engine/packages/depot/src/conveyer/read/cold.rs @@ -1,10 +1,8 @@ use std::collections::BTreeMap; -use anyhow::{Context, Result}; #[cfg(feature = "test-faults")] -use crate::fault::{ - DepotFaultAction, DepotFaultFired, ReadFaultPoint, -}; +use crate::fault::{DepotFaultAction, DepotFaultFired, ReadFaultPoint}; +use anyhow::{Context, Result}; use futures_util::TryStreamExt; use universaldb::{ RangeOption, @@ -279,10 +277,7 @@ impl Db { .await } - async fn find_cold_layers( - &self, - candidate: ColdLayerCandidate, - ) -> Result> { + async fn find_cold_layers(&self, candidate: ColdLayerCandidate) -> Result> { let manifest = self.load_cold_manifest(candidate.branch_id).await?; let mut layers = Vec::new(); @@ -305,13 +300,8 @@ impl Db { Ok(layers) } - async fn load_cold_manifest( - &self, - branch_id: DatabaseBranchId, - ) -> Result { - let cached_manifest = { - self.cold_manifest_cache.write().await.get(branch_id) - }; + async fn load_cold_manifest(&self, branch_id: DatabaseBranchId) -> Result { + let cached_manifest = { self.cold_manifest_cache.write().await.get(branch_id) }; if let Some(manifest) = cached_manifest { return Ok(manifest); } @@ -341,11 +331,9 @@ impl Db { else { continue; }; - chunks.push( - decode_cold_manifest_chunk(&chunk_bytes).with_context(|| { - format!("decode sqlite cold manifest chunk {}", chunk_ref.object_key) - })?, - ); + chunks.push(decode_cold_manifest_chunk(&chunk_bytes).with_context(|| { + format!("decode sqlite cold manifest chunk {}", chunk_ref.object_key) + })?); } let manifest = CachedColdManifest { chunks }; @@ -374,7 +362,8 @@ pub(super) async fn tx_load_latest_compaction_cold_ref( }; let as_of_txid = source.max_txid; let prefix = keys::branch_compaction_cold_shard_version_prefix(source.branch_id, shard_id); - let end_key = keys::branch_compaction_cold_shard_key(source.branch_id, shard_id, as_of_txid); + let end_key = + keys::branch_compaction_cold_shard_key(source.branch_id, shard_id, as_of_txid); let end = end_of_key_range(&end_key); let informal = tx.informal(); let mut stream = informal.get_ranges_keyvalues( @@ -450,5 +439,8 @@ fn cold_source_key(object_key: &str) -> Vec { } fn cold_manifest_index_object_key(branch_id: DatabaseBranchId) -> String { - format!("db/{}/cold_manifest/index.bare", branch_id.as_uuid().simple()) + format!( + "db/{}/cold_manifest/index.bare", + branch_id.as_uuid().simple() + ) } diff --git a/engine/packages/depot/src/conveyer/read/pidx.rs b/engine/packages/depot/src/conveyer/read/pidx.rs index 4870c5f97d..6c803e18ba 100644 --- a/engine/packages/depot/src/conveyer/read/pidx.rs +++ b/engine/packages/depot/src/conveyer/read/pidx.rs @@ -1,9 +1,6 @@ use anyhow::{Context, Result, ensure}; -use crate::conveyer::{ - keys, - types::DatabaseBranchId, -}; +use crate::conveyer::{keys, types::DatabaseBranchId}; const PIDX_PGNO_BYTES: usize = std::mem::size_of::(); const PIDX_TXID_BYTES: usize = std::mem::size_of::(); @@ -14,10 +11,7 @@ pub(super) struct PageRef { pub(super) txid: u64, } -pub(super) fn decode_branch_pidx_pgno( - branch_id: DatabaseBranchId, - key: &[u8], -) -> Result { +pub(super) fn decode_branch_pidx_pgno(branch_id: DatabaseBranchId, key: &[u8]) -> Result { let prefix = keys::branch_pidx_prefix(branch_id); ensure!( key.starts_with(&prefix), diff --git a/engine/packages/depot/src/conveyer/read/plan.rs b/engine/packages/depot/src/conveyer/read/plan.rs index 0ea53b0811..af7a32a904 100644 --- a/engine/packages/depot/src/conveyer/read/plan.rs +++ b/engine/packages/depot/src/conveyer/read/plan.rs @@ -6,7 +6,7 @@ use crate::conveyer::{ db::{BranchAncestry, load_branch_ancestry}, error::SqliteStorageError, keys::{self, SHARD_SIZE}, - types::{DBHead, DatabaseBranchId, BucketId, decode_db_head}, + types::{BucketId, DBHead, DatabaseBranchId, decode_db_head}, }; #[derive(Debug, Clone)] @@ -27,10 +27,7 @@ impl StorageScope { } } - pub(super) fn cold_layer_candidates( - &self, - pgno: u32, - ) -> Vec { + pub(super) fn cold_layer_candidates(&self, pgno: u32) -> Vec { match self { Self::Branch(plan) => plan .sources diff --git a/engine/packages/depot/src/conveyer/read/shard.rs b/engine/packages/depot/src/conveyer/read/shard.rs index c8ed3b6da6..26cfd78ff6 100644 --- a/engine/packages/depot/src/conveyer/read/shard.rs +++ b/engine/packages/depot/src/conveyer/read/shard.rs @@ -44,11 +44,9 @@ fn decode_delta_chunk_idx(delta_prefix: &[u8], key: &[u8]) -> Result { std::mem::size_of::() ); - Ok(u32::from_be_bytes( - suffix - .try_into() - .context("sqlite delta chunk suffix should decode as u32")?, - )) + Ok(u32::from_be_bytes(suffix.try_into().context( + "sqlite delta chunk suffix should decode as u32", + )?)) } pub(super) async fn tx_load_latest_shard_blob( diff --git a/engine/packages/depot/src/conveyer/read/tx.rs b/engine/packages/depot/src/conveyer/read/tx.rs index 11e38a2083..07674a460e 100644 --- a/engine/packages/depot/src/conveyer/read/tx.rs +++ b/engine/packages/depot/src/conveyer/read/tx.rs @@ -1,20 +1,12 @@ use anyhow::Result; use futures_util::TryStreamExt; -use universaldb::{ - RangeOption, - options::StreamingMode, - utils::IsolationLevel::Snapshot, -}; +use universaldb::{RangeOption, options::StreamingMode, utils::IsolationLevel::Snapshot}; pub(super) async fn tx_get_value( tx: &universaldb::Transaction, key: &[u8], ) -> Result>> { - Ok(tx - .informal() - .get(key, Snapshot) - .await? - .map(Vec::::from)) + Ok(tx.informal().get(key, Snapshot).await?.map(Vec::::from)) } pub(super) async fn tx_scan_prefix_values( diff --git a/engine/packages/depot/src/conveyer/restore_point.rs b/engine/packages/depot/src/conveyer/restore_point.rs index ad9bbd0b39..b6fedd9925 100644 --- a/engine/packages/depot/src/conveyer/restore_point.rs +++ b/engine/packages/depot/src/conveyer/restore_point.rs @@ -13,12 +13,15 @@ mod test_hooks; use anyhow::Result; use super::{ - Db, metrics, + Db, error::SqliteStorageError, - types::{RestorePointId, PinStatus, ResolvedRestoreTarget, ResolvedVersionstamp, SnapshotSelector}, + metrics, + types::{ + PinStatus, ResolvedRestoreTarget, ResolvedVersionstamp, RestorePointId, SnapshotSelector, + }, }; -pub use pinned::{restore_point_status, create_restore_point, delete_restore_point}; +pub use pinned::{create_restore_point, delete_restore_point, restore_point_status}; pub use resolve::{resolve_restore_point, resolve_restore_target}; pub use restore::restore_database; @@ -33,12 +36,18 @@ impl Db { ) .await; metrics::SQLITE_RESTORE_POINT_CREATE_TOTAL - .with_label_values(&[node_id.as_str(), restore_point_create_outcome(result.as_ref().err())]) + .with_label_values(&[ + node_id.as_str(), + restore_point_create_outcome(result.as_ref().err()), + ]) .inc(); result } - pub async fn restore_point_status(&self, restore_point: RestorePointId) -> Result> { + pub async fn restore_point_status( + &self, + restore_point: RestorePointId, + ) -> Result> { restore_point_status( &self.udb, self.sqlite_bucket_id(), @@ -58,7 +67,10 @@ impl Db { .await } - pub async fn resolve_restore_point(&self, restore_point: RestorePointId) -> Result { + pub async fn resolve_restore_point( + &self, + restore_point: RestorePointId, + ) -> Result { let node_id = self.node_id.to_string(); let _timer = metrics::SQLITE_RESTORE_POINT_RESOLVE_DURATION .with_label_values(&[node_id.as_str()]) @@ -71,7 +83,10 @@ impl Db { ) .await; metrics::SQLITE_RESTORE_POINT_RESOLVE_TOTAL - .with_label_values(&[node_id.as_str(), restore_point_resolve_outcome(result.as_ref().err())]) + .with_label_values(&[ + node_id.as_str(), + restore_point_resolve_outcome(result.as_ref().err()), + ]) .inc(); result } @@ -92,7 +107,10 @@ impl Db { ) .await; metrics::SQLITE_RESTORE_POINT_RESOLVE_TOTAL - .with_label_values(&[node_id.as_str(), restore_point_resolve_outcome(result.as_ref().err())]) + .with_label_values(&[ + node_id.as_str(), + restore_point_resolve_outcome(result.as_ref().err()), + ]) .inc(); result } @@ -109,11 +127,7 @@ impl Db { } fn restore_point_create_outcome(err: Option<&anyhow::Error>) -> &'static str { - if err.is_none() { - "ok" - } else { - "err" - } + if err.is_none() { "ok" } else { "err" } } fn restore_point_resolve_outcome(err: Option<&anyhow::Error>) -> &'static str { diff --git a/engine/packages/depot/src/conveyer/restore_point/pinned.rs b/engine/packages/depot/src/conveyer/restore_point/pinned.rs index cd3b233bb1..52022fc6be 100644 --- a/engine/packages/depot/src/conveyer/restore_point/pinned.rs +++ b/engine/packages/depot/src/conveyer/restore_point/pinned.rs @@ -3,18 +3,19 @@ use universaldb::options::MutationType; use universaldb::utils::IsolationLevel::{Serializable, Snapshot}; use crate::conveyer::{ - branch, history_pin, keys, + branch, + constants::MAX_RESTORE_POINTS_PER_BUCKET, + error::SqliteStorageError, + history_pin, keys, restore_point::{ - resolve, recompute::recompute_database_branch_restore_point_pin, - shared::{RestorePointCreateResult, ResolvedRestorePointPin, decode_i64_counter}, + resolve, + shared::{ResolvedRestorePointPin, RestorePointCreateResult, decode_i64_counter}, test_hooks, }, - constants::MAX_RESTORE_POINTS_PER_BUCKET, - error::SqliteStorageError, types::{ - RestorePointId, BucketBranchId, BucketId, PinStatus, RestorePointRecord, - SnapshotSelector, decode_restore_point_record, encode_restore_point_record, + BucketBranchId, BucketId, PinStatus, RestorePointId, RestorePointRecord, SnapshotSelector, + decode_restore_point_record, encode_restore_point_record, }, }; @@ -50,50 +51,45 @@ pub async fn delete_restore_point( database_id: String, restore_point: RestorePointId, ) -> Result<()> { - udb - .run(move |tx| { - let database_id = database_id.clone(); - let restore_point = restore_point.clone(); - - async move { - let pinned_key = keys::restore_point_key(&database_id, restore_point.as_str()); - let Some(pinned_bytes) = tx.informal().get(&pinned_key, Serializable).await? else { - return Ok(()); - }; - let pinned = decode_restore_point_record(&pinned_bytes) - .context("decode sqlite restore point record")?; - let bucket_branch_id = - branch::resolve_bucket_branch(&tx, bucket_id, Serializable) - .await? - .unwrap_or_else(BucketBranchId::nil); - let pin_count_key = keys::bucket_branches_pin_count_key(bucket_branch_id); - - tx.informal().clear(&pinned_key); - history_pin::delete_restore_point_pin(&tx, pinned.database_branch_id, &restore_point); - tx.informal().atomic_op( - &pin_count_key, - &(-1_i64).to_le_bytes(), - MutationType::Add, - ); - - let recomputed_pin = recompute_database_branch_restore_point_pin( - &tx, - &database_id, - pinned.database_branch_id, - &pinned_key, - ) - .await?; - let branch_pin_key = keys::branches_restore_point_pin_key(pinned.database_branch_id); - if let Some(recomputed_pin) = recomputed_pin { - tx.informal().set(&branch_pin_key, &recomputed_pin); - } else { - tx.informal().clear(&branch_pin_key); - } - - Ok(()) + udb.run(move |tx| { + let database_id = database_id.clone(); + let restore_point = restore_point.clone(); + + async move { + let pinned_key = keys::restore_point_key(&database_id, restore_point.as_str()); + let Some(pinned_bytes) = tx.informal().get(&pinned_key, Serializable).await? else { + return Ok(()); + }; + let pinned = decode_restore_point_record(&pinned_bytes) + .context("decode sqlite restore point record")?; + let bucket_branch_id = branch::resolve_bucket_branch(&tx, bucket_id, Serializable) + .await? + .unwrap_or_else(BucketBranchId::nil); + let pin_count_key = keys::bucket_branches_pin_count_key(bucket_branch_id); + + tx.informal().clear(&pinned_key); + history_pin::delete_restore_point_pin(&tx, pinned.database_branch_id, &restore_point); + tx.informal() + .atomic_op(&pin_count_key, &(-1_i64).to_le_bytes(), MutationType::Add); + + let recomputed_pin = recompute_database_branch_restore_point_pin( + &tx, + &database_id, + pinned.database_branch_id, + &pinned_key, + ) + .await?; + let branch_pin_key = keys::branches_restore_point_pin_key(pinned.database_branch_id); + if let Some(recomputed_pin) = recomputed_pin { + tx.informal().set(&branch_pin_key, &recomputed_pin); + } else { + tx.informal().clear(&branch_pin_key); } - }) - .await?; + + Ok(()) + } + }) + .await?; Ok(()) } @@ -138,9 +134,7 @@ pub(super) async fn create_restore_point_for_resolved( let database_id = database_id.clone(); let pin = pin.clone(); - async move { - create_restore_point_for_resolved_tx(&tx, bucket_id, &database_id, &pin).await - } + async move { create_restore_point_for_resolved_tx(&tx, bucket_id, &database_id, &pin).await } }) .await } @@ -167,7 +161,12 @@ pub(super) async fn create_restore_point_for_resolved_tx( .await? .ok_or(SqliteStorageError::RestoreTargetExpired)?; - if tx.informal().get(&pinned_key, Serializable).await?.is_none() { + if tx + .informal() + .get(&pinned_key, Serializable) + .await? + .is_none() + { let pin_count_key = keys::bucket_branches_pin_count_key(bucket_branch_id); let pin_count = tx .informal() @@ -200,11 +199,8 @@ pub(super) async fn create_restore_point_for_resolved_tx( restore_point_txid, pin.created_at_ms, )?; - tx.informal().atomic_op( - &pin_count_key, - &1_i64.to_le_bytes(), - MutationType::Add, - ); + tx.informal() + .atomic_op(&pin_count_key, &1_i64.to_le_bytes(), MutationType::Add); } tx.informal().atomic_op( &keys::branches_restore_point_pin_key(pin.database_branch_id), diff --git a/engine/packages/depot/src/conveyer/restore_point/recompute.rs b/engine/packages/depot/src/conveyer/restore_point/recompute.rs index d0a18cea06..dd4316c821 100644 --- a/engine/packages/depot/src/conveyer/restore_point/recompute.rs +++ b/engine/packages/depot/src/conveyer/restore_point/recompute.rs @@ -1,8 +1,8 @@ use anyhow::{Context, Result}; use futures_util::TryStreamExt; +use universaldb::RangeOption; use universaldb::options::StreamingMode; use universaldb::utils::IsolationLevel::Serializable; -use universaldb::RangeOption; use crate::conveyer::{ keys, @@ -37,8 +37,7 @@ pub(super) async fn recompute_database_branch_restore_point_pin( .context("decode sqlite restore point record during pin recompute")?; if record.database_branch_id == branch_id { pin = Some( - pin - .map(|current: [u8; 16]| current.min(record.versionstamp)) + pin.map(|current: [u8; 16]| current.min(record.versionstamp)) .unwrap_or(record.versionstamp), ); } diff --git a/engine/packages/depot/src/conveyer/restore_point/resolve.rs b/engine/packages/depot/src/conveyer/restore_point/resolve.rs index 62dd487469..3397c88d52 100644 --- a/engine/packages/depot/src/conveyer/restore_point/resolve.rs +++ b/engine/packages/depot/src/conveyer/restore_point/resolve.rs @@ -4,13 +4,14 @@ use anyhow::{Context, Result}; use universaldb::utils::IsolationLevel::Snapshot; use crate::conveyer::{ - branch, keys, pitr_interval, + branch, error::SqliteStorageError, + keys, pitr_interval, types::{ - RestorePointRef, RestorePointId, DatabaseBranchId, BucketBranchId, BucketId, - ResolvedVersionstamp, decode_database_pointer, decode_bucket_branch_record, - decode_restore_point_record, SnapshotSelector, ResolvedRestoreTarget, SnapshotKind, - PinStatus, decode_commit_row, decode_db_head, + BucketBranchId, BucketId, DatabaseBranchId, PinStatus, ResolvedRestoreTarget, + ResolvedVersionstamp, RestorePointId, RestorePointRef, SnapshotKind, SnapshotSelector, + decode_bucket_branch_record, decode_commit_row, decode_database_pointer, decode_db_head, + decode_restore_point_record, }, }; @@ -28,8 +29,16 @@ pub async fn resolve_restore_point( async move { let (branch_id, bucket_cap) = - resolve_visible_database_branch_for_restore_point(&tx, bucket_id, &database_id).await?; - resolve_restore_point_in_branch_chain(&tx, &database_id, branch_id, bucket_cap, restore_point).await + resolve_visible_database_branch_for_restore_point(&tx, bucket_id, &database_id) + .await?; + resolve_restore_point_in_branch_chain( + &tx, + &database_id, + branch_id, + bucket_cap, + restore_point, + ) + .await } }) .await @@ -48,7 +57,8 @@ pub async fn resolve_restore_target( async move { let (branch_id, bucket_cap) = - resolve_visible_database_branch_for_restore_point(&tx, bucket_id, &database_id).await?; + resolve_visible_database_branch_for_restore_point(&tx, bucket_id, &database_id) + .await?; match selector { SnapshotSelector::Latest => { resolve_latest_in_branch_chain(&tx, branch_id, bucket_cap).await @@ -84,11 +94,11 @@ async fn resolve_visible_database_branch_for_restore_point( bucket_id: BucketId, database_id: &str, ) -> Result<(DatabaseBranchId, [u8; 16])> { - let Some(mut bucket_branch_id) = - branch::resolve_bucket_branch(tx, bucket_id, Snapshot).await? + let Some(mut bucket_branch_id) = branch::resolve_bucket_branch(tx, bucket_id, Snapshot).await? else { if let Some(pointer) = - branch::resolve_database_pointer(tx, BucketBranchId::nil(), database_id, Snapshot).await? + branch::resolve_database_pointer(tx, BucketBranchId::nil(), database_id, Snapshot) + .await? { return Ok((pointer.current_branch, VERSIONSTAMP_INFINITY)); } @@ -152,9 +162,7 @@ async fn resolve_latest_in_branch_chain( let mut current_branch_id = branch_id; let mut cap = bucket_cap; for _ in 0..=crate::constants::MAX_FORK_DEPTH { - if let Some(target) = - read_latest_target_in_branch(tx, current_branch_id, cap).await? - { + if let Some(target) = read_latest_target_in_branch(tx, current_branch_id, cap).await? { return Ok(target); } @@ -256,10 +264,8 @@ async fn read_timestamp_target_in_branch( now_ms: i64, ) -> Result> { let rows = pitr_interval::scan_pitr_interval_coverage(tx, branch_id, Snapshot).await?; - let Some((_bucket_start_ms, coverage)) = rows - .into_iter() - .rev() - .find(|(bucket_start_ms, coverage)| { + let Some((_bucket_start_ms, coverage)) = + rows.into_iter().rev().find(|(bucket_start_ms, coverage)| { *bucket_start_ms <= timestamp_ms && coverage.wall_clock_ms <= timestamp_ms && coverage.expires_at_ms > now_ms @@ -303,7 +309,9 @@ async fn resolve_restore_point_in_branch_chain( let mut current_branch_id = branch_id; let mut cap = bucket_cap; for _ in 0..=crate::constants::MAX_FORK_DEPTH { - if pinned_record.database_branch_id == current_branch_id && pinned_record.versionstamp <= cap { + if pinned_record.database_branch_id == current_branch_id + && pinned_record.versionstamp <= cap + { return Ok(ResolvedVersionstamp { versionstamp: pinned_record.versionstamp, restore_point: Some(RestorePointRef { @@ -355,7 +363,9 @@ async fn resolve_restore_point_target_in_branch_chain( let mut current_branch_id = branch_id; let mut cap = bucket_cap; for _ in 0..=crate::constants::MAX_FORK_DEPTH { - if pinned_record.database_branch_id == current_branch_id && pinned_record.versionstamp <= cap { + if pinned_record.database_branch_id == current_branch_id + && pinned_record.versionstamp <= cap + { let commit = read_commit_row(tx, current_branch_id, txid).await?; return Ok(ResolvedRestoreTarget { database_branch_id: current_branch_id, diff --git a/engine/packages/depot/src/conveyer/restore_point/restore.rs b/engine/packages/depot/src/conveyer/restore_point/restore.rs index 0372e54947..3918252dd7 100644 --- a/engine/packages/depot/src/conveyer/restore_point/restore.rs +++ b/engine/packages/depot/src/conveyer/restore_point/restore.rs @@ -2,16 +2,17 @@ use anyhow::{Context, Result}; use universaldb::utils::IsolationLevel::Serializable; use crate::conveyer::{ - branch, keys, + branch, + error::SqliteStorageError, + keys, restore_point::{ pinned::create_restore_point_for_resolved_tx, resolve::resolve_restore_target, shared::{ResolvedRestorePointPin, RestorePointCreateResult}, test_hooks, }, - error::SqliteStorageError, types::{ - RestorePointId, BucketId, DatabaseBranchId, SnapshotSelector, decode_commit_row, + BucketId, DatabaseBranchId, RestorePointId, SnapshotSelector, decode_commit_row, decode_db_head, }, }; @@ -22,9 +23,9 @@ pub async fn restore_database( database_id: String, selector: SnapshotSelector, ) -> Result { - let target = - resolve_restore_target(udb, bucket_id, database_id.clone(), selector).await?; - let undo = capture_current_restore_point_for_restore(udb, bucket_id, database_id.clone()).await?; + let target = resolve_restore_target(udb, bucket_id, database_id.clone(), selector).await?; + let undo = + capture_current_restore_point_for_restore(udb, bucket_id, database_id.clone()).await?; let result = restore_database_to_target_and_pin_undo(udb, bucket_id, database_id, target, undo).await?; @@ -71,9 +72,10 @@ async fn capture_current_restore_point_for_restore( let database_id = database_id.clone(); async move { - let branch_id = branch::resolve_database_branch(&tx, bucket_id, &database_id, Serializable) - .await? - .ok_or(SqliteStorageError::DatabaseNotFound)?; + let branch_id = + branch::resolve_database_branch(&tx, bucket_id, &database_id, Serializable) + .await? + .ok_or(SqliteStorageError::DatabaseNotFound)?; let head_bytes = tx .informal() .get(&keys::branch_meta_head_key(branch_id), Serializable) @@ -82,11 +84,14 @@ async fn capture_current_restore_point_for_restore( let head = decode_db_head(&head_bytes).context("decode sqlite database branch head")?; let commit_bytes = tx .informal() - .get(&keys::branch_commit_key(branch_id, head.head_txid), Serializable) + .get( + &keys::branch_commit_key(branch_id, head.head_txid), + Serializable, + ) .await? .context("sqlite database branch head commit row is missing")?; - let commit = - decode_commit_row(&commit_bytes).context("decode sqlite database branch commit row")?; + let commit = decode_commit_row(&commit_bytes) + .context("decode sqlite database branch commit row")?; Ok(ResolvedRestorePointPin { restore_point: RestorePointId::format(commit.wall_clock_ms, head.head_txid)?, diff --git a/engine/packages/depot/src/conveyer/restore_point/shared.rs b/engine/packages/depot/src/conveyer/restore_point/shared.rs index ef21bc9e2c..9ad501d794 100644 --- a/engine/packages/depot/src/conveyer/restore_point/shared.rs +++ b/engine/packages/depot/src/conveyer/restore_point/shared.rs @@ -1,6 +1,6 @@ use anyhow::{Context, Result}; -use crate::conveyer::types::{RestorePointId, DatabaseBranchId}; +use crate::conveyer::types::{DatabaseBranchId, RestorePointId}; pub(super) struct RestorePointCreateResult { pub(super) restore_point: RestorePointId, diff --git a/engine/packages/depot/src/conveyer/restore_point/test_hooks.rs b/engine/packages/depot/src/conveyer/restore_point/test_hooks.rs index cef6133e9c..e6b3d1a75d 100644 --- a/engine/packages/depot/src/conveyer/restore_point/test_hooks.rs +++ b/engine/packages/depot/src/conveyer/restore_point/test_hooks.rs @@ -7,8 +7,7 @@ use parking_lot::Mutex; use tokio::sync::Notify; #[cfg(debug_assertions)] -static PAUSE_AFTER_RESOLVE: Mutex, Arc)>> = - Mutex::new(None); +static PAUSE_AFTER_RESOLVE: Mutex, Arc)>> = Mutex::new(None); #[cfg(debug_assertions)] static FAIL_AFTER_RESTORE_ROLLBACK: Mutex> = Mutex::new(None); @@ -26,8 +25,11 @@ pub struct FailureGuard { pub fn pause_after_resolve(database_id: &str) -> (PauseGuard, Arc, Arc) { let reached = Arc::new(Notify::new()); let release = Arc::new(Notify::new()); - *PAUSE_AFTER_RESOLVE.lock() = - Some((database_id.to_string(), Arc::clone(&reached), Arc::clone(&release))); + *PAUSE_AFTER_RESOLVE.lock() = Some(( + database_id.to_string(), + Arc::clone(&reached), + Arc::clone(&release), + )); ( PauseGuard { @@ -55,8 +57,7 @@ pub(super) async fn maybe_pause_after_resolve(database_id: &str) { .as_ref() .is_some_and(|(hook_database_id, _, _)| hook_database_id == database_id) { - slot.take() - .map(|(_, reached, release)| (reached, release)) + slot.take().map(|(_, reached, release)| (reached, release)) } else { None } diff --git a/engine/packages/depot/src/conveyer/types.rs b/engine/packages/depot/src/conveyer/types.rs index bf6440e102..d0486ce718 100644 --- a/engine/packages/depot/src/conveyer/types.rs +++ b/engine/packages/depot/src/conveyer/types.rs @@ -1,4 +1,3 @@ -mod restore_points; mod branch; mod cold_manifest; mod compaction; @@ -6,10 +5,10 @@ mod history_pin; mod ids; mod pages; mod policy; +mod restore_points; mod serialization; mod storage; -pub use restore_points::*; pub use branch::*; pub use cold_manifest::*; pub use compaction::*; @@ -17,6 +16,7 @@ pub use history_pin::*; pub use ids::*; pub use pages::*; pub use policy::*; +pub use restore_points::*; pub use serialization::*; pub use storage::*; diff --git a/engine/packages/depot/src/conveyer/types/branch.rs b/engine/packages/depot/src/conveyer/types/branch.rs index 05cf830821..f0c3faaf22 100644 --- a/engine/packages/depot/src/conveyer/types/branch.rs +++ b/engine/packages/depot/src/conveyer/types/branch.rs @@ -2,11 +2,9 @@ use anyhow::{Context, Result, bail}; use serde::{Deserialize, Serialize}; use vbare::OwnedVersionedData; +use super::ids::{BucketBranchId, BucketIdUuid, DatabaseBranchId, DatabaseIdStr}; use super::restore_points::RestorePointRef; -use super::ids::{DatabaseBranchId, DatabaseIdStr, BucketBranchId, BucketIdUuid}; -use super::serialization::{ - SQLITE_DATABASE_BRANCH_RECORD_VERSION, SQLITE_STORAGE_META_VERSION, -}; +use super::serialization::{SQLITE_DATABASE_BRANCH_RECORD_VERSION, SQLITE_STORAGE_META_VERSION}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum BranchState { diff --git a/engine/packages/depot/src/conveyer/types/cold_manifest.rs b/engine/packages/depot/src/conveyer/types/cold_manifest.rs index 9302e7044a..d0771a5f61 100644 --- a/engine/packages/depot/src/conveyer/types/cold_manifest.rs +++ b/engine/packages/depot/src/conveyer/types/cold_manifest.rs @@ -2,8 +2,8 @@ use anyhow::{Context, Result, bail}; use serde::{Deserialize, Serialize}; use vbare::OwnedVersionedData; -use super::restore_points::RestorePointIndexEntry; use super::ids::DatabaseBranchId; +use super::restore_points::RestorePointIndexEntry; use super::serialization::SQLITE_STORAGE_META_VERSION; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/engine/packages/depot/src/conveyer/types/compaction.rs b/engine/packages/depot/src/conveyer/types/compaction.rs index 94cd3a1925..7d8c2dd22b 100644 --- a/engine/packages/depot/src/conveyer/types/compaction.rs +++ b/engine/packages/depot/src/conveyer/types/compaction.rs @@ -3,7 +3,7 @@ use gas::prelude::Id; use serde::{Deserialize, Serialize}; use vbare::OwnedVersionedData; -use super::ids::{DatabaseBranchId, BucketBranchId}; +use super::ids::{BucketBranchId, DatabaseBranchId}; use super::serialization::SQLITE_STORAGE_META_VERSION; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -150,11 +150,7 @@ impl_compaction_versioned_data!( PitrIntervalCoverage, "PitrIntervalCoverage" ); -impl_compaction_versioned_data!( - VersionedBucketForkFact, - BucketForkFact, - "BucketForkFact" -); +impl_compaction_versioned_data!(VersionedBucketForkFact, BucketForkFact, "BucketForkFact"); impl_compaction_versioned_data!( VersionedBucketCatalogDbFact, BucketCatalogDbFact, diff --git a/engine/packages/depot/src/conveyer/types/history_pin.rs b/engine/packages/depot/src/conveyer/types/history_pin.rs index d0e29c15ce..b1592c0eda 100644 --- a/engine/packages/depot/src/conveyer/types/history_pin.rs +++ b/engine/packages/depot/src/conveyer/types/history_pin.rs @@ -2,8 +2,8 @@ use anyhow::{Context, Result, bail}; use serde::{Deserialize, Serialize}; use vbare::OwnedVersionedData; +use super::ids::{BucketBranchId, DatabaseBranchId}; use super::restore_points::RestorePointId; -use super::ids::{DatabaseBranchId, BucketBranchId}; use super::serialization::SQLITE_STORAGE_META_VERSION; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] diff --git a/engine/packages/depot/src/conveyer/udb.rs b/engine/packages/depot/src/conveyer/udb.rs index 1254340a80..3661d3dba0 100644 --- a/engine/packages/depot/src/conveyer/udb.rs +++ b/engine/packages/depot/src/conveyer/udb.rs @@ -12,11 +12,7 @@ pub const INCOMPLETE_VERSIONSTAMP: [u8; 16] = [ 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0, 0, 0, 0, 0, 0, ]; -pub fn compare_and_clear( - tx: &universaldb::Transaction, - key: &[u8], - expected_value: &[u8], -) { +pub fn compare_and_clear(tx: &universaldb::Transaction, key: &[u8], expected_value: &[u8]) { tx.informal() .atomic_op(key, expected_value, MutationType::CompareAndClear); } @@ -45,7 +41,8 @@ pub async fn scan_prefix_values( async move { let subspace_prefix = subspace.bytes().to_vec(); let full_prefix = [subspace_prefix.as_slice(), prefix.as_slice()].concat(); - let prefix_subspace = Subspace::from(universaldb::tuple::Subspace::from_bytes(full_prefix)); + let prefix_subspace = + Subspace::from(universaldb::tuple::Subspace::from_bytes(full_prefix)); let informal = tx.informal(); let mut stream = informal.get_ranges_keyvalues( RangeOption { diff --git a/engine/packages/depot/src/fault/controller.rs b/engine/packages/depot/src/fault/controller.rs index f080aa7d25..de84ea9192 100644 --- a/engine/packages/depot/src/fault/controller.rs +++ b/engine/packages/depot/src/fault/controller.rs @@ -199,7 +199,11 @@ impl DepotFaultController { pub fn replay_log_with_unfired(&self) -> Vec { let inner = self.inner.lock(); let mut replay = inner.replay.clone(); - for rule in inner.rules.iter().filter(|rule| rule.expected && rule.fired_count == 0) { + for rule in inner + .rules + .iter() + .filter(|rule| rule.expected && rule.fired_count == 0) + { if replay.iter().any(|event| { event.rule_id == rule.id && event.kind == DepotFaultReplayEventKind::ExpectedButUnfired @@ -348,7 +352,11 @@ impl DepotFaultControllerInner { } fn pause_state(&mut self, checkpoint: &str) -> Arc { - if let Some(entry) = self.pauses.iter().find(|entry| entry.checkpoint == checkpoint) { + if let Some(entry) = self + .pauses + .iter() + .find(|entry| entry.checkpoint == checkpoint) + { return Arc::clone(&entry.state); } @@ -486,10 +494,13 @@ impl<'a> DepotFaultRuleBuilder<'a> { } fn insert(self, action: DepotFaultAction) -> Result { - self.controller - .inner - .lock() - .insert_rule(self.point, self.scope, self.invocation, action, self.expected) + self.controller.inner.lock().insert_rule( + self.point, + self.scope, + self.invocation, + action, + self.expected, + ) } } diff --git a/engine/packages/depot/src/fault/mod.rs b/engine/packages/depot/src/fault/mod.rs index 2a6284ecc1..1d64fe0dae 100644 --- a/engine/packages/depot/src/fault/mod.rs +++ b/engine/packages/depot/src/fault/mod.rs @@ -10,7 +10,6 @@ pub use controller::{ DepotFaultReplayEvent, DepotFaultReplayEventKind, DepotFaultRuleId, }; pub use points::{ - ColdCompactionFaultPoint, ColdTierFaultPoint, CommitFaultPoint, DepotFaultPoint, - FaultBoundary, HotCompactionFaultPoint, ReadFaultPoint, ReclaimFaultPoint, - ShardCacheFillFaultPoint, + ColdCompactionFaultPoint, ColdTierFaultPoint, CommitFaultPoint, DepotFaultPoint, FaultBoundary, + HotCompactionFaultPoint, ReadFaultPoint, ReclaimFaultPoint, ShardCacheFillFaultPoint, }; diff --git a/engine/packages/depot/src/fault/points.rs b/engine/packages/depot/src/fault/points.rs index ea43e69259..9d95248bf7 100644 --- a/engine/packages/depot/src/fault/points.rs +++ b/engine/packages/depot/src/fault/points.rs @@ -135,8 +135,9 @@ impl CommitFaultPoint { | CommitFaultPoint::BeforeCommitRows | CommitFaultPoint::BeforeQuotaMutation => FaultBoundary::PreDurableCommit, CommitFaultPoint::AfterUdbCommit => FaultBoundary::AmbiguousAfterDurableCommit, - CommitFaultPoint::BeforeCompactionSignal - | CommitFaultPoint::AfterCompactionSignal => FaultBoundary::PostDurableNonData, + CommitFaultPoint::BeforeCompactionSignal | CommitFaultPoint::AfterCompactionSignal => { + FaultBoundary::PostDurableNonData + } } } } diff --git a/engine/packages/depot/src/gc/mod.rs b/engine/packages/depot/src/gc/mod.rs index bdfe4b8de7..cff07795e7 100644 --- a/engine/packages/depot/src/gc/mod.rs +++ b/engine/packages/depot/src/gc/mod.rs @@ -7,8 +7,7 @@ use universaldb::{ }; use crate::conveyer::{ - history_pin, - keys, + history_pin, keys, types::{ DatabaseBranchId, DatabaseBranchRecord, DbHistoryPinKind, decode_database_branch_record, decode_db_history_pin, @@ -79,9 +78,11 @@ pub(crate) async fn read_branch_gc_pin_tx( else { return Ok(None); }; - let record = - decode_database_branch_record(&record_bytes).context("decode sqlite branch record for GC")?; - read_branch_gc_pin_from_record_tx(tx, &record).await.map(Some) + let record = decode_database_branch_record(&record_bytes) + .context("decode sqlite branch record for GC")?; + read_branch_gc_pin_from_record_tx(tx, &record) + .await + .map(Some) } pub(crate) async fn sweep_branch_hot_history_tx( @@ -206,7 +207,8 @@ async fn read_branch_gc_pin_from_record_tx( VERSIONSTAMP_INFINITY }; let desc_pin = read_versionstamp_pin(tx, &keys::branches_desc_pin_key(branch_id)).await?; - let restore_point_pin = read_versionstamp_pin(tx, &keys::branches_restore_point_pin_key(branch_id)).await?; + let restore_point_pin = + read_versionstamp_pin(tx, &keys::branches_restore_point_pin_key(branch_id)).await?; let gc_pin = root_pin.min(desc_pin).min(restore_point_pin); Ok(BranchGcPin { @@ -243,7 +245,8 @@ async fn recompute_parent_desc_pin( let deleted_pin_key = keys::db_pin_key(parent_branch_id, deleted_pin_id); let mut desc_pin = VERSIONSTAMP_INFINITY; - for (key, value) in scan_prefix(tx, &keys::db_pin_prefix(parent_branch_id), Serializable).await? + for (key, value) in + scan_prefix(tx, &keys::db_pin_prefix(parent_branch_id), Serializable).await? { if key == deleted_pin_key { continue; @@ -292,10 +295,7 @@ async fn read_i64_le(tx: &universaldb::Transaction, key: &[u8]) -> Result { Ok(i64::from_le_bytes(bytes)) } -async fn read_versionstamp_pin( - tx: &universaldb::Transaction, - key: &[u8], -) -> Result<[u8; 16]> { +async fn read_versionstamp_pin(tx: &universaldb::Transaction, key: &[u8]) -> Result<[u8; 16]> { let Some(bytes) = tx.informal().get(key, Serializable).await? else { return Ok(VERSIONSTAMP_INFINITY); }; diff --git a/engine/packages/depot/src/inspect.rs b/engine/packages/depot/src/inspect.rs index a2a1849b6c..fce38f6879 100644 --- a/engine/packages/depot/src/inspect.rs +++ b/engine/packages/depot/src/inspect.rs @@ -9,15 +9,10 @@ use rivet_pools::NodeId; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; -use universaldb::{ - RangeOption, - options::StreamingMode, - utils::IsolationLevel::Snapshot, -}; +use universaldb::{RangeOption, options::StreamingMode, utils::IsolationLevel::Snapshot}; use uuid::Uuid; use crate::{ - gc, conveyer::{ keys, types::{ @@ -28,6 +23,7 @@ use crate::{ decode_sqlite_cmp_dirty, }, }, + gc, }; pub const DEFAULT_LIMIT: usize = 100; @@ -201,9 +197,13 @@ pub async fn catalog( let database_filter = database_filter.clone(); async move { let bucket_pointer = if let Some(bucket_id) = bucket_filter { - tx_get_decoded(&tx, keys::bucket_pointer_cur_key(bucket_id), decode_bucket_pointer) - .await? - .map(|pointer| (bucket_id, pointer)) + tx_get_decoded( + &tx, + keys::bucket_pointer_cur_key(bucket_id), + decode_bucket_pointer, + ) + .await? + .map(|pointer| (bucket_id, pointer)) } else { None }; @@ -253,7 +253,8 @@ pub async fn catalog( })] } else { let bucket_rows = - scan_prefix_page(&tx, keys::bucket_pointer_cur_prefix(), None, limit).await?; + scan_prefix_page(&tx, keys::bucket_pointer_cur_prefix(), None, limit) + .await?; let mut buckets = Vec::new(); for row in bucket_rows.rows { let bucket_id = keys::decode_bucket_pointer_cur_bucket_id(&row.key)?; @@ -293,18 +294,29 @@ pub async fn bucket( let include_history = query.include_history.unwrap_or(false); let data = db .run(move |tx| async move { - let pointer = tx_get_decoded(&tx, keys::bucket_pointer_cur_key(bucket_id), decode_bucket_pointer).await?; + let pointer = tx_get_decoded( + &tx, + keys::bucket_pointer_cur_key(bucket_id), + decode_bucket_pointer, + ) + .await?; let current_branch = match &pointer { Some(pointer) => Some(pointer.current_branch), None => None, }; let branch_record = if let Some(branch_id) = current_branch { - tx_get_decoded(&tx, keys::bucket_branches_list_key(branch_id), decode_bucket_branch_record).await? + tx_get_decoded( + &tx, + keys::bucket_branches_list_key(branch_id), + decode_bucket_branch_record, + ) + .await? } else { None }; let catalog = if let Some(branch_id) = current_branch { - summary_for_prefix(&tx, keys::bucket_catalog_prefix(branch_id), sample_limit).await? + summary_for_prefix(&tx, keys::bucket_catalog_prefix(branch_id), sample_limit) + .await? } else { empty_summary() }; @@ -319,7 +331,12 @@ pub async fn bucket( empty_summary() }; let history = if include_history { - summary_for_prefix(&tx, keys::bucket_pointer_history_prefix(bucket_id), sample_limit).await? + summary_for_prefix( + &tx, + keys::bucket_pointer_history_prefix(bucket_id), + sample_limit, + ) + .await? } else { empty_summary() }; @@ -340,7 +357,11 @@ pub async fn bucket( }) .await?; - response(node_id, json!({ "kind": "bucket", "bucket_id": bucket_id }), data) + response( + node_id, + json!({ "kind": "bucket", "bucket_id": bucket_id }), + data, + ) } pub async fn database( @@ -356,10 +377,16 @@ pub async fn database( .run(move |tx| { let database_id = database_id.clone(); async move { - let bucket_pointer = - tx_get_decoded(&tx, keys::bucket_pointer_cur_key(bucket_id), decode_bucket_pointer).await?; + let bucket_pointer = tx_get_decoded( + &tx, + keys::bucket_pointer_cur_key(bucket_id), + decode_bucket_pointer, + ) + .await?; let Some(bucket_pointer) = bucket_pointer else { - return Ok(json!({ "bucket_id": bucket_id, "database_id": database_id, "pointer": null })); + return Ok( + json!({ "bucket_id": bucket_id, "database_id": database_id, "pointer": null }), + ); }; let pointer = tx_get_decoded( &tx, @@ -429,7 +456,13 @@ pub async fn branch_rows( .await?; let mut rows = Vec::new(); for row in scan.rows { - rows.push(decode_row_value(branch_id, family, &row.key, &row.value, include_bytes)); + rows.push(decode_row_value( + branch_id, + family, + &row.key, + &row.value, + include_bytes, + )); } Ok(PaginatedRowsResponse { @@ -539,10 +572,7 @@ pub async fn page_trace( tx_get_decoded(&tx, keys::branch_meta_head_key(branch_id), decode_db_head).await }) .await?; - let outcome = if head - .as_ref() - .is_some_and(|head| pgno <= head.db_size_pages) - { + let outcome = if head.as_ref().is_some_and(|head| pgno <= head.db_size_pages) { "found" } else { "above_eof" @@ -578,7 +608,10 @@ fn response(node_id: NodeId, scope: Value, data: Value) -> Result) -> Result { let limit = limit.unwrap_or(DEFAULT_LIMIT); - ensure!(limit <= MAX_LIMIT, "Depot inspect limit exceeds hard cap of {MAX_LIMIT}"); + ensure!( + limit <= MAX_LIMIT, + "Depot inspect limit exceeds hard cap of {MAX_LIMIT}" + ); Ok(limit) } @@ -745,13 +778,31 @@ async fn branch_blob_in_tx( branch_id: DatabaseBranchId, sample_limit: usize, ) -> Result { - let record = tx_get_decoded(tx, keys::branches_list_key(branch_id), decode_database_branch_record).await?; + let record = tx_get_decoded( + tx, + keys::branches_list_key(branch_id), + decode_database_branch_record, + ) + .await?; let head = tx_get_decoded(tx, keys::branch_meta_head_key(branch_id), decode_db_head).await?; - let head_at_fork = - tx_get_decoded(tx, keys::branch_meta_head_at_fork_key(branch_id), decode_db_head).await?; - let compaction_root = - tx_get_decoded(tx, keys::branch_compaction_root_key(branch_id), decode_compaction_root).await?; - let dirty = tx_get_decoded(tx, keys::sqlite_cmp_dirty_key(branch_id), decode_sqlite_cmp_dirty).await?; + let head_at_fork = tx_get_decoded( + tx, + keys::branch_meta_head_at_fork_key(branch_id), + decode_db_head, + ) + .await?; + let compaction_root = tx_get_decoded( + tx, + keys::branch_compaction_root_key(branch_id), + decode_compaction_root, + ) + .await?; + let dirty = tx_get_decoded( + tx, + keys::sqlite_cmp_dirty_key(branch_id), + decode_sqlite_cmp_dirty, + ) + .await?; let gc_pin = gc::read_branch_gc_pin_tx(tx, branch_id).await?; let mut row_families = serde_json::Map::new(); for family in [ @@ -847,9 +898,15 @@ fn decode_value_by_key(key: &[u8], value: &[u8]) -> Value { match key[1] { keys::DBPTR_PARTITION => return value_or_error(decode_database_pointer(value)), keys::BUCKET_PTR_PARTITION => return value_or_error(decode_bucket_pointer(value)), - keys::BRANCHES_PARTITION => return value_or_error(decode_database_branch_record(value)), - keys::BUCKET_BRANCH_PARTITION => return value_or_error(decode_bucket_branch_record(value)), - keys::SQLITE_CMP_DIRTY_PARTITION => return value_or_error(decode_sqlite_cmp_dirty(value)), + keys::BRANCHES_PARTITION => { + return value_or_error(decode_database_branch_record(value)); + } + keys::BUCKET_BRANCH_PARTITION => { + return value_or_error(decode_bucket_branch_record(value)); + } + keys::SQLITE_CMP_DIRTY_PARTITION => { + return value_or_error(decode_sqlite_cmp_dirty(value)); + } keys::DB_PIN_PARTITION => return value_or_error(decode_db_history_pin(value)), _ => {} } @@ -922,9 +979,9 @@ fn value_or_error(result: Result) -> Value { fn result_to_value(result: Result) -> Value { match result { - Ok(value) => serde_json::to_value(value).unwrap_or_else(|err| { - json!({ "decode_error": format!("failed to encode decoded value as JSON: {err}") }) - }), + Ok(value) => serde_json::to_value(value).unwrap_or_else( + |err| json!({ "decode_error": format!("failed to encode decoded value as JSON: {err}") }), + ), Err(err) => json!({ "decode_error": err.to_string() }), } } diff --git a/engine/packages/depot/src/lib.rs b/engine/packages/depot/src/lib.rs index fc9c46dbf6..46544d3bb9 100644 --- a/engine/packages/depot/src/lib.rs +++ b/engine/packages/depot/src/lib.rs @@ -1,18 +1,18 @@ pub mod burst_mode; pub mod cold_tier; mod compaction; -pub mod gc; pub mod conveyer; #[cfg(feature = "test-faults")] pub mod fault; +pub mod gc; pub mod inspect; pub mod metrics; -pub mod workflows; #[cfg(debug_assertions)] pub mod takeover; +pub mod workflows; +pub use conveyer::constants::*; #[cfg(debug_assertions)] pub use conveyer::debug; -pub use conveyer::{constants, error, keys, ltx, page_index, policy, quota, types, udb}; pub use conveyer::pitr_interval; -pub use conveyer::constants::*; +pub use conveyer::{constants, error, keys, ltx, page_index, policy, quota, types, udb}; diff --git a/engine/packages/depot/src/takeover.rs b/engine/packages/depot/src/takeover.rs index bd22b6b62c..815cfc7768 100644 --- a/engine/packages/depot/src/takeover.rs +++ b/engine/packages/depot/src/takeover.rs @@ -17,8 +17,11 @@ pub struct PauseGuard { pub fn pause_reconcile_for_test(database_id: &str) -> (PauseGuard, Arc, Arc) { let reached = Arc::new(Notify::new()); let release = Arc::new(Notify::new()); - *PAUSE_RECONCILE.lock() = - Some((database_id.to_string(), Arc::clone(&reached), Arc::clone(&release))); + *PAUSE_RECONCILE.lock() = Some(( + database_id.to_string(), + Arc::clone(&reached), + Arc::clone(&release), + )); ( PauseGuard { diff --git a/engine/packages/depot/src/workflows/db_cold_compacter.rs b/engine/packages/depot/src/workflows/db_cold_compacter.rs index b0dc061a52..bf25e096ff 100644 --- a/engine/packages/depot/src/workflows/db_cold_compacter.rs +++ b/engine/packages/depot/src/workflows/db_cold_compacter.rs @@ -1,7 +1,7 @@ use crate::compaction::{ - *, companion::{CompanionKind, run_companion_loop}, shared::*, + *, }; use crate::workflows::db_manager::branch_record_is_live_at_generation; @@ -60,7 +60,10 @@ pub async fn upload_cold_job( { return Ok(output); } - if let Err(err) = cold_tier.put_object(&object.object_key, &object.bytes).await { + if let Err(err) = cold_tier + .put_object(&object.object_key, &object.bytes) + .await + { tracing::warn!( ?input.database_branch_id, ?input.job_id, @@ -110,7 +113,9 @@ async fn prepare_cold_upload_tx( input: &UploadColdJobInput, ) -> Result { if input.job_kind != CompactionJobKind::Cold { - return Ok(rejected_cold_upload("cold compacter received a non-cold job")); + return Ok(rejected_cold_upload( + "cold compacter received a non-cold job", + )); } #[cfg(feature = "test-faults")] if let Some(output) = cold_prepare_upload_fault_output( @@ -132,10 +137,8 @@ async fn prepare_cold_upload_tx( .map(decode_database_branch_record) .transpose() .context("decode sqlite database branch record for cold upload")?; - if !branch_record_is_live_at_generation( - branch_record.as_ref(), - input.base_lifecycle_generation, - ) { + if !branch_record_is_live_at_generation(branch_record.as_ref(), input.base_lifecycle_generation) + { return Ok(rejected_cold_upload("database branch lifecycle changed")); } @@ -168,12 +171,15 @@ async fn prepare_cold_upload_tx( if cold_inputs.min_versionstamp != input.input_range.min_versionstamp || cold_inputs.max_versionstamp != input.input_range.max_versionstamp { - return Ok(rejected_cold_upload("cold compaction versionstamp bounds changed")); + return Ok(rejected_cold_upload( + "cold compaction versionstamp bounds changed", + )); } - let input_fingerprint = - fingerprint_cold_inputs(input.database_branch_id, &root, &cold_inputs); + let input_fingerprint = fingerprint_cold_inputs(input.database_branch_id, &root, &cold_inputs); if input_fingerprint != input.input_fingerprint { - return Ok(rejected_cold_upload("cold compaction input fingerprint changed")); + return Ok(rejected_cold_upload( + "cold compaction input fingerprint changed", + )); } #[cfg(feature = "test-faults")] if let Some(output) = cold_prepare_upload_fault_output( @@ -190,7 +196,9 @@ async fn prepare_cold_upload_tx( let publish_generation = root.manifest_generation.saturating_add(1); for blob in cold_inputs.shard_blobs { if blob.as_of_txid != input.input_range.txids.max_txid { - return Ok(rejected_cold_upload("cold shard txid does not match planned range")); + return Ok(rejected_cold_upload( + "cold shard txid does not match planned range", + )); } let content_hash = content_hash(&blob.bytes); let object_key = cold_shard_object_key( @@ -316,10 +324,8 @@ async fn publish_cold_job_tx( .map(decode_database_branch_record) .transpose() .context("decode sqlite database branch record for cold publish")?; - if !branch_record_is_live_at_generation( - branch_record.as_ref(), - input.base_lifecycle_generation, - ) { + if !branch_record_is_live_at_generation(branch_record.as_ref(), input.base_lifecycle_generation) + { return Ok(rejected_cold_publish("database branch lifecycle changed")); } @@ -358,18 +364,23 @@ async fn publish_cold_job_tx( if cold_inputs.min_versionstamp != input.input_range.min_versionstamp || cold_inputs.max_versionstamp != input.input_range.max_versionstamp { - return Ok(rejected_cold_publish("cold compaction versionstamp bounds changed")); + return Ok(rejected_cold_publish( + "cold compaction versionstamp bounds changed", + )); } - let input_fingerprint = - fingerprint_cold_inputs(input.database_branch_id, &root, &cold_inputs); + let input_fingerprint = fingerprint_cold_inputs(input.database_branch_id, &root, &cold_inputs); if input_fingerprint != input.input_fingerprint { - return Ok(rejected_cold_publish("cold compaction input fingerprint changed")); + return Ok(rejected_cold_publish( + "cold compaction input fingerprint changed", + )); } let publish_generation = root.manifest_generation.saturating_add(1); let expected_outputs = expected_cold_output_refs(input, &cold_inputs, publish_generation); if expected_outputs != input.output_refs { - return Ok(rejected_cold_publish("cold output refs do not match planned inputs")); + return Ok(rejected_cold_publish( + "cold output refs do not match planned inputs", + )); } #[cfg(feature = "test-faults")] if let Some(output) = cold_publish_fault_output( diff --git a/engine/packages/depot/src/workflows/db_hot_compacter.rs b/engine/packages/depot/src/workflows/db_hot_compacter.rs index f1f009ccec..841467a962 100644 --- a/engine/packages/depot/src/workflows/db_hot_compacter.rs +++ b/engine/packages/depot/src/workflows/db_hot_compacter.rs @@ -1,7 +1,7 @@ use crate::compaction::{ - *, companion::{CompanionKind, run_companion_loop}, shared::*, + *, }; use crate::workflows::db_manager::branch_record_is_live_at_generation; @@ -40,9 +40,11 @@ async fn stage_hot_job_tx( return Ok(rejected_hot_job("hot compacter received a non-hot job")); } #[cfg(feature = "test-faults")] - if let Some(output) = - hot_stage_fault_output(input.database_branch_id, HotCompactionFaultPoint::StageBeforeInputRead) - .await? + if let Some(output) = hot_stage_fault_output( + input.database_branch_id, + HotCompactionFaultPoint::StageBeforeInputRead, + ) + .await? { return Ok(output); } @@ -57,10 +59,8 @@ async fn stage_hot_job_tx( .map(decode_database_branch_record) .transpose() .context("decode sqlite database branch record for hot compaction")?; - if !branch_record_is_live_at_generation( - branch_record.as_ref(), - input.base_lifecycle_generation, - ) { + if !branch_record_is_live_at_generation(branch_record.as_ref(), input.base_lifecycle_generation) + { return Ok(rejected_hot_job("database branch lifecycle changed")); } @@ -99,27 +99,20 @@ async fn stage_hot_job_tx( return Ok(rejected_hot_job("database branch head is missing")); }; - let db_pins = - history_pin::read_db_history_pins(tx, input.database_branch_id, Snapshot).await?; - let pitr_policy = - read_effective_pitr_policy_for_branch(tx, branch_record.as_ref()).await?; - let hot_inputs = - read_hot_input_snapshot( - tx, - input.database_branch_id, - Some(&head), - &root, - Snapshot, - pitr_policy, - now_ms, - ) - .await?; - let coverage_txids = selected_hot_coverage_txids( + let db_pins = history_pin::read_db_history_pins(tx, input.database_branch_id, Snapshot).await?; + let pitr_policy = read_effective_pitr_policy_for_branch(tx, branch_record.as_ref()).await?; + let hot_inputs = read_hot_input_snapshot( + tx, + input.database_branch_id, + Some(&head), &root, - &head, - &db_pins, - &hot_inputs.pitr_interval_coverage, - ); + Snapshot, + pitr_policy, + now_ms, + ) + .await?; + let coverage_txids = + selected_hot_coverage_txids(&root, &head, &db_pins, &hot_inputs.pitr_interval_coverage); if coverage_txids != input.input_range.coverage_txids { return Ok(rejected_hot_job("hot compaction coverage targets changed")); } @@ -134,18 +127,18 @@ async fn stage_hot_job_tx( return Ok(rejected_hot_job("hot compaction input fingerprint changed")); } #[cfg(feature = "test-faults")] - if let Some(output) = - hot_stage_fault_output(input.database_branch_id, HotCompactionFaultPoint::StageAfterInputRead) - .await? + if let Some(output) = hot_stage_fault_output( + input.database_branch_id, + HotCompactionFaultPoint::StageAfterInputRead, + ) + .await? { return Ok(output); } let output_refs = write_staged_hot_shards(tx, input, &head, &hot_inputs).await?; #[cfg(feature = "test-faults")] - if let Some(output) = - hot_stage_after_shard_write_fault_output(tx, input, &output_refs).await? - { + if let Some(output) = hot_stage_after_shard_write_fault_output(tx, input, &output_refs).await? { return Ok(output); } @@ -189,13 +182,14 @@ async fn hot_stage_after_shard_write_fault_output( { Ok(Some(fired)) if fired.action == DepotFaultAction::DropArtifact => { for output_ref in output_refs { - tx.informal().clear(&keys::branch_compaction_stage_hot_shard_key( - input.database_branch_id, - input.job_id, - output_ref.shard_id, - output_ref.as_of_txid, - 0, - )); + tx.informal() + .clear(&keys::branch_compaction_stage_hot_shard_key( + input.database_branch_id, + input.job_id, + output_ref.shard_id, + output_ref.as_of_txid, + 0, + )); } Ok(Some(StageHotJobOutput { status: CompactionJobStatus::Succeeded, @@ -252,10 +246,8 @@ async fn install_hot_job_tx( .map(decode_database_branch_record) .transpose() .context("decode sqlite database branch record for hot install")?; - if !branch_record_is_live_at_generation( - branch_record.as_ref(), - input.base_lifecycle_generation, - ) { + if !branch_record_is_live_at_generation(branch_record.as_ref(), input.base_lifecycle_generation) + { return Ok(rejected_hot_install("database branch lifecycle changed")); } @@ -299,8 +291,7 @@ async fn install_hot_job_tx( if resolve_bucket_fork_pins(tx, input.database_branch_id, &mut db_pins).await? { return Ok(rejected_hot_install("bucket fork proof is ambiguous")); } - let pitr_policy = - read_effective_pitr_policy_for_branch(tx, branch_record.as_ref()).await?; + let pitr_policy = read_effective_pitr_policy_for_branch(tx, branch_record.as_ref()).await?; let hot_inputs = read_hot_input_snapshot( tx, input.database_branch_id, @@ -311,14 +302,12 @@ async fn install_hot_job_tx( now_ms, ) .await?; - let coverage_txids = selected_hot_coverage_txids( - &root, - &head, - &db_pins, - &hot_inputs.pitr_interval_coverage, - ); + let coverage_txids = + selected_hot_coverage_txids(&root, &head, &db_pins, &hot_inputs.pitr_interval_coverage); if coverage_txids != input.input_range.coverage_txids { - return Ok(rejected_hot_install("hot compaction coverage targets changed")); + return Ok(rejected_hot_install( + "hot compaction coverage targets changed", + )); } let input_fingerprint = fingerprint_hot_inputs( input.database_branch_id, @@ -328,7 +317,9 @@ async fn install_hot_job_tx( &hot_inputs, ); if input_fingerprint != input.input_fingerprint { - return Ok(rejected_hot_install("hot compaction input fingerprint changed")); + return Ok(rejected_hot_install( + "hot compaction input fingerprint changed", + )); } let mut staged_blobs = Vec::with_capacity(input.output_refs.len()); @@ -349,15 +340,21 @@ async fn install_hot_job_tx( || output_ref.min_txid != input.input_range.txids.min_txid || output_ref.max_txid != output_ref.as_of_txid { - return Ok(rejected_hot_install("hot output ref does not match planned txid range")); + return Ok(rejected_hot_install( + "hot output ref does not match planned txid range", + )); } if !staged_outputs.insert((output_ref.shard_id, output_ref.as_of_txid)) { - return Ok(rejected_hot_install("duplicate staged hot shard output ref")); + return Ok(rejected_hot_install( + "duplicate staged hot shard output ref", + )); } if output_ref.as_of_txid == input.input_range.txids.max_txid && !latest_staged_shards.insert(output_ref.shard_id) { - return Ok(rejected_hot_install("duplicate latest hot shard output ref")); + return Ok(rejected_hot_install( + "duplicate latest hot shard output ref", + )); } let stage_key = keys::branch_compaction_stage_hot_shard_key( @@ -420,7 +417,9 @@ async fn install_hot_job_tx( let pgno = decode_branch_pidx_pgno(input.database_branch_id, key)?; let shard_id = pgno / keys::SHARD_SIZE; if !latest_staged_shards.contains(&shard_id) { - return Ok(rejected_hot_install("missing staged hot shard for PIDX row")); + return Ok(rejected_hot_install( + "missing staged hot shard for PIDX row", + )); } decode_pidx_txid(value)?; } @@ -431,10 +430,7 @@ async fn install_hot_job_tx( for selection in &hot_inputs.pitr_interval_coverage { tx.informal().set( - &keys::branch_pitr_interval_key( - input.database_branch_id, - selection.bucket_start_ms, - ), + &keys::branch_pitr_interval_key(input.database_branch_id, selection.bucket_start_ms), &encode_pitr_interval_coverage(selection.coverage.clone()) .context("encode sqlite PITR interval coverage for hot install")?, ); @@ -452,13 +448,16 @@ async fn install_hot_job_tx( let next_root = CompactionRoot { schema_version: root.schema_version, manifest_generation: root.manifest_generation.saturating_add(1), - hot_watermark_txid: root.hot_watermark_txid.max(input.input_range.txids.max_txid), + hot_watermark_txid: root + .hot_watermark_txid + .max(input.input_range.txids.max_txid), cold_watermark_txid: root.cold_watermark_txid, cold_watermark_versionstamp: root.cold_watermark_versionstamp, }; tx.informal().set( &keys::branch_compaction_root_key(input.database_branch_id), - &encode_compaction_root(next_root).context("encode sqlite compaction root for hot install")?, + &encode_compaction_root(next_root) + .context("encode sqlite compaction root for hot install")?, ); #[cfg(feature = "test-faults")] if let Some(output) = hot_install_fault_output( @@ -498,13 +497,14 @@ async fn hot_install_before_staged_read_fault_output( { Ok(Some(fired)) if fired.action == DepotFaultAction::DropArtifact => { for output_ref in &input.output_refs { - tx.informal().clear(&keys::branch_compaction_stage_hot_shard_key( - input.database_branch_id, - input.job_id, - output_ref.shard_id, - output_ref.as_of_txid, - 0, - )); + tx.informal() + .clear(&keys::branch_compaction_stage_hot_shard_key( + input.database_branch_id, + input.job_id, + output_ref.shard_id, + output_ref.as_of_txid, + 0, + )); } Ok(None) } diff --git a/engine/packages/depot/src/workflows/db_manager.rs b/engine/packages/depot/src/workflows/db_manager.rs index ff311becdd..08db38a7c4 100644 --- a/engine/packages/depot/src/workflows/db_manager.rs +++ b/engine/packages/depot/src/workflows/db_manager.rs @@ -1,4 +1,4 @@ -use crate::compaction::{*, shared::*}; +use crate::compaction::{shared::*, *}; #[cfg(feature = "test-faults")] use crate::compaction::test_hooks; @@ -7,7 +7,8 @@ use crate::fault::ReclaimFaultPoint; #[workflow(DbManagerWorkflow)] pub async fn db_manager(ctx: &mut WorkflowCtx, input: &DbManagerInput) -> Result<()> { - let companion_workflow_ids = dispatch_companion_workflows(ctx, input.database_branch_id).await?; + let companion_workflow_ids = + dispatch_companion_workflows(ctx, input.database_branch_id).await?; let initial_deadline_ms = ctx.create_ts(); let initial_state = if manager_planning_timers_disabled(input) { DbManagerState::new(companion_workflow_ids) @@ -20,10 +21,7 @@ pub async fn db_manager(ctx: &mut WorkflowCtx, input: &DbManagerInput) -> Result .with_state(initial_state) .run(|ctx, state| { let input = input.clone(); - async move { - run_manager_iteration(ctx, state, &input).await - } - .boxed() + async move { run_manager_iteration(ctx, state, &input).await }.boxed() }) .await } @@ -255,15 +253,9 @@ async fn execute_manager_refresh( input: &DbManagerInput, force: ForceCompactionWork, ) -> Result { - let executions = execute_manager_effects( - ctx, - state, - input, - vec![ManagerEffect::Refresh { force }], - ) - .await?; - let [ManagerExecution::Refresh(refresh)] = executions.as_slice() - else { + let executions = + execute_manager_effects(ctx, state, input, vec![ManagerEffect::Refresh { force }]).await?; + let [ManagerExecution::Refresh(refresh)] = executions.as_slice() else { bail!("refresh effect did not return refresh output"); }; @@ -295,26 +287,31 @@ async fn execute_manager_effects( execute_publish_cold_output_effect(ctx, state, input, signal, active_job).await?; } ManagerEffect::FinishHotJob { job_id, status } => { - state - .force_compactions - .record_job_finished(CompactionJobKind::Hot, job_id, &status); + state.force_compactions.record_job_finished( + CompactionJobKind::Hot, + job_id, + &status, + ); state.active_jobs.hot = None; } ManagerEffect::FinishColdJob { job_id, status } => { - state - .force_compactions - .record_job_finished(CompactionJobKind::Cold, job_id, &status); + state.force_compactions.record_job_finished( + CompactionJobKind::Cold, + job_id, + &status, + ); state.active_jobs.cold = None; } ManagerEffect::FinishReclaimJob { job_id, status } => { - state - .force_compactions - .record_job_finished(CompactionJobKind::Reclaim, job_id, &status); + state.force_compactions.record_job_finished( + CompactionJobKind::Reclaim, + job_id, + &status, + ); state.active_jobs.reclaim = None; } ManagerEffect::ScheduleStaleHotOutputCleanup { signal, actor_id } => { - schedule_stale_hot_output_cleanup(ctx, state, &signal, actor_id.as_deref()) - .await?; + schedule_stale_hot_output_cleanup(ctx, state, &signal, actor_id.as_deref()).await?; } ManagerEffect::ScheduleUploadedColdOutputCleanup { signal, @@ -342,12 +339,7 @@ async fn execute_manager_effects( execute_run_reclaim_job_effect(ctx, state, active_job).await?; } ManagerEffect::StopCompanions { request } => { - signal_companions_destroy( - ctx, - &state.companion_workflow_ids, - &request, - ) - .await?; + signal_companions_destroy(ctx, &state.companion_workflow_ids, &request).await?; state.active_jobs.clear(); state.branch_stop_state = BranchStopState::Stopped { stopped_at_ms: ctx.create_ts(), @@ -882,7 +874,10 @@ async fn schedule_stale_hot_output_cleanup( let input_range = repair_reclaim_input_range( staged_hot_shards, Vec::new(), - signal.output_refs.iter().map(|output_ref| output_ref.as_of_txid), + signal + .output_refs + .iter() + .map(|output_ref| output_ref.as_of_txid), ); schedule_repair_reclaim_job( @@ -925,7 +920,10 @@ async fn schedule_uploaded_cold_output_cleanup( let input_range = repair_reclaim_input_range( Vec::new(), signal.output_refs.clone(), - signal.output_refs.iter().map(|output_ref| output_ref.as_of_txid), + signal + .output_refs + .iter() + .map(|output_ref| output_ref.as_of_txid), ); schedule_repair_reclaim_job( ctx, @@ -956,14 +954,14 @@ pub(super) fn repair_reclaim_input_range( min_txid = 0; } - ReclaimJobInputRange { - txids: TxidRange { min_txid, max_txid }, - txid_refs: Vec::new(), - cold_objects: Vec::new(), - shard_cache_evictions: Vec::new(), - staged_hot_shards, - orphan_cold_objects, - max_keys: CMP_FDB_BATCH_MAX_KEYS as u32, + ReclaimJobInputRange { + txids: TxidRange { min_txid, max_txid }, + txid_refs: Vec::new(), + cold_objects: Vec::new(), + shard_cache_evictions: Vec::new(), + staged_hot_shards, + orphan_cold_objects, + max_keys: CMP_FDB_BATCH_MAX_KEYS as u32, max_bytes: CMP_FDB_BATCH_MAX_VALUE_BYTES as u64, } } @@ -992,8 +990,7 @@ async fn schedule_repair_reclaim_job( } let cleanup_job_id = Id::new_v1(ctx.config().dc_label()); - let input_fingerprint = - fingerprint_repair_reclaim_range(database_branch_id, &input_range); + let input_fingerprint = fingerprint_repair_reclaim_range(database_branch_id, &input_range); tracing::warn!( actor_id = log_actor_id(actor_id), ?database_branch_id, diff --git a/engine/packages/depot/src/workflows/db_reclaimer.rs b/engine/packages/depot/src/workflows/db_reclaimer.rs index 3645aa2ef4..acdce56a30 100644 --- a/engine/packages/depot/src/workflows/db_reclaimer.rs +++ b/engine/packages/depot/src/workflows/db_reclaimer.rs @@ -1,9 +1,8 @@ use crate::{ compaction::{ - *, companion::{CompanionKind, run_companion_loop}, shared::*, - test_hooks, + test_hooks, *, }, conveyer::metrics, workflows::db_manager::branch_record_is_live_at_generation, @@ -24,10 +23,12 @@ pub async fn reclaim_fdb_job( ) -> Result { let input = input.clone(); let input_for_tx = input.clone(); - let cold_storage_enabled = workflow_cold_storage_enabled(ctx.config(), input.database_branch_id); + let cold_storage_enabled = + workflow_cold_storage_enabled(ctx.config(), input.database_branch_id); let now_ms = ctx.ts(); - let output = ctx.udb()? + let output = ctx + .udb()? .run(move |tx| { let input = input_for_tx.clone(); async move { reclaim_fdb_job_tx(&tx, &input, cold_storage_enabled, now_ms).await } @@ -71,9 +72,11 @@ async fn reclaim_fdb_job_tx( return Ok(rejected_reclaim_job("reclaimer received a non-reclaim job")); } #[cfg(feature = "test-faults")] - if let Some(output) = - reclaim_fdb_fault_output(input.database_branch_id, ReclaimFaultPoint::PlanBeforeSnapshot) - .await? + if let Some(output) = reclaim_fdb_fault_output( + input.database_branch_id, + ReclaimFaultPoint::PlanBeforeSnapshot, + ) + .await? { return Ok(output); } @@ -96,10 +99,8 @@ async fn reclaim_fdb_job_tx( .map(decode_database_branch_record) .transpose() .context("decode sqlite database branch record for FDB reclaim")?; - if !branch_record_is_live_at_generation( - branch_record.as_ref(), - input.base_lifecycle_generation, - ) { + if !branch_record_is_live_at_generation(branch_record.as_ref(), input.base_lifecycle_generation) + { return Ok(rejected_reclaim_job("database branch lifecycle changed")); } @@ -141,7 +142,8 @@ async fn reclaim_fdb_job_tx( now_ms, ) .await?; - if !input.input_range.txid_refs.is_empty() && snapshot.txid_refs != input.input_range.txid_refs { + if !input.input_range.txid_refs.is_empty() && snapshot.txid_refs != input.input_range.txid_refs + { return Ok(rejected_reclaim_job("reclaim txid set changed")); } if snapshot.cold_object_refs != input.input_range.cold_objects { @@ -161,19 +163,22 @@ async fn reclaim_fdb_job_tx( return Ok(rejected_reclaim_job("PIDX still references reclaim txids")); } if !reclaim_coverage_is_complete(&snapshot) { - return Ok(rejected_reclaim_job("replacement SHARD coverage is missing")); + return Ok(rejected_reclaim_job( + "replacement SHARD coverage is missing", + )); } } - let input_fingerprint = - fingerprint_reclaim_inputs(input.database_branch_id, &root, &snapshot); + let input_fingerprint = fingerprint_reclaim_inputs(input.database_branch_id, &root, &snapshot); if input_fingerprint != input.input_fingerprint { return Ok(rejected_reclaim_job("reclaim input fingerprint changed")); } #[cfg(feature = "test-faults")] - if let Some(output) = - reclaim_fdb_fault_output(input.database_branch_id, ReclaimFaultPoint::PlanAfterSnapshot) - .await? + if let Some(output) = reclaim_fdb_fault_output( + input.database_branch_id, + ReclaimFaultPoint::PlanAfterSnapshot, + ) + .await? { return Ok(output); } @@ -266,7 +271,9 @@ pub(super) async fn cleanup_repair_fdb_outputs_tx( let input_fingerprint = fingerprint_repair_reclaim_range(input.database_branch_id, &input.input_range); if input_fingerprint != input.input_fingerprint { - return Ok(rejected_reclaim_job("repair cleanup input fingerprint changed")); + return Ok(rejected_reclaim_job( + "repair cleanup input fingerprint changed", + )); } let branch_record = tx_get_value( @@ -279,10 +286,8 @@ pub(super) async fn cleanup_repair_fdb_outputs_tx( .map(decode_database_branch_record) .transpose() .context("decode sqlite database branch record for repair cleanup")?; - if !branch_record_is_live_at_generation( - branch_record.as_ref(), - input.base_lifecycle_generation, - ) { + if !branch_record_is_live_at_generation(branch_record.as_ref(), input.base_lifecycle_generation) + { return Ok(rejected_reclaim_job("database branch lifecycle changed")); } @@ -326,7 +331,9 @@ pub(super) async fn cleanup_repair_fdb_outputs_tx( repair_action = "retain_staged_hot_output", "staged hot shard cleanup found mismatched bytes" ); - return Ok(rejected_reclaim_job("staged hot shard cleanup bytes changed")); + return Ok(rejected_reclaim_job( + "staged hot shard cleanup bytes changed", + )); } tracing::warn!( @@ -340,7 +347,8 @@ pub(super) async fn cleanup_repair_fdb_outputs_tx( ); udb::compare_and_clear(tx, &stage_key, &stage_value); key_count = key_count.saturating_add(1); - byte_count = byte_count.saturating_add(u64::try_from(stage_value.len()).unwrap_or(u64::MAX)); + byte_count = + byte_count.saturating_add(u64::try_from(stage_value.len()).unwrap_or(u64::MAX)); } Ok(ReclaimFdbJobOutput { @@ -400,7 +408,9 @@ async fn retire_cold_objects_tx( input: &RetireColdObjectsInput, ) -> Result { if input.job_kind != CompactionJobKind::Reclaim { - return Ok(rejected_cold_object_retire("reclaimer received a non-reclaim job")); + return Ok(rejected_cold_object_retire( + "reclaimer received a non-reclaim job", + )); } if input.cold_objects.is_empty() { return Ok(RetireColdObjectsOutput { @@ -410,9 +420,11 @@ async fn retire_cold_objects_tx( }); } #[cfg(feature = "test-faults")] - if let Some(output) = - retire_cold_fault_output(input.database_branch_id, ReclaimFaultPoint::BeforeColdRetire) - .await? + if let Some(output) = retire_cold_fault_output( + input.database_branch_id, + ReclaimFaultPoint::BeforeColdRetire, + ) + .await? { return Ok(output); } @@ -427,11 +439,11 @@ async fn retire_cold_objects_tx( .map(decode_database_branch_record) .transpose() .context("decode sqlite database branch record for cold retire")?; - if !branch_record_is_live_at_generation( - branch_record.as_ref(), - input.base_lifecycle_generation, - ) { - return Ok(rejected_cold_object_retire("database branch lifecycle changed")); + if !branch_record_is_live_at_generation(branch_record.as_ref(), input.base_lifecycle_generation) + { + return Ok(rejected_cold_object_retire( + "database branch lifecycle changed", + )); } let root = tx_get_value( @@ -452,12 +464,17 @@ async fn retire_cold_objects_tx( cold_watermark_versionstamp: [0; 16], }); if root.manifest_generation != input.base_manifest_generation { - return Ok(rejected_cold_object_retire("base manifest generation changed")); + return Ok(rejected_cold_object_retire( + "base manifest generation changed", + )); } - let delete_after_ms = input - .retired_at_ms - .saturating_add(test_hooks::cold_object_delete_grace_ms(input.database_branch_id)); + let delete_after_ms = + input + .retired_at_ms + .saturating_add(test_hooks::cold_object_delete_grace_ms( + input.database_branch_id, + )); let retired_manifest_generation = root.manifest_generation.saturating_add(1); let mut retired_objects = Vec::with_capacity(input.cold_objects.len()); @@ -468,7 +485,9 @@ async fn retire_cold_objects_tx( cold_object.as_of_txid, ); let Some(live_value) = tx_get_value(tx, &live_key, Serializable).await? else { - return Ok(rejected_cold_object_retire("cold shard ref is already absent")); + return Ok(rejected_cold_object_retire( + "cold shard ref is already absent", + )); }; let live_ref = decode_cold_shard_ref(&live_value) .context("decode sqlite cold shard ref for cold retire")?; @@ -480,8 +499,13 @@ async fn retire_cold_objects_tx( input.database_branch_id, content_hash(cold_object.object_key.as_bytes()), ); - if tx_get_value(tx, &retired_key, Serializable).await?.is_some() { - return Ok(rejected_cold_object_retire("cold object is already retired")); + if tx_get_value(tx, &retired_key, Serializable) + .await? + .is_some() + { + return Ok(rejected_cold_object_retire( + "cold object is already retired", + )); } udb::compare_and_clear(tx, &live_key, &live_value); @@ -511,7 +535,8 @@ async fn retire_cold_objects_tx( }; tx.informal().set( &keys::branch_compaction_root_key(input.database_branch_id), - &encode_compaction_root(next_root).context("encode sqlite compaction root for cold retire")?, + &encode_compaction_root(next_root) + .context("encode sqlite compaction root for cold retire")?, ); #[cfg(feature = "test-faults")] if let Some(output) = @@ -619,7 +644,9 @@ async fn mark_retired_cold_objects_delete_issued_tx( content_hash(cold_object.object_key.as_bytes()), ); let Some(retired_value) = tx_get_value(tx, &retired_key, Serializable).await? else { - return Ok(rejected_cold_object_delete("retired cold object is missing")); + return Ok(rejected_cold_object_delete( + "retired cold object is missing", + )); }; let mut retired = decode_retired_cold_object(&retired_value) .context("decode sqlite retired cold object for S3 delete")?; @@ -627,7 +654,9 @@ async fn mark_retired_cold_objects_delete_issued_tx( return Ok(rejected_cold_object_delete("retired cold object changed")); } if retired.delete_after_ms > input.now_ms { - return Ok(rejected_cold_object_delete("retired cold object is still in grace window")); + return Ok(rejected_cold_object_delete( + "retired cold object is still in grace window", + )); } let live_key = keys::branch_compaction_cold_shard_key( @@ -642,7 +671,9 @@ async fn mark_retired_cold_objects_delete_issued_tx( publish_generation = cold_object.expected_publish_generation, "live cold ref exists for retired object before S3 delete" ); - return Ok(rejected_cold_object_delete("live cold ref exists for retired object")); + return Ok(rejected_cold_object_delete( + "live cold ref exists for retired object", + )); } if retired.delete_state == RetiredColdObjectDeleteState::Retired { @@ -709,9 +740,11 @@ async fn cleanup_retired_cold_objects_tx( ) -> Result { let mut cleaned = Vec::with_capacity(input.cold_objects.len()); #[cfg(feature = "test-faults")] - if let Some(output) = - cleanup_cold_fault_output(input.database_branch_id, ReclaimFaultPoint::BeforeCleanupRows) - .await? + if let Some(output) = cleanup_cold_fault_output( + input.database_branch_id, + ReclaimFaultPoint::BeforeCleanupRows, + ) + .await? { return Ok(output); } @@ -747,7 +780,9 @@ async fn cleanup_retired_cold_objects_tx( return Ok(rejected_cold_object_cleanup("retired cold object changed")); } if retired.delete_state != RetiredColdObjectDeleteState::DeleteIssued { - return Ok(rejected_cold_object_cleanup("retired cold object delete was not issued")); + return Ok(rejected_cold_object_cleanup( + "retired cold object delete was not issued", + )); } let completed = RetiredColdObject { @@ -875,10 +910,12 @@ async fn validate_reclaim_cold_objects_tx( )); } - if let Some(retired) = - read_retired_cold_object_by_object_key(tx, input.database_branch_id, &cold_object.object_key) - .await? - && retired.delete_state == RetiredColdObjectDeleteState::DeleteIssued + if let Some(retired) = read_retired_cold_object_by_object_key( + tx, + input.database_branch_id, + &cold_object.object_key, + ) + .await? && retired.delete_state == RetiredColdObjectDeleteState::DeleteIssued { tracing::error!( ?input.database_branch_id, @@ -941,7 +978,9 @@ pub async fn delete_orphan_cold_objects( } let Some(cold_tier) = workflow_cold_tier(ctx.config(), input.database_branch_id).await? else { - return Ok(rejected_orphan_cold_object_delete("cold storage is disabled")); + return Ok(rejected_orphan_cold_object_delete( + "cold storage is disabled", + )); }; #[cfg(feature = "test-faults")] if let Some(output) = delete_orphan_cold_fault_output( @@ -985,10 +1024,8 @@ pub(super) async fn plan_orphan_cold_object_deletes_tx( .map(decode_database_branch_record) .transpose() .context("decode sqlite database branch record for orphan cleanup")?; - if !branch_record_is_live_at_generation( - branch_record.as_ref(), - input.base_lifecycle_generation, - ) { + if !branch_record_is_live_at_generation(branch_record.as_ref(), input.base_lifecycle_generation) + { return Ok(rejected_orphan_cold_object_delete( "database branch lifecycle changed", )); @@ -1021,17 +1058,19 @@ pub(super) async fn plan_orphan_cold_object_deletes_tx( let mut delete_keys = Vec::new(); for orphan in &input.orphan_cold_objects { - let retired = - read_retired_cold_object_by_object_key(tx, input.database_branch_id, &orphan.object_key) - .await?; + let retired = read_retired_cold_object_by_object_key( + tx, + input.database_branch_id, + &orphan.object_key, + ) + .await?; let live_ref = live_refs .iter() .find(|live_ref| live_ref.object_key == orphan.object_key); if let Some(live_ref) = live_ref { - if retired - .as_ref() - .is_some_and(|retired| retired.delete_state == RetiredColdObjectDeleteState::DeleteIssued) - { + if retired.as_ref().is_some_and(|retired| { + retired.delete_state == RetiredColdObjectDeleteState::DeleteIssued + }) { tracing::error!( ?input.database_branch_id, manifest_generation, @@ -1074,9 +1113,7 @@ pub(super) async fn plan_orphan_cold_object_deletes_tx( }) } -fn rejected_orphan_cold_object_delete( - reason: impl Into, -) -> DeleteOrphanColdObjectsOutput { +fn rejected_orphan_cold_object_delete(reason: impl Into) -> DeleteOrphanColdObjectsOutput { DeleteOrphanColdObjectsOutput { status: CompactionJobStatus::Rejected { reason: reason.into(), diff --git a/engine/packages/depot/src/workflows/mod.rs b/engine/packages/depot/src/workflows/mod.rs index 2e7b229ce4..8399d655ef 100644 --- a/engine/packages/depot/src/workflows/mod.rs +++ b/engine/packages/depot/src/workflows/mod.rs @@ -11,10 +11,10 @@ pub mod compaction { pub use super::db_manager::*; pub use super::db_reclaimer::*; - #[cfg(debug_assertions)] - pub use crate::compaction::test_hooks; #[cfg(feature = "test-faults")] pub use crate::compaction::test_driver::*; + #[cfg(debug_assertions)] + pub use crate::compaction::test_hooks; } #[cfg(test)] @@ -23,6 +23,8 @@ use crate::compaction::shared::{ read_reclaim_input_snapshot, }; #[cfg(test)] +use compaction::*; +#[cfg(test)] use db_manager::{ ManagerEffect, manager_effect_for_requested_stop, manager_effects_after_refresh, manager_effects_for_cold_job_finished, manager_effects_for_hot_job_finished, @@ -30,8 +32,6 @@ use db_manager::{ }; #[cfg(test)] use db_reclaimer::{cleanup_repair_fdb_outputs_tx, plan_orphan_cold_object_deletes_tx}; -#[cfg(test)] -use compaction::*; #[cfg(test)] #[path = "../../tests/inline/workflows_compaction.rs"] diff --git a/engine/packages/depot/tests/burst_mode.rs b/engine/packages/depot/tests/burst_mode.rs index 36bf8fca16..23e43e7731 100644 --- a/engine/packages/depot/tests/burst_mode.rs +++ b/engine/packages/depot/tests/burst_mode.rs @@ -2,10 +2,11 @@ mod common; use anyhow::Result; use depot::{ - HOT_BURST_COLD_LAG_THRESHOLD_TXIDS, - burst_mode, - keys::{branch_compaction_root_key, branch_manifest_cold_drained_txid_key, branch_meta_head_key}, - types::{CompactionRoot, DatabaseBranchId, DBHead, encode_compaction_root, encode_db_head}, + HOT_BURST_COLD_LAG_THRESHOLD_TXIDS, burst_mode, + keys::{ + branch_compaction_root_key, branch_manifest_cold_drained_txid_key, branch_meta_head_key, + }, + types::{CompactionRoot, DBHead, DatabaseBranchId, encode_compaction_root, encode_db_head}, }; use universaldb::utils::IsolationLevel::Snapshot; @@ -22,66 +23,73 @@ fn head_with_branch(branch_id: DatabaseBranchId, head_txid: u64) -> DBHead { #[tokio::test] async fn burst_signal_is_derived_from_workflow_compaction_root() -> Result<()> { - common::test_matrix("depot-burst-mode", |_tier, ctx| Box::pin(async move { - let db = ctx.udb.clone(); - let branch_id = DatabaseBranchId::new_v4(); - db.run(move |tx| async move { - tx.informal().set( - &branch_meta_head_key(branch_id), - &encode_db_head(head_with_branch(branch_id, HOT_BURST_COLD_LAG_THRESHOLD_TXIDS))?, - ); - tx.informal().set( - &branch_compaction_root_key(branch_id), - &encode_compaction_root(CompactionRoot { - schema_version: 1, - manifest_generation: 1, - hot_watermark_txid: 0, - cold_watermark_txid: 0, - cold_watermark_versionstamp: [0; 16], - })?, - ); - tx.informal().set( - &branch_manifest_cold_drained_txid_key(branch_id), - &HOT_BURST_COLD_LAG_THRESHOLD_TXIDS.to_be_bytes(), - ); - Ok(()) - }) - .await?; - - let active = db - .run(move |tx| async move { - burst_mode::read_branch_signal(&tx, branch_id, Snapshot).await + common::test_matrix("depot-burst-mode", |_tier, ctx| { + Box::pin(async move { + let db = ctx.udb.clone(); + let branch_id = DatabaseBranchId::new_v4(); + db.run(move |tx| async move { + tx.informal().set( + &branch_meta_head_key(branch_id), + &encode_db_head(head_with_branch( + branch_id, + HOT_BURST_COLD_LAG_THRESHOLD_TXIDS, + ))?, + ); + tx.informal().set( + &branch_compaction_root_key(branch_id), + &encode_compaction_root(CompactionRoot { + schema_version: 1, + manifest_generation: 1, + hot_watermark_txid: 0, + cold_watermark_txid: 0, + cold_watermark_versionstamp: [0; 16], + })?, + ); + tx.informal().set( + &branch_manifest_cold_drained_txid_key(branch_id), + &HOT_BURST_COLD_LAG_THRESHOLD_TXIDS.to_be_bytes(), + ); + Ok(()) }) .await?; - assert!(active.active); - assert_eq!(active.lag_txids, HOT_BURST_COLD_LAG_THRESHOLD_TXIDS); - db.run(move |tx| async move { - tx.informal().set( - &branch_compaction_root_key(branch_id), - &encode_compaction_root(CompactionRoot { - schema_version: 1, - manifest_generation: 2, - hot_watermark_txid: HOT_BURST_COLD_LAG_THRESHOLD_TXIDS, - cold_watermark_txid: HOT_BURST_COLD_LAG_THRESHOLD_TXIDS, - cold_watermark_versionstamp: [1; 16], - })?, - ); - tx.informal() - .set(&branch_manifest_cold_drained_txid_key(branch_id), &0_u64.to_be_bytes()); - Ok(()) - }) - .await?; + let active = db + .run(move |tx| async move { + burst_mode::read_branch_signal(&tx, branch_id, Snapshot).await + }) + .await?; + assert!(active.active); + assert_eq!(active.lag_txids, HOT_BURST_COLD_LAG_THRESHOLD_TXIDS); - let recovered = db - .run(move |tx| async move { - burst_mode::read_branch_signal(&tx, branch_id, Snapshot).await + db.run(move |tx| async move { + tx.informal().set( + &branch_compaction_root_key(branch_id), + &encode_compaction_root(CompactionRoot { + schema_version: 1, + manifest_generation: 2, + hot_watermark_txid: HOT_BURST_COLD_LAG_THRESHOLD_TXIDS, + cold_watermark_txid: HOT_BURST_COLD_LAG_THRESHOLD_TXIDS, + cold_watermark_versionstamp: [1; 16], + })?, + ); + tx.informal().set( + &branch_manifest_cold_drained_txid_key(branch_id), + &0_u64.to_be_bytes(), + ); + Ok(()) }) .await?; - assert!(!recovered.active); - assert_eq!(recovered.lag_txids, 0); - Ok(()) - })) + let recovered = db + .run(move |tx| async move { + burst_mode::read_branch_signal(&tx, branch_id, Snapshot).await + }) + .await?; + assert!(!recovered.active); + assert_eq!(recovered.lag_txids, 0); + + Ok(()) + }) + }) .await } diff --git a/engine/packages/depot/tests/cold_tier.rs b/engine/packages/depot/tests/cold_tier.rs index eb7424a695..6c448c3b40 100644 --- a/engine/packages/depot/tests/cold_tier.rs +++ b/engine/packages/depot/tests/cold_tier.rs @@ -67,7 +67,11 @@ async fn faulty_tier_injects_operation_failures() -> Result<()> { .get(); tier.fail_operation(ColdTierOperation::Put, true); - assert!(tier.put_object("db/a/image/0001.ltx", b"image").await.is_err()); + assert!( + tier.put_object("db/a/image/0001.ltx", b"image") + .await + .is_err() + ); assert_eq!( metrics::SQLITE_S3_REQUEST_FAILURES_TOTAL .with_label_values(&[node_id, "put"]) @@ -121,7 +125,11 @@ async fn faulty_tier_controller_supports_operation_faults() -> Result<()> { controller.clone(), ); - assert!(tier.put_object("db/a/image/0001.ltx", b"image").await.is_err()); + assert!( + tier.put_object("db/a/image/0001.ltx", b"image") + .await + .is_err() + ); tier.put_object("db/a/image/0001.ltx", b"image").await?; assert_eq!(None, tier.get_object("db/a/image/0001.ltx").await?); assert_eq!( @@ -130,10 +138,11 @@ async fn faulty_tier_controller_supports_operation_faults() -> Result<()> { ); let listed = tier.list_prefix("db/a").await?; assert_eq!(listed.len(), 1); - assert!(tier - .delete_objects(&["db/a/image/0001.ltx".to_string()]) - .await - .is_err()); + assert!( + tier.delete_objects(&["db/a/image/0001.ltx".to_string()]) + .await + .is_err() + ); controller.assert_expected_fired()?; Ok(()) @@ -154,7 +163,11 @@ async fn faulty_tier_put_drop_artifact_writes_before_error() -> Result<()> { controller.clone(), ); - assert!(tier.put_object("db/a/image/0001.ltx", b"image").await.is_err()); + assert!( + tier.put_object("db/a/image/0001.ltx", b"image") + .await + .is_err() + ); assert_eq!( Some(b"image".to_vec()), tier.get_object("db/a/image/0001.ltx").await? @@ -168,7 +181,11 @@ async fn faulty_tier_put_drop_artifact_writes_before_error() -> Result<()> { async fn disabled_tier_fails_explicitly() -> Result<()> { let tier = DisabledColdTier; - assert!(tier.put_object("db/a/image/0001.ltx", b"image").await.is_err()); + assert!( + tier.put_object("db/a/image/0001.ltx", b"image") + .await + .is_err() + ); assert!(tier.get_object("db/a/image/0001.ltx").await.is_err()); assert!(tier.list_prefix("db/a").await.is_err()); diff --git a/engine/packages/depot/tests/common/mod.rs b/engine/packages/depot/tests/common/mod.rs index d56bbc6542..a5bf71fe18 100644 --- a/engine/packages/depot/tests/common/mod.rs +++ b/engine/packages/depot/tests/common/mod.rs @@ -19,12 +19,9 @@ pub async fn test_db(prefix: &str) -> Result { Ok(universaldb::Database::new(Arc::new(driver))) } -pub async fn test_db_with_dir( - prefix: &str, -) -> Result<(Arc, TempDir)> { +pub async fn test_db_with_dir(prefix: &str) -> Result<(Arc, TempDir)> { let dir = Builder::new().prefix(prefix).tempdir()?; - let driver = - universaldb::driver::RocksDbDatabaseDriver::new(dir.path().to_path_buf()).await?; + let driver = universaldb::driver::RocksDbDatabaseDriver::new(dir.path().to_path_buf()).await?; Ok((Arc::new(universaldb::Database::new(Arc::new(driver))), dir)) } @@ -67,11 +64,7 @@ pub struct TestDb { } impl TestDb { - pub fn make_db( - &self, - bucket_id: Id, - database_id: impl Into, - ) -> Db { + pub fn make_db(&self, bucket_id: Id, database_id: impl Into) -> Db { let database_id = database_id.into(); match &self.cold_tier { Some(cold_tier) => Db::new_with_cold_tier( @@ -138,10 +131,7 @@ where Ok(()) } -pub async fn read_value( - db: &universaldb::Database, - key: Vec, -) -> Result>> { +pub async fn read_value(db: &universaldb::Database, key: Vec) -> Result>> { db.run(move |tx| { let key = key.clone(); async move { diff --git a/engine/packages/depot/tests/compaction_fault_hooks.rs b/engine/packages/depot/tests/compaction_fault_hooks.rs index ea02389618..c1ca8e57d1 100644 --- a/engine/packages/depot/tests/compaction_fault_hooks.rs +++ b/engine/packages/depot/tests/compaction_fault_hooks.rs @@ -10,10 +10,10 @@ use depot::{ ColdCompactionFaultPoint, DepotFaultController, DepotFaultPoint, HotCompactionFaultPoint, ReclaimFaultPoint, }, - keys::{ - PAGE_SIZE, branch_compaction_cold_shard_key, - branch_compaction_retired_cold_object_key, branch_shard_key, - }, + keys::{ + PAGE_SIZE, branch_compaction_cold_shard_key, branch_compaction_retired_cold_object_key, + branch_shard_key, + }, types::{ BucketId, DatabaseBranchId, DirtyPage, RetiredColdObjectDeleteState, SnapshotSelector, decode_retired_cold_object, @@ -34,7 +34,9 @@ use uuid::Uuid; fn build_registry() -> Registry { let mut registry = Registry::new(); registry.register_workflow::().unwrap(); - registry.register_workflow::().unwrap(); + registry + .register_workflow::() + .unwrap(); registry .register_workflow::() .unwrap(); @@ -89,13 +91,11 @@ async fn test_ctx_with_cold_tier(root: &Path) -> Result { let mut test_deps = TestDeps::new().await?; let mut config_root = (**test_deps.config()).clone(); config_root.sqlite = Some(rivet_config::config::Sqlite { - workflow_cold_storage: Some( - rivet_config::config::SqliteWorkflowColdStorage::FileSystem( - rivet_config::config::SqliteWorkflowColdStorageFileSystem { - root: root.display().to_string(), - }, - ), - ), + workflow_cold_storage: Some(rivet_config::config::SqliteWorkflowColdStorage::FileSystem( + rivet_config::config::SqliteWorkflowColdStorageFileSystem { + root: root.display().to_string(), + }, + )), }); test_deps.config = rivet_config::Config::from_root(config_root); TestCtx::new_with_deps(build_registry(), test_deps).await @@ -145,7 +145,11 @@ async fn start_timer_disabled_manager( database_branch_id: DatabaseBranchId, ) -> Result { DepotCompactionTestDriver::new(test_ctx) - .start_manager(database_branch_id, Some("compaction-fault-test".to_string()), true) + .start_manager( + database_branch_id, + Some("compaction-fault-test".to_string()), + true, + ) .await } @@ -281,7 +285,11 @@ async fn cold_upload_succeeds_before_publish_fault() -> Result<()> { ) .await?; - assert!(result.attempted_job_kinds.contains(&CompactionJobKind::Cold)); + assert!( + result + .attempted_job_kinds + .contains(&CompactionJobKind::Cold) + ); assert!(!result.completed_job_ids.is_empty()); assert!( result @@ -290,9 +298,12 @@ async fn cold_upload_succeeds_before_publish_fault() -> Result<()> { .is_some_and(|err| err.contains("cold publish failed after upload")) ); assert!( - read_value(&test_ctx, branch_compaction_cold_shard_key(database_branch_id, 0, 1)) - .await? - .is_some() + read_value( + &test_ctx, + branch_compaction_cold_shard_key(database_branch_id, 0, 1) + ) + .await? + .is_some() ); controller.assert_expected_fired()?; db.delete_restore_point(restore_point).await?; @@ -315,7 +326,8 @@ async fn forced_reclaim_deletes_retired_cold_object_with_short_grace() -> Result let database_branch_id = read_database_branch_id(&test_ctx, database_id).await?; let manager_workflow_id = start_timer_disabled_manager(&test_ctx, database_branch_id).await?; let driver = DepotCompactionTestDriver::new(&test_ctx); - let _grace_guard = test_hooks::override_cold_object_delete_grace_for_test(database_branch_id, 0); + let _grace_guard = + test_hooks::override_cold_object_delete_grace_for_test(database_branch_id, 0); driver .force_compaction( @@ -341,12 +353,15 @@ async fn forced_reclaim_deletes_retired_cold_object_with_short_grace() -> Result }, ) .await?; - let old_ref = read_value(&test_ctx, branch_compaction_cold_shard_key(database_branch_id, 0, 1)) - .await? - .as_deref() - .map(depot::types::decode_cold_shard_ref) - .transpose()? - .context("old cold ref should exist")?; + let old_ref = read_value( + &test_ctx, + branch_compaction_cold_shard_key(database_branch_id, 0, 1), + ) + .await? + .as_deref() + .map(depot::types::decode_cold_shard_ref) + .transpose()? + .context("old cold ref should exist")?; db.delete_restore_point(old_restore_point).await?; db.commit(vec![dirty_page(1, 0x52)], 2, 1_002).await?; @@ -372,8 +387,16 @@ async fn forced_reclaim_deletes_retired_cold_object_with_short_grace() -> Result ) .await?; - assert!(result.attempted_job_kinds.contains(&CompactionJobKind::Cold)); - assert!(result.attempted_job_kinds.contains(&CompactionJobKind::Reclaim)); + assert!( + result + .attempted_job_kinds + .contains(&CompactionJobKind::Cold) + ); + assert!( + result + .attempted_job_kinds + .contains(&CompactionJobKind::Reclaim) + ); assert!(result.terminal_error.is_none()); assert!(tier.get_object(&old_ref.object_key).await?.is_none()); let retired = read_value( diff --git a/engine/packages/depot/tests/conveyer_branch.rs b/engine/packages/depot/tests/conveyer_branch.rs index b27fab89ed..c3b5e3845a 100644 --- a/engine/packages/depot/tests/conveyer_branch.rs +++ b/engine/packages/depot/tests/conveyer_branch.rs @@ -1,30 +1,29 @@ mod common; use anyhow::Result; -use futures_util::TryStreamExt; -use gas::prelude::Id; use depot::{ + conveyer::{branch, history_pin}, keys::{ - database_pointer_cur_key, database_pointer_history_prefix, branch_commit_key, - branch_meta_head_at_fork_key, branch_meta_head_key, branches_restore_point_pin_key, - branches_desc_pin_key, branches_list_key, branches_refcount_key, - branch_shard_key, branch_vtx_key, db_pin_key, - bucket_branches_database_name_tombstone_key, bucket_branches_restore_point_pin_key, - bucket_branches_desc_pin_key, bucket_branches_list_key, - bucket_branches_refcount_key, bucket_pointer_cur_key, - bucket_pointer_history_prefix, + branch_commit_key, branch_meta_head_at_fork_key, branch_meta_head_key, branch_shard_key, + branch_vtx_key, branches_desc_pin_key, branches_list_key, branches_refcount_key, + branches_restore_point_pin_key, bucket_branches_database_name_tombstone_key, + bucket_branches_desc_pin_key, bucket_branches_list_key, bucket_branches_refcount_key, + bucket_branches_restore_point_pin_key, bucket_pointer_cur_key, + bucket_pointer_history_prefix, database_pointer_cur_key, database_pointer_history_prefix, + db_pin_key, }, ltx::{LtxHeader, encode_ltx_v3}, - conveyer::{branch, history_pin}, types::{ - DatabaseBranchId, DatabaseBranchRecord, BranchState, CommitRow, DBHead, DbHistoryPinKind, DirtyPage, - BucketBranchId, BucketBranchRecord, BucketId, ResolvedVersionstamp, - decode_database_branch_record, decode_database_pointer, decode_commit_row, decode_db_head, - decode_db_history_pin, - decode_bucket_branch_record, decode_bucket_pointer, encode_commit_row, - encode_database_branch_record, encode_bucket_branch_record, + BranchState, BucketBranchId, BucketBranchRecord, BucketId, CommitRow, DBHead, + DatabaseBranchId, DatabaseBranchRecord, DbHistoryPinKind, DirtyPage, ResolvedVersionstamp, + decode_bucket_branch_record, decode_bucket_pointer, decode_commit_row, + decode_database_branch_record, decode_database_pointer, decode_db_head, + decode_db_history_pin, encode_bucket_branch_record, encode_commit_row, + encode_database_branch_record, }, }; +use futures_util::TryStreamExt; +use gas::prelude::Id; use universaldb::{ RangeOption, options::{MutationType, StreamingMode}, @@ -81,16 +80,12 @@ async fn clear_value(db: &universaldb::Database, key: Vec) -> Result<()> { .await } -async fn read_prefix_values( - db: &universaldb::Database, - prefix: Vec, -) -> Result>> { +async fn read_prefix_values(db: &universaldb::Database, prefix: Vec) -> Result>> { db.run(move |tx| { let prefix = prefix.clone(); async move { - let prefix_subspace = universaldb::Subspace::from( - universaldb::tuple::Subspace::from_bytes(prefix), - ); + let prefix_subspace = + universaldb::Subspace::from(universaldb::tuple::Subspace::from_bytes(prefix)); let informal = tx.informal(); let mut stream = informal.get_ranges_keyvalues( RangeOption { @@ -210,21 +205,25 @@ async fn read_bucket_pin( macro_rules! branch_matrix { ($prefix:expr, |$ctx:ident, $db:ident, $database_db:ident| $body:block) => { - common::test_matrix($prefix, |_tier, $ctx| Box::pin(async move { - let $db = $ctx.udb.clone(); - let $database_db = $ctx.make_db(test_bucket(), TEST_DATABASE); - $body - })) + common::test_matrix($prefix, |_tier, $ctx| { + Box::pin(async move { + let $db = $ctx.udb.clone(); + let $database_db = $ctx.make_db(test_bucket(), TEST_DATABASE); + $body + }) + }) .await }; } macro_rules! udb_matrix { ($prefix:expr, |$ctx:ident, $db:ident| $body:block) => { - common::test_matrix($prefix, |_tier, $ctx| Box::pin(async move { - let $db = $ctx.udb.clone(); - $body - })) + common::test_matrix($prefix, |_tier, $ctx| { + Box::pin(async move { + let $db = $ctx.udb.clone(); + $body + }) + }) .await }; } @@ -387,8 +386,10 @@ async fn derive_branch_at_rejects_versionstamp_below_parent_gc_floor() -> Result &branches_refcount_key(source_branch_id), &1_i64.to_le_bytes(), ); - tx.informal() - .set(&branch_vtx_key(source_branch_id, old_versionstamp), &1_u64.to_be_bytes()); + tx.informal().set( + &branch_vtx_key(source_branch_id, old_versionstamp), + &1_u64.to_be_bytes(), + ); tx.informal().set( &branch_commit_key(source_branch_id, 1), &encode_commit_row(CommitRow { @@ -581,12 +582,8 @@ async fn derive_bucket_branch_at_enforces_max_bucket_depth() -> Result<()> { let source_branch_id = BucketBranchId::from_uuid(uuid::Uuid::from_u128(0xbbbb)); let new_branch_id = BucketBranchId::from_uuid(uuid::Uuid::from_u128(0xcccc)); - seed_bucket_branch_record( - &db, - source_branch_id, - depot::constants::MAX_BUCKET_DEPTH, - ) - .await?; + seed_bucket_branch_record(&db, source_branch_id, depot::constants::MAX_BUCKET_DEPTH) + .await?; let err = db .run(move |tx| async move { @@ -631,7 +628,8 @@ async fn fork_bucket_writes_pointer_and_metadata_without_eager_aptr() -> Result< ResolvedVersionstamp { versionstamp: source_commit.versionstamp, restore_point: None, - }) + }, + ) .await?; assert_ne!(forked_bucket_id, source_bucket_id); @@ -710,43 +708,52 @@ async fn fork_bucket_enforces_max_bucket_depth() -> Result<()> { #[tokio::test] async fn resolve_database_pointer_walks_bucket_parent_chain_after_fork() -> Result<()> { - branch_matrix!("depot-branch-resolve-bucket-parent", |ctx, db, database_db| { - database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; - let source_database_branch = read_branch_id(&db).await?; - let source_commit_bytes = read_value(&db, branch_commit_key(source_database_branch, 1)) - .await? - .expect("source commit row should exist"); - let source_commit = decode_commit_row(&source_commit_bytes)?; - - let forked_bucket_id = branch::fork_bucket( - &db, - BucketId::from_gas_id(test_bucket()), - ResolvedVersionstamp { - versionstamp: source_commit.versionstamp, - restore_point: None, - }) - .await?; - let pointer_bytes = read_value(&db, bucket_pointer_cur_key(forked_bucket_id)) - .await? - .expect("forked bucket pointer should exist"); - let forked_pointer = decode_bucket_pointer(&pointer_bytes)?; + branch_matrix!( + "depot-branch-resolve-bucket-parent", + |ctx, db, database_db| { + database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; + let source_database_branch = read_branch_id(&db).await?; + let source_commit_bytes = read_value(&db, branch_commit_key(source_database_branch, 1)) + .await? + .expect("source commit row should exist"); + let source_commit = decode_commit_row(&source_commit_bytes)?; - let resolved_pointer = db - .run(move |tx| async move { - branch::resolve_database_pointer(&tx, forked_pointer.current_branch, TEST_DATABASE, Snapshot) + let forked_bucket_id = branch::fork_bucket( + &db, + BucketId::from_gas_id(test_bucket()), + ResolvedVersionstamp { + versionstamp: source_commit.versionstamp, + restore_point: None, + }, + ) + .await?; + let pointer_bytes = read_value(&db, bucket_pointer_cur_key(forked_bucket_id)) + .await? + .expect("forked bucket pointer should exist"); + let forked_pointer = decode_bucket_pointer(&pointer_bytes)?; + + let resolved_pointer = db + .run(move |tx| async move { + branch::resolve_database_pointer( + &tx, + forked_pointer.current_branch, + TEST_DATABASE, + Snapshot, + ) .await - }) - .await? - .expect("database pointer should be inherited from parent bucket branch"); - assert_eq!(resolved_pointer.current_branch, source_database_branch); + }) + .await? + .expect("database pointer should be inherited from parent bucket branch"); + assert_eq!(resolved_pointer.current_branch, source_database_branch); - let forked_database_db = - ctx.make_db(Id::v1(forked_bucket_id.as_uuid(), 1), TEST_DATABASE); - let pages = forked_database_db.get_pages(vec![1]).await?; - assert_eq!(pages[0].bytes, Some(page_bytes(0x11))); + let forked_database_db = + ctx.make_db(Id::v1(forked_bucket_id.as_uuid(), 1), TEST_DATABASE); + let pages = forked_database_db.get_pages(vec![1]).await?; + assert_eq!(pages[0].bytes, Some(page_bytes(0x11))); - Ok(()) - }) + Ok(()) + } + ) } #[tokio::test] @@ -765,7 +772,8 @@ async fn resolve_database_pointer_honors_bucket_branch_tombstones() -> Result<() ResolvedVersionstamp { versionstamp: source_commit.versionstamp, restore_point: None, - }) + }, + ) .await?; let pointer_bytes = read_value(&db, bucket_pointer_cur_key(forked_bucket_id)) .await? @@ -784,7 +792,8 @@ async fn resolve_database_pointer_honors_bucket_branch_tombstones() -> Result<() let err = db .run(move |tx| async move { - branch::resolve_database_pointer(&tx, forked_bucket_branch, TEST_DATABASE, Snapshot).await + branch::resolve_database_pointer(&tx, forked_bucket_branch, TEST_DATABASE, Snapshot) + .await }) .await .expect_err("tombstone should block inherited database pointer"); @@ -796,8 +805,7 @@ async fn resolve_database_pointer_honors_bucket_branch_tombstones() -> Result<() )) ); - let forked_database_db = - ctx.make_db(Id::v1(forked_bucket_id.as_uuid(), 1), TEST_DATABASE); + let forked_database_db = ctx.make_db(Id::v1(forked_bucket_id.as_uuid(), 1), TEST_DATABASE); let err = forked_database_db .get_pages(vec![1]) .await @@ -816,273 +824,313 @@ async fn resolve_database_pointer_honors_bucket_branch_tombstones() -> Result<() #[tokio::test] async fn fork_database_writes_target_pointer_and_reads_source_data() -> Result<()> { - branch_matrix!("depot-branch-fork-database", |ctx, db, source_database_db| { - source_database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; - let source_branch_id = read_branch_id(&db).await?; - let source_commit_bytes = read_value(&db, branch_commit_key(source_branch_id, 1)) - .await? - .expect("source commit row should exist"); - let source_commit = decode_commit_row(&source_commit_bytes)?; + branch_matrix!( + "depot-branch-fork-database", + |ctx, db, source_database_db| { + source_database_db + .commit(vec![page(1, 0x11)], 2, 1_000) + .await?; + let source_branch_id = read_branch_id(&db).await?; + let source_commit_bytes = read_value(&db, branch_commit_key(source_branch_id, 1)) + .await? + .expect("source commit row should exist"); + let source_commit = decode_commit_row(&source_commit_bytes)?; - let target_seed = ctx.make_db(target_bucket(), "target-seed"); - target_seed.commit(vec![page(1, 0xaa)], 1, 1_100).await?; - let target_bucket_branch = read_bucket_branch_id_for(&db, target_bucket()).await?; + let target_seed = ctx.make_db(target_bucket(), "target-seed"); + target_seed.commit(vec![page(1, 0xaa)], 1, 1_100).await?; + let target_bucket_branch = read_bucket_branch_id_for(&db, target_bucket()).await?; - let forked_database_id = branch::fork_database( - &db, - BucketId::from_gas_id(test_bucket()), - TEST_DATABASE.to_string(), - ResolvedVersionstamp { - versionstamp: source_commit.versionstamp, - restore_point: None, - }, - BucketId::from_gas_id(target_bucket())) - .await?; + let forked_database_id = branch::fork_database( + &db, + BucketId::from_gas_id(test_bucket()), + TEST_DATABASE.to_string(), + ResolvedVersionstamp { + versionstamp: source_commit.versionstamp, + restore_point: None, + }, + BucketId::from_gas_id(target_bucket()), + ) + .await?; - assert_ne!(forked_database_id, TEST_DATABASE); - let forked_branch_id = read_database_branch_id(&db, target_bucket(), &forked_database_id).await?; - let forked_record_bytes = read_value(&db, branches_list_key(forked_branch_id)) - .await? - .expect("forked database branch record should exist"); - let forked_record = decode_database_branch_record(&forked_record_bytes)?; - assert_eq!(forked_record.bucket_branch, target_bucket_branch); - assert_eq!(forked_record.parent, Some(source_branch_id)); - assert_eq!( - forked_record.parent_versionstamp, - Some(source_commit.versionstamp) - ); - assert_eq!(read_refcount(&db, source_branch_id).await?, 2); - assert_eq!(read_refcount(&db, forked_branch_id).await?, 1); + assert_ne!(forked_database_id, TEST_DATABASE); + let forked_branch_id = + read_database_branch_id(&db, target_bucket(), &forked_database_id).await?; + let forked_record_bytes = read_value(&db, branches_list_key(forked_branch_id)) + .await? + .expect("forked database branch record should exist"); + let forked_record = decode_database_branch_record(&forked_record_bytes)?; + assert_eq!(forked_record.bucket_branch, target_bucket_branch); + assert_eq!(forked_record.parent, Some(source_branch_id)); + assert_eq!( + forked_record.parent_versionstamp, + Some(source_commit.versionstamp) + ); + assert_eq!(read_refcount(&db, source_branch_id).await?, 2); + assert_eq!(read_refcount(&db, forked_branch_id).await?, 1); - let forked_database_db = ctx.make_db(target_bucket(), forked_database_id); - let pages = forked_database_db.get_pages(vec![1]).await?; - assert_eq!(pages[0].bytes, Some(page_bytes(0x11))); + let forked_database_db = ctx.make_db(target_bucket(), forked_database_id); + let pages = forked_database_db.get_pages(vec![1]).await?; + assert_eq!(pages[0].bytes, Some(page_bytes(0x11))); - Ok(()) - }) + Ok(()) + } + ) } #[tokio::test] async fn fresh_fork_uses_head_at_fork_until_first_commit() -> Result<()> { - branch_matrix!("depot-branch-fresh-fork-head", |ctx, db, source_database_db| { - source_database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; - let source_branch_id = read_branch_id(&db).await?; - let source_commit = decode_commit_row( - &read_value(&db, branch_commit_key(source_branch_id, 1)) - .await? - .expect("source commit row should exist"), - )?; - - let forked_database_id = branch::fork_database( - &db, - BucketId::from_gas_id(test_bucket()), - TEST_DATABASE.to_string(), - ResolvedVersionstamp { - versionstamp: source_commit.versionstamp, - restore_point: None, - }, - BucketId::from_gas_id(test_bucket())) - .await?; - let forked_branch_id = - read_database_branch_id(&db, test_bucket(), &forked_database_id).await?; - assert!( - read_value(&db, branch_meta_head_key(forked_branch_id)) - .await? - .is_none() - ); - let head_at_fork = decode_db_head( - &read_value(&db, branch_meta_head_at_fork_key(forked_branch_id)) - .await? - .expect("forked branch head_at_fork should exist"), - )?; - assert_eq!(head_at_fork.head_txid, 1); - assert_eq!(head_at_fork.db_size_pages, 2); - - let forked_database_db = ctx.make_db(test_bucket(), forked_database_id); - let pages = forked_database_db.get_pages(vec![1]).await?; - assert_eq!(pages[0].bytes, Some(page_bytes(0x11))); + branch_matrix!( + "depot-branch-fresh-fork-head", + |ctx, db, source_database_db| { + source_database_db + .commit(vec![page(1, 0x11)], 2, 1_000) + .await?; + let source_branch_id = read_branch_id(&db).await?; + let source_commit = decode_commit_row( + &read_value(&db, branch_commit_key(source_branch_id, 1)) + .await? + .expect("source commit row should exist"), + )?; - forked_database_db - .commit(vec![page(2, 0x22)], 3, 2_000) + let forked_database_id = branch::fork_database( + &db, + BucketId::from_gas_id(test_bucket()), + TEST_DATABASE.to_string(), + ResolvedVersionstamp { + versionstamp: source_commit.versionstamp, + restore_point: None, + }, + BucketId::from_gas_id(test_bucket()), + ) .await?; - assert!( - read_value(&db, branch_meta_head_at_fork_key(forked_branch_id)) - .await? - .is_none() - ); - let forked_head = read_branch_head(&db, forked_branch_id).await?; - assert_eq!(forked_head.head_txid, 2); - assert_eq!(forked_head.db_size_pages, 3); - assert!( - read_value(&db, branch_commit_key(forked_branch_id, 2)) - .await? - .is_some() - ); + let forked_branch_id = + read_database_branch_id(&db, test_bucket(), &forked_database_id).await?; + assert!( + read_value(&db, branch_meta_head_key(forked_branch_id)) + .await? + .is_none() + ); + let head_at_fork = decode_db_head( + &read_value(&db, branch_meta_head_at_fork_key(forked_branch_id)) + .await? + .expect("forked branch head_at_fork should exist"), + )?; + assert_eq!(head_at_fork.head_txid, 1); + assert_eq!(head_at_fork.db_size_pages, 2); + + let forked_database_db = ctx.make_db(test_bucket(), forked_database_id); + let pages = forked_database_db.get_pages(vec![1]).await?; + assert_eq!(pages[0].bytes, Some(page_bytes(0x11))); - let pages = forked_database_db.get_pages(vec![1, 2]).await?; - assert_eq!(pages[0].bytes, Some(page_bytes(0x11))); - assert_eq!(pages[1].bytes, Some(page_bytes(0x22))); + forked_database_db + .commit(vec![page(2, 0x22)], 3, 2_000) + .await?; + assert!( + read_value(&db, branch_meta_head_at_fork_key(forked_branch_id)) + .await? + .is_none() + ); + let forked_head = read_branch_head(&db, forked_branch_id).await?; + assert_eq!(forked_head.head_txid, 2); + assert_eq!(forked_head.db_size_pages, 3); + assert!( + read_value(&db, branch_commit_key(forked_branch_id, 2)) + .await? + .is_some() + ); - Ok(()) - }) + let pages = forked_database_db.get_pages(vec![1, 2]).await?; + assert_eq!(pages[0].bytes, Some(page_bytes(0x11))); + assert_eq!(pages[1].bytes, Some(page_bytes(0x22))); + + Ok(()) + } + ) } #[tokio::test] async fn fork_database_reads_parent_shard_when_parent_pidx_is_newer_than_fork() -> Result<()> { - branch_matrix!("depot-branch-parent-shard-newer-pidx", |ctx, db, source_database_db| { - source_database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; - let source_branch_id = read_branch_id(&db).await?; - let source_commit_bytes = read_value(&db, branch_commit_key(source_branch_id, 1)) - .await? - .expect("source commit row should exist"); - let source_commit = decode_commit_row(&source_commit_bytes)?; - - db.run(move |tx| async move { - tx.informal().set( - &branch_shard_key(source_branch_id, 0, 1), - &encoded_blob(1, vec![page(1, 0x11)])?, - ); - Ok(()) - }) - .await?; + branch_matrix!( + "depot-branch-parent-shard-newer-pidx", + |ctx, db, source_database_db| { + source_database_db + .commit(vec![page(1, 0x11)], 2, 1_000) + .await?; + let source_branch_id = read_branch_id(&db).await?; + let source_commit_bytes = read_value(&db, branch_commit_key(source_branch_id, 1)) + .await? + .expect("source commit row should exist"); + let source_commit = decode_commit_row(&source_commit_bytes)?; + + db.run(move |tx| async move { + tx.informal().set( + &branch_shard_key(source_branch_id, 0, 1), + &encoded_blob(1, vec![page(1, 0x11)])?, + ); + Ok(()) + }) + .await?; - let forked_database_id = branch::fork_database( - &db, - BucketId::from_gas_id(test_bucket()), - TEST_DATABASE.to_string(), - ResolvedVersionstamp { - versionstamp: source_commit.versionstamp, - restore_point: None, - }, - BucketId::from_gas_id(test_bucket())) - .await?; + let forked_database_id = branch::fork_database( + &db, + BucketId::from_gas_id(test_bucket()), + TEST_DATABASE.to_string(), + ResolvedVersionstamp { + versionstamp: source_commit.versionstamp, + restore_point: None, + }, + BucketId::from_gas_id(test_bucket()), + ) + .await?; - source_database_db.commit(vec![page(1, 0x22)], 2, 2_000).await?; + source_database_db + .commit(vec![page(1, 0x22)], 2, 2_000) + .await?; - let forked_database_db = ctx.make_db(test_bucket(), forked_database_id); - let pages = forked_database_db.get_pages(vec![1]).await?; - assert_eq!(pages[0].bytes, Some(page_bytes(0x11))); + let forked_database_db = ctx.make_db(test_bucket(), forked_database_id); + let pages = forked_database_db.get_pages(vec![1]).await?; + assert_eq!(pages[0].bytes, Some(page_bytes(0x11))); - Ok(()) - }) + Ok(()) + } + ) } #[tokio::test] async fn fork_database_can_use_depth_one_source_branch() -> Result<()> { - branch_matrix!("depot-branch-depth-one-source", |ctx, db, root_database_db| { - root_database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; - let root_branch_id = read_branch_id(&db).await?; - let root_commit_bytes = read_value(&db, branch_commit_key(root_branch_id, 1)) - .await? - .expect("root commit row should exist"); - let root_commit = decode_commit_row(&root_commit_bytes)?; + branch_matrix!( + "depot-branch-depth-one-source", + |ctx, db, root_database_db| { + root_database_db + .commit(vec![page(1, 0x11)], 2, 1_000) + .await?; + let root_branch_id = read_branch_id(&db).await?; + let root_commit_bytes = read_value(&db, branch_commit_key(root_branch_id, 1)) + .await? + .expect("root commit row should exist"); + let root_commit = decode_commit_row(&root_commit_bytes)?; - let depth_one_database_id = branch::fork_database( - &db, - BucketId::from_gas_id(test_bucket()), - TEST_DATABASE.to_string(), - ResolvedVersionstamp { - versionstamp: root_commit.versionstamp, - restore_point: None, - }, - BucketId::from_gas_id(test_bucket())) - .await?; - let depth_one_database_db = ctx.make_db(test_bucket(), depth_one_database_id.clone()); - depth_one_database_db - .commit(vec![page(2, 0x22)], 3, 2_000) + let depth_one_database_id = branch::fork_database( + &db, + BucketId::from_gas_id(test_bucket()), + TEST_DATABASE.to_string(), + ResolvedVersionstamp { + versionstamp: root_commit.versionstamp, + restore_point: None, + }, + BucketId::from_gas_id(test_bucket()), + ) .await?; - let depth_one_branch_id = - read_database_branch_id(&db, test_bucket(), &depth_one_database_id).await?; - let depth_one_commit = read_head_commit(&db, depth_one_branch_id).await?; + let depth_one_database_db = ctx.make_db(test_bucket(), depth_one_database_id.clone()); + depth_one_database_db + .commit(vec![page(2, 0x22)], 3, 2_000) + .await?; + let depth_one_branch_id = + read_database_branch_id(&db, test_bucket(), &depth_one_database_id).await?; + let depth_one_commit = read_head_commit(&db, depth_one_branch_id).await?; - let depth_two_database_id = branch::fork_database( - &db, - BucketId::from_gas_id(test_bucket()), - depth_one_database_id, - ResolvedVersionstamp { - versionstamp: depth_one_commit.versionstamp, - restore_point: None, - }, - BucketId::from_gas_id(test_bucket())) - .await?; - let depth_two_branch_id = - read_database_branch_id(&db, test_bucket(), &depth_two_database_id).await?; - let depth_two_record_bytes = read_value(&db, branches_list_key(depth_two_branch_id)) - .await? - .expect("depth-two branch record should exist"); - let depth_two_record = decode_database_branch_record(&depth_two_record_bytes)?; - assert_eq!(depth_two_record.parent, Some(depth_one_branch_id)); - assert_eq!(depth_two_record.fork_depth, 2); + let depth_two_database_id = branch::fork_database( + &db, + BucketId::from_gas_id(test_bucket()), + depth_one_database_id, + ResolvedVersionstamp { + versionstamp: depth_one_commit.versionstamp, + restore_point: None, + }, + BucketId::from_gas_id(test_bucket()), + ) + .await?; + let depth_two_branch_id = + read_database_branch_id(&db, test_bucket(), &depth_two_database_id).await?; + let depth_two_record_bytes = read_value(&db, branches_list_key(depth_two_branch_id)) + .await? + .expect("depth-two branch record should exist"); + let depth_two_record = decode_database_branch_record(&depth_two_record_bytes)?; + assert_eq!(depth_two_record.parent, Some(depth_one_branch_id)); + assert_eq!(depth_two_record.fork_depth, 2); - let depth_two_database_db = ctx.make_db(test_bucket(), depth_two_database_id); - let pages = depth_two_database_db.get_pages(vec![1, 2]).await?; - assert_eq!(pages[0].bytes, Some(page_bytes(0x11))); - assert_eq!(pages[1].bytes, Some(page_bytes(0x22))); + let depth_two_database_db = ctx.make_db(test_bucket(), depth_two_database_id); + let pages = depth_two_database_db.get_pages(vec![1, 2]).await?; + assert_eq!(pages[0].bytes, Some(page_bytes(0x11))); + assert_eq!(pages[1].bytes, Some(page_bytes(0x22))); - Ok(()) - }) + Ok(()) + } + ) } #[tokio::test] async fn database_db_reuses_cached_branch_ancestry_for_reads() -> Result<()> { - branch_matrix!("depot-branch-cached-ancestry", |ctx, db, root_database_db| { - root_database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; - let root_branch_id = read_branch_id(&db).await?; - let root_commit = decode_commit_row( - &read_value(&db, branch_commit_key(root_branch_id, 1)) - .await? - .expect("root commit row should exist"), - )?; - - let child_database_id = branch::fork_database( - &db, - BucketId::from_gas_id(test_bucket()), - TEST_DATABASE.to_string(), - ResolvedVersionstamp { - versionstamp: root_commit.versionstamp, - restore_point: None, - }, - BucketId::from_gas_id(test_bucket())) - .await?; - let child_database_db = ctx.make_db(test_bucket(), child_database_id.clone()); - child_database_db.commit(vec![page(2, 0x22)], 3, 2_000).await?; - let child_branch_id = read_database_branch_id(&db, test_bucket(), &child_database_id).await?; - let child_commit = read_head_commit(&db, child_branch_id).await?; + branch_matrix!( + "depot-branch-cached-ancestry", + |ctx, db, root_database_db| { + root_database_db + .commit(vec![page(1, 0x11)], 2, 1_000) + .await?; + let root_branch_id = read_branch_id(&db).await?; + let root_commit = decode_commit_row( + &read_value(&db, branch_commit_key(root_branch_id, 1)) + .await? + .expect("root commit row should exist"), + )?; + + let child_database_id = branch::fork_database( + &db, + BucketId::from_gas_id(test_bucket()), + TEST_DATABASE.to_string(), + ResolvedVersionstamp { + versionstamp: root_commit.versionstamp, + restore_point: None, + }, + BucketId::from_gas_id(test_bucket()), + ) + .await?; + let child_database_db = ctx.make_db(test_bucket(), child_database_id.clone()); + child_database_db + .commit(vec![page(2, 0x22)], 3, 2_000) + .await?; + let child_branch_id = + read_database_branch_id(&db, test_bucket(), &child_database_id).await?; + let child_commit = read_head_commit(&db, child_branch_id).await?; - let grandchild_database_id = branch::fork_database( - &db, - BucketId::from_gas_id(test_bucket()), - child_database_id, - ResolvedVersionstamp { - versionstamp: child_commit.versionstamp, - restore_point: None, - }, - BucketId::from_gas_id(test_bucket())) - .await?; - let grandchild_branch_id = - read_database_branch_id(&db, test_bucket(), &grandchild_database_id).await?; - let grandchild_database_db = ctx.make_db(test_bucket(), grandchild_database_id); + let grandchild_database_id = branch::fork_database( + &db, + BucketId::from_gas_id(test_bucket()), + child_database_id, + ResolvedVersionstamp { + versionstamp: child_commit.versionstamp, + restore_point: None, + }, + BucketId::from_gas_id(test_bucket()), + ) + .await?; + let grandchild_branch_id = + read_database_branch_id(&db, test_bucket(), &grandchild_database_id).await?; + let grandchild_database_db = ctx.make_db(test_bucket(), grandchild_database_id); - let pages = grandchild_database_db.get_pages(vec![1, 2]).await?; - assert_eq!(pages[0].bytes, Some(page_bytes(0x11))); - assert_eq!(pages[1].bytes, Some(page_bytes(0x22))); + let pages = grandchild_database_db.get_pages(vec![1, 2]).await?; + assert_eq!(pages[0].bytes, Some(page_bytes(0x11))); + assert_eq!(pages[1].bytes, Some(page_bytes(0x22))); - clear_value(&db, branches_list_key(grandchild_branch_id)).await?; - clear_value(&db, branches_list_key(child_branch_id)).await?; - clear_value(&db, branches_list_key(root_branch_id)).await?; + clear_value(&db, branches_list_key(grandchild_branch_id)).await?; + clear_value(&db, branches_list_key(child_branch_id)).await?; + clear_value(&db, branches_list_key(root_branch_id)).await?; - let pages = grandchild_database_db.get_pages(vec![1, 2]).await?; - assert_eq!(pages[0].bytes, Some(page_bytes(0x11))); - assert_eq!(pages[1].bytes, Some(page_bytes(0x22))); + let pages = grandchild_database_db.get_pages(vec![1, 2]).await?; + assert_eq!(pages[0].bytes, Some(page_bytes(0x11))); + assert_eq!(pages[1].bytes, Some(page_bytes(0x22))); - Ok(()) - }) + Ok(()) + } + ) } #[tokio::test] async fn fork_database_can_use_deep_source_branch() -> Result<()> { branch_matrix!("depot-branch-deep-source", |ctx, db, root_database_db| { - root_database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; + root_database_db + .commit(vec![page(1, 0x11)], 2, 1_000) + .await?; let mut source_database_id = TEST_DATABASE.to_string(); let mut source_branch_id = read_branch_id(&db).await?; let mut source_commit = decode_commit_row( @@ -1100,14 +1148,20 @@ async fn fork_database_can_use_deep_source_branch() -> Result<()> { versionstamp: source_commit.versionstamp, restore_point: None, }, - BucketId::from_gas_id(test_bucket())) + BucketId::from_gas_id(test_bucket()), + ) .await?; let forked_database_db = ctx.make_db(test_bucket(), forked_database_id.clone()); forked_database_db - .commit(vec![page(depth + 1, 0x20 + depth as u8)], depth + 2, 2_000 + depth as i64) + .commit( + vec![page(depth + 1, 0x20 + depth as u8)], + depth + 2, + 2_000 + depth as i64, + ) .await?; source_database_id = forked_database_id; - source_branch_id = read_database_branch_id(&db, test_bucket(), &source_database_id).await?; + source_branch_id = + read_database_branch_id(&db, test_bucket(), &source_database_id).await?; source_commit = read_head_commit(&db, source_branch_id).await?; } @@ -1119,9 +1173,11 @@ async fn fork_database_can_use_deep_source_branch() -> Result<()> { versionstamp: source_commit.versionstamp, restore_point: None, }, - BucketId::from_gas_id(test_bucket())) + BucketId::from_gas_id(test_bucket()), + ) .await?; - let final_branch_id = read_database_branch_id(&db, test_bucket(), &final_database_id).await?; + let final_branch_id = + read_database_branch_id(&db, test_bucket(), &final_database_id).await?; let final_record_bytes = read_value(&db, branches_list_key(final_branch_id)) .await? .expect("final branch record should exist"); @@ -1247,14 +1303,12 @@ async fn rollback_bucket_freezes_old_branch_and_swaps_pointer() -> Result<()> { let old_record = decode_bucket_branch_record(&old_record_bytes)?; assert_eq!(old_record.state, BranchState::Frozen); assert_eq!(read_bucket_refcount(&db, old_bucket_branch_id).await?, 1); - assert_eq!( - read_bucket_refcount(&db, rolled_bucket_branch_id).await?, - 1 - ); + assert_eq!(read_bucket_refcount(&db, rolled_bucket_branch_id).await?, 1); - let rolled_record_bytes = read_value(&db, bucket_branches_list_key(rolled_bucket_branch_id)) - .await? - .expect("rolled bucket branch record should exist"); + let rolled_record_bytes = + read_value(&db, bucket_branches_list_key(rolled_bucket_branch_id)) + .await? + .expect("rolled bucket branch record should exist"); let rolled_record = decode_bucket_branch_record(&rolled_record_bytes)?; assert_eq!(rolled_record.parent, Some(old_bucket_branch_id)); assert_eq!( diff --git a/engine/packages/depot/tests/conveyer_commit.rs b/engine/packages/depot/tests/conveyer_commit.rs index 2e35044f65..5d396701ca 100644 --- a/engine/packages/depot/tests/conveyer_commit.rs +++ b/engine/packages/depot/tests/conveyer_commit.rs @@ -3,41 +3,38 @@ mod common; use std::sync::Arc; use anyhow::Result; -use futures_util::FutureExt; -use gas::prelude::Id; -use parking_lot::Mutex; -use rivet_pools::NodeId; +#[cfg(feature = "test-faults")] +use depot::fault::{CommitFaultPoint, DepotFaultController, DepotFaultPoint, FaultBoundary}; use depot::{ ACCESS_TOUCH_THROTTLE_MS, + conveyer::Db, conveyer::{ commit::{clear_sqlite_cmp_dirty_if_observed_idle, test_hooks}, db::CompactionSignaler, }, - keys::{ - PAGE_SIZE, database_pointer_cur_key, branch_commit_key, branch_delta_chunk_key, - branch_compaction_root_key, - branch_manifest_last_access_bucket_key, branch_manifest_last_access_ts_ms_key, - branch_meta_compact_key, branch_meta_head_key, branch_pidx_key, branch_shard_key, - branch_vtx_key, branches_list_key, ctr_eviction_index_key, bucket_branches_list_key, - bucket_branches_refcount_key, bucket_pointer_cur_key, sqlite_cmp_dirty_key, - }, + keys::{ + PAGE_SIZE, branch_commit_key, branch_compaction_root_key, branch_delta_chunk_key, + branch_manifest_last_access_bucket_key, branch_manifest_last_access_ts_ms_key, + branch_meta_compact_key, branch_meta_head_key, branch_pidx_key, branch_shard_key, + branch_vtx_key, branches_list_key, bucket_branches_list_key, bucket_branches_refcount_key, + bucket_pointer_cur_key, ctr_eviction_index_key, database_pointer_cur_key, + sqlite_cmp_dirty_key, + }, ltx::{LtxHeader, encode_ltx_v3}, - conveyer::Db, quota::{self, SQLITE_MAX_STORAGE_BYTES}, types::{ - CompactionRoot, DatabaseBranchId, DBHead, DirtyPage, FetchedPage, MetaCompact, BucketId, - SqliteCmpDirty, - decode_sqlite_cmp_dirty, encode_compaction_root, - decode_database_branch_record, decode_database_pointer, decode_commit_row, decode_db_head, - decode_bucket_branch_record, decode_bucket_pointer, encode_db_head, encode_meta_compact, + BucketId, CompactionRoot, DBHead, DatabaseBranchId, DirtyPage, FetchedPage, MetaCompact, + SqliteCmpDirty, decode_bucket_branch_record, decode_bucket_pointer, decode_commit_row, + decode_database_branch_record, decode_database_pointer, decode_db_head, + decode_sqlite_cmp_dirty, encode_compaction_root, encode_db_head, encode_meta_compact, encode_sqlite_cmp_dirty, }, workflows::compaction::DeltasAvailable, }; -#[cfg(feature = "test-faults")] -use depot::fault::{ - CommitFaultPoint, DepotFaultController, DepotFaultPoint, FaultBoundary, -}; +use futures_util::FutureExt; +use gas::prelude::Id; +use parking_lot::Mutex; +use rivet_pools::NodeId; use universaldb::utils::IsolationLevel::Snapshot; const TEST_DATABASE: &str = "test-database"; @@ -46,9 +43,7 @@ fn test_bucket() -> Id { Id::v1(uuid::Uuid::from_u128(0x1234), 1) } -fn recording_compaction_signaler( - signals: Arc>>, -) -> CompactionSignaler { +fn recording_compaction_signaler(signals: Arc>>) -> CompactionSignaler { Arc::new(move |signal| { let signals = Arc::clone(&signals); async move { @@ -61,11 +56,13 @@ fn recording_compaction_signaler( macro_rules! commit_matrix { ($prefix:expr, |$ctx:ident, $db:ident, $database_db:ident| $body:block) => { - common::test_matrix($prefix, |_tier, $ctx| Box::pin(async move { - let $db = $ctx.udb.clone(); - let $database_db = $ctx.make_db(test_bucket(), TEST_DATABASE); - $body - })) + common::test_matrix($prefix, |_tier, $ctx| { + Box::pin(async move { + let $db = $ctx.udb.clone(); + let $database_db = $ctx.make_db(test_bucket(), TEST_DATABASE); + $body + }) + }) .await }; } @@ -146,14 +143,17 @@ async fn read_value(db: &universaldb::Database, key: Vec) -> Result) -> Result> { - read_value(db, key).await?.map(|value| { - Ok(i64::from_le_bytes( - value - .as_slice() - .try_into() - .expect("test value should be i64"), - )) - }).transpose() + read_value(db, key) + .await? + .map(|value| { + Ok(i64::from_le_bytes( + value + .as_slice() + .try_into() + .expect("test value should be i64"), + )) + }) + .transpose() } async fn read_head(db: &universaldb::Database) -> Result { @@ -180,12 +180,9 @@ async fn read_branch_id(db: &universaldb::Database) -> Result .await? .expect("bucket pointer should exist"); let bucket_branch = decode_bucket_pointer(&bucket_pointer_bytes)?.current_branch; - let bytes = read_value( - db, - database_pointer_cur_key(bucket_branch, TEST_DATABASE), - ) - .await? - .expect("database pointer should exist"); + let bytes = read_value(db, database_pointer_cur_key(bucket_branch, TEST_DATABASE)) + .await? + .expect("database pointer should exist"); Ok(decode_database_pointer(&bytes)?.current_branch) } @@ -194,7 +191,7 @@ async fn read_quota(db: &universaldb::Database) -> Result { db.run(|tx| async move { quota::read_in_bucket(&tx, BucketId::from_gas_id(test_bucket()), TEST_DATABASE).await }) - .await + .await } async fn assert_commit_rejected( @@ -221,10 +218,7 @@ fn error_chain_contains(err: &anyhow::Error, expected: &str) -> bool { } #[cfg(feature = "test-faults")] -fn faulting_db( - db: Arc, - controller: DepotFaultController, -) -> Db { +fn faulting_db(db: Arc, controller: DepotFaultController) -> Db { Db::new_with_fault_controller_for_test( db, test_bucket(), @@ -304,7 +298,11 @@ async fn commit_rejects_invalid_dirty_pages_before_storage_writes() -> Result<() ) .await?; assert_eq!( - read_value(&db, bucket_pointer_cur_key(BucketId::from_gas_id(test_bucket()))).await?, + read_value( + &db, + bucket_pointer_cur_key(BucketId::from_gas_id(test_bucket())) + ) + .await?, None ); @@ -336,14 +334,21 @@ async fn commit_pre_durable_fault_leaves_old_state() -> Result<()> { "expected injected fault error, got {err:?}" ); controller.assert_expected_fired()?; - assert_eq!(controller.replay_log()[0].boundary, FaultBoundary::PreDurableCommit); + assert_eq!( + controller.replay_log()[0].boundary, + FaultBoundary::PreDurableCommit + ); assert_eq!(read_head(&db).await?, head_with_branch(branch_id, 1, 2)); assert!( read_value(&db, branch_delta_chunk_key(branch_id, 2, 0)) .await? .is_none() ); - assert!(read_value(&db, branch_pidx_key(branch_id, 2)).await?.is_none()); + assert!( + read_value(&db, branch_pidx_key(branch_id, 2)) + .await? + .is_none() + ); Ok(()) }) @@ -473,7 +478,11 @@ async fn commit_throttles_access_touch_by_bucket() -> Result<()> { read_i64_le(&db, branch_manifest_last_access_bucket_key(branch_id)).await?, Some(0) ); - assert!(read_value(&db, ctr_eviction_index_key(0, branch_id)).await?.is_none()); + assert!( + read_value(&db, ctr_eviction_index_key(0, branch_id)) + .await? + .is_none() + ); for now_ms in 2..=120 { database_db @@ -488,7 +497,11 @@ async fn commit_throttles_access_touch_by_bucket() -> Result<()> { read_i64_le(&db, branch_manifest_last_access_bucket_key(branch_id)).await?, Some(0) ); - assert!(read_value(&db, ctr_eviction_index_key(0, branch_id)).await?.is_none()); + assert!( + read_value(&db, ctr_eviction_index_key(0, branch_id)) + .await? + .is_none() + ); database_db .commit(vec![page(1, 0xfe)], 1, ACCESS_TOUCH_THROTTLE_MS) @@ -501,8 +514,16 @@ async fn commit_throttles_access_touch_by_bucket() -> Result<()> { read_i64_le(&db, branch_manifest_last_access_bucket_key(branch_id)).await?, Some(1) ); - assert!(read_value(&db, ctr_eviction_index_key(0, branch_id)).await?.is_none()); - assert!(read_value(&db, ctr_eviction_index_key(1, branch_id)).await?.is_none()); + assert!( + read_value(&db, ctr_eviction_index_key(0, branch_id)) + .await? + .is_none() + ); + assert!( + read_value(&db, ctr_eviction_index_key(1, branch_id)) + .await? + .is_none() + ); Ok(()) }) @@ -520,11 +541,17 @@ async fn get_pages_does_not_touch_access_bucket_on_delta_read() -> Result<()> { vec![fetched_page(1, 0x11)] ); - let last_access_ts_ms = read_i64_le(&db, branch_manifest_last_access_ts_ms_key(branch_id)).await?; - let last_access_bucket = read_i64_le(&db, branch_manifest_last_access_bucket_key(branch_id)).await?; + let last_access_ts_ms = + read_i64_le(&db, branch_manifest_last_access_ts_ms_key(branch_id)).await?; + let last_access_bucket = + read_i64_le(&db, branch_manifest_last_access_bucket_key(branch_id)).await?; assert_eq!(last_access_ts_ms, Some(1)); assert_eq!(last_access_bucket, Some(0)); - assert!(read_value(&db, ctr_eviction_index_key(0, branch_id)).await?.is_none()); + assert!( + read_value(&db, ctr_eviction_index_key(0, branch_id)) + .await? + .is_none() + ); Ok(()) }) @@ -741,9 +768,18 @@ async fn shrink_commit_deletes_above_eof_pidx_and_shards() -> Result<()> { encoded_blob(7, &[(64, 0x64), (129, 0x81)])?, ), (branch_pidx_key(branch_id, 64), 7_u64.to_be_bytes().to_vec()), - (branch_pidx_key(branch_id, 129), 7_u64.to_be_bytes().to_vec()), - (branch_shard_key(branch_id, 1, 7), encoded_blob(7, &[(64, 0x64)])?), - (branch_shard_key(branch_id, 2, 7), encoded_blob(7, &[(129, 0x81)])?), + ( + branch_pidx_key(branch_id, 129), + 7_u64.to_be_bytes().to_vec(), + ), + ( + branch_shard_key(branch_id, 1, 7), + encoded_blob(7, &[(64, 0x64)])?, + ), + ( + branch_shard_key(branch_id, 2, 7), + encoded_blob(7, &[(129, 0x81)])?, + ), ], ) .await?; @@ -757,14 +793,26 @@ async fn shrink_commit_deletes_above_eof_pidx_and_shards() -> Result<()> { database_db.commit(vec![page(1, 0x11)], 63, 4_000).await?; assert_eq!(read_head(&db).await?, head_with_branch(branch_id, 8, 63)); - assert!(read_value(&db, branch_pidx_key(branch_id, 64)).await?.is_none()); + assert!( + read_value(&db, branch_pidx_key(branch_id, 64)) + .await? + .is_none() + ); assert!( read_value(&db, branch_pidx_key(branch_id, 129)) .await? .is_none() ); - assert!(read_value(&db, branch_shard_key(branch_id, 1, 7)).await?.is_none()); - assert!(read_value(&db, branch_shard_key(branch_id, 2, 7)).await?.is_none()); + assert!( + read_value(&db, branch_shard_key(branch_id, 1, 7)) + .await? + .is_none() + ); + assert!( + read_value(&db, branch_shard_key(branch_id, 2, 7)) + .await? + .is_none() + ); assert_eq!( read_value(&db, branch_pidx_key(branch_id, 1)).await?, Some(8_u64.to_be_bytes().to_vec()) @@ -776,243 +824,255 @@ async fn shrink_commit_deletes_above_eof_pidx_and_shards() -> Result<()> { #[tokio::test] async fn commit_writes_dirty_marker_and_sends_first_deltas_available() -> Result<()> { - common::test_matrix("depot-commit-dirty-first", |_tier, ctx| Box::pin(async move { - let db = ctx.udb.clone(); - let signals = Arc::new(Mutex::new(Vec::new())); - let database_db = Db::new_with_compaction_signaler( - db.clone(), - test_bucket(), - TEST_DATABASE.to_string(), - NodeId::new(), - ctx.cold_tier.clone(), - recording_compaction_signaler(Arc::clone(&signals))); - database_db.commit(vec![page(1, 0x01)], 1, 1_000).await?; - let branch_id = read_branch_id(&db).await?; - seed( - &db, - vec![ - ( - branch_meta_head_key(branch_id), - encode_db_head(head_with_branch(branch_id, 31, 1))?, - ), - ( - branch_meta_compact_key(branch_id), - encode_meta_compact(MetaCompact { - materialized_txid: 0, - })?, - ), - ], - ) - .await?; - db.run(|tx| async move { - quota::atomic_add_branch(&tx, branch_id, 1_000); - Ok(()) - }) - .await?; + common::test_matrix("depot-commit-dirty-first", |_tier, ctx| { + Box::pin(async move { + let db = ctx.udb.clone(); + let signals = Arc::new(Mutex::new(Vec::new())); + let database_db = Db::new_with_compaction_signaler( + db.clone(), + test_bucket(), + TEST_DATABASE.to_string(), + NodeId::new(), + ctx.cold_tier.clone(), + recording_compaction_signaler(Arc::clone(&signals)), + ); + database_db.commit(vec![page(1, 0x01)], 1, 1_000).await?; + let branch_id = read_branch_id(&db).await?; + seed( + &db, + vec![ + ( + branch_meta_head_key(branch_id), + encode_db_head(head_with_branch(branch_id, 31, 1))?, + ), + ( + branch_meta_compact_key(branch_id), + encode_meta_compact(MetaCompact { + materialized_txid: 0, + })?, + ), + ], + ) + .await?; + db.run(|tx| async move { + quota::atomic_add_branch(&tx, branch_id, 1_000); + Ok(()) + }) + .await?; - database_db.commit(vec![page(1, 0x11)], 1, 5_000).await?; + database_db.commit(vec![page(1, 0x11)], 1, 5_000).await?; - let dirty = read_dirty_marker(&db, branch_id) - .await? - .expect("dirty marker should exist"); - assert_eq!(dirty.observed_head_txid, 32); - assert_eq!(dirty.updated_at_ms, 5_000); - let signals = signals.lock().clone(); - assert_eq!( - signals, - vec![DeltasAvailable { - database_branch_id: branch_id, - observed_head_txid: 32, - dirty_updated_at_ms: 5_000, - }] - ); + let dirty = read_dirty_marker(&db, branch_id) + .await? + .expect("dirty marker should exist"); + assert_eq!(dirty.observed_head_txid, 32); + assert_eq!(dirty.updated_at_ms, 5_000); + let signals = signals.lock().clone(); + assert_eq!( + signals, + vec![DeltasAvailable { + database_branch_id: branch_id, + observed_head_txid: 32, + dirty_updated_at_ms: 5_000, + }] + ); - Ok(()) - })) + Ok(()) + }) + }) .await } #[tokio::test] async fn commit_refreshes_dirty_marker_and_throttles_deltas_available() -> Result<()> { - common::test_matrix("depot-commit-dirty-refresh", |_tier, ctx| Box::pin(async move { - let db = ctx.udb.clone(); - let signals = Arc::new(Mutex::new(Vec::new())); - let database_db = Db::new_with_compaction_signaler( - db.clone(), - test_bucket(), - TEST_DATABASE.to_string(), - NodeId::new(), - ctx.cold_tier.clone(), - recording_compaction_signaler(Arc::clone(&signals))); - database_db.commit(vec![page(1, 0x01)], 1, 1_000).await?; - let branch_id = read_branch_id(&db).await?; - seed( - &db, - vec![ - ( - branch_meta_head_key(branch_id), - encode_db_head(head_with_branch(branch_id, 31, 1))?, - ), - ( - branch_meta_compact_key(branch_id), - encode_meta_compact(MetaCompact { - materialized_txid: 0, - })?, - ), - ], - ) - .await?; - db.run(|tx| async move { - quota::atomic_add_branch(&tx, branch_id, 1_000); - Ok(()) - }) - .await?; + common::test_matrix("depot-commit-dirty-refresh", |_tier, ctx| { + Box::pin(async move { + let db = ctx.udb.clone(); + let signals = Arc::new(Mutex::new(Vec::new())); + let database_db = Db::new_with_compaction_signaler( + db.clone(), + test_bucket(), + TEST_DATABASE.to_string(), + NodeId::new(), + ctx.cold_tier.clone(), + recording_compaction_signaler(Arc::clone(&signals)), + ); + database_db.commit(vec![page(1, 0x01)], 1, 1_000).await?; + let branch_id = read_branch_id(&db).await?; + seed( + &db, + vec![ + ( + branch_meta_head_key(branch_id), + encode_db_head(head_with_branch(branch_id, 31, 1))?, + ), + ( + branch_meta_compact_key(branch_id), + encode_meta_compact(MetaCompact { + materialized_txid: 0, + })?, + ), + ], + ) + .await?; + db.run(|tx| async move { + quota::atomic_add_branch(&tx, branch_id, 1_000); + Ok(()) + }) + .await?; - database_db.commit(vec![page(1, 0x11)], 1, 5_000).await?; - database_db.commit(vec![page(1, 0x22)], 1, 5_100).await?; - let dirty = read_dirty_marker(&db, branch_id) - .await? - .expect("dirty marker should refresh"); - assert_eq!(dirty.observed_head_txid, 33); - assert_eq!(dirty.updated_at_ms, 5_100); - assert_eq!(signals.lock().len(), 1); + database_db.commit(vec![page(1, 0x11)], 1, 5_000).await?; + database_db.commit(vec![page(1, 0x22)], 1, 5_100).await?; + let dirty = read_dirty_marker(&db, branch_id) + .await? + .expect("dirty marker should refresh"); + assert_eq!(dirty.observed_head_txid, 33); + assert_eq!(dirty.updated_at_ms, 5_100); + assert_eq!(signals.lock().len(), 1); - database_db.commit(vec![page(1, 0x33)], 1, 5_500).await?; - let dirty = read_dirty_marker(&db, branch_id) - .await? - .expect("dirty marker should refresh again"); - assert_eq!(dirty.observed_head_txid, 34); - assert_eq!(dirty.updated_at_ms, 5_500); - let signals = signals.lock().clone(); - assert_eq!(signals.len(), 2); - assert_eq!(signals[1].observed_head_txid, 34); - assert_eq!(signals[1].dirty_updated_at_ms, 5_500); + database_db.commit(vec![page(1, 0x33)], 1, 5_500).await?; + let dirty = read_dirty_marker(&db, branch_id) + .await? + .expect("dirty marker should refresh again"); + assert_eq!(dirty.observed_head_txid, 34); + assert_eq!(dirty.updated_at_ms, 5_500); + let signals = signals.lock().clone(); + assert_eq!(signals.len(), 2); + assert_eq!(signals[1].observed_head_txid, 34); + assert_eq!(signals[1].dirty_updated_at_ms, 5_500); - Ok(()) - })) + Ok(()) + }) + }) .await } #[tokio::test] async fn dirty_marker_clear_rejects_stale_observed_value() -> Result<()> { - common::test_matrix("depot-commit-dirty-stale-clear", |_tier, ctx| Box::pin(async move { - let db = ctx.udb.clone(); - let branch_id = DatabaseBranchId::new_v4(); - let old_dirty = SqliteCmpDirty { - observed_head_txid: 40, - updated_at_ms: 1_000, - }; - let new_dirty = SqliteCmpDirty { - observed_head_txid: 41, - updated_at_ms: 1_100, - }; - seed( - &db, - vec![( - sqlite_cmp_dirty_key(branch_id), - encode_sqlite_cmp_dirty(new_dirty.clone())?, - )], - ) - .await?; + common::test_matrix("depot-commit-dirty-stale-clear", |_tier, ctx| { + Box::pin(async move { + let db = ctx.udb.clone(); + let branch_id = DatabaseBranchId::new_v4(); + let old_dirty = SqliteCmpDirty { + observed_head_txid: 40, + updated_at_ms: 1_000, + }; + let new_dirty = SqliteCmpDirty { + observed_head_txid: 41, + updated_at_ms: 1_100, + }; + seed( + &db, + vec![( + sqlite_cmp_dirty_key(branch_id), + encode_sqlite_cmp_dirty(new_dirty.clone())?, + )], + ) + .await?; - assert!( - !clear_sqlite_cmp_dirty_if_observed_idle(&db, branch_id, old_dirty).await?, - "stale dirty marker should not clear" - ); - assert_eq!(read_dirty_marker(&db, branch_id).await?, Some(new_dirty)); + assert!( + !clear_sqlite_cmp_dirty_if_observed_idle(&db, branch_id, old_dirty).await?, + "stale dirty marker should not clear" + ); + assert_eq!(read_dirty_marker(&db, branch_id).await?, Some(new_dirty)); - Ok(()) - })) + Ok(()) + }) + }) .await } #[tokio::test] async fn dirty_marker_clear_requires_workflow_compaction_root() -> Result<()> { - common::test_matrix("depot-commit-dirty-root-required", |_tier, ctx| Box::pin(async move { - let db = ctx.udb.clone(); - let branch_id = DatabaseBranchId::new_v4(); - let dirty = SqliteCmpDirty { - observed_head_txid: 40, - updated_at_ms: 1_000, - }; - seed( - &db, - vec![ - ( - branch_meta_head_key(branch_id), - encode_db_head(head_with_branch(branch_id, 40, 1))?, - ), - ( - branch_meta_compact_key(branch_id), - encode_meta_compact(MetaCompact { - materialized_txid: 40, - })?, - ), - ( - sqlite_cmp_dirty_key(branch_id), - encode_sqlite_cmp_dirty(dirty.clone())?, - ), - ], - ) - .await?; + common::test_matrix("depot-commit-dirty-root-required", |_tier, ctx| { + Box::pin(async move { + let db = ctx.udb.clone(); + let branch_id = DatabaseBranchId::new_v4(); + let dirty = SqliteCmpDirty { + observed_head_txid: 40, + updated_at_ms: 1_000, + }; + seed( + &db, + vec![ + ( + branch_meta_head_key(branch_id), + encode_db_head(head_with_branch(branch_id, 40, 1))?, + ), + ( + branch_meta_compact_key(branch_id), + encode_meta_compact(MetaCompact { + materialized_txid: 40, + })?, + ), + ( + sqlite_cmp_dirty_key(branch_id), + encode_sqlite_cmp_dirty(dirty.clone())?, + ), + ], + ) + .await?; - let err = clear_sqlite_cmp_dirty_if_observed_idle(&db, branch_id, dirty) - .await - .expect_err("dirty clear without workflow compaction root should fail"); - assert!( - err.chain().any(|cause| cause - .to_string() - .contains("sqlite compaction root missing for dirty clear")), - "unexpected error: {err:?}" - ); - assert!(read_dirty_marker(&db, branch_id).await?.is_some()); + let err = clear_sqlite_cmp_dirty_if_observed_idle(&db, branch_id, dirty) + .await + .expect_err("dirty clear without workflow compaction root should fail"); + assert!( + err.chain().any(|cause| cause + .to_string() + .contains("sqlite compaction root missing for dirty clear")), + "unexpected error: {err:?}" + ); + assert!(read_dirty_marker(&db, branch_id).await?.is_some()); - Ok(()) - })) + Ok(()) + }) + }) .await } #[tokio::test] async fn dirty_marker_clear_removes_exact_idle_marker() -> Result<()> { - common::test_matrix("depot-commit-dirty-clear", |_tier, ctx| Box::pin(async move { - let db = ctx.udb.clone(); - let branch_id = DatabaseBranchId::new_v4(); - let dirty = SqliteCmpDirty { - observed_head_txid: 40, - updated_at_ms: 1_000, - }; - seed( - &db, - vec![ - ( - branch_meta_head_key(branch_id), - encode_db_head(head_with_branch(branch_id, 40, 1))?, - ), - ( - sqlite_cmp_dirty_key(branch_id), - encode_sqlite_cmp_dirty(dirty.clone())?, - ), - ( - branch_compaction_root_key(branch_id), - encode_compaction_root(CompactionRoot { - schema_version: 1, - manifest_generation: 7, - hot_watermark_txid: 40, - cold_watermark_txid: 40, - cold_watermark_versionstamp: [0x22; 16], - })?, - ), - ], - ) - .await?; + common::test_matrix("depot-commit-dirty-clear", |_tier, ctx| { + Box::pin(async move { + let db = ctx.udb.clone(); + let branch_id = DatabaseBranchId::new_v4(); + let dirty = SqliteCmpDirty { + observed_head_txid: 40, + updated_at_ms: 1_000, + }; + seed( + &db, + vec![ + ( + branch_meta_head_key(branch_id), + encode_db_head(head_with_branch(branch_id, 40, 1))?, + ), + ( + sqlite_cmp_dirty_key(branch_id), + encode_sqlite_cmp_dirty(dirty.clone())?, + ), + ( + branch_compaction_root_key(branch_id), + encode_compaction_root(CompactionRoot { + schema_version: 1, + manifest_generation: 7, + hot_watermark_txid: 40, + cold_watermark_txid: 40, + cold_watermark_versionstamp: [0x22; 16], + })?, + ), + ], + ) + .await?; - assert!( - clear_sqlite_cmp_dirty_if_observed_idle(&db, branch_id, dirty).await?, - "idle exact marker should clear" - ); - assert!(read_dirty_marker(&db, branch_id).await?.is_none()); + assert!( + clear_sqlite_cmp_dirty_if_observed_idle(&db, branch_id, dirty).await?, + "idle exact marker should clear" + ); + assert!(read_dirty_marker(&db, branch_id).await?.is_none()); - Ok(()) - })) + Ok(()) + }) + }) .await } diff --git a/engine/packages/depot/tests/conveyer_compaction_payloads.rs b/engine/packages/depot/tests/conveyer_compaction_payloads.rs index ca659c25ac..af0000cbcf 100644 --- a/engine/packages/depot/tests/conveyer_compaction_payloads.rs +++ b/engine/packages/depot/tests/conveyer_compaction_payloads.rs @@ -1,13 +1,12 @@ use depot::types::{ - RestorePointId, ColdShardRef, CompactionRoot, DatabaseBranchId, DbHistoryPin, DbHistoryPinKind, - BucketBranchId, BucketCatalogDbFact, BucketForkFact, RetiredColdObject, - PitrIntervalCoverage, RetiredColdObjectDeleteState, SQLITE_STORAGE_META_VERSION, SqliteCmpDirty, - decode_cold_shard_ref, decode_compaction_root, decode_db_history_pin, - decode_bucket_catalog_db_fact, decode_bucket_fork_fact, decode_retired_cold_object, - decode_pitr_interval_coverage, decode_sqlite_cmp_dirty, encode_cold_shard_ref, - encode_compaction_root, encode_db_history_pin, encode_bucket_catalog_db_fact, - encode_bucket_fork_fact, encode_pitr_interval_coverage, encode_retired_cold_object, - encode_sqlite_cmp_dirty, + BucketBranchId, BucketCatalogDbFact, BucketForkFact, ColdShardRef, CompactionRoot, + DatabaseBranchId, DbHistoryPin, DbHistoryPinKind, PitrIntervalCoverage, RestorePointId, + RetiredColdObject, RetiredColdObjectDeleteState, SQLITE_STORAGE_META_VERSION, SqliteCmpDirty, + decode_bucket_catalog_db_fact, decode_bucket_fork_fact, decode_cold_shard_ref, + decode_compaction_root, decode_db_history_pin, decode_pitr_interval_coverage, + decode_retired_cold_object, decode_sqlite_cmp_dirty, encode_bucket_catalog_db_fact, + encode_bucket_fork_fact, encode_cold_shard_ref, encode_compaction_root, encode_db_history_pin, + encode_pitr_interval_coverage, encode_retired_cold_object, encode_sqlite_cmp_dirty, }; use gas::prelude::Id; use uuid::Uuid; @@ -117,8 +116,8 @@ fn pitr_interval_coverage_round_trips_with_embedded_version() { .expect("PITR interval coverage should encode"); assert_embedded_version(&encoded); - let decoded = decode_pitr_interval_coverage(&encoded) - .expect("PITR interval coverage should decode"); + let decoded = + decode_pitr_interval_coverage(&encoded).expect("PITR interval coverage should decode"); assert_eq!(decoded, coverage); } @@ -172,10 +171,8 @@ fn db_history_pin_round_trips_each_kind_with_embedded_version() { #[test] fn bucket_proof_facts_round_trip_with_embedded_version() { - let source_bucket_branch_id = - bucket_branch_id(0x1111_2222_3333_4444_5555_6666_7777_8888); - let target_bucket_branch_id = - bucket_branch_id(0x9999_aaaa_bbbb_cccc_dddd_eeee_ffff_0000); + let source_bucket_branch_id = bucket_branch_id(0x1111_2222_3333_4444_5555_6666_7777_8888); + let target_bucket_branch_id = bucket_branch_id(0x9999_aaaa_bbbb_cccc_dddd_eeee_ffff_0000); let fork_fact = BucketForkFact { source_bucket_branch_id, target_bucket_branch_id, @@ -201,8 +198,7 @@ fn bucket_proof_facts_round_trip_with_embedded_version() { .expect("bucket catalog fact should encode"); assert_embedded_version(&encoded_catalog); assert_eq!( - decode_bucket_catalog_db_fact(&encoded_catalog) - .expect("bucket catalog fact should decode"), + decode_bucket_catalog_db_fact(&encoded_catalog).expect("bucket catalog fact should decode"), catalog_fact ); } diff --git a/engine/packages/depot/tests/conveyer_constants.rs b/engine/packages/depot/tests/conveyer_constants.rs index 9a30f6f563..4b3aaba3d7 100644 --- a/engine/packages/depot/tests/conveyer_constants.rs +++ b/engine/packages/depot/tests/conveyer_constants.rs @@ -1,10 +1,10 @@ use depot::{ - ACCESS_TOUCH_THROTTLE_MS, FROZEN_BRANCH_RETENTION_MS, HOT_BURST_COLD_LAG_THRESHOLD_TXIDS, - HOT_BURST_MULTIPLIER, HOT_CACHE_WINDOW_MS, HOT_RETENTION_FLOOR_MS, MAX_FORK_DEPTH, - MAX_BUCKET_DEPTH, MAX_RESTORE_POINTS_PER_BUCKET, MAX_SHARD_VERSIONS_PER_SHARD, - CMP_ACTIVITY_TARGET_MS, CMP_FDB_BATCH_MAX_KEYS, CMP_FDB_BATCH_MAX_VALUE_BYTES, - CMP_JOB_DESCRIPTOR_LIMIT_BYTES, CMP_S3_DELETE_MAX_OBJECTS, CMP_S3_UPLOAD_LIMIT_BYTES, - CMP_S3_UPLOAD_MAX_OBJECTS, CMP_SIGNAL_PAYLOAD_LIMIT_BYTES, SHARD_RETENTION_MARGIN, + ACCESS_TOUCH_THROTTLE_MS, CMP_ACTIVITY_TARGET_MS, CMP_FDB_BATCH_MAX_KEYS, + CMP_FDB_BATCH_MAX_VALUE_BYTES, CMP_JOB_DESCRIPTOR_LIMIT_BYTES, CMP_S3_DELETE_MAX_OBJECTS, + CMP_S3_UPLOAD_LIMIT_BYTES, CMP_S3_UPLOAD_MAX_OBJECTS, CMP_SIGNAL_PAYLOAD_LIMIT_BYTES, + FROZEN_BRANCH_RETENTION_MS, HOT_BURST_COLD_LAG_THRESHOLD_TXIDS, HOT_BURST_MULTIPLIER, + HOT_CACHE_WINDOW_MS, HOT_RETENTION_FLOOR_MS, MAX_BUCKET_DEPTH, MAX_FORK_DEPTH, + MAX_RESTORE_POINTS_PER_BUCKET, MAX_SHARD_VERSIONS_PER_SHARD, SHARD_RETENTION_MARGIN, STALE_MARKER_AGE_MS, }; diff --git a/engine/packages/depot/tests/conveyer_error.rs b/engine/packages/depot/tests/conveyer_error.rs index e296f9292d..f757b3daa3 100644 --- a/engine/packages/depot/tests/conveyer_error.rs +++ b/engine/packages/depot/tests/conveyer_error.rs @@ -13,10 +13,7 @@ fn pitr_errors_are_typed_and_downcastable() { #[test] fn pitr_errors_have_rivet_error_codes() { let cases = [ - ( - SqliteStorageError::ForkChainTooDeep, - "fork_chain_too_deep", - ), + (SqliteStorageError::ForkChainTooDeep, "fork_chain_too_deep"), ( SqliteStorageError::BucketForkChainTooDeep, "bucket_fork_chain_too_deep", @@ -25,7 +22,10 @@ fn pitr_errors_have_rivet_error_codes() { SqliteStorageError::ForkOutOfRetention, "fork_out_of_retention", ), - (SqliteStorageError::RestoreTargetExpired, "restore_point_expired"), + ( + SqliteStorageError::RestoreTargetExpired, + "restore_point_expired", + ), ( SqliteStorageError::BranchNotReachable, "branch_not_reachable", diff --git a/engine/packages/depot/tests/conveyer_keys.rs b/engine/packages/depot/tests/conveyer_keys.rs index 9c7f2ee77b..21eddbabca 100644 --- a/engine/packages/depot/tests/conveyer_keys.rs +++ b/engine/packages/depot/tests/conveyer_keys.rs @@ -1,36 +1,34 @@ use depot::conveyer::keys::{ - DBPTR_PARTITION, RESTORE_POINT_PARTITION, BR_PARTITION, BRANCHES_PARTITION, CMPC_PARTITION, - CTR_PARTITION, DB_PIN_PARTITION, CompactorQueueKind, BUCKET_CHILD_PARTITION, - BUCKET_FORK_PIN_PARTITION, BUCKET_PROOF_EPOCH_PARTITION, BUCKET_PTR_PARTITION, BUCKET_BRANCH_PARTITION, - BUCKET_CATALOG_BY_DB_PARTITION, PAGE_SIZE, SHARD_SIZE, SQLITE_CMP_DIRTY_PARTITION, - SQLITE_SUBSPACE_PREFIX, restore_point_prefix, restore_point_key, - branch_commit_key, branch_compaction_cold_shard_key, + BR_PARTITION, BRANCHES_PARTITION, BUCKET_BRANCH_PARTITION, BUCKET_CATALOG_BY_DB_PARTITION, + BUCKET_CHILD_PARTITION, BUCKET_FORK_PIN_PARTITION, BUCKET_PROOF_EPOCH_PARTITION, + BUCKET_PTR_PARTITION, CMPC_PARTITION, CTR_PARTITION, CompactorQueueKind, DB_PIN_PARTITION, + DBPTR_PARTITION, PAGE_SIZE, RESTORE_POINT_PARTITION, SHARD_SIZE, SQLITE_CMP_DIRTY_PARTITION, + SQLITE_SUBSPACE_PREFIX, branch_commit_key, branch_compaction_cold_shard_key, branch_compaction_cold_shard_version_prefix, branch_compaction_retired_cold_object_key, branch_compaction_root_key, branch_compaction_stage_hot_shard_key, branch_compaction_stage_hot_shard_version_prefix, branch_delta_chunk_key, - branch_manifest_cold_drained_txid_key, - branch_manifest_last_access_bucket_key, branch_manifest_last_access_ts_ms_key, - branch_manifest_last_hot_pass_txid_key, branch_meta_cold_compact_key, - branch_meta_cold_lease_key, branch_meta_compact_key, branch_meta_compactor_lease_key, - branch_meta_head_at_fork_key, branch_meta_head_key, branch_meta_quota_key, branch_pidx_key, - branch_pitr_interval_key, branch_pitr_interval_prefix, branch_prefix, branch_range, - branch_shard_key, branch_shard_version_prefix, branch_vtx_key, - branches_restore_point_pin_key, branches_desc_pin_key, branches_list_key, branches_refcount_key, - bucket_policy_pitr_key, bucket_policy_shard_cache_key, commit_key, compactor_enqueue_key, - compactor_global_lease_key, ctr_eviction_index_key, ctr_eviction_index_range, ctr_quota_global_key, - database_pitr_policy_key, database_pointer_cur_key, - database_pointer_history_key, database_prefix, database_range, db_pin_key, db_pin_prefix, - database_shard_cache_policy_key, decode_ctr_eviction_index_key, delta_chunk_key, - delta_chunk_prefix, delta_prefix, meta_compact_key, meta_compactor_lease_key, meta_head_key, - meta_quota_key, bucket_child_key, - bucket_child_prefix, bucket_fork_pin_key, bucket_fork_pin_prefix, bucket_proof_epoch_key, bucket_catalog_by_db_key, - bucket_catalog_by_db_prefix, bucket_branches_restore_point_pin_key, - bucket_branches_database_name_tombstone_key, bucket_branches_desc_pin_key, - bucket_branches_list_key, bucket_branches_refcount_key, bucket_pointer_cur_key, - bucket_pointer_history_key, pidx_delta_key, pidx_delta_prefix, shard_key, shard_prefix, - shard_version_key, shard_version_prefix, sqlite_cmp_dirty_key, vtx_key, + branch_manifest_cold_drained_txid_key, branch_manifest_last_access_bucket_key, + branch_manifest_last_access_ts_ms_key, branch_manifest_last_hot_pass_txid_key, + branch_meta_cold_compact_key, branch_meta_cold_lease_key, branch_meta_compact_key, + branch_meta_compactor_lease_key, branch_meta_head_at_fork_key, branch_meta_head_key, + branch_meta_quota_key, branch_pidx_key, branch_pitr_interval_key, branch_pitr_interval_prefix, + branch_prefix, branch_range, branch_shard_key, branch_shard_version_prefix, branch_vtx_key, + branches_desc_pin_key, branches_list_key, branches_refcount_key, + branches_restore_point_pin_key, bucket_branches_database_name_tombstone_key, + bucket_branches_desc_pin_key, bucket_branches_list_key, bucket_branches_refcount_key, + bucket_branches_restore_point_pin_key, bucket_catalog_by_db_key, bucket_catalog_by_db_prefix, + bucket_child_key, bucket_child_prefix, bucket_fork_pin_key, bucket_fork_pin_prefix, + bucket_pointer_cur_key, bucket_pointer_history_key, bucket_policy_pitr_key, + bucket_policy_shard_cache_key, bucket_proof_epoch_key, commit_key, compactor_enqueue_key, + compactor_global_lease_key, ctr_eviction_index_key, ctr_eviction_index_range, + ctr_quota_global_key, database_pitr_policy_key, database_pointer_cur_key, + database_pointer_history_key, database_prefix, database_range, database_shard_cache_policy_key, + db_pin_key, db_pin_prefix, decode_ctr_eviction_index_key, delta_chunk_key, delta_chunk_prefix, + delta_prefix, meta_compact_key, meta_compactor_lease_key, meta_head_key, meta_quota_key, + pidx_delta_key, pidx_delta_prefix, restore_point_key, restore_point_prefix, shard_key, + shard_prefix, shard_version_key, shard_version_prefix, sqlite_cmp_dirty_key, vtx_key, }; -use depot::conveyer::types::{DatabaseBranchId, BucketBranchId, BucketId}; +use depot::conveyer::types::{BucketBranchId, BucketId, DatabaseBranchId}; use gas::prelude::Id; use uuid::Uuid; @@ -50,7 +48,10 @@ fn bucket_id() -> BucketId { } fn compaction_job_id() -> Id { - Id::v1(Uuid::from_u128(0x1234_5678_9abc_def0_1122_3344_5566_7788), 42) + Id::v1( + Uuid::from_u128(0x1234_5678_9abc_def0_1122_3344_5566_7788), + 42, + ) } fn uuid_bytes(uuid: Uuid) -> Vec { @@ -144,7 +145,9 @@ fn data_prefixes_match_full_keys() { .starts_with(&delta_chunk_prefix(TEST_DATABASE, 0x0102_0304_0506_0708)) ); assert!(shard_key(TEST_DATABASE, 3).starts_with(&shard_prefix(TEST_DATABASE))); - assert!(shard_version_key(TEST_DATABASE, 3, 7).starts_with(&shard_version_prefix(TEST_DATABASE, 3))); + assert!( + shard_version_key(TEST_DATABASE, 3, 7).starts_with(&shard_version_prefix(TEST_DATABASE, 3)) + ); assert!(shard_version_key(TEST_DATABASE, 3, 8) > shard_version_key(TEST_DATABASE, 3, 7)); assert!(commit_key(TEST_DATABASE, 8) > commit_key(TEST_DATABASE, 7)); } @@ -196,7 +199,10 @@ fn pointer_keys_include_current_and_history_paths() { let mut expected_database = vec![SQLITE_SUBSPACE_PREFIX, DBPTR_PARTITION, b'/']; expected_database.extend_from_slice(&uuid_bytes(bucket_branch.as_uuid())); expected_database.extend_from_slice(b"/test-database/cur"); - assert_eq!(database_pointer_cur_key(bucket_branch, TEST_DATABASE), expected_database); + assert_eq!( + database_pointer_cur_key(bucket_branch, TEST_DATABASE), + expected_database + ); let mut expected_database_history = vec![SQLITE_SUBSPACE_PREFIX, DBPTR_PARTITION, b'/']; expected_database_history.extend_from_slice(&uuid_bytes(bucket_branch.as_uuid())); @@ -236,7 +242,11 @@ fn bucket_policy_keys_use_bucket_scope_and_database_overrides() { ); assert_eq!( database_shard_cache_policy_key(bucket, TEST_DATABASE), - [bucket_base.as_slice(), b"/DB_POLICY/test-database/SHARD_CACHE"].concat() + [ + bucket_base.as_slice(), + b"/DB_POLICY/test-database/SHARD_CACHE" + ] + .concat() ); } @@ -249,9 +259,18 @@ fn branch_record_keys_include_counter_and_pin_subkeys() { database_base.extend_from_slice(b"/list/"); database_base.extend_from_slice(&uuid_bytes(database_branch.as_uuid())); assert_eq!(branches_list_key(database_branch), database_base); - assert_eq!(branches_refcount_key(database_branch), [database_base.as_slice(), b"/refcount"].concat()); - assert_eq!(branches_desc_pin_key(database_branch), [database_base.as_slice(), b"/desc_pin"].concat()); - assert_eq!(branches_restore_point_pin_key(database_branch), [database_base.as_slice(), b"/restore_point_pin"].concat()); + assert_eq!( + branches_refcount_key(database_branch), + [database_base.as_slice(), b"/refcount"].concat() + ); + assert_eq!( + branches_desc_pin_key(database_branch), + [database_base.as_slice(), b"/desc_pin"].concat() + ); + assert_eq!( + branches_restore_point_pin_key(database_branch), + [database_base.as_slice(), b"/restore_point_pin"].concat() + ); let mut bucket_base = vec![SQLITE_SUBSPACE_PREFIX, BUCKET_BRANCH_PARTITION]; bucket_base.extend_from_slice(b"/list/"); @@ -271,7 +290,11 @@ fn branch_record_keys_include_counter_and_pin_subkeys() { ); assert_eq!( bucket_branches_database_name_tombstone_key(bucket_branch, TEST_DATABASE), - [bucket_base.as_slice(), b"/database_tombstones/test-database"].concat() + [ + bucket_base.as_slice(), + b"/database_tombstones/test-database" + ] + .concat() ); } @@ -328,14 +351,14 @@ fn database_branch_data_keys_live_under_br_partition() { .starts_with(&branch_compaction_cold_shard_version_prefix(branch, 4)) ); assert!( - branch_compaction_stage_hot_shard_key(branch, compaction_job_id(), 4, 7, 2) - .starts_with(&branch_compaction_stage_hot_shard_version_prefix( - branch, - compaction_job_id(), - 4 - )) - ); - assert!(branch_pitr_interval_key(branch, 1_700_000_000_000).starts_with(&branch_pitr_interval_prefix(branch))); + branch_compaction_stage_hot_shard_key(branch, compaction_job_id(), 4, 7, 2).starts_with( + &branch_compaction_stage_hot_shard_version_prefix(branch, compaction_job_id(), 4) + ) + ); + assert!( + branch_pitr_interval_key(branch, 1_700_000_000_000) + .starts_with(&branch_pitr_interval_prefix(branch)) + ); assert!(branch_commit_key(branch, 8) > branch_commit_key(branch, 7)); } @@ -346,7 +369,10 @@ fn global_restore_point_and_compactor_keys_match_expected_suffixes() { let eviction = ctr_eviction_index_key(42, branch); assert!(eviction.starts_with(&[SQLITE_SUBSPACE_PREFIX, CTR_PARTITION])); assert!(eviction.ends_with(database_branch_id().as_uuid().as_bytes())); - assert_eq!(decode_ctr_eviction_index_key(&eviction).unwrap(), (42, branch)); + assert_eq!( + decode_ctr_eviction_index_key(&eviction).unwrap(), + (42, branch) + ); let (eviction_start, eviction_end) = ctr_eviction_index_range(); assert!(eviction >= eviction_start); assert!(eviction < eviction_end); @@ -448,13 +474,24 @@ fn workflow_compaction_branch_keys_sort_by_big_endian_components() { fn workflow_compaction_global_keys_sort_by_big_endian_components() { let branch = database_branch_id(); let source_bucket = bucket_branch_id(); - let target_a = BucketBranchId::from_uuid(Uuid::from_u128(0x0000_0000_0000_0000_0000_0000_0000_0001)); - let target_b = BucketBranchId::from_uuid(Uuid::from_u128(0x0000_0000_0000_0000_0000_0000_0000_0002)); + let target_a = + BucketBranchId::from_uuid(Uuid::from_u128(0x0000_0000_0000_0000_0000_0000_0000_0001)); + let target_b = + BucketBranchId::from_uuid(Uuid::from_u128(0x0000_0000_0000_0000_0000_0000_0000_0002)); assert!(db_pin_key(branch, b"restore_point/a").starts_with(&db_pin_prefix(branch))); - assert!(bucket_catalog_by_db_key(branch, source_bucket).starts_with(&bucket_catalog_by_db_prefix(branch))); - assert!(bucket_fork_pin_key(source_bucket, [1; 16], target_a).starts_with(&bucket_fork_pin_prefix(source_bucket))); - assert!(bucket_child_key(source_bucket, [1; 16], target_a).starts_with(&bucket_child_prefix(source_bucket))); + assert!( + bucket_catalog_by_db_key(branch, source_bucket) + .starts_with(&bucket_catalog_by_db_prefix(branch)) + ); + assert!( + bucket_fork_pin_key(source_bucket, [1; 16], target_a) + .starts_with(&bucket_fork_pin_prefix(source_bucket)) + ); + assert!( + bucket_child_key(source_bucket, [1; 16], target_a) + .starts_with(&bucket_child_prefix(source_bucket)) + ); let mut fork_pins = vec![ bucket_fork_pin_key(source_bucket, [2; 16], target_a), diff --git a/engine/packages/depot/tests/conveyer_page_index.rs b/engine/packages/depot/tests/conveyer_page_index.rs index aa8399de35..7d8d3d6d37 100644 --- a/engine/packages/depot/tests/conveyer_page_index.rs +++ b/engine/packages/depot/tests/conveyer_page_index.rs @@ -52,47 +52,57 @@ fn range_returns_sorted_pages_within_bounds() { #[tokio::test] async fn load_from_store_reads_scan_prefix_entries() -> Result<()> { - common::test_matrix("depot-page-index-load", |_tier, ctx| Box::pin(async move { - let db = ctx.udb.clone(); - let subspace = Subspace::new(&("depot-page-index", Uuid::new_v4().to_string())); - db.run({ - let subspace = subspace.clone(); - move |tx| { + common::test_matrix("depot-page-index-load", |_tier, ctx| { + Box::pin(async move { + let db = ctx.udb.clone(); + let subspace = Subspace::new(&("depot-page-index", Uuid::new_v4().to_string())); + db.run({ let subspace = subspace.clone(); - async move { - let key = |logical_key: Vec| { - [subspace.bytes(), logical_key.as_slice()].concat() - }; - tx.informal() - .set(&key(pidx_delta_key(TEST_DATABASE, 8)), &81_u64.to_be_bytes()); - tx.informal() - .set(&key(pidx_delta_key(TEST_DATABASE, 2)), &21_u64.to_be_bytes()); - tx.informal() - .set(&key(pidx_delta_key(TEST_DATABASE, 17)), &171_u64.to_be_bytes()); - tx.informal() - .set(&key(pidx_delta_key("other-database", 2)), &999_u64.to_be_bytes()); - Ok(()) + move |tx| { + let subspace = subspace.clone(); + async move { + let key = |logical_key: Vec| { + [subspace.bytes(), logical_key.as_slice()].concat() + }; + tx.informal().set( + &key(pidx_delta_key(TEST_DATABASE, 8)), + &81_u64.to_be_bytes(), + ); + tx.informal().set( + &key(pidx_delta_key(TEST_DATABASE, 2)), + &21_u64.to_be_bytes(), + ); + tx.informal().set( + &key(pidx_delta_key(TEST_DATABASE, 17)), + &171_u64.to_be_bytes(), + ); + tx.informal().set( + &key(pidx_delta_key("other-database", 2)), + &999_u64.to_be_bytes(), + ); + Ok(()) + } } - } + }) + .await?; + + let counter = AtomicUsize::new(0); + let index = DeltaPageIndex::load_from_store( + &db, + &subspace, + &counter, + pidx_delta_prefix(TEST_DATABASE), + ) + .await?; + + assert_eq!(index.get(2), Some(21)); + assert_eq!(index.get(8), Some(81)); + assert_eq!(index.get(17), Some(171)); + assert_eq!(index.range(1, 20), vec![(2, 21), (8, 81), (17, 171)]); + assert_eq!(counter.load(Ordering::SeqCst), 1); + + Ok(()) }) - .await?; - - let counter = AtomicUsize::new(0); - let index = DeltaPageIndex::load_from_store( - &db, - &subspace, - &counter, - pidx_delta_prefix(TEST_DATABASE), - ) - .await?; - - assert_eq!(index.get(2), Some(21)); - assert_eq!(index.get(8), Some(81)); - assert_eq!(index.get(17), Some(171)); - assert_eq!(index.range(1, 20), vec![(2, 21), (8, 81), (17, 171)]); - assert_eq!(counter.load(Ordering::SeqCst), 1); - - Ok(()) - })) + }) .await } diff --git a/engine/packages/depot/tests/conveyer_pitr_interval.rs b/engine/packages/depot/tests/conveyer_pitr_interval.rs index deab680b60..fe619f4398 100644 --- a/engine/packages/depot/tests/conveyer_pitr_interval.rs +++ b/engine/packages/depot/tests/conveyer_pitr_interval.rs @@ -64,65 +64,69 @@ fn pitr_interval_keys_sort_by_bucket_start_ms() { ); assert!(keys[0].starts_with(&keys::branch_pitr_interval_prefix(branch))); assert_eq!( - keys::decode_branch_pitr_interval_bucket(branch, &keys[1]) - .expect("bucket should decode"), + keys::decode_branch_pitr_interval_bucket(branch, &keys[1]).expect("bucket should decode"), 1_700_000_300_000 ); } #[tokio::test] async fn pitr_interval_helpers_read_exact_between_and_quiet_period_targets() -> Result<()> { - common::test_matrix("pitr-interval-read", |_tier, ctx| Box::pin(async move { - let db = ctx.udb.clone(); - let branch = branch_id(); + common::test_matrix("pitr-interval-read", |_tier, ctx| { + Box::pin(async move { + let db = ctx.udb.clone(); + let branch = branch_id(); - seed_rows( - &db, - branch, - vec![ - (1_000, coverage(10, 1_000, 10_000)), - (2_000, coverage(20, 2_500, 10_000)), - (3_000, coverage(30, 3_000, 10_000)), - ], - ) - .await?; + seed_rows( + &db, + branch, + vec![ + (1_000, coverage(10, 1_000, 10_000)), + (2_000, coverage(20, 2_500, 10_000)), + (3_000, coverage(30, 3_000, 10_000)), + ], + ) + .await?; - let exact = db - .run(move |tx| async move { - read_pitr_interval_coverage(&tx, branch, 1_000, Snapshot).await - }) - .await? - .expect("exact bucket should exist"); - assert_eq!(exact.txid, 10); + let exact = db + .run(move |tx| async move { + read_pitr_interval_coverage(&tx, branch, 1_000, Snapshot).await + }) + .await? + .expect("exact bucket should exist"); + assert_eq!(exact.txid, 10); - let between = db - .run(move |tx| async move { - read_latest_pitr_interval_coverage_at_or_before(&tx, branch, 2_750, Snapshot).await - }) - .await? - .expect("between-bucket target should resolve"); - assert_eq!(between.0, 2_000); - assert_eq!(between.1.txid, 20); + let between = db + .run(move |tx| async move { + read_latest_pitr_interval_coverage_at_or_before(&tx, branch, 2_750, Snapshot) + .await + }) + .await? + .expect("between-bucket target should resolve"); + assert_eq!(between.0, 2_000); + assert_eq!(between.1.txid, 20); - let quiet_period = db - .run(move |tx| async move { - read_latest_pitr_interval_coverage_at_or_before(&tx, branch, 2_999, Snapshot).await - }) - .await? - .expect("quiet-period target should resolve to the previous retained commit"); - assert_eq!(quiet_period.0, 2_000); - assert_eq!(quiet_period.1.txid, 20); + let quiet_period = db + .run(move |tx| async move { + read_latest_pitr_interval_coverage_at_or_before(&tx, branch, 2_999, Snapshot) + .await + }) + .await? + .expect("quiet-period target should resolve to the previous retained commit"); + assert_eq!(quiet_period.0, 2_000); + assert_eq!(quiet_period.1.txid, 20); - let walked_back = db - .run(move |tx| async move { - read_latest_pitr_interval_coverage_at_or_before(&tx, branch, 2_100, Snapshot).await - }) - .await? - .expect("newer selected row should walk back"); - assert_eq!(walked_back.0, 1_000); - assert_eq!(walked_back.1.txid, 10); + let walked_back = db + .run(move |tx| async move { + read_latest_pitr_interval_coverage_at_or_before(&tx, branch, 2_100, Snapshot) + .await + }) + .await? + .expect("newer selected row should walk back"); + assert_eq!(walked_back.0, 1_000); + assert_eq!(walked_back.1.txid, 10); - Ok(()) - })) + Ok(()) + }) + }) .await } diff --git a/engine/packages/depot/tests/conveyer_policy.rs b/engine/packages/depot/tests/conveyer_policy.rs index d6521ec721..78555fd833 100644 --- a/engine/packages/depot/tests/conveyer_policy.rs +++ b/engine/packages/depot/tests/conveyer_policy.rs @@ -12,19 +12,16 @@ use depot::{ set_database_pitr_policy_override, set_database_shard_cache_policy_override, }, types::{ - DEFAULT_PITR_INTERVAL_MS, DEFAULT_PITR_RETENTION_MS, - DEFAULT_SHARD_CACHE_RETENTION_MS, PitrPolicy, SQLITE_STORAGE_META_VERSION, - ShardCachePolicy, decode_pitr_policy, decode_shard_cache_policy, encode_pitr_policy, - encode_shard_cache_policy, + DEFAULT_PITR_INTERVAL_MS, DEFAULT_PITR_RETENTION_MS, DEFAULT_SHARD_CACHE_RETENTION_MS, + PitrPolicy, SQLITE_STORAGE_META_VERSION, ShardCachePolicy, decode_pitr_policy, + decode_shard_cache_policy, encode_pitr_policy, encode_shard_cache_policy, }, }; use universaldb::utils::IsolationLevel::Snapshot; use uuid::Uuid; fn bucket_id() -> depot::types::BucketId { - depot::types::BucketId::from_uuid(Uuid::from_u128( - 0x1020_3040_5060_7080_90a0_b0c0_d0e0_f000, - )) + depot::types::BucketId::from_uuid(Uuid::from_u128(0x1020_3040_5060_7080_90a0_b0c0_d0e0_f000)) } fn assert_embedded_version(encoded: &[u8]) { @@ -34,7 +31,12 @@ fn assert_embedded_version(encoded: &[u8]) { ); } -fn has_policy_error(err: &anyhow::Error, policy: &'static str, field: &'static str, value: i64) -> bool { +fn has_policy_error( + err: &anyhow::Error, + policy: &'static str, + field: &'static str, + value: i64, +) -> bool { err.chain().any(|cause| { matches!( cause.downcast_ref::(), @@ -68,207 +70,210 @@ fn policy_payloads_round_trip_with_embedded_version() { encode_shard_cache_policy(shard_cache).expect("shard cache policy should encode"); assert_embedded_version(&encoded_shard_cache); assert_eq!( - decode_shard_cache_policy(&encoded_shard_cache) - .expect("shard cache policy should decode"), + decode_shard_cache_policy(&encoded_shard_cache).expect("shard cache policy should decode"), shard_cache ); } #[tokio::test] async fn policy_lookup_falls_back_from_database_to_bucket_to_defaults() -> Result<()> { - common::test_matrix("conveyer-policy-fallback", |_tier, ctx| Box::pin(async move { - let db = ctx.udb.clone(); - let bucket_id = bucket_id(); - let database_id = ctx.database_id.clone(); - let bucket_pitr = PitrPolicy { - interval_ms: 120_000, - retention_ms: 2_400_000, - }; - let database_pitr = PitrPolicy { - interval_ms: 300_000, - retention_ms: 9_600_000, - }; - let bucket_shard_cache = ShardCachePolicy { - retention_ms: 4_800_000, - }; - let database_shard_cache = ShardCachePolicy { - retention_ms: 19_200_000, - }; + common::test_matrix("conveyer-policy-fallback", |_tier, ctx| { + Box::pin(async move { + let db = ctx.udb.clone(); + let bucket_id = bucket_id(); + let database_id = ctx.database_id.clone(); + let bucket_pitr = PitrPolicy { + interval_ms: 120_000, + retention_ms: 2_400_000, + }; + let database_pitr = PitrPolicy { + interval_ms: 300_000, + retention_ms: 9_600_000, + }; + let bucket_shard_cache = ShardCachePolicy { + retention_ms: 4_800_000, + }; + let database_shard_cache = ShardCachePolicy { + retention_ms: 19_200_000, + }; - assert_eq!( - get_bucket_pitr_policy(&db, bucket_id).await?, - PitrPolicy { - interval_ms: DEFAULT_PITR_INTERVAL_MS, - retention_ms: DEFAULT_PITR_RETENTION_MS, - } - ); - assert_eq!( - get_effective_pitr_policy(&db, bucket_id, &database_id).await?, - PitrPolicy::default() - ); - assert_eq!( - get_effective_shard_cache_policy(&db, bucket_id, &database_id).await?, - ShardCachePolicy { - retention_ms: DEFAULT_SHARD_CACHE_RETENTION_MS, - } - ); - assert_eq!( - get_bucket_shard_cache_policy(&db, bucket_id).await?, - ShardCachePolicy::default() - ); + assert_eq!( + get_bucket_pitr_policy(&db, bucket_id).await?, + PitrPolicy { + interval_ms: DEFAULT_PITR_INTERVAL_MS, + retention_ms: DEFAULT_PITR_RETENTION_MS, + } + ); + assert_eq!( + get_effective_pitr_policy(&db, bucket_id, &database_id).await?, + PitrPolicy::default() + ); + assert_eq!( + get_effective_shard_cache_policy(&db, bucket_id, &database_id).await?, + ShardCachePolicy { + retention_ms: DEFAULT_SHARD_CACHE_RETENTION_MS, + } + ); + assert_eq!( + get_bucket_shard_cache_policy(&db, bucket_id).await?, + ShardCachePolicy::default() + ); - set_bucket_pitr_policy(&db, bucket_id, bucket_pitr).await?; - set_bucket_shard_cache_policy(&db, bucket_id, bucket_shard_cache).await?; - assert_eq!( - get_effective_pitr_policy(&db, bucket_id, &database_id).await?, - bucket_pitr - ); - assert_eq!( - get_effective_shard_cache_policy(&db, bucket_id, &database_id).await?, - bucket_shard_cache - ); + set_bucket_pitr_policy(&db, bucket_id, bucket_pitr).await?; + set_bucket_shard_cache_policy(&db, bucket_id, bucket_shard_cache).await?; + assert_eq!( + get_effective_pitr_policy(&db, bucket_id, &database_id).await?, + bucket_pitr + ); + assert_eq!( + get_effective_shard_cache_policy(&db, bucket_id, &database_id).await?, + bucket_shard_cache + ); - set_database_pitr_policy_override(&db, bucket_id, &database_id, database_pitr).await?; - set_database_shard_cache_policy_override( - &db, - bucket_id, - &database_id, - database_shard_cache, - ) - .await?; - assert_eq!( - get_database_pitr_policy_override(&db, bucket_id, &database_id).await?, - Some(database_pitr) - ); - assert_eq!( - get_database_shard_cache_policy_override(&db, bucket_id, &database_id).await?, - Some(database_shard_cache) - ); - assert_eq!( - get_effective_pitr_policy(&db, bucket_id, &database_id).await?, - database_pitr - ); - assert_eq!( - get_effective_shard_cache_policy(&db, bucket_id, &database_id).await?, - database_shard_cache - ); + set_database_pitr_policy_override(&db, bucket_id, &database_id, database_pitr).await?; + set_database_shard_cache_policy_override( + &db, + bucket_id, + &database_id, + database_shard_cache, + ) + .await?; + assert_eq!( + get_database_pitr_policy_override(&db, bucket_id, &database_id).await?, + Some(database_pitr) + ); + assert_eq!( + get_database_shard_cache_policy_override(&db, bucket_id, &database_id).await?, + Some(database_shard_cache) + ); + assert_eq!( + get_effective_pitr_policy(&db, bucket_id, &database_id).await?, + database_pitr + ); + assert_eq!( + get_effective_shard_cache_policy(&db, bucket_id, &database_id).await?, + database_shard_cache + ); - clear_database_pitr_policy_override(&db, bucket_id, &database_id).await?; - clear_database_shard_cache_policy_override(&db, bucket_id, &database_id).await?; - assert_eq!( - get_database_pitr_policy_override(&db, bucket_id, &database_id).await?, - None - ); - assert_eq!( - get_database_shard_cache_policy_override(&db, bucket_id, &database_id).await?, - None - ); - assert_eq!( - get_effective_pitr_policy(&db, bucket_id, &database_id).await?, - bucket_pitr - ); - assert_eq!( - get_effective_shard_cache_policy(&db, bucket_id, &database_id).await?, - bucket_shard_cache - ); + clear_database_pitr_policy_override(&db, bucket_id, &database_id).await?; + clear_database_shard_cache_policy_override(&db, bucket_id, &database_id).await?; + assert_eq!( + get_database_pitr_policy_override(&db, bucket_id, &database_id).await?, + None + ); + assert_eq!( + get_database_shard_cache_policy_override(&db, bucket_id, &database_id).await?, + None + ); + assert_eq!( + get_effective_pitr_policy(&db, bucket_id, &database_id).await?, + bucket_pitr + ); + assert_eq!( + get_effective_shard_cache_policy(&db, bucket_id, &database_id).await?, + bucket_shard_cache + ); - Ok(()) - })) + Ok(()) + }) + }) .await } #[tokio::test] async fn policy_writes_use_expected_storage_keys() -> Result<()> { - common::test_matrix("conveyer-policy-storage", |_tier, ctx| Box::pin(async move { - let db = ctx.udb.clone(); - let bucket_id = bucket_id(); - let database_id = ctx.database_id.clone(); - let pitr = PitrPolicy { - interval_ms: 60_000, - retention_ms: 600_000, - }; - let shard_cache = ShardCachePolicy { - retention_ms: 1_200_000, - }; - - set_bucket_pitr_policy(&db, bucket_id, pitr).await?; - set_database_shard_cache_policy_override(&db, bucket_id, &database_id, shard_cache).await?; + common::test_matrix("conveyer-policy-storage", |_tier, ctx| { + Box::pin(async move { + let db = ctx.udb.clone(); + let bucket_id = bucket_id(); + let database_id = ctx.database_id.clone(); + let pitr = PitrPolicy { + interval_ms: 60_000, + retention_ms: 600_000, + }; + let shard_cache = ShardCachePolicy { + retention_ms: 1_200_000, + }; - let bucket_policy = db - .run(move |tx| async move { - Ok(tx - .informal() - .get(&keys::bucket_policy_pitr_key(bucket_id), Snapshot) - .await?) - }) - .await? - .expect("bucket pitr policy should be stored"); - assert_eq!(decode_pitr_policy(&bucket_policy)?, pitr); + set_bucket_pitr_policy(&db, bucket_id, pitr).await?; + set_database_shard_cache_policy_override(&db, bucket_id, &database_id, shard_cache) + .await?; - let database_policy = db - .run(move |tx| { - let database_id = database_id.clone(); - async move { + let bucket_policy = db + .run(move |tx| async move { Ok(tx .informal() - .get( - &keys::database_shard_cache_policy_key(bucket_id, &database_id), - Snapshot, - ) + .get(&keys::bucket_policy_pitr_key(bucket_id), Snapshot) .await?) - } - }) - .await? - .expect("database shard cache policy should be stored"); - assert_eq!(decode_shard_cache_policy(&database_policy)?, shard_cache); + }) + .await? + .expect("bucket pitr policy should be stored"); + assert_eq!(decode_pitr_policy(&bucket_policy)?, pitr); - Ok(()) - })) + let database_policy = db + .run(move |tx| { + let database_id = database_id.clone(); + async move { + Ok(tx + .informal() + .get( + &keys::database_shard_cache_policy_key(bucket_id, &database_id), + Snapshot, + ) + .await?) + } + }) + .await? + .expect("database shard cache policy should be stored"); + assert_eq!(decode_shard_cache_policy(&database_policy)?, shard_cache); + + Ok(()) + }) + }) .await } #[tokio::test] async fn invalid_policy_values_return_typed_errors() -> Result<()> { - common::test_matrix("conveyer-policy-invalid", |_tier, ctx| Box::pin(async move { - let db = ctx.udb.clone(); - let bucket_id = bucket_id(); - let database_id = ctx.database_id.clone(); + common::test_matrix("conveyer-policy-invalid", |_tier, ctx| { + Box::pin(async move { + let db = ctx.udb.clone(); + let bucket_id = bucket_id(); + let database_id = ctx.database_id.clone(); - let err = set_bucket_pitr_policy( - &db, - bucket_id, - PitrPolicy { - interval_ms: 0, - retention_ms: 1, - }, - ) - .await - .expect_err("zero pitr interval should fail"); - assert!(has_policy_error(&err, "pitr", "interval_ms", 0)); + let err = set_bucket_pitr_policy( + &db, + bucket_id, + PitrPolicy { + interval_ms: 0, + retention_ms: 1, + }, + ) + .await + .expect_err("zero pitr interval should fail"); + assert!(has_policy_error(&err, "pitr", "interval_ms", 0)); - let err = set_database_pitr_policy_override( - &db, - bucket_id, - &database_id, - PitrPolicy { - interval_ms: 1, - retention_ms: -1, - }, - ) - .await - .expect_err("negative pitr retention should fail"); - assert!(has_policy_error(&err, "pitr", "retention_ms", -1)); + let err = set_database_pitr_policy_override( + &db, + bucket_id, + &database_id, + PitrPolicy { + interval_ms: 1, + retention_ms: -1, + }, + ) + .await + .expect_err("negative pitr retention should fail"); + assert!(has_policy_error(&err, "pitr", "retention_ms", -1)); - let err = set_bucket_shard_cache_policy( - &db, - bucket_id, - ShardCachePolicy { retention_ms: 0 }, - ) - .await - .expect_err("zero shard cache retention should fail"); - assert!(has_policy_error(&err, "shard_cache", "retention_ms", 0)); + let err = + set_bucket_shard_cache_policy(&db, bucket_id, ShardCachePolicy { retention_ms: 0 }) + .await + .expect_err("zero shard cache retention should fail"); + assert!(has_policy_error(&err, "shard_cache", "retention_ms", 0)); - Ok(()) - })) + Ok(()) + }) + }) .await } diff --git a/engine/packages/depot/tests/conveyer_quota.rs b/engine/packages/depot/tests/conveyer_quota.rs index f9086c2a8e..85afd71028 100644 --- a/engine/packages/depot/tests/conveyer_quota.rs +++ b/engine/packages/depot/tests/conveyer_quota.rs @@ -8,54 +8,58 @@ use depot::quota::{ #[tokio::test] async fn quota_defaults_to_zero() -> Result<()> { - common::test_matrix("depot-quota-defaults", |_tier, ctx| Box::pin(async move { - let db = ctx.udb.clone(); - let database_id = ctx.database_id.clone(); + common::test_matrix("depot-quota-defaults", |_tier, ctx| { + Box::pin(async move { + let db = ctx.udb.clone(); + let database_id = ctx.database_id.clone(); - let storage_used = db - .run(move |tx| { - let database_id = database_id.clone(); - async move { read(&tx, &database_id).await } - }) - .await?; + let storage_used = db + .run(move |tx| { + let database_id = database_id.clone(); + async move { read(&tx, &database_id).await } + }) + .await?; - assert_eq!(storage_used, 0); + assert_eq!(storage_used, 0); - Ok(()) - })) + Ok(()) + }) + }) .await } #[tokio::test] async fn atomic_add_uses_signed_little_endian_counter() -> Result<()> { - common::test_matrix("depot-quota-atomic-add", |_tier, ctx| Box::pin(async move { - let db = ctx.udb.clone(); - let database_id = ctx.database_id.clone(); + common::test_matrix("depot-quota-atomic-add", |_tier, ctx| { + Box::pin(async move { + let db = ctx.udb.clone(); + let database_id = ctx.database_id.clone(); - db.run({ - let database_id = database_id.clone(); - move |tx| { + db.run({ let database_id = database_id.clone(); - async move { - atomic_add(&tx, &database_id, 128); - atomic_add(&tx, &database_id, -8); - Ok(()) + move |tx| { + let database_id = database_id.clone(); + async move { + atomic_add(&tx, &database_id, 128); + atomic_add(&tx, &database_id, -8); + Ok(()) + } } - } - }) - .await?; - - let storage_used = db - .run(move |tx| { - let database_id = database_id.clone(); - async move { read(&tx, &database_id).await } }) .await?; - assert_eq!(storage_used, 120); + let storage_used = db + .run(move |tx| { + let database_id = database_id.clone(); + async move { read(&tx, &database_id).await } + }) + .await?; - Ok(()) - })) + assert_eq!(storage_used, 120); + + Ok(()) + }) + }) .await } diff --git a/engine/packages/depot/tests/conveyer_read.rs b/engine/packages/depot/tests/conveyer_read.rs index 9ab3c77e5e..bca6f131e6 100644 --- a/engine/packages/depot/tests/conveyer_read.rs +++ b/engine/packages/depot/tests/conveyer_read.rs @@ -4,24 +4,23 @@ use std::{sync::Arc, time::Duration}; use anyhow::Result; use async_trait::async_trait; -use futures_util::TryStreamExt; use depot::{ ACCESS_TOUCH_THROTTLE_MS, cold_tier::{ColdTier, ColdTierObjectMetadata, FilesystemColdTier}, conveyer::{Db, branch, metrics}, error::SqliteStorageError, keys::{ - branch_compaction_cold_shard_key, branch_compaction_root_key, branch_delta_chunk_key, - branch_delta_chunk_prefix, - branch_manifest_last_access_bucket_key, branch_manifest_last_access_ts_ms_key, - branch_commit_key, branch_meta_head_key, branch_pidx_key, branch_shard_key, PAGE_SIZE, + PAGE_SIZE, branch_commit_key, branch_compaction_cold_shard_key, branch_compaction_root_key, + branch_delta_chunk_key, branch_delta_chunk_prefix, branch_manifest_last_access_bucket_key, + branch_manifest_last_access_ts_ms_key, branch_meta_head_key, branch_pidx_key, + branch_shard_key, }, ltx::{LtxHeader, encode_ltx_v3}, types::{ ColdManifestChunk, ColdManifestChunkRef, ColdManifestIndex, ColdShardRef, CompactionRoot, DBHead, DatabaseBranchId, DirtyPage, FetchedPage, LayerEntry, LayerKind, ResolvedVersionstamp, SQLITE_STORAGE_COLD_SCHEMA_VERSION, decode_commit_row, - encode_cold_manifest_chunk, decode_compaction_root, encode_cold_manifest_index, + decode_compaction_root, encode_cold_manifest_chunk, encode_cold_manifest_index, encode_cold_shard_ref, encode_compaction_root, encode_db_head, }, }; @@ -30,6 +29,7 @@ use depot::{ cold_tier::FaultyColdTier, fault::{DepotFaultController, DepotFaultPoint, ReadFaultPoint}, }; +use futures_util::TryStreamExt; use gas::prelude::Id; use rivet_pools::NodeId; use sha2::{Digest, Sha256}; @@ -191,9 +191,8 @@ async fn read_prefix_keys(db: &universaldb::Database, prefix: Vec) -> Result db.run(move |tx| { let prefix = prefix.clone(); async move { - let prefix_subspace = universaldb::Subspace::from( - universaldb::tuple::Subspace::from_bytes(prefix), - ); + let prefix_subspace = + universaldb::Subspace::from(universaldb::tuple::Subspace::from_bytes(prefix)); let informal = tx.informal(); let mut stream = informal.get_ranges_keyvalues( universaldb::RangeOption { @@ -278,12 +277,14 @@ async fn assert_shard_coverage_missing(database_db: &Db, pgno: u32) -> Result<() macro_rules! read_matrix { ($prefix:expr, |$ctx:ident, $db:ident, $database_db:ident| $body:block) => { - common::test_matrix($prefix, |_tier, $ctx| Box::pin(async move { - #[allow(unused_variables)] - let $db = $ctx.udb.clone(); - let $database_db = $ctx.make_db(test_bucket(), TEST_DATABASE); - $body - })) + common::test_matrix($prefix, |_tier, $ctx| { + Box::pin(async move { + #[allow(unused_variables)] + let $db = $ctx.udb.clone(); + let $database_db = $ctx.make_db(test_bucket(), TEST_DATABASE); + $body + }) + }) .await }; } @@ -303,27 +304,32 @@ async fn get_pages_rejects_page_zero() -> Result<()> { #[tokio::test] async fn missing_delta_without_fallback_errors_instead_of_zero_fill() -> Result<()> { - read_matrix!("depot-read-missing-delta-no-fallback", |ctx, db, database_db| { - database_db.commit(vec![dirty_page(1, 0x11)], 1, 1_000).await?; - let branch_id = read_database_branch_id(&db).await?; - seed( - &db, - Vec::new(), - vec![branch_delta_chunk_key(branch_id, 1, 0)], - ) - .await?; + read_matrix!( + "depot-read-missing-delta-no-fallback", + |ctx, db, database_db| { + database_db + .commit(vec![dirty_page(1, 0x11)], 1, 1_000) + .await?; + let branch_id = read_database_branch_id(&db).await?; + seed( + &db, + Vec::new(), + vec![branch_delta_chunk_key(branch_id, 1, 0)], + ) + .await?; - let err = database_db - .get_pages(vec![1]) - .await - .expect_err("missing delta without fallback should fail loudly"); - assert!(matches!( - err.downcast_ref::(), - Some(SqliteStorageError::ShardCoverageMissing { pgno: 1 }) - )); + let err = database_db + .get_pages(vec![1]) + .await + .expect_err("missing delta without fallback should fail loudly"); + assert!(matches!( + err.downcast_ref::(), + Some(SqliteStorageError::ShardCoverageMissing { pgno: 1 }) + )); - Ok(()) - }) + Ok(()) + } + ) } #[tokio::test] @@ -341,9 +347,7 @@ async fn missing_delta_chunks_fail_loudly() -> Result<()> { seed(&db, Vec::new(), existing_chunk_keys).await?; let blob = encoded_blob( 1, - &(1..=20) - .map(|pgno| (pgno, pgno as u8)) - .collect::>(), + &(1..=20).map(|pgno| (pgno, pgno as u8)).collect::>(), )?; let chunk_writes = blob .chunks(10) @@ -420,10 +424,11 @@ async fn read_fault_before_return_pages_fails_with_page_scope() -> Result<()> { async fn cold_ref_retired_during_cold_object_fetch_errors_instead_of_zero_fill() -> Result<()> { let (db, _db_dir) = common::test_db_with_dir("depot-read-cold-ref-retire-race").await?; let cold_dir = tempfile::tempdir()?; - let filesystem_tier: Arc = - Arc::new(FilesystemColdTier::new(cold_dir.path())); + let filesystem_tier: Arc = Arc::new(FilesystemColdTier::new(cold_dir.path())); let writer_db = common::make_db(db.clone(), test_bucket(), TEST_DATABASE.to_string()); - writer_db.commit(vec![dirty_page(1, 0x11)], 1, 1_000).await?; + writer_db + .commit(vec![dirty_page(1, 0x11)], 1, 1_000) + .await?; let branch_id = read_database_branch_id(&db).await?; let object_key = format!( "db/{}/shard/00000000/0000000000000001-retire-race.ltx", @@ -431,7 +436,9 @@ async fn cold_ref_retired_during_cold_object_fetch_errors_instead_of_zero_fill() ); let object_bytes = encoded_blob(1, &[(1, 0x99)])?; let cold_ref = cold_shard_ref(object_key.clone(), 0, 1, 2, &object_bytes); - filesystem_tier.put_object(&object_key, &object_bytes).await?; + filesystem_tier + .put_object(&object_key, &object_bytes) + .await?; seed( &db, vec![ @@ -496,7 +503,9 @@ async fn cold_ref_retired_during_cold_object_fetch_errors_instead_of_zero_fill() #[tokio::test] async fn get_pages_reads_with_cold_pidx_scan() -> Result<()> { read_matrix!("depot-read-pidx-scan", |ctx, db, database_db| { - database_db.commit(vec![dirty_page(2, 0x22)], 3, 1_000).await?; + database_db + .commit(vec![dirty_page(2, 0x22)], 3, 1_000) + .await?; assert_eq!( database_db.get_pages(vec![2]).await?, @@ -512,99 +521,108 @@ async fn get_pages_reads_with_cold_pidx_scan() -> Result<()> { #[tokio::test] async fn branch_cache_snapshot_is_atomic_across_dbptr_move() -> Result<()> { - read_matrix!("depot-read-cache-snapshot-atomic", |ctx, db, database_db| { - let database_db = Arc::new(database_db); - database_db.commit(vec![dirty_page(1, 0x11)], 2, 1_000).await?; - database_db.commit(vec![dirty_page(1, 0x22)], 2, 2_000).await?; - let old_branch_id = read_database_branch_id(&db).await?; - let first_commit = decode_commit_row( - &read_value(&db, branch_commit_key(old_branch_id, 1)) - .await? - .expect("first commit row should exist"), - )?; - - assert_eq!( - database_db.get_pages(vec![1]).await?, - vec![FetchedPage { - pgno: 1, - bytes: Some(page(0x22)), - }] - ); - let (cached_branch_id, cached_root_branch_id, _, _) = database_db - .branch_cache_snapshot_for_test() - .await - .expect("branch cache should be warm"); - assert_eq!(cached_branch_id, old_branch_id); - assert_eq!(cached_root_branch_id, old_branch_id); - - let new_branch_id = branch::rollback_database( - &db, - depot::types::BucketId::from_gas_id(test_bucket()), - TEST_DATABASE.to_string(), - ResolvedVersionstamp { - versionstamp: first_commit.versionstamp, - restore_point: None, - }, - ) - .await?; - assert_ne!(new_branch_id, old_branch_id); - - let start = Arc::new(Barrier::new(4)); - let mut readers = Vec::new(); - for _ in 0..2 { - let reader_db = Arc::clone(&database_db); - let reader_start = Arc::clone(&start); - readers.push(tokio::spawn(async move { - reader_start.wait().await; - reader_db.get_pages(vec![1]).await - })); - } + read_matrix!( + "depot-read-cache-snapshot-atomic", + |ctx, db, database_db| { + let database_db = Arc::new(database_db); + database_db + .commit(vec![dirty_page(1, 0x11)], 2, 1_000) + .await?; + database_db + .commit(vec![dirty_page(1, 0x22)], 2, 2_000) + .await?; + let old_branch_id = read_database_branch_id(&db).await?; + let first_commit = decode_commit_row( + &read_value(&db, branch_commit_key(old_branch_id, 1)) + .await? + .expect("first commit row should exist"), + )?; - let observer_db = Arc::clone(&database_db); - let observer_start = Arc::clone(&start); - let observer = tokio::spawn(async move { - observer_start.wait().await; - for _ in 0..8 { - if let Some((branch_id, root_branch_id, _, _)) = - observer_db.branch_cache_snapshot_for_test().await - { - assert!( - branch_id != new_branch_id || root_branch_id == new_branch_id, - "branch cache exposed new branch id with stale ancestry" - ); - } - tokio::task::yield_now().await; - } - }); - - start.wait().await; - for reader in readers { - let pages = reader.await??; assert_eq!( - pages, + database_db.get_pages(vec![1]).await?, vec![FetchedPage { pgno: 1, - bytes: Some(page(0x11)), + bytes: Some(page(0x22)), }] ); - } - observer.await?; + let (cached_branch_id, cached_root_branch_id, _, _) = database_db + .branch_cache_snapshot_for_test() + .await + .expect("branch cache should be warm"); + assert_eq!(cached_branch_id, old_branch_id); + assert_eq!(cached_root_branch_id, old_branch_id); - let (cached_branch_id, cached_root_branch_id, _, _) = database_db - .branch_cache_snapshot_for_test() - .await - .expect("branch cache should stay warm"); - assert_eq!(cached_branch_id, new_branch_id); - assert_eq!(cached_root_branch_id, new_branch_id); + let new_branch_id = branch::rollback_database( + &db, + depot::types::BucketId::from_gas_id(test_bucket()), + TEST_DATABASE.to_string(), + ResolvedVersionstamp { + versionstamp: first_commit.versionstamp, + restore_point: None, + }, + ) + .await?; + assert_ne!(new_branch_id, old_branch_id); + + let start = Arc::new(Barrier::new(4)); + let mut readers = Vec::new(); + for _ in 0..2 { + let reader_db = Arc::clone(&database_db); + let reader_start = Arc::clone(&start); + readers.push(tokio::spawn(async move { + reader_start.wait().await; + reader_db.get_pages(vec![1]).await + })); + } - Ok(()) - }) + let observer_db = Arc::clone(&database_db); + let observer_start = Arc::clone(&start); + let observer = tokio::spawn(async move { + observer_start.wait().await; + for _ in 0..8 { + if let Some((branch_id, root_branch_id, _, _)) = + observer_db.branch_cache_snapshot_for_test().await + { + assert!( + branch_id != new_branch_id || root_branch_id == new_branch_id, + "branch cache exposed new branch id with stale ancestry" + ); + } + tokio::task::yield_now().await; + } + }); + + start.wait().await; + for reader in readers { + let pages = reader.await??; + assert_eq!( + pages, + vec![FetchedPage { + pgno: 1, + bytes: Some(page(0x11)), + }] + ); + } + observer.await?; + + let (cached_branch_id, cached_root_branch_id, _, _) = database_db + .branch_cache_snapshot_for_test() + .await + .expect("branch cache should stay warm"); + assert_eq!(cached_branch_id, new_branch_id); + assert_eq!(cached_root_branch_id, new_branch_id); + + Ok(()) + } + ) } #[tokio::test] async fn get_pages_uses_warm_cache_without_pidx_row() -> Result<()> { read_matrix!("depot-read-warm-cache", |ctx, db, database_db| { - database_db.commit(vec![dirty_page(2, 0x22)], 3, 1_000).await?; + database_db + .commit(vec![dirty_page(2, 0x22)], 3, 1_000) + .await?; let branch_id = read_database_branch_id(&db).await?; assert_eq!( database_db.get_pages(vec![2]).await?, @@ -631,7 +649,9 @@ async fn get_pages_uses_warm_cache_without_pidx_row() -> Result<()> { #[tokio::test] async fn get_pages_falls_back_to_shard_when_cached_pidx_is_stale() -> Result<()> { read_matrix!("depot-read-stale-pidx", |ctx, db, database_db| { - database_db.commit(vec![dirty_page(2, 0x22)], 3, 1_000).await?; + database_db + .commit(vec![dirty_page(2, 0x22)], 3, 1_000) + .await?; let branch_id = read_database_branch_id(&db).await?; assert_eq!( database_db.get_pages(vec![2]).await?, @@ -643,7 +663,10 @@ async fn get_pages_falls_back_to_shard_when_cached_pidx_is_stale() -> Result<()> seed( &db, - vec![(branch_shard_key(branch_id, 0, 1), encoded_blob(1, &[(2, 0x44)])?)], + vec![( + branch_shard_key(branch_id, 0, 1), + encoded_blob(1, &[(2, 0x44)])?, + )], vec![ branch_delta_chunk_key(branch_id, 1, 0), branch_pidx_key(branch_id, 2), @@ -666,15 +689,29 @@ async fn get_pages_falls_back_to_shard_when_cached_pidx_is_stale() -> Result<()> #[tokio::test] async fn get_pages_reads_latest_shard_version_not_past_head() -> Result<()> { read_matrix!("depot-read-shard-version", |ctx, db, database_db| { - database_db.commit(vec![dirty_page(1, 0x11)], 3, 1_000).await?; + database_db + .commit(vec![dirty_page(1, 0x11)], 3, 1_000) + .await?; let branch_id = read_database_branch_id(&db).await?; seed( &db, vec![ - (branch_meta_head_key(branch_id), encode_db_head(head_at(4, 3))?), - (branch_shard_key(branch_id, 0, 2), encoded_blob(2, &[(2, 0x22)])?), - (branch_shard_key(branch_id, 0, 4), encoded_blob(4, &[(2, 0x44)])?), - (branch_shard_key(branch_id, 0, 5), encoded_blob(5, &[(2, 0x55)])?), + ( + branch_meta_head_key(branch_id), + encode_db_head(head_at(4, 3))?, + ), + ( + branch_shard_key(branch_id, 0, 2), + encoded_blob(2, &[(2, 0x22)])?, + ), + ( + branch_shard_key(branch_id, 0, 4), + encoded_blob(4, &[(2, 0x44)])?, + ), + ( + branch_shard_key(branch_id, 0, 5), + encoded_blob(5, &[(2, 0x55)])?, + ), ], Vec::new(), ) @@ -695,14 +732,22 @@ async fn get_pages_reads_latest_shard_version_not_past_head() -> Result<()> { #[tokio::test] async fn get_pages_reads_delta_before_published_branch_shard() -> Result<()> { read_matrix!("depot-read-delta-before-shard", |ctx, db, database_db| { - database_db.commit(vec![dirty_page(1, 0x11)], 1, 1_000).await?; + database_db + .commit(vec![dirty_page(1, 0x11)], 1, 1_000) + .await?; let branch_id = read_database_branch_id(&db).await?; seed( &db, vec![ - (branch_compaction_root_key(branch_id), encode_compaction_root(compaction_root(1))?), - (branch_shard_key(branch_id, 0, 1), encoded_blob(1, &[(1, 0x44)])?), + ( + branch_compaction_root_key(branch_id), + encode_compaction_root(compaction_root(1))?, + ), + ( + branch_shard_key(branch_id, 0, 1), + encoded_blob(1, &[(1, 0x44)])?, + ), ], Vec::new(), ) @@ -726,14 +771,22 @@ async fn get_pages_falls_back_to_published_branch_shard_when_delta_is_missing() let fdb_hit = metrics::SQLITE_SHARD_CACHE_READ_TOTAL .with_label_values(&[metrics::SHARD_CACHE_READ_FDB_HIT]); let fdb_hit_before = fdb_hit.get(); - database_db.commit(vec![dirty_page(1, 0x11)], 1, 1_000).await?; + database_db + .commit(vec![dirty_page(1, 0x11)], 1, 1_000) + .await?; let branch_id = read_database_branch_id(&db).await?; seed( &db, vec![ - (branch_compaction_root_key(branch_id), encode_compaction_root(compaction_root(1))?), - (branch_shard_key(branch_id, 0, 1), encoded_blob(1, &[(1, 0x44)])?), + ( + branch_compaction_root_key(branch_id), + encode_compaction_root(compaction_root(1))?, + ), + ( + branch_shard_key(branch_id, 0, 1), + encoded_blob(1, &[(1, 0x44)])?, + ), ], vec![branch_delta_chunk_key(branch_id, 1, 0)], ) @@ -754,42 +807,48 @@ async fn get_pages_falls_back_to_published_branch_shard_when_delta_is_missing() #[tokio::test] async fn get_pages_records_shard_cache_miss_when_no_shard_or_cold_ref_covers_page() -> Result<()> { - common::test_matrix("depot-read-cache-miss", |_tier, ctx| Box::pin(async move { - let db = ctx.udb.clone(); - let database_db = ctx.make_db(test_bucket(), TEST_DATABASE); - let miss = metrics::SQLITE_SHARD_CACHE_READ_TOTAL - .with_label_values(&[metrics::SHARD_CACHE_READ_MISS]); - let miss_before = miss.get(); - database_db.commit(vec![dirty_page(1, 0x11)], 1, 1_000).await?; - let branch_id = read_database_branch_id(&db).await?; - seed( - &db, - Vec::new(), - vec![ - branch_delta_chunk_key(branch_id, 1, 0), - branch_pidx_key(branch_id, 1), - ], - ) - .await?; + common::test_matrix("depot-read-cache-miss", |_tier, ctx| { + Box::pin(async move { + let db = ctx.udb.clone(); + let database_db = ctx.make_db(test_bucket(), TEST_DATABASE); + let miss = metrics::SQLITE_SHARD_CACHE_READ_TOTAL + .with_label_values(&[metrics::SHARD_CACHE_READ_MISS]); + let miss_before = miss.get(); + database_db + .commit(vec![dirty_page(1, 0x11)], 1, 1_000) + .await?; + let branch_id = read_database_branch_id(&db).await?; + seed( + &db, + Vec::new(), + vec![ + branch_delta_chunk_key(branch_id, 1, 0), + branch_pidx_key(branch_id, 1), + ], + ) + .await?; - assert_eq!( - database_db.get_pages(vec![1]).await?, - vec![FetchedPage { - pgno: 1, - bytes: Some(page(0)), - }] - ); - assert!(miss.get() >= miss_before + 1); + assert_eq!( + database_db.get_pages(vec![1]).await?, + vec![FetchedPage { + pgno: 1, + bytes: Some(page(0)), + }] + ); + assert!(miss.get() >= miss_before + 1); - Ok(()) - })) + Ok(()) + }) + }) .await } #[tokio::test] async fn get_pages_zero_fills_sparse_page_without_any_source() -> Result<()> { read_matrix!("depot-read-sparse-zero", |ctx, db, database_db| { - database_db.commit(vec![dirty_page(1, 0x11)], 3, 1_000).await?; + database_db + .commit(vec![dirty_page(1, 0x11)], 3, 1_000) + .await?; assert_eq!( database_db.get_pages(vec![2]).await?, @@ -806,11 +865,16 @@ async fn get_pages_zero_fills_sparse_page_without_any_source() -> Result<()> { #[tokio::test] async fn get_pages_errors_for_corrupted_delta_source() -> Result<()> { read_matrix!("depot-read-corrupt-delta", |ctx, db, database_db| { - database_db.commit(vec![dirty_page(1, 0x11)], 1, 1_000).await?; + database_db + .commit(vec![dirty_page(1, 0x11)], 1, 1_000) + .await?; let branch_id = read_database_branch_id(&db).await?; seed( &db, - vec![(branch_delta_chunk_key(branch_id, 1, 0), b"not an ltx blob".to_vec())], + vec![( + branch_delta_chunk_key(branch_id, 1, 0), + b"not an ltx blob".to_vec(), + )], Vec::new(), ) .await?; @@ -819,9 +883,10 @@ async fn get_pages_errors_for_corrupted_delta_source() -> Result<()> { .get_pages(vec![1]) .await .expect_err("corrupted delta source should error instead of zero-filling"); - assert!(err - .chain() - .any(|cause| cause.to_string().contains("decode source blob for page 1"))); + assert!( + err.chain() + .any(|cause| cause.to_string().contains("decode source blob for page 1")) + ); Ok(()) }) @@ -830,7 +895,9 @@ async fn get_pages_errors_for_corrupted_delta_source() -> Result<()> { #[tokio::test] async fn get_pages_returns_zero_for_hot_only_missing_in_range_page() -> Result<()> { read_matrix!("depot-read-hot-missing-zero", |ctx, db, database_db| { - database_db.commit(vec![dirty_page(1, 0x11)], 3, 1_000).await?; + database_db + .commit(vec![dirty_page(1, 0x11)], 3, 1_000) + .await?; assert_eq!( database_db.get_pages(vec![2]).await?, @@ -847,12 +914,17 @@ async fn get_pages_returns_zero_for_hot_only_missing_in_range_page() -> Result<( #[tokio::test] async fn get_pages_throttles_access_touch_for_same_bucket_shard_reads() -> Result<()> { read_matrix!("depot-read-access-touch", |ctx, db, database_db| { - database_db.commit(vec![dirty_page(1, 0x11)], 1, 1_000).await?; + database_db + .commit(vec![dirty_page(1, 0x11)], 1, 1_000) + .await?; let branch_id = read_database_branch_id(&db).await?; seed( &db, - vec![(branch_shard_key(branch_id, 0, 1), encoded_blob(1, &[(1, 0x44)])?)], + vec![( + branch_shard_key(branch_id, 0, 1), + encoded_blob(1, &[(1, 0x44)])?, + )], vec![ branch_delta_chunk_key(branch_id, 1, 0), branch_pidx_key(branch_id, 1), @@ -873,7 +945,10 @@ async fn get_pages_throttles_access_touch_for_same_bucket_shard_reads() -> Resul let first_bucket = read_i64_le(&db, branch_manifest_last_access_bucket_key(branch_id)) .await? .expect("shard read should touch access bucket"); - assert_eq!(first_bucket, first_touch.div_euclid(ACCESS_TOUCH_THROTTLE_MS)); + assert_eq!( + first_bucket, + first_touch.div_euclid(ACCESS_TOUCH_THROTTLE_MS) + ); assert_eq!( database_db.get_pages(vec![1]).await?, @@ -898,12 +973,17 @@ async fn get_pages_throttles_access_touch_for_same_bucket_shard_reads() -> Resul #[tokio::test] async fn get_pages_keeps_branch_shard_fallback_without_compaction_root() -> Result<()> { read_matrix!("depot-read-shard-no-root", |ctx, db, database_db| { - database_db.commit(vec![dirty_page(1, 0x11)], 1, 1_000).await?; + database_db + .commit(vec![dirty_page(1, 0x11)], 1, 1_000) + .await?; let branch_id = read_database_branch_id(&db).await?; seed( &db, - vec![(branch_shard_key(branch_id, 0, 1), encoded_blob(1, &[(1, 0x44)])?)], + vec![( + branch_shard_key(branch_id, 0, 1), + encoded_blob(1, &[(1, 0x44)])?, + )], vec![branch_delta_chunk_key(branch_id, 1, 0)], ) .await?; @@ -922,76 +1002,79 @@ async fn get_pages_keeps_branch_shard_fallback_without_compaction_root() -> Resu #[tokio::test] async fn get_pages_falls_back_to_compaction_cold_shard_ref() -> Result<()> { - common::test_matrix("depot-read-compaction-cold-ref", |tier_mode, ctx| Box::pin(async move { - let db = ctx.udb.clone(); - let database_db = ctx.make_db(test_bucket(), TEST_DATABASE); - database_db.commit(vec![dirty_page(1, 0x11)], 1, 1_000).await?; - let branch_id = read_database_branch_id(&db).await?; - let object_key = format!( - "db/{}/shard/00000000/0000000000000001-{}-workflow.ltx", - branch_id.as_uuid().simple(), - Id::v1(uuid::Uuid::from_u128(0x1234), 7) - ); - let object_bytes = encoded_blob(1, &[(1, 0x66)])?; + common::test_matrix("depot-read-compaction-cold-ref", |tier_mode, ctx| { + Box::pin(async move { + let db = ctx.udb.clone(); + let database_db = ctx.make_db(test_bucket(), TEST_DATABASE); + database_db + .commit(vec![dirty_page(1, 0x11)], 1, 1_000) + .await?; + let branch_id = read_database_branch_id(&db).await?; + let object_key = format!( + "db/{}/shard/00000000/0000000000000001-{}-workflow.ltx", + branch_id.as_uuid().simple(), + Id::v1(uuid::Uuid::from_u128(0x1234), 7) + ); + let object_bytes = encoded_blob(1, &[(1, 0x66)])?; - if let Some(tier) = &ctx.cold_tier { - tier.put_object(&object_key, &object_bytes).await?; - } - seed( - &db, - vec![ - (branch_compaction_root_key(branch_id), encode_compaction_root(compaction_root(2))?), - ( - branch_compaction_cold_shard_key(branch_id, 0, 1), - encode_cold_shard_ref(cold_shard_ref( - object_key, - 0, - 1, - 2, - &object_bytes, - ))?, - ), - ], - vec![ - branch_delta_chunk_key(branch_id, 1, 0), - branch_pidx_key(branch_id, 1), - ], - ) - .await?; + if let Some(tier) = &ctx.cold_tier { + tier.put_object(&object_key, &object_bytes).await?; + } + seed( + &db, + vec![ + ( + branch_compaction_root_key(branch_id), + encode_compaction_root(compaction_root(2))?, + ), + ( + branch_compaction_cold_shard_key(branch_id, 0, 1), + encode_cold_shard_ref(cold_shard_ref(object_key, 0, 1, 2, &object_bytes))?, + ), + ], + vec![ + branch_delta_chunk_key(branch_id, 1, 0), + branch_pidx_key(branch_id, 1), + ], + ) + .await?; - if tier_mode == common::TierMode::Disabled { - let err = database_db - .get_pages(vec![1]) - .await - .expect_err("cold-disabled reads should fail on cold-only coverage"); - assert!(matches!( - err.downcast_ref::(), - Some(SqliteStorageError::ShardCoverageMissing { pgno: 1 }) - )); - return Ok(()); - } + if tier_mode == common::TierMode::Disabled { + let err = database_db + .get_pages(vec![1]) + .await + .expect_err("cold-disabled reads should fail on cold-only coverage"); + assert!(matches!( + err.downcast_ref::(), + Some(SqliteStorageError::ShardCoverageMissing { pgno: 1 }) + )); + return Ok(()); + } - assert_eq!( - database_db.get_pages(vec![1]).await?, - vec![FetchedPage { - pgno: 1, - bytes: Some(page(0x66)), - }] - ); - let last_access_ts_ms = read_i64_le(&db, branch_manifest_last_access_ts_ms_key(branch_id)) - .await? - .expect("cold-backed read should touch access timestamp"); - let last_access_bucket = read_i64_le(&db, branch_manifest_last_access_bucket_key(branch_id)) - .await? - .expect("cold-backed read should touch access bucket"); - assert!(last_access_ts_ms > 1_000); - assert_eq!( - last_access_bucket, - last_access_ts_ms.div_euclid(ACCESS_TOUCH_THROTTLE_MS) - ); + assert_eq!( + database_db.get_pages(vec![1]).await?, + vec![FetchedPage { + pgno: 1, + bytes: Some(page(0x66)), + }] + ); + let last_access_ts_ms = + read_i64_le(&db, branch_manifest_last_access_ts_ms_key(branch_id)) + .await? + .expect("cold-backed read should touch access timestamp"); + let last_access_bucket = + read_i64_le(&db, branch_manifest_last_access_bucket_key(branch_id)) + .await? + .expect("cold-backed read should touch access bucket"); + assert!(last_access_ts_ms > 1_000); + assert_eq!( + last_access_bucket, + last_access_ts_ms.div_euclid(ACCESS_TOUCH_THROTTLE_MS) + ); - Ok(()) - })) + Ok(()) + }) + }) .await } @@ -1009,19 +1092,22 @@ async fn cold_tier_drop_artifact_fault_errors_instead_of_zero_fill() -> Result<( branch_id.as_uuid().simple(), ); let object_bytes = encoded_blob(1, &[(1, 0x66)])?; - filesystem_tier.put_object(&object_key, &object_bytes).await?; + filesystem_tier + .put_object(&object_key, &object_bytes) + .await?; let controller = DepotFaultController::new(); controller - .at(DepotFaultPoint::ColdTier(depot::fault::ColdTierFaultPoint::GetObject)) + .at(DepotFaultPoint::ColdTier( + depot::fault::ColdTierFaultPoint::GetObject, + )) .once() .drop_artifact()?; - let faulty_tier: Arc = Arc::new( - FaultyColdTier::new_with_fault_controller_for_test( + let faulty_tier: Arc = + Arc::new(FaultyColdTier::new_with_fault_controller_for_test( filesystem_tier, "cold-drop-artifact-node", controller.clone(), - ), - ); + )); let reader = Db::new_with_cold_tier_and_fault_controller_for_test( db.clone(), test_bucket(), @@ -1033,16 +1119,13 @@ async fn cold_tier_drop_artifact_fault_errors_instead_of_zero_fill() -> Result<( seed( &db, vec![ - (branch_compaction_root_key(branch_id), encode_compaction_root(compaction_root(2))?), + ( + branch_compaction_root_key(branch_id), + encode_compaction_root(compaction_root(2))?, + ), ( branch_compaction_cold_shard_key(branch_id, 0, 1), - encode_cold_shard_ref(cold_shard_ref( - object_key, - 0, - 1, - 2, - &object_bytes, - ))?, + encode_cold_shard_ref(cold_shard_ref(object_key, 0, 1, 2, &object_bytes))?, ), ], vec![ @@ -1107,7 +1190,10 @@ async fn cold_shard_reads_cover_shard_boundaries() -> Result<()> { seed( &db, vec![ - (branch_compaction_root_key(branch_id), encode_compaction_root(compaction_root(2))?), + ( + branch_compaction_root_key(branch_id), + encode_compaction_root(compaction_root(2))?, + ), ( branch_compaction_cold_shard_key(branch_id, 0, 1), encode_cold_shard_ref(cold_shard_ref(shard_0_key, 0, 1, 2, &shard_0_bytes))?, @@ -1128,12 +1214,30 @@ async fn cold_shard_reads_cover_shard_boundaries() -> Result<()> { assert_eq!( database_db.get_pages(pgnos.to_vec()).await?, vec![ - FetchedPage { pgno: 63, bytes: Some(page(0x63)) }, - FetchedPage { pgno: 64, bytes: Some(page(0x64)) }, - FetchedPage { pgno: 65, bytes: Some(page(0x65)) }, - FetchedPage { pgno: 127, bytes: Some(page(0x7f)) }, - FetchedPage { pgno: 128, bytes: Some(page(0x80)) }, - FetchedPage { pgno: 129, bytes: Some(page(0x81)) }, + FetchedPage { + pgno: 63, + bytes: Some(page(0x63)) + }, + FetchedPage { + pgno: 64, + bytes: Some(page(0x64)) + }, + FetchedPage { + pgno: 65, + bytes: Some(page(0x65)) + }, + FetchedPage { + pgno: 127, + bytes: Some(page(0x7f)) + }, + FetchedPage { + pgno: 128, + bytes: Some(page(0x80)) + }, + FetchedPage { + pgno: 129, + bytes: Some(page(0x81)) + }, ] ); @@ -1142,68 +1246,75 @@ async fn cold_shard_reads_cover_shard_boundaries() -> Result<()> { #[tokio::test] async fn cold_shard_read_returns_before_background_cache_fill() -> Result<()> { - common::test_matrix("depot-read-fill-before", |tier_mode, ctx| Box::pin(async move { - let db = ctx.udb.clone(); - let database_db = match ctx.cold_tier.clone() { - Some(tier) => Db::new_with_cold_tier_and_shard_cache_fill_limits_for_test( - db.clone(), - test_bucket(), - TEST_DATABASE.to_string(), - NodeId::new(), - tier, - 1, - 0, - ), - None => ctx.make_db(test_bucket(), TEST_DATABASE), - }; - database_db.commit(vec![dirty_page(1, 0x11)], 1, 1_000).await?; - let branch_id = read_database_branch_id(&db).await?; - let object_key = format!( - "db/{}/shard/00000000/0000000000000001-read-before-fill.ltx", - branch_id.as_uuid().simple(), - ); - let object_bytes = encoded_blob(1, &[(1, 0x77)])?; - let cold_ref = cold_shard_ref(object_key.clone(), 0, 1, 2, &object_bytes); - - if let Some(tier) = &ctx.cold_tier { - tier.put_object(&object_key, &object_bytes).await?; - } - seed( - &db, - vec![ - (branch_compaction_root_key(branch_id), encode_compaction_root(compaction_root(2))?), - ( - branch_compaction_cold_shard_key(branch_id, 0, 1), - encode_cold_shard_ref(cold_ref)?, + common::test_matrix("depot-read-fill-before", |tier_mode, ctx| { + Box::pin(async move { + let db = ctx.udb.clone(); + let database_db = match ctx.cold_tier.clone() { + Some(tier) => Db::new_with_cold_tier_and_shard_cache_fill_limits_for_test( + db.clone(), + test_bucket(), + TEST_DATABASE.to_string(), + NodeId::new(), + tier, + 1, + 0, ), - ], - vec![ - branch_delta_chunk_key(branch_id, 1, 0), - branch_pidx_key(branch_id, 1), - ], - ) - .await?; + None => ctx.make_db(test_bucket(), TEST_DATABASE), + }; + database_db + .commit(vec![dirty_page(1, 0x11)], 1, 1_000) + .await?; + let branch_id = read_database_branch_id(&db).await?; + let object_key = format!( + "db/{}/shard/00000000/0000000000000001-read-before-fill.ltx", + branch_id.as_uuid().simple(), + ); + let object_bytes = encoded_blob(1, &[(1, 0x77)])?; + let cold_ref = cold_shard_ref(object_key.clone(), 0, 1, 2, &object_bytes); - if tier_mode == common::TierMode::Disabled { - assert_shard_coverage_missing(&database_db, 1).await?; - return Ok(()); - } + if let Some(tier) = &ctx.cold_tier { + tier.put_object(&object_key, &object_bytes).await?; + } + seed( + &db, + vec![ + ( + branch_compaction_root_key(branch_id), + encode_compaction_root(compaction_root(2))?, + ), + ( + branch_compaction_cold_shard_key(branch_id, 0, 1), + encode_cold_shard_ref(cold_ref)?, + ), + ], + vec![ + branch_delta_chunk_key(branch_id, 1, 0), + branch_pidx_key(branch_id, 1), + ], + ) + .await?; - assert_eq!( - database_db.get_pages(vec![1]).await?, - vec![FetchedPage { - pgno: 1, - bytes: Some(page(0x77)), - }] - ); - assert_eq!( - read_value(&db, branch_shard_key(branch_id, 0, 1)).await?, - None - ); - assert_eq!(database_db.shard_cache_fill_outstanding_for_test(), 1); + if tier_mode == common::TierMode::Disabled { + assert_shard_coverage_missing(&database_db, 1).await?; + return Ok(()); + } - Ok(()) - })) + assert_eq!( + database_db.get_pages(vec![1]).await?, + vec![FetchedPage { + pgno: 1, + bytes: Some(page(0x77)), + }] + ); + assert_eq!( + read_value(&db, branch_shard_key(branch_id, 0, 1)).await?, + None + ); + assert_eq!(database_db.shard_cache_fill_outstanding_for_test(), 1); + + Ok(()) + }) + }) .await } @@ -1232,7 +1343,10 @@ async fn shard_cache_fill_enqueue_drop_artifact_skips_background_fill() -> Resul seed( &db, vec![ - (branch_compaction_root_key(branch_id), encode_compaction_root(compaction_root(2))?), + ( + branch_compaction_root_key(branch_id), + encode_compaction_root(compaction_root(2))?, + ), ( branch_compaction_cold_shard_key(branch_id, 0, 1), encode_cold_shard_ref(cold_ref)?, @@ -1267,7 +1381,10 @@ async fn shard_cache_fill_enqueue_drop_artifact_skips_background_fill() -> Resul }] ); reader.wait_for_shard_cache_fill_idle_for_test().await; - assert_eq!(read_value(&db, branch_shard_key(branch_id, 0, 1)).await?, None); + assert_eq!( + read_value(&db, branch_shard_key(branch_id, 0, 1)).await?, + None + ); controller.assert_expected_fired()?; Ok(()) @@ -1275,91 +1392,98 @@ async fn shard_cache_fill_enqueue_drop_artifact_skips_background_fill() -> Resul #[tokio::test] async fn cold_shard_read_background_fills_shard_without_changing_watermarks() -> Result<()> { - common::test_matrix("depot-read-fill-success", |tier_mode, ctx| Box::pin(async move { - let db = ctx.udb.clone(); - let database_db = ctx.make_db(test_bucket(), TEST_DATABASE); - let cold_hit = metrics::SQLITE_SHARD_CACHE_READ_TOTAL - .with_label_values(&[metrics::SHARD_CACHE_READ_COLD_HIT]); - let fill_scheduled = metrics::SQLITE_SHARD_CACHE_FILL_TOTAL - .with_label_values(&[metrics::SHARD_CACHE_FILL_SCHEDULED]); - let fill_succeeded = metrics::SQLITE_SHARD_CACHE_FILL_TOTAL - .with_label_values(&[metrics::SHARD_CACHE_FILL_SUCCEEDED]); - let cold_hit_before = cold_hit.get(); - let fill_scheduled_before = fill_scheduled.get(); - let fill_succeeded_before = fill_succeeded.get(); - let fill_bytes_before = metrics::SQLITE_SHARD_CACHE_FILL_BYTES_TOTAL.get(); - database_db.commit(vec![dirty_page(1, 0x11)], 1, 1_000).await?; - let branch_id = read_database_branch_id(&db).await?; - let object_key = format!( - "db/{}/shard/00000000/0000000000000001-fill-success.ltx", - branch_id.as_uuid().simple(), - ); - let object_bytes = encoded_blob(1, &[(1, 0x88)])?; - let cold_ref = cold_shard_ref(object_key.clone(), 0, 1, 2, &object_bytes); - let root = CompactionRoot { - hot_watermark_txid: 1, - cold_watermark_txid: 1, - cold_watermark_versionstamp: [8; 16], - ..compaction_root(2) - }; - - if let Some(tier) = &ctx.cold_tier { - tier.put_object(&object_key, &object_bytes).await?; - } - seed( - &db, - vec![ - (branch_compaction_root_key(branch_id), encode_compaction_root(root.clone())?), - ( - branch_compaction_cold_shard_key(branch_id, 0, 1), - encode_cold_shard_ref(cold_ref)?, - ), - ], - vec![ - branch_delta_chunk_key(branch_id, 1, 0), - branch_pidx_key(branch_id, 1), - ], - ) - .await?; - - if tier_mode == common::TierMode::Disabled { - assert_shard_coverage_missing(&database_db, 1).await?; - return Ok(()); - } + common::test_matrix("depot-read-fill-success", |tier_mode, ctx| { + Box::pin(async move { + let db = ctx.udb.clone(); + let database_db = ctx.make_db(test_bucket(), TEST_DATABASE); + let cold_hit = metrics::SQLITE_SHARD_CACHE_READ_TOTAL + .with_label_values(&[metrics::SHARD_CACHE_READ_COLD_HIT]); + let fill_scheduled = metrics::SQLITE_SHARD_CACHE_FILL_TOTAL + .with_label_values(&[metrics::SHARD_CACHE_FILL_SCHEDULED]); + let fill_succeeded = metrics::SQLITE_SHARD_CACHE_FILL_TOTAL + .with_label_values(&[metrics::SHARD_CACHE_FILL_SUCCEEDED]); + let cold_hit_before = cold_hit.get(); + let fill_scheduled_before = fill_scheduled.get(); + let fill_succeeded_before = fill_succeeded.get(); + let fill_bytes_before = metrics::SQLITE_SHARD_CACHE_FILL_BYTES_TOTAL.get(); + database_db + .commit(vec![dirty_page(1, 0x11)], 1, 1_000) + .await?; + let branch_id = read_database_branch_id(&db).await?; + let object_key = format!( + "db/{}/shard/00000000/0000000000000001-fill-success.ltx", + branch_id.as_uuid().simple(), + ); + let object_bytes = encoded_blob(1, &[(1, 0x88)])?; + let cold_ref = cold_shard_ref(object_key.clone(), 0, 1, 2, &object_bytes); + let root = CompactionRoot { + hot_watermark_txid: 1, + cold_watermark_txid: 1, + cold_watermark_versionstamp: [8; 16], + ..compaction_root(2) + }; + + if let Some(tier) = &ctx.cold_tier { + tier.put_object(&object_key, &object_bytes).await?; + } + seed( + &db, + vec![ + ( + branch_compaction_root_key(branch_id), + encode_compaction_root(root.clone())?, + ), + ( + branch_compaction_cold_shard_key(branch_id, 0, 1), + encode_cold_shard_ref(cold_ref)?, + ), + ], + vec![ + branch_delta_chunk_key(branch_id, 1, 0), + branch_pidx_key(branch_id, 1), + ], + ) + .await?; - assert_eq!( - database_db.get_pages(vec![1]).await?, - vec![FetchedPage { - pgno: 1, - bytes: Some(page(0x88)), - }] - ); - database_db.wait_for_shard_cache_fill_idle_for_test().await; + if tier_mode == common::TierMode::Disabled { + assert_shard_coverage_missing(&database_db, 1).await?; + return Ok(()); + } - assert!(cold_hit.get() >= cold_hit_before + 1); - assert!(fill_scheduled.get() >= fill_scheduled_before + 1); - assert!(fill_succeeded.get() >= fill_succeeded_before + 1); - assert!( - metrics::SQLITE_SHARD_CACHE_FILL_BYTES_TOTAL.get() - >= fill_bytes_before + u64::try_from(object_bytes.len()).unwrap_or(u64::MAX) - ); - assert_eq!( - read_value(&db, branch_shard_key(branch_id, 0, 1)).await?, - Some(object_bytes) - ); - let root_after = read_value(&db, branch_compaction_root_key(branch_id)) - .await? - .expect("compaction root should remain present"); - let decoded = decode_compaction_root(&root_after)?; - assert_eq!(decoded.hot_watermark_txid, root.hot_watermark_txid); - assert_eq!(decoded.cold_watermark_txid, root.cold_watermark_txid); - assert_eq!( - decoded.cold_watermark_versionstamp, - root.cold_watermark_versionstamp - ); + assert_eq!( + database_db.get_pages(vec![1]).await?, + vec![FetchedPage { + pgno: 1, + bytes: Some(page(0x88)), + }] + ); + database_db.wait_for_shard_cache_fill_idle_for_test().await; + + assert!(cold_hit.get() >= cold_hit_before + 1); + assert!(fill_scheduled.get() >= fill_scheduled_before + 1); + assert!(fill_succeeded.get() >= fill_succeeded_before + 1); + assert!( + metrics::SQLITE_SHARD_CACHE_FILL_BYTES_TOTAL.get() + >= fill_bytes_before + u64::try_from(object_bytes.len()).unwrap_or(u64::MAX) + ); + assert_eq!( + read_value(&db, branch_shard_key(branch_id, 0, 1)).await?, + Some(object_bytes) + ); + let root_after = read_value(&db, branch_compaction_root_key(branch_id)) + .await? + .expect("compaction root should remain present"); + let decoded = decode_compaction_root(&root_after)?; + assert_eq!(decoded.hot_watermark_txid, root.hot_watermark_txid); + assert_eq!(decoded.cold_watermark_txid, root.cold_watermark_txid); + assert_eq!( + decoded.cold_watermark_versionstamp, + root.cold_watermark_versionstamp + ); - Ok(()) - })) + Ok(()) + }) + }) .await } @@ -1395,104 +1519,109 @@ async fn shard_cache_fill_wait_idle_prearms_before_rechecking_outstanding() -> R #[tokio::test] async fn cold_shard_cache_fill_coalesces_duplicates_and_skips_when_queue_full() -> Result<()> { - common::test_matrix("depot-read-fill-full", |tier_mode, ctx| Box::pin(async move { - let db = ctx.udb.clone(); - let node_id = NodeId::new(); - let node_label = node_id.to_string(); - let database_db = match ctx.cold_tier.clone() { - Some(tier) => Db::new_with_cold_tier_and_shard_cache_fill_limits_for_test( - db.clone(), - test_bucket(), - TEST_DATABASE.to_string(), - node_id, - tier, - 1, - 0, - ), - None => ctx.make_db(test_bucket(), TEST_DATABASE), - }; - database_db - .commit(vec![dirty_page(1, 0x11), dirty_page(65, 0x22)], 65, 1_000) - .await?; - let branch_id = read_database_branch_id(&db).await?; - let object_0_key = format!( - "db/{}/shard/00000000/0000000000000001-full-0.ltx", - branch_id.as_uuid().simple(), - ); - let object_1_key = format!( - "db/{}/shard/00000001/0000000000000001-full-1.ltx", - branch_id.as_uuid().simple(), - ); - let object_0_bytes = encoded_blob(1, &[(1, 0x91)])?; - let object_1_bytes = encoded_blob(1, &[(65, 0x92)])?; - if let Some(tier) = &ctx.cold_tier { - tier.put_object(&object_0_key, &object_0_bytes).await?; - tier.put_object(&object_1_key, &object_1_bytes).await?; - } - seed( - &db, - vec![ - (branch_compaction_root_key(branch_id), encode_compaction_root(compaction_root(2))?), - ( - branch_compaction_cold_shard_key(branch_id, 0, 1), - encode_cold_shard_ref(cold_shard_ref( - object_0_key, - 0, - 1, - 2, - &object_0_bytes, - ))?, - ), - ( - branch_compaction_cold_shard_key(branch_id, 1, 1), - encode_cold_shard_ref(cold_shard_ref( - object_1_key, - 1, - 1, - 2, - &object_1_bytes, - ))?, + common::test_matrix("depot-read-fill-full", |tier_mode, ctx| { + Box::pin(async move { + let db = ctx.udb.clone(); + let node_id = NodeId::new(); + let node_label = node_id.to_string(); + let database_db = match ctx.cold_tier.clone() { + Some(tier) => Db::new_with_cold_tier_and_shard_cache_fill_limits_for_test( + db.clone(), + test_bucket(), + TEST_DATABASE.to_string(), + node_id, + tier, + 1, + 0, ), - ], - vec![ - branch_delta_chunk_key(branch_id, 1, 0), - branch_pidx_key(branch_id, 1), - branch_pidx_key(branch_id, 65), - ], - ) - .await?; + None => ctx.make_db(test_bucket(), TEST_DATABASE), + }; + database_db + .commit(vec![dirty_page(1, 0x11), dirty_page(65, 0x22)], 65, 1_000) + .await?; + let branch_id = read_database_branch_id(&db).await?; + let object_0_key = format!( + "db/{}/shard/00000000/0000000000000001-full-0.ltx", + branch_id.as_uuid().simple(), + ); + let object_1_key = format!( + "db/{}/shard/00000001/0000000000000001-full-1.ltx", + branch_id.as_uuid().simple(), + ); + let object_0_bytes = encoded_blob(1, &[(1, 0x91)])?; + let object_1_bytes = encoded_blob(1, &[(65, 0x92)])?; + if let Some(tier) = &ctx.cold_tier { + tier.put_object(&object_0_key, &object_0_bytes).await?; + tier.put_object(&object_1_key, &object_1_bytes).await?; + } + seed( + &db, + vec![ + ( + branch_compaction_root_key(branch_id), + encode_compaction_root(compaction_root(2))?, + ), + ( + branch_compaction_cold_shard_key(branch_id, 0, 1), + encode_cold_shard_ref(cold_shard_ref( + object_0_key, + 0, + 1, + 2, + &object_0_bytes, + ))?, + ), + ( + branch_compaction_cold_shard_key(branch_id, 1, 1), + encode_cold_shard_ref(cold_shard_ref( + object_1_key, + 1, + 1, + 2, + &object_1_bytes, + ))?, + ), + ], + vec![ + branch_delta_chunk_key(branch_id, 1, 0), + branch_pidx_key(branch_id, 1), + branch_pidx_key(branch_id, 65), + ], + ) + .await?; - if tier_mode == common::TierMode::Disabled { - assert_shard_coverage_missing(&database_db, 1).await?; - return Ok(()); - } + if tier_mode == common::TierMode::Disabled { + assert_shard_coverage_missing(&database_db, 1).await?; + return Ok(()); + } - let skipped = metrics::SQLITE_SHARD_CACHE_FILL_SKIPPED_QUEUE_FULL_TOTAL - .with_label_values(&[node_label.as_str()]); - let fill_scheduled = metrics::SQLITE_SHARD_CACHE_FILL_TOTAL - .with_label_values(&[metrics::SHARD_CACHE_FILL_SCHEDULED]); - let fill_duplicate = metrics::SQLITE_SHARD_CACHE_FILL_TOTAL - .with_label_values(&[metrics::SHARD_CACHE_FILL_SKIPPED_DUPLICATE]); - let fill_queue_full = metrics::SQLITE_SHARD_CACHE_FILL_TOTAL - .with_label_values(&[metrics::SHARD_CACHE_FILL_SKIPPED_QUEUE_FULL]); - let skipped_before = skipped.get(); - let fill_scheduled_before = fill_scheduled.get(); - let fill_duplicate_before = fill_duplicate.get(); - let fill_queue_full_before = fill_queue_full.get(); - database_db.get_pages(vec![1]).await?; - assert_eq!(database_db.shard_cache_fill_outstanding_for_test(), 1); - assert!(fill_scheduled.get() >= fill_scheduled_before + 1); - database_db.get_pages(vec![1]).await?; - assert_eq!(database_db.shard_cache_fill_outstanding_for_test(), 1); - assert_eq!(skipped.get(), skipped_before); - assert!(fill_duplicate.get() >= fill_duplicate_before + 1); - database_db.get_pages(vec![65]).await?; - assert_eq!(database_db.shard_cache_fill_outstanding_for_test(), 1); - assert_eq!(skipped.get(), skipped_before + 1); - assert!(fill_queue_full.get() >= fill_queue_full_before + 1); + let skipped = metrics::SQLITE_SHARD_CACHE_FILL_SKIPPED_QUEUE_FULL_TOTAL + .with_label_values(&[node_label.as_str()]); + let fill_scheduled = metrics::SQLITE_SHARD_CACHE_FILL_TOTAL + .with_label_values(&[metrics::SHARD_CACHE_FILL_SCHEDULED]); + let fill_duplicate = metrics::SQLITE_SHARD_CACHE_FILL_TOTAL + .with_label_values(&[metrics::SHARD_CACHE_FILL_SKIPPED_DUPLICATE]); + let fill_queue_full = metrics::SQLITE_SHARD_CACHE_FILL_TOTAL + .with_label_values(&[metrics::SHARD_CACHE_FILL_SKIPPED_QUEUE_FULL]); + let skipped_before = skipped.get(); + let fill_scheduled_before = fill_scheduled.get(); + let fill_duplicate_before = fill_duplicate.get(); + let fill_queue_full_before = fill_queue_full.get(); + database_db.get_pages(vec![1]).await?; + assert_eq!(database_db.shard_cache_fill_outstanding_for_test(), 1); + assert!(fill_scheduled.get() >= fill_scheduled_before + 1); + database_db.get_pages(vec![1]).await?; + assert_eq!(database_db.shard_cache_fill_outstanding_for_test(), 1); + assert_eq!(skipped.get(), skipped_before); + assert!(fill_duplicate.get() >= fill_duplicate_before + 1); + database_db.get_pages(vec![65]).await?; + assert_eq!(database_db.shard_cache_fill_outstanding_for_test(), 1); + assert_eq!(skipped.get(), skipped_before + 1); + assert!(fill_queue_full.get() >= fill_queue_full_before + 1); - Ok(()) - })) + Ok(()) + }) + }) .await } @@ -1502,7 +1631,9 @@ async fn direct_shard_cache_fill_skips_when_cold_ref_changes() -> Result<()> { let skipped_no_cold_ref = metrics::SQLITE_SHARD_CACHE_FILL_TOTAL .with_label_values(&[metrics::SHARD_CACHE_FILL_SKIPPED_NO_COLD_REF]); let skipped_no_cold_ref_before = skipped_no_cold_ref.get(); - database_db.commit(vec![dirty_page(1, 0x11)], 1, 1_000).await?; + database_db + .commit(vec![dirty_page(1, 0x11)], 1, 1_000) + .await?; let branch_id = read_database_branch_id(&db).await?; let object_bytes = encoded_blob(1, &[(1, 0xaa)])?; let old_ref = cold_shard_ref("old-object.ltx".to_string(), 0, 1, 2, &object_bytes); @@ -1533,7 +1664,9 @@ async fn direct_shard_cache_fill_skips_when_cold_ref_changes() -> Result<()> { #[tokio::test] async fn direct_shard_cache_fill_is_idempotent_for_matching_shard_bytes() -> Result<()> { read_matrix!("depot-read-fill-idem", |ctx, db, database_db| { - database_db.commit(vec![dirty_page(1, 0x11)], 1, 1_000).await?; + database_db + .commit(vec![dirty_page(1, 0x11)], 1, 1_000) + .await?; let branch_id = read_database_branch_id(&db).await?; let object_bytes = encoded_blob(1, &[(1, 0xbb)])?; let cold_ref = cold_shard_ref("idem-object.ltx".to_string(), 0, 1, 2, &object_bytes); @@ -1568,7 +1701,9 @@ async fn direct_shard_cache_fill_reports_corruption_for_conflicting_shard_bytes( let failed = metrics::SQLITE_SHARD_CACHE_FILL_TOTAL .with_label_values(&[metrics::SHARD_CACHE_FILL_FAILED]); let failed_before = failed.get(); - database_db.commit(vec![dirty_page(1, 0x11)], 1, 1_000).await?; + database_db + .commit(vec![dirty_page(1, 0x11)], 1, 1_000) + .await?; let branch_id = read_database_branch_id(&db).await?; let object_bytes = encoded_blob(1, &[(1, 0xcc)])?; let existing_bytes = encoded_blob(1, &[(1, 0xdd)])?; @@ -1609,95 +1744,104 @@ async fn direct_shard_cache_fill_reports_corruption_for_conflicting_shard_bytes( #[tokio::test] async fn get_pages_falls_through_to_cold_tier_when_hot_branch_data_is_evicted() -> Result<()> { - common::test_matrix("depot-read-cold-manifest-fallback", |tier_mode, ctx| Box::pin(async move { - let db = ctx.udb.clone(); - let database_db = ctx.make_db(test_bucket(), TEST_DATABASE); - database_db.commit(vec![dirty_page(1, 0x66)], 1, 1_000).await?; - let branch_id = read_database_branch_id(&db).await?; - let layer_key = format!( - "db/{}/image/00000000/00000000-0000000000000001.ltx", - branch_id.as_uuid().simple() - ); - let chunk_key = format!( - "db/{}/cold_manifest/chunks/read-cold.bare", - branch_id.as_uuid().simple() - ); - let index_key = format!("db/{}/cold_manifest/index.bare", branch_id.as_uuid().simple()); - let layer_bytes = encoded_blob(1, &[(1, 0x66)])?; - - if let Some(tier) = &ctx.cold_tier { - tier.put_object(&layer_key, &layer_bytes).await?; - tier.put_object( - &chunk_key, - &encode_cold_manifest_chunk(ColdManifestChunk { - schema_version: SQLITE_STORAGE_COLD_SCHEMA_VERSION, - branch_id, - pass_versionstamp: [1; 16], - layers: vec![LayerEntry { - kind: LayerKind::Image, - shard_id: Some(0), - min_txid: 1, - max_txid: 1, - min_versionstamp: [1; 16], - max_versionstamp: [1; 16], - byte_size: layer_bytes.len() as u64, - checksum: 0, - object_key: layer_key, - }], - restore_points: Vec::new(), - })?, - ) - .await?; - tier.put_object( - &index_key, - &encode_cold_manifest_index(ColdManifestIndex { - schema_version: SQLITE_STORAGE_COLD_SCHEMA_VERSION, - branch_id, - chunks: vec![ColdManifestChunkRef { - object_key: chunk_key, + common::test_matrix("depot-read-cold-manifest-fallback", |tier_mode, ctx| { + Box::pin(async move { + let db = ctx.udb.clone(); + let database_db = ctx.make_db(test_bucket(), TEST_DATABASE); + database_db + .commit(vec![dirty_page(1, 0x66)], 1, 1_000) + .await?; + let branch_id = read_database_branch_id(&db).await?; + let layer_key = format!( + "db/{}/image/00000000/00000000-0000000000000001.ltx", + branch_id.as_uuid().simple() + ); + let chunk_key = format!( + "db/{}/cold_manifest/chunks/read-cold.bare", + branch_id.as_uuid().simple() + ); + let index_key = format!( + "db/{}/cold_manifest/index.bare", + branch_id.as_uuid().simple() + ); + let layer_bytes = encoded_blob(1, &[(1, 0x66)])?; + + if let Some(tier) = &ctx.cold_tier { + tier.put_object(&layer_key, &layer_bytes).await?; + tier.put_object( + &chunk_key, + &encode_cold_manifest_chunk(ColdManifestChunk { + schema_version: SQLITE_STORAGE_COLD_SCHEMA_VERSION, + branch_id, pass_versionstamp: [1; 16], - min_versionstamp: [1; 16], - max_versionstamp: [1; 16], - byte_size: 1, - }], - last_pass_at_ms: 1_000, - last_pass_versionstamp: [1; 16], - })?, + layers: vec![LayerEntry { + kind: LayerKind::Image, + shard_id: Some(0), + min_txid: 1, + max_txid: 1, + min_versionstamp: [1; 16], + max_versionstamp: [1; 16], + byte_size: layer_bytes.len() as u64, + checksum: 0, + object_key: layer_key, + }], + restore_points: Vec::new(), + })?, + ) + .await?; + tier.put_object( + &index_key, + &encode_cold_manifest_index(ColdManifestIndex { + schema_version: SQLITE_STORAGE_COLD_SCHEMA_VERSION, + branch_id, + chunks: vec![ColdManifestChunkRef { + object_key: chunk_key, + pass_versionstamp: [1; 16], + min_versionstamp: [1; 16], + max_versionstamp: [1; 16], + byte_size: 1, + }], + last_pass_at_ms: 1_000, + last_pass_versionstamp: [1; 16], + })?, + ) + .await?; + } + + seed( + &db, + Vec::new(), + vec![ + branch_delta_chunk_key(branch_id, 1, 0), + branch_pidx_key(branch_id, 1), + ], ) .await?; - } - seed( - &db, - Vec::new(), - vec![ - branch_delta_chunk_key(branch_id, 1, 0), - branch_pidx_key(branch_id, 1), - ], - ) - .await?; - - assert_eq!( - database_db.get_pages(vec![1]).await?, - vec![FetchedPage { - pgno: 1, - bytes: Some(if tier_mode == common::TierMode::Disabled { - page(0) - } else { - page(0x66) - }), - }] - ); + assert_eq!( + database_db.get_pages(vec![1]).await?, + vec![FetchedPage { + pgno: 1, + bytes: Some(if tier_mode == common::TierMode::Disabled { + page(0) + } else { + page(0x66) + }), + }] + ); - Ok(()) - })) + Ok(()) + }) + }) .await } #[tokio::test] async fn get_pages_returns_none_above_eof() -> Result<()> { read_matrix!("depot-read-above-eof", |ctx, db, database_db| { - database_db.commit(vec![dirty_page(1, 0x11)], 3, 1_000).await?; + database_db + .commit(vec![dirty_page(1, 0x11)], 3, 1_000) + .await?; assert_eq!( database_db.get_pages(vec![4]).await?, diff --git a/engine/packages/depot/tests/conveyer_restore_point.rs b/engine/packages/depot/tests/conveyer_restore_point.rs index f0b2d0cd29..40ad14ebac 100644 --- a/engine/packages/depot/tests/conveyer_restore_point.rs +++ b/engine/packages/depot/tests/conveyer_restore_point.rs @@ -3,23 +3,24 @@ mod common; use std::time::Duration; use anyhow::Result; -use gas::prelude::Id; use depot::{ + conveyer::{branch, history_pin, restore_point}, error::SqliteStorageError, keys::{ - restore_point_key, branch_commit_key, branch_meta_head_at_fork_key, - branch_pitr_interval_key, branch_pidx_key, branch_vtx_key, branches_restore_point_pin_key, - db_pin_key, bucket_branches_pin_count_key, + branch_commit_key, branch_meta_head_at_fork_key, branch_pidx_key, branch_pitr_interval_key, + branch_vtx_key, branches_restore_point_pin_key, bucket_branches_pin_count_key, db_pin_key, + restore_point_key, }, - conveyer::{restore_point, branch, history_pin}, pitr_interval::write_pitr_interval_coverage, types::{ - DatabaseBranchId, RestorePointRef, RestorePointId, CommitRow, DbHistoryPinKind, DirtyPage, BucketId, PinStatus, - PitrIntervalCoverage, RestorePointRecord, ResolvedRestoreTarget, ResolvedVersionstamp, - SnapshotKind, SnapshotSelector, decode_commit_row, - decode_db_head, decode_db_history_pin, decode_restore_point_record, encode_restore_point_record, + BucketId, CommitRow, DatabaseBranchId, DbHistoryPinKind, DirtyPage, PinStatus, + PitrIntervalCoverage, ResolvedRestoreTarget, ResolvedVersionstamp, RestorePointId, + RestorePointRecord, RestorePointRef, SnapshotKind, SnapshotSelector, decode_commit_row, + decode_db_head, decode_db_history_pin, decode_restore_point_record, + encode_restore_point_record, }, }; +use gas::prelude::Id; use universaldb::utils::IsolationLevel::Serializable; const TEST_DATABASE: &str = "test-database"; @@ -113,19 +114,22 @@ fn decode_i64_counter(bytes: &[u8]) -> i64 { macro_rules! restore_matrix { ($prefix:expr, |$ctx:ident, $db:ident, $database_db:ident| $body:block) => { - common::test_matrix($prefix, |_tier, $ctx| Box::pin(async move { - #[allow(unused_variables)] - let $db = $ctx.udb.clone(); - let $database_db = $ctx.make_db(test_bucket(), TEST_DATABASE); - $body - })) + common::test_matrix($prefix, |_tier, $ctx| { + Box::pin(async move { + #[allow(unused_variables)] + let $db = $ctx.udb.clone(); + let $database_db = $ctx.make_db(test_bucket(), TEST_DATABASE); + $body + }) + }) .await }; } #[test] fn restore_point_format_is_fixed_width_hex() { - let restore_point = RestorePointId::format(1_700_000_000_000, 42).expect("restore_point should format"); + let restore_point = + RestorePointId::format(1_700_000_000_000, 42).expect("restore_point should format"); assert_eq!(restore_point.as_str(), "0000018bcfe56800-000000000000002a"); assert_eq!( @@ -147,7 +151,10 @@ fn restore_point_new_rejects_malformed_wire_strings() { ]; for case in cases { - assert!(RestorePointId::new(case).is_err(), "{case} should be rejected"); + assert!( + RestorePointId::new(case).is_err(), + "{case} should be rejected" + ); } } @@ -158,19 +165,13 @@ fn restore_point_format_rejects_negative_timestamps() { #[test] fn restore_point_round_trip_property_for_representative_values() { - let timestamps = [ - 0, - 1, - 999, - 1_700_000_000_000, - i64::MAX / 2, - i64::MAX, - ]; + let timestamps = [0, 1, 999, 1_700_000_000_000, i64::MAX / 2, i64::MAX]; let txids = [0, 1, 42, u32::MAX as u64, u64::MAX - 1, u64::MAX]; for ts_ms in timestamps { for txid in txids { - let restore_point = RestorePointId::format(ts_ms, txid).expect("restore_point should format"); + let restore_point = + RestorePointId::format(ts_ms, txid).expect("restore_point should format"); assert_eq!(restore_point.as_str().len(), 33); assert_eq!( restore_point.parse().expect("restore_point should parse"), @@ -201,1006 +202,1207 @@ fn restore_point_lex_order_matches_chronological_order_for_one_branch() { #[tokio::test] async fn create_restore_point_returns_retained_restore_point_for_latest_commit() -> Result<()> { - restore_matrix!("create_restore_point_returns_retained_restore_point_for_latest_commit", |ctx, db, database_db| { + restore_matrix!( + "create_restore_point_returns_retained_restore_point_for_latest_commit", + |ctx, db, database_db| { + database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; + let restore_point = database_db + .create_restore_point(SnapshotSelector::Latest) + .await?; - database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; - let restore_point = database_db.create_restore_point(SnapshotSelector::Latest).await?; - - assert_eq!(restore_point.as_str().len(), 33); - assert_eq!(restore_point.parse()?, (1_000, 1)); - assert!( - common::read_value(&db, restore_point_key(TEST_DATABASE, restore_point.as_str())) - .await? - .is_some() - ); + assert_eq!(restore_point.as_str().len(), 33); + assert_eq!(restore_point.parse()?, (1_000, 1)); + assert!( + common::read_value( + &db, + restore_point_key(TEST_DATABASE, restore_point.as_str()) + ) + .await? + .is_some() + ); - Ok(()) - }) + Ok(()) + } + ) } #[tokio::test] async fn create_restore_point_from_timestamp_selector_pins_selected_interval() -> Result<()> { - restore_matrix!("create_restore_point_from_timestamp_selector_pins_selected_interval", |ctx, db, database_db| { - database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; - database_db.commit(vec![page(1, 0x22)], 2, 2_500).await?; - let bucket_id = BucketId::from_gas_id(test_bucket()); - let branch_id = database_branch_id(&db, bucket_id, TEST_DATABASE).await?; - let first = commit_row(&db, branch_id, 1).await?; - seed_pitr_interval( - &db, - branch_id, - 1_000, - PitrIntervalCoverage { - txid: 1, - versionstamp: first.versionstamp, - wall_clock_ms: 1_000, - expires_at_ms: i64::MAX, - }, - ) - .await?; + restore_matrix!( + "create_restore_point_from_timestamp_selector_pins_selected_interval", + |ctx, db, database_db| { + database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; + database_db.commit(vec![page(1, 0x22)], 2, 2_500).await?; + let bucket_id = BucketId::from_gas_id(test_bucket()); + let branch_id = database_branch_id(&db, bucket_id, TEST_DATABASE).await?; + let first = commit_row(&db, branch_id, 1).await?; + seed_pitr_interval( + &db, + branch_id, + 1_000, + PitrIntervalCoverage { + txid: 1, + versionstamp: first.versionstamp, + wall_clock_ms: 1_000, + expires_at_ms: i64::MAX, + }, + ) + .await?; - let restore_point = database_db - .create_restore_point(SnapshotSelector::AtTimestamp { timestamp_ms: 1_500 }) - .await?; + let restore_point = database_db + .create_restore_point(SnapshotSelector::AtTimestamp { + timestamp_ms: 1_500, + }) + .await?; - assert_eq!(restore_point.parse()?, (1_000, 1)); - let record_bytes = common::read_value(&db, restore_point_key(TEST_DATABASE, restore_point.as_str())) - .await? - .expect("restore point record should exist"); - let record = decode_restore_point_record(&record_bytes)?; - assert_eq!(record.database_branch_id, branch_id); - assert_eq!(record.versionstamp, first.versionstamp); - let pin_bytes = common::read_value( - &db, - db_pin_key(branch_id, &history_pin::restore_point_pin_id(&restore_point)), - ) - .await? - .expect("restore point DB_PIN should exist"); - let pin = decode_db_history_pin(&pin_bytes)?; - assert_eq!(pin.at_txid, 1); - assert_eq!(pin.at_versionstamp, first.versionstamp); + assert_eq!(restore_point.parse()?, (1_000, 1)); + let record_bytes = common::read_value( + &db, + restore_point_key(TEST_DATABASE, restore_point.as_str()), + ) + .await? + .expect("restore point record should exist"); + let record = decode_restore_point_record(&record_bytes)?; + assert_eq!(record.database_branch_id, branch_id); + assert_eq!(record.versionstamp, first.versionstamp); + let pin_bytes = common::read_value( + &db, + db_pin_key( + branch_id, + &history_pin::restore_point_pin_id(&restore_point), + ), + ) + .await? + .expect("restore point DB_PIN should exist"); + let pin = decode_db_history_pin(&pin_bytes)?; + assert_eq!(pin.at_txid, 1); + assert_eq!(pin.at_versionstamp, first.versionstamp); - Ok(()) - }) + Ok(()) + } + ) } #[tokio::test] async fn create_restore_point_revalidates_target_commit_after_resolve_race() -> Result<()> { - restore_matrix!("create_restore_point_revalidates_target_commit_after_resolve_race", |ctx, db, _database_db| { - let database_id = "test-database-create-race"; - let database_db = ctx.make_db(test_bucket(), database_id); - database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; - let bucket_id = BucketId::from_gas_id(test_bucket()); - let branch_id = database_branch_id(&db, bucket_id, database_id).await?; - let row = commit_row(&db, branch_id, 1).await?; - seed_pitr_interval( - &db, - branch_id, - 1_000, - PitrIntervalCoverage { - txid: 1, - versionstamp: row.versionstamp, - wall_clock_ms: 1_000, - expires_at_ms: i64::MAX, - }, - ) - .await?; - let restore_point = RestorePointId::format(1_000, 1)?; - let (_guard, reached, release) = restore_point::test_hooks::pause_after_resolve(database_id); - let create_task = { - let db = db.clone(); - let database_id = database_id.to_string(); - tokio::spawn(async move { - restore_point::create_restore_point( + restore_matrix!( + "create_restore_point_revalidates_target_commit_after_resolve_race", + |ctx, db, _database_db| { + let database_id = "test-database-create-race"; + let database_db = ctx.make_db(test_bucket(), database_id); + database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; + let bucket_id = BucketId::from_gas_id(test_bucket()); + let branch_id = database_branch_id(&db, bucket_id, database_id).await?; + let row = commit_row(&db, branch_id, 1).await?; + seed_pitr_interval( &db, - bucket_id, - database_id, - SnapshotSelector::AtTimestamp { timestamp_ms: 1_500 }, + branch_id, + 1_000, + PitrIntervalCoverage { + txid: 1, + versionstamp: row.versionstamp, + wall_clock_ms: 1_000, + expires_at_ms: i64::MAX, + }, ) - .await - }) - }; - - tokio::time::timeout(Duration::from_secs(2), reached.notified()) - .await - .expect("restore point creation should pause after resolving target"); - clear_value(&db, branch_commit_key(branch_id, 1)).await?; - clear_value(&db, branch_vtx_key(branch_id, row.versionstamp)).await?; - clear_value(&db, branch_pitr_interval_key(branch_id, 1_000)).await?; - release.notify_waiters(); - - let err = create_task - .await - .expect("restore point task should not panic") - .expect_err("deleted target commit should abort restore point creation"); - assert_sqlite_error(err, SqliteStorageError::RestoreTargetExpired); - assert!( - common::read_value(&db, restore_point_key(database_id, restore_point.as_str())) - .await? - .is_none() - ); + .await?; + let restore_point = RestorePointId::format(1_000, 1)?; + let (_guard, reached, release) = + restore_point::test_hooks::pause_after_resolve(database_id); + let create_task = { + let db = db.clone(); + let database_id = database_id.to_string(); + tokio::spawn(async move { + restore_point::create_restore_point( + &db, + bucket_id, + database_id, + SnapshotSelector::AtTimestamp { + timestamp_ms: 1_500, + }, + ) + .await + }) + }; + + tokio::time::timeout(Duration::from_secs(2), reached.notified()) + .await + .expect("restore point creation should pause after resolving target"); + clear_value(&db, branch_commit_key(branch_id, 1)).await?; + clear_value(&db, branch_vtx_key(branch_id, row.versionstamp)).await?; + clear_value(&db, branch_pitr_interval_key(branch_id, 1_000)).await?; + release.notify_waiters(); + + let err = create_task + .await + .expect("restore point task should not panic") + .expect_err("deleted target commit should abort restore point creation"); + assert_sqlite_error(err, SqliteStorageError::RestoreTargetExpired); + assert!( + common::read_value(&db, restore_point_key(database_id, restore_point.as_str())) + .await? + .is_none() + ); - Ok(()) - }) + Ok(()) + } + ) } #[tokio::test] async fn restore_point_status_reads_pinned_record_or_absent() -> Result<()> { - restore_matrix!("restore_point_status_reads_pinned_record_or_absent", |ctx, db, database_db| { - database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; - let restore_point = database_db.create_restore_point(SnapshotSelector::Latest).await?; - let bucket_id = BucketId::from_gas_id(test_bucket()); + restore_matrix!( + "restore_point_status_reads_pinned_record_or_absent", + |ctx, db, database_db| { + database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; + let restore_point = database_db + .create_restore_point(SnapshotSelector::Latest) + .await?; + let bucket_id = BucketId::from_gas_id(test_bucket()); - assert_eq!( - database_db - .restore_point_status(RestorePointId::format(1_001, 1)?) - .await?, - None - ); + assert_eq!( + database_db + .restore_point_status(RestorePointId::format(1_001, 1)?) + .await?, + None + ); - let database_branch_id = db - .run({ - let restore_point = restore_point.clone(); + let database_branch_id = db + .run({ + let restore_point = restore_point.clone(); - move |tx| { - let restore_point = restore_point.clone(); + move |tx| { + let restore_point = restore_point.clone(); - async move { - let branch_id = - branch::resolve_database_branch(&tx, bucket_id, TEST_DATABASE, Serializable) + async move { + let branch_id = branch::resolve_database_branch( + &tx, + bucket_id, + TEST_DATABASE, + Serializable, + ) .await? .expect("database branch should exist"); - let pinned_key = - depot::keys::restore_point_key(TEST_DATABASE, restore_point.as_str()); - let record = RestorePointRecord { - restore_point_id: restore_point, - database_branch_id: branch_id, - versionstamp: [9; 16], - status: PinStatus::Ready, - pin_object_key: None, - created_at_ms: 1_000, - updated_at_ms: 1_100, - }; - let encoded = encode_restore_point_record(record)?; - tx.informal().set(&pinned_key, &encoded); - - Ok(branch_id) - } - } - }) - .await?; - - assert_ne!(database_branch_id.as_uuid(), uuid::Uuid::nil()); - assert_eq!(database_db.restore_point_status(restore_point.clone()).await?, Some(PinStatus::Ready)); - assert_eq!( - restore_point::restore_point_status(&db, bucket_id, TEST_DATABASE.to_string(), restore_point).await?, - Some(PinStatus::Ready) - ); + let pinned_key = depot::keys::restore_point_key( + TEST_DATABASE, + restore_point.as_str(), + ); + let record = RestorePointRecord { + restore_point_id: restore_point, + database_branch_id: branch_id, + versionstamp: [9; 16], + status: PinStatus::Ready, + pin_object_key: None, + created_at_ms: 1_000, + updated_at_ms: 1_100, + }; + let encoded = encode_restore_point_record(record)?; + tx.informal().set(&pinned_key, &encoded); + + Ok(branch_id) + } + } + }) + .await?; + + assert_ne!(database_branch_id.as_uuid(), uuid::Uuid::nil()); + assert_eq!( + database_db + .restore_point_status(restore_point.clone()) + .await?, + Some(PinStatus::Ready) + ); + assert_eq!( + restore_point::restore_point_status( + &db, + bucket_id, + TEST_DATABASE.to_string(), + restore_point + ) + .await?, + Some(PinStatus::Ready) + ); - Ok(()) - }) + Ok(()) + } + ) } #[tokio::test] async fn restore_point_ready_transition_writes_history_pin_without_object_key() -> Result<()> { - restore_matrix!("restore_point_ready_transition_writes_history_pin_without_object_key", |ctx, db, database_db| { - database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; - let bucket_id = BucketId::from_gas_id(test_bucket()); - let branch_id = database_branch_id(&db, bucket_id, TEST_DATABASE).await?; - let bucket_branch_id = bucket_branch_id(&db, bucket_id).await?; - let row = commit_row(&db, branch_id, 1).await?; - - let restore_point = database_db.create_restore_point(SnapshotSelector::Latest).await?; - - assert_eq!(restore_point.parse()?, (1_000, 1)); - let pinned_bytes = common::read_value( - &db, - depot::keys::restore_point_key(TEST_DATABASE, restore_point.as_str()), - ) - .await? - .expect("restore point record should exist"); - let pinned = decode_restore_point_record(&pinned_bytes)?; - assert_eq!(pinned.restore_point_id, restore_point); - assert_eq!(pinned.database_branch_id, branch_id); - assert_eq!(pinned.versionstamp, row.versionstamp); - assert_eq!(pinned.status, PinStatus::Ready); - assert_eq!(pinned.pin_object_key, None); - assert_eq!( - common::read_value(&db, branches_restore_point_pin_key(branch_id)) + restore_matrix!( + "restore_point_ready_transition_writes_history_pin_without_object_key", + |ctx, db, database_db| { + database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; + let bucket_id = BucketId::from_gas_id(test_bucket()); + let branch_id = database_branch_id(&db, bucket_id, TEST_DATABASE).await?; + let bucket_branch_id = bucket_branch_id(&db, bucket_id).await?; + let row = commit_row(&db, branch_id, 1).await?; + + let restore_point = database_db + .create_restore_point(SnapshotSelector::Latest) + .await?; + + assert_eq!(restore_point.parse()?, (1_000, 1)); + let pinned_bytes = common::read_value( + &db, + depot::keys::restore_point_key(TEST_DATABASE, restore_point.as_str()), + ) .await? - .expect("branch restore_point_pin should be written"), - row.versionstamp - ); - let db_pin_bytes = common::read_value( - &db, - db_pin_key(branch_id, &history_pin::restore_point_pin_id(&restore_point)), - ) - .await? - .expect("restore_point DB_PIN should exist"); - let db_pin = decode_db_history_pin(&db_pin_bytes)?; - assert_eq!(db_pin.kind, DbHistoryPinKind::RestorePoint); - assert_eq!(db_pin.at_txid, 1); - assert_eq!(db_pin.at_versionstamp, row.versionstamp); - assert_eq!(db_pin.owner_restore_point, Some(restore_point.clone())); - let pin_count = common::read_value(&db, bucket_branches_pin_count_key(bucket_branch_id)) - .await? - .expect("bucket pin count should be incremented"); - assert_eq!(decode_i64_counter(&pin_count), 1); + .expect("restore point record should exist"); + let pinned = decode_restore_point_record(&pinned_bytes)?; + assert_eq!(pinned.restore_point_id, restore_point); + assert_eq!(pinned.database_branch_id, branch_id); + assert_eq!(pinned.versionstamp, row.versionstamp); + assert_eq!(pinned.status, PinStatus::Ready); + assert_eq!(pinned.pin_object_key, None); + assert_eq!( + common::read_value(&db, branches_restore_point_pin_key(branch_id)) + .await? + .expect("branch restore_point_pin should be written"), + row.versionstamp + ); + let db_pin_bytes = common::read_value( + &db, + db_pin_key( + branch_id, + &history_pin::restore_point_pin_id(&restore_point), + ), + ) + .await? + .expect("restore_point DB_PIN should exist"); + let db_pin = decode_db_history_pin(&db_pin_bytes)?; + assert_eq!(db_pin.kind, DbHistoryPinKind::RestorePoint); + assert_eq!(db_pin.at_txid, 1); + assert_eq!(db_pin.at_versionstamp, row.versionstamp); + assert_eq!(db_pin.owner_restore_point, Some(restore_point.clone())); + let pin_count = + common::read_value(&db, bucket_branches_pin_count_key(bucket_branch_id)) + .await? + .expect("bucket pin count should be incremented"); + assert_eq!(decode_i64_counter(&pin_count), 1); - Ok(()) - }) + Ok(()) + } + ) } #[tokio::test] async fn legacy_failed_restore_point_status_preserves_object_key() -> Result<()> { - restore_matrix!("legacy_failed_restore_point_status_preserves_object_key", |ctx, db, database_db| { - database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; - let bucket_id = BucketId::from_gas_id(test_bucket()); - let branch_id = database_branch_id(&db, bucket_id, TEST_DATABASE).await?; - let row = commit_row(&db, branch_id, 1).await?; - let restore_point = RestorePointId::format(1_010, 1)?; - let pin_object_key = "db/legacy/pin/object.ltx".to_string(); - - db.run({ - let restore_point = restore_point.clone(); - let pin_object_key = pin_object_key.clone(); - - move |tx| { - let restore_point = restore_point.clone(); - let pin_object_key = pin_object_key.clone(); - - async move { - tx.informal().set( - &restore_point_key(TEST_DATABASE, restore_point.as_str()), - &encode_restore_point_record(RestorePointRecord { - restore_point_id: restore_point, - database_branch_id: branch_id, - versionstamp: row.versionstamp, - status: PinStatus::Failed, - pin_object_key: Some(pin_object_key), - created_at_ms: 1_010, - updated_at_ms: 1_020, - })?, - ); - Ok(()) - } - } - }) - .await?; + restore_matrix!( + "legacy_failed_restore_point_status_preserves_object_key", + |ctx, db, database_db| { + database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; + let bucket_id = BucketId::from_gas_id(test_bucket()); + let branch_id = database_branch_id(&db, bucket_id, TEST_DATABASE).await?; + let row = commit_row(&db, branch_id, 1).await?; + let restore_point = RestorePointId::format(1_010, 1)?; + let pin_object_key = "db/legacy/pin/object.ltx".to_string(); + + db.run({ + let restore_point = restore_point.clone(); + let pin_object_key = pin_object_key.clone(); + + move |tx| { + let restore_point = restore_point.clone(); + let pin_object_key = pin_object_key.clone(); + + async move { + tx.informal().set( + &restore_point_key(TEST_DATABASE, restore_point.as_str()), + &encode_restore_point_record(RestorePointRecord { + restore_point_id: restore_point, + database_branch_id: branch_id, + versionstamp: row.versionstamp, + status: PinStatus::Failed, + pin_object_key: Some(pin_object_key), + created_at_ms: 1_010, + updated_at_ms: 1_020, + })?, + ); + Ok(()) + } + } + }) + .await?; - assert_eq!( - database_db.restore_point_status(restore_point.clone()).await?, - Some(PinStatus::Failed) - ); - let pinned_bytes = common::read_value(&db, restore_point_key(TEST_DATABASE, restore_point.as_str())) - .await? - .expect("legacy failed restore point record should exist"); - let pinned = decode_restore_point_record(&pinned_bytes)?; - assert_eq!(pinned.status, PinStatus::Failed); - assert_eq!(pinned.pin_object_key, Some(pin_object_key)); - assert_eq!( - common::read_value(&db, db_pin_key(branch_id, &history_pin::restore_point_pin_id(&restore_point))).await?, - None - ); + assert_eq!( + database_db + .restore_point_status(restore_point.clone()) + .await?, + Some(PinStatus::Failed) + ); + let pinned_bytes = common::read_value( + &db, + restore_point_key(TEST_DATABASE, restore_point.as_str()), + ) + .await? + .expect("legacy failed restore point record should exist"); + let pinned = decode_restore_point_record(&pinned_bytes)?; + assert_eq!(pinned.status, PinStatus::Failed); + assert_eq!(pinned.pin_object_key, Some(pin_object_key)); + assert_eq!( + common::read_value( + &db, + db_pin_key( + branch_id, + &history_pin::restore_point_pin_id(&restore_point) + ) + ) + .await?, + None + ); - Ok(()) - }) + Ok(()) + } + ) } #[tokio::test] async fn create_restore_point_enforces_bucket_pin_cap() -> Result<()> { - restore_matrix!("create_restore_point_enforces_bucket_pin_cap", |ctx, db, database_db| { - database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; - let bucket_id = BucketId::from_gas_id(test_bucket()); - let bucket_branch_id = bucket_branch_id(&db, bucket_id).await?; - db.run(move |tx| async move { - tx.informal().set( - &bucket_branches_pin_count_key(bucket_branch_id), - &i64::from(depot::constants::MAX_RESTORE_POINTS_PER_BUCKET).to_le_bytes(), - ); - Ok(()) - }) - .await?; + restore_matrix!( + "create_restore_point_enforces_bucket_pin_cap", + |ctx, db, database_db| { + database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; + let bucket_id = BucketId::from_gas_id(test_bucket()); + let bucket_branch_id = bucket_branch_id(&db, bucket_id).await?; + db.run(move |tx| async move { + tx.informal().set( + &bucket_branches_pin_count_key(bucket_branch_id), + &i64::from(depot::constants::MAX_RESTORE_POINTS_PER_BUCKET).to_le_bytes(), + ); + Ok(()) + }) + .await?; - let err = database_db - .create_restore_point(SnapshotSelector::Latest) - .await - .expect_err("pin cap should reject new restore_points"); + let err = database_db + .create_restore_point(SnapshotSelector::Latest) + .await + .expect_err("pin cap should reject new restore_points"); - assert_sqlite_error(err, SqliteStorageError::TooManyRestorePoints); + assert_sqlite_error(err, SqliteStorageError::TooManyRestorePoints); - Ok(()) - }) + Ok(()) + } + ) } #[tokio::test] async fn delete_restore_point_removes_pin_and_recomputes_branch_pin() -> Result<()> { - restore_matrix!("delete_restore_point_removes_pin_and_recomputes_branch_pin", |ctx, db, database_db| { - database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; - let first = database_db.create_restore_point(SnapshotSelector::Latest).await?; - let bucket_id = BucketId::from_gas_id(test_bucket()); - let branch_id = database_branch_id(&db, bucket_id, TEST_DATABASE).await?; - let bucket_branch_id = bucket_branch_id(&db, bucket_id).await?; - let first_row = commit_row(&db, branch_id, 1).await?; - - database_db.commit(vec![page(2, 0x22)], 3, 1_020).await?; - let second = database_db.create_restore_point(SnapshotSelector::Latest).await?; - let second_row = commit_row(&db, branch_id, 2).await?; - assert_eq!( - common::read_value(&db, branches_restore_point_pin_key(branch_id)) - .await? - .expect("branch restore_point_pin should be the oldest pin"), - first_row.versionstamp - ); + restore_matrix!( + "delete_restore_point_removes_pin_and_recomputes_branch_pin", + |ctx, db, database_db| { + database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; + let first = database_db + .create_restore_point(SnapshotSelector::Latest) + .await?; + let bucket_id = BucketId::from_gas_id(test_bucket()); + let branch_id = database_branch_id(&db, bucket_id, TEST_DATABASE).await?; + let bucket_branch_id = bucket_branch_id(&db, bucket_id).await?; + let first_row = commit_row(&db, branch_id, 1).await?; + + database_db.commit(vec![page(2, 0x22)], 3, 1_020).await?; + let second = database_db + .create_restore_point(SnapshotSelector::Latest) + .await?; + let second_row = commit_row(&db, branch_id, 2).await?; + assert_eq!( + common::read_value(&db, branches_restore_point_pin_key(branch_id)) + .await? + .expect("branch restore_point_pin should be the oldest pin"), + first_row.versionstamp + ); - database_db.delete_restore_point(first.clone()).await?; + database_db.delete_restore_point(first.clone()).await?; - assert_eq!( - common::read_value(&db, restore_point_key(TEST_DATABASE, first.as_str())).await?, - None - ); - assert_eq!( - common::read_value(&db, db_pin_key(branch_id, &history_pin::restore_point_pin_id(&first))).await?, - None - ); - assert!( - common::read_value(&db, restore_point_key(TEST_DATABASE, second.as_str())) - .await? - .is_some() - ); - assert!( - common::read_value(&db, db_pin_key(branch_id, &history_pin::restore_point_pin_id(&second))) - .await? - .is_some() - ); - assert_eq!( - common::read_value(&db, branches_restore_point_pin_key(branch_id)) - .await? - .expect("branch restore_point_pin should advance to the next remaining pin"), - second_row.versionstamp - ); - let pin_count = common::read_value(&db, bucket_branches_pin_count_key(bucket_branch_id)) - .await? - .expect("bucket pin count should remain present"); - assert_eq!(decode_i64_counter(&pin_count), 1); + assert_eq!( + common::read_value(&db, restore_point_key(TEST_DATABASE, first.as_str())).await?, + None + ); + assert_eq!( + common::read_value( + &db, + db_pin_key(branch_id, &history_pin::restore_point_pin_id(&first)) + ) + .await?, + None + ); + assert!( + common::read_value(&db, restore_point_key(TEST_DATABASE, second.as_str())) + .await? + .is_some() + ); + assert!( + common::read_value( + &db, + db_pin_key(branch_id, &history_pin::restore_point_pin_id(&second)) + ) + .await? + .is_some() + ); + assert_eq!( + common::read_value(&db, branches_restore_point_pin_key(branch_id)) + .await? + .expect("branch restore_point_pin should advance to the next remaining pin"), + second_row.versionstamp + ); + let pin_count = + common::read_value(&db, bucket_branches_pin_count_key(bucket_branch_id)) + .await? + .expect("bucket pin count should remain present"); + assert_eq!(decode_i64_counter(&pin_count), 1); - Ok(()) - }) + Ok(()) + } + ) } #[tokio::test] async fn deleting_last_restore_point_clears_branch_pin_for_later_pin() -> Result<()> { - restore_matrix!("deleting_last_restore_point_clears_branch_pin_for_later_pin", |ctx, db, database_db| { - database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; - let first = database_db.create_restore_point(SnapshotSelector::Latest).await?; - let bucket_id = BucketId::from_gas_id(test_bucket()); - let branch_id = database_branch_id(&db, bucket_id, TEST_DATABASE).await?; - - database_db.delete_restore_point(first).await?; + restore_matrix!( + "deleting_last_restore_point_clears_branch_pin_for_later_pin", + |ctx, db, database_db| { + database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; + let first = database_db + .create_restore_point(SnapshotSelector::Latest) + .await?; + let bucket_id = BucketId::from_gas_id(test_bucket()); + let branch_id = database_branch_id(&db, bucket_id, TEST_DATABASE).await?; + + database_db.delete_restore_point(first).await?; - assert_eq!( - common::read_value(&db, branches_restore_point_pin_key(branch_id)).await?, - None - ); + assert_eq!( + common::read_value(&db, branches_restore_point_pin_key(branch_id)).await?, + None + ); - database_db.commit(vec![page(2, 0x22)], 3, 1_020).await?; - let second = database_db.create_restore_point(SnapshotSelector::Latest).await?; - let second_row = commit_row(&db, branch_id, 2).await?; + database_db.commit(vec![page(2, 0x22)], 3, 1_020).await?; + let second = database_db + .create_restore_point(SnapshotSelector::Latest) + .await?; + let second_row = commit_row(&db, branch_id, 2).await?; - assert!( - common::read_value(&db, restore_point_key(TEST_DATABASE, second.as_str())) - .await? - .is_some() - ); - assert_eq!( - common::read_value(&db, branches_restore_point_pin_key(branch_id)) - .await? - .expect("branch restore_point_pin should initialize to the later pin"), - second_row.versionstamp - ); + assert!( + common::read_value(&db, restore_point_key(TEST_DATABASE, second.as_str())) + .await? + .is_some() + ); + assert_eq!( + common::read_value(&db, branches_restore_point_pin_key(branch_id)) + .await? + .expect("branch restore_point_pin should initialize to the later pin"), + second_row.versionstamp + ); - Ok(()) - }) + Ok(()) + } + ) } #[tokio::test] -async fn restore_database_from_restore_point_rolls_back_then_pins_undo_restore_point() -> Result<()> { - restore_matrix!("restore_database_from_restore_point_rolls_back_then_pins_undo_restore_point", |ctx, db, database_db| { - database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; - let target = database_db.create_restore_point(SnapshotSelector::Latest).await?; - database_db - .commit(vec![page(1, 0x22), page(2, 0x33)], 3, 2_000) - .await?; - let bucket_id = BucketId::from_gas_id(test_bucket()); - let bucket_branch_id = bucket_branch_id(&db, bucket_id).await?; - let old_branch_id = database_branch_id(&db, bucket_id, TEST_DATABASE).await?; - let target_row = commit_row(&db, old_branch_id, 1).await?; - let undo_row = commit_row(&db, old_branch_id, 2).await?; - - let undo = database_db - .restore_database(SnapshotSelector::RestorePoint { restore_point: target }) - .await?; - - assert_eq!(undo.parse()?, (2_000, 2)); - let new_branch_id = database_branch_id(&db, bucket_id, TEST_DATABASE).await?; - assert_ne!(new_branch_id, old_branch_id); - let restored_head_bytes = common::read_value(&db, branch_meta_head_at_fork_key(new_branch_id)) - .await? - .expect("rollback branch should snapshot head_at_fork"); - let restored_head = decode_db_head(&restored_head_bytes)?; - assert_eq!(restored_head.head_txid, 1); - assert_eq!(restored_head.db_size_pages, 2); - - let pinned_bytes = common::read_value(&db, restore_point_key(TEST_DATABASE, undo.as_str())) - .await? - .expect("undo restore point should exist"); - let pinned = decode_restore_point_record(&pinned_bytes)?; - assert_eq!(pinned.restore_point_id, undo); - assert_eq!(pinned.database_branch_id, old_branch_id); - assert_eq!(pinned.versionstamp, undo_row.versionstamp); - assert_eq!(pinned.status, PinStatus::Ready); - assert_eq!( - common::read_value(&db, branches_restore_point_pin_key(old_branch_id)) - .await? - .expect("old branch restore_point_pin should keep the oldest restore point"), - target_row.versionstamp - ); - let pin_count = common::read_value(&db, bucket_branches_pin_count_key(bucket_branch_id)) - .await? - .expect("bucket pin count should be incremented"); - assert_eq!(decode_i64_counter(&pin_count), 2); +async fn restore_database_from_restore_point_rolls_back_then_pins_undo_restore_point() -> Result<()> +{ + restore_matrix!( + "restore_database_from_restore_point_rolls_back_then_pins_undo_restore_point", + |ctx, db, database_db| { + database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; + let target = database_db + .create_restore_point(SnapshotSelector::Latest) + .await?; + database_db + .commit(vec![page(1, 0x22), page(2, 0x33)], 3, 2_000) + .await?; + let bucket_id = BucketId::from_gas_id(test_bucket()); + let bucket_branch_id = bucket_branch_id(&db, bucket_id).await?; + let old_branch_id = database_branch_id(&db, bucket_id, TEST_DATABASE).await?; + let target_row = commit_row(&db, old_branch_id, 1).await?; + let undo_row = commit_row(&db, old_branch_id, 2).await?; + + let undo = database_db + .restore_database(SnapshotSelector::RestorePoint { + restore_point: target, + }) + .await?; + + assert_eq!(undo.parse()?, (2_000, 2)); + let new_branch_id = database_branch_id(&db, bucket_id, TEST_DATABASE).await?; + assert_ne!(new_branch_id, old_branch_id); + let restored_head_bytes = + common::read_value(&db, branch_meta_head_at_fork_key(new_branch_id)) + .await? + .expect("rollback branch should snapshot head_at_fork"); + let restored_head = decode_db_head(&restored_head_bytes)?; + assert_eq!(restored_head.head_txid, 1); + assert_eq!(restored_head.db_size_pages, 2); + + let pinned_bytes = + common::read_value(&db, restore_point_key(TEST_DATABASE, undo.as_str())) + .await? + .expect("undo restore point should exist"); + let pinned = decode_restore_point_record(&pinned_bytes)?; + assert_eq!(pinned.restore_point_id, undo); + assert_eq!(pinned.database_branch_id, old_branch_id); + assert_eq!(pinned.versionstamp, undo_row.versionstamp); + assert_eq!(pinned.status, PinStatus::Ready); + assert_eq!( + common::read_value(&db, branches_restore_point_pin_key(old_branch_id)) + .await? + .expect("old branch restore_point_pin should keep the oldest restore point"), + target_row.versionstamp + ); + let pin_count = + common::read_value(&db, bucket_branches_pin_count_key(bucket_branch_id)) + .await? + .expect("bucket pin count should be incremented"); + assert_eq!(decode_i64_counter(&pin_count), 2); - Ok(()) - }) + Ok(()) + } + ) } #[tokio::test] async fn restore_database_from_timestamp_rolls_back_then_pins_undo_restore_point() -> Result<()> { - restore_matrix!("restore_database_from_timestamp_rolls_back_then_pins_undo_restore_point", |ctx, db, database_db| { - database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; - database_db - .commit(vec![page(1, 0x22), page(2, 0x33)], 3, 2_000) - .await?; - let bucket_id = BucketId::from_gas_id(test_bucket()); - let old_branch_id = database_branch_id(&db, bucket_id, TEST_DATABASE).await?; - let target_row = commit_row(&db, old_branch_id, 1).await?; - let undo_row = commit_row(&db, old_branch_id, 2).await?; - seed_pitr_interval( - &db, - old_branch_id, - 1_000, - PitrIntervalCoverage { - txid: 1, - versionstamp: target_row.versionstamp, - wall_clock_ms: 1_000, - expires_at_ms: i64::MAX, - }, - ) - .await?; - - let undo = database_db - .restore_database(SnapshotSelector::AtTimestamp { timestamp_ms: 1_500 }) - .await?; - - assert_eq!(undo.parse()?, (2_000, 2)); - let new_branch_id = database_branch_id(&db, bucket_id, TEST_DATABASE).await?; - assert_ne!(new_branch_id, old_branch_id); - let restored_head_bytes = common::read_value(&db, branch_meta_head_at_fork_key(new_branch_id)) - .await? - .expect("rollback branch should snapshot head_at_fork"); - let restored_head = decode_db_head(&restored_head_bytes)?; - assert_eq!(restored_head.head_txid, 1); - assert_eq!(restored_head.db_size_pages, 2); + restore_matrix!( + "restore_database_from_timestamp_rolls_back_then_pins_undo_restore_point", + |ctx, db, database_db| { + database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; + database_db + .commit(vec![page(1, 0x22), page(2, 0x33)], 3, 2_000) + .await?; + let bucket_id = BucketId::from_gas_id(test_bucket()); + let old_branch_id = database_branch_id(&db, bucket_id, TEST_DATABASE).await?; + let target_row = commit_row(&db, old_branch_id, 1).await?; + let undo_row = commit_row(&db, old_branch_id, 2).await?; + seed_pitr_interval( + &db, + old_branch_id, + 1_000, + PitrIntervalCoverage { + txid: 1, + versionstamp: target_row.versionstamp, + wall_clock_ms: 1_000, + expires_at_ms: i64::MAX, + }, + ) + .await?; - let pinned_bytes = common::read_value(&db, restore_point_key(TEST_DATABASE, undo.as_str())) - .await? - .expect("undo restore point should exist"); - let pinned = decode_restore_point_record(&pinned_bytes)?; - assert_eq!(pinned.database_branch_id, old_branch_id); - assert_eq!(pinned.versionstamp, undo_row.versionstamp); + let undo = database_db + .restore_database(SnapshotSelector::AtTimestamp { + timestamp_ms: 1_500, + }) + .await?; + + assert_eq!(undo.parse()?, (2_000, 2)); + let new_branch_id = database_branch_id(&db, bucket_id, TEST_DATABASE).await?; + assert_ne!(new_branch_id, old_branch_id); + let restored_head_bytes = + common::read_value(&db, branch_meta_head_at_fork_key(new_branch_id)) + .await? + .expect("rollback branch should snapshot head_at_fork"); + let restored_head = decode_db_head(&restored_head_bytes)?; + assert_eq!(restored_head.head_txid, 1); + assert_eq!(restored_head.db_size_pages, 2); + + let pinned_bytes = + common::read_value(&db, restore_point_key(TEST_DATABASE, undo.as_str())) + .await? + .expect("undo restore point should exist"); + let pinned = decode_restore_point_record(&pinned_bytes)?; + assert_eq!(pinned.database_branch_id, old_branch_id); + assert_eq!(pinned.versionstamp, undo_row.versionstamp); - Ok(()) - }) + Ok(()) + } + ) } #[tokio::test] async fn restore_database_rollback_and_undo_pin_are_atomic() -> Result<()> { - restore_matrix!("restore_database_rollback_and_undo_pin_are_atomic", |ctx, db, _database_db| { - let database_id = "test-database-atomic-restore"; - let database_db = ctx.make_db(test_bucket(), database_id); - database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; - let target = database_db.create_restore_point(SnapshotSelector::Latest).await?; - database_db - .commit(vec![page(1, 0x22), page(2, 0x33)], 3, 2_000) - .await?; - let bucket_id = BucketId::from_gas_id(test_bucket()); - let old_branch_id = database_branch_id(&db, bucket_id, database_id).await?; - let undo = RestorePointId::format(2_000, 2)?; - let _guard = restore_point::test_hooks::fail_after_restore_rollback(database_id); - - let err = database_db - .restore_database(SnapshotSelector::RestorePoint { restore_point: target }) - .await - .expect_err("injected failure should abort restore"); - - assert!( - err.chain() - .any(|cause| cause.to_string().contains("injected failure after sqlite restore rollback")) - ); - assert_eq!( - database_branch_id(&db, bucket_id, database_id).await?, - old_branch_id - ); - assert!( - common::read_value(&db, restore_point_key(database_id, undo.as_str())) - .await? - .is_none() - ); + restore_matrix!( + "restore_database_rollback_and_undo_pin_are_atomic", + |ctx, db, _database_db| { + let database_id = "test-database-atomic-restore"; + let database_db = ctx.make_db(test_bucket(), database_id); + database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; + let target = database_db + .create_restore_point(SnapshotSelector::Latest) + .await?; + database_db + .commit(vec![page(1, 0x22), page(2, 0x33)], 3, 2_000) + .await?; + let bucket_id = BucketId::from_gas_id(test_bucket()); + let old_branch_id = database_branch_id(&db, bucket_id, database_id).await?; + let undo = RestorePointId::format(2_000, 2)?; + let _guard = restore_point::test_hooks::fail_after_restore_rollback(database_id); + + let err = database_db + .restore_database(SnapshotSelector::RestorePoint { + restore_point: target, + }) + .await + .expect_err("injected failure should abort restore"); + + assert!(err.chain().any(|cause| { + cause + .to_string() + .contains("injected failure after sqlite restore rollback") + })); + assert_eq!( + database_branch_id(&db, bucket_id, database_id).await?, + old_branch_id + ); + assert!( + common::read_value(&db, restore_point_key(database_id, undo.as_str())) + .await? + .is_none() + ); - Ok(()) - }) + Ok(()) + } + ) } #[tokio::test] async fn write_after_pitr_restore_lands_on_restored_branch() -> Result<()> { - restore_matrix!("write_after_pitr_restore_lands_on_restored_branch", |ctx, db, database_db| { - database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; - database_db - .commit(vec![page(1, 0x22), page(2, 0x33)], 3, 2_000) - .await?; - let bucket_id = BucketId::from_gas_id(test_bucket()); - let old_branch_id = database_branch_id(&db, bucket_id, TEST_DATABASE).await?; - let target_row = commit_row(&db, old_branch_id, 1).await?; - seed_pitr_interval( - &db, - old_branch_id, - 1_000, - PitrIntervalCoverage { - txid: 1, - versionstamp: target_row.versionstamp, - wall_clock_ms: 1_000, - expires_at_ms: i64::MAX, - }, - ) - .await?; + restore_matrix!( + "write_after_pitr_restore_lands_on_restored_branch", + |ctx, db, database_db| { + database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; + database_db + .commit(vec![page(1, 0x22), page(2, 0x33)], 3, 2_000) + .await?; + let bucket_id = BucketId::from_gas_id(test_bucket()); + let old_branch_id = database_branch_id(&db, bucket_id, TEST_DATABASE).await?; + let target_row = commit_row(&db, old_branch_id, 1).await?; + seed_pitr_interval( + &db, + old_branch_id, + 1_000, + PitrIntervalCoverage { + txid: 1, + versionstamp: target_row.versionstamp, + wall_clock_ms: 1_000, + expires_at_ms: i64::MAX, + }, + ) + .await?; - database_db - .restore_database(SnapshotSelector::AtTimestamp { timestamp_ms: 1_500 }) - .await?; - let restored_branch_id = database_branch_id(&db, bucket_id, TEST_DATABASE).await?; - assert_ne!(restored_branch_id, old_branch_id); + database_db + .restore_database(SnapshotSelector::AtTimestamp { + timestamp_ms: 1_500, + }) + .await?; + let restored_branch_id = database_branch_id(&db, bucket_id, TEST_DATABASE).await?; + assert_ne!(restored_branch_id, old_branch_id); - database_db.commit(vec![page(3, 0x44)], 3, 3_000).await?; + database_db.commit(vec![page(3, 0x44)], 3, 3_000).await?; - let post_restore_commit = commit_row(&db, restored_branch_id, 2).await?; - assert_eq!(post_restore_commit.db_size_pages, 3); - assert_eq!( - common::read_value(&db, branch_pidx_key(restored_branch_id, 1)).await?, - None - ); - assert!( - common::read_value(&db, branch_pidx_key(old_branch_id, 1)) - .await? - .is_some() - ); - let restored_database_db = ctx.make_db(test_bucket(), TEST_DATABASE); - let pages = restored_database_db.get_pages(vec![1, 2, 3]).await?; - assert_eq!(pages[0].bytes, Some(page(1, 0x11).bytes)); - assert_eq!(pages[1].bytes, Some(page(2, 0).bytes)); - assert_eq!(pages[2].bytes, Some(page(3, 0x44).bytes)); + let post_restore_commit = commit_row(&db, restored_branch_id, 2).await?; + assert_eq!(post_restore_commit.db_size_pages, 3); + assert_eq!( + common::read_value(&db, branch_pidx_key(restored_branch_id, 1)).await?, + None + ); + assert!( + common::read_value(&db, branch_pidx_key(old_branch_id, 1)) + .await? + .is_some() + ); + let restored_database_db = ctx.make_db(test_bucket(), TEST_DATABASE); + let pages = restored_database_db.get_pages(vec![1, 2, 3]).await?; + assert_eq!(pages[0].bytes, Some(page(1, 0x11).bytes)); + assert_eq!(pages[1].bytes, Some(page(2, 0).bytes)); + assert_eq!(pages[2].bytes, Some(page(3, 0x44).bytes)); - Ok(()) - }) + Ok(()) + } + ) } #[tokio::test] async fn restore_database_rejects_expired_timestamp_without_undo_or_pointer_swap() -> Result<()> { - restore_matrix!("restore_database_rejects_expired_timestamp_without_undo_or_pointer_swap", |ctx, db, database_db| { - database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; - let bucket_id = BucketId::from_gas_id(test_bucket()); - let old_branch_id = database_branch_id(&db, bucket_id, TEST_DATABASE).await?; - let undo_id = RestorePointId::format(1_000, 1)?; - - let err = database_db - .restore_database(SnapshotSelector::AtTimestamp { timestamp_ms: 1_500 }) - .await - .expect_err("expired selector should reject restore"); - - assert_sqlite_error(err, SqliteStorageError::RestoreTargetExpired); - assert_eq!( - database_branch_id(&db, bucket_id, TEST_DATABASE).await?, - old_branch_id - ); - assert!( - common::read_value(&db, restore_point_key(TEST_DATABASE, undo_id.as_str())) - .await? - .is_none() - ); + restore_matrix!( + "restore_database_rejects_expired_timestamp_without_undo_or_pointer_swap", + |ctx, db, database_db| { + database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; + let bucket_id = BucketId::from_gas_id(test_bucket()); + let old_branch_id = database_branch_id(&db, bucket_id, TEST_DATABASE).await?; + let undo_id = RestorePointId::format(1_000, 1)?; + + let err = database_db + .restore_database(SnapshotSelector::AtTimestamp { + timestamp_ms: 1_500, + }) + .await + .expect_err("expired selector should reject restore"); + + assert_sqlite_error(err, SqliteStorageError::RestoreTargetExpired); + assert_eq!( + database_branch_id(&db, bucket_id, TEST_DATABASE).await?, + old_branch_id + ); + assert!( + common::read_value(&db, restore_point_key(TEST_DATABASE, undo_id.as_str())) + .await? + .is_none() + ); - Ok(()) - }) + Ok(()) + } + ) } #[tokio::test] async fn resolve_restore_point_uses_retained_record_without_vtx() -> Result<()> { - restore_matrix!("resolve_restore_point_uses_retained_record_without_vtx", |ctx, db, database_db| { - database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; - let restore_point = database_db.create_restore_point(SnapshotSelector::Latest).await?; - let bucket_id = BucketId::from_gas_id(test_bucket()); - let branch_id = database_branch_id(&db, bucket_id, TEST_DATABASE).await?; - let row = commit_row(&db, branch_id, 1).await?; - - let resolved = database_db.resolve_restore_point(restore_point.clone()).await?; - - assert_eq!(resolved.versionstamp, row.versionstamp); - assert_eq!( - resolved.restore_point, - Some(RestorePointRef { - restore_point: restore_point.clone(), - resolved_versionstamp: Some(row.versionstamp), - }) - ); + restore_matrix!( + "resolve_restore_point_uses_retained_record_without_vtx", + |ctx, db, database_db| { + database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; + let restore_point = database_db + .create_restore_point(SnapshotSelector::Latest) + .await?; + let bucket_id = BucketId::from_gas_id(test_bucket()); + let branch_id = database_branch_id(&db, bucket_id, TEST_DATABASE).await?; + let row = commit_row(&db, branch_id, 1).await?; + + let resolved = database_db + .resolve_restore_point(restore_point.clone()) + .await?; + + assert_eq!(resolved.versionstamp, row.versionstamp); + assert_eq!( + resolved.restore_point, + Some(RestorePointRef { + restore_point: restore_point.clone(), + resolved_versionstamp: Some(row.versionstamp), + }) + ); - clear_value(&db, branch_vtx_key(branch_id, row.versionstamp)).await?; - let resolved = database_db.resolve_restore_point(restore_point.clone()).await?; - assert_eq!(resolved.versionstamp, row.versionstamp); + clear_value(&db, branch_vtx_key(branch_id, row.versionstamp)).await?; + let resolved = database_db + .resolve_restore_point(restore_point.clone()) + .await?; + assert_eq!(resolved.versionstamp, row.versionstamp); - clear_value(&db, restore_point_key(TEST_DATABASE, restore_point.as_str())).await?; - let err = database_db - .resolve_restore_point(restore_point) - .await - .expect_err("missing restore point record should be rejected"); - assert_sqlite_error(err, SqliteStorageError::RestorePointNotFound); + clear_value( + &db, + restore_point_key(TEST_DATABASE, restore_point.as_str()), + ) + .await?; + let err = database_db + .resolve_restore_point(restore_point) + .await + .expect_err("missing restore point record should be rejected"); + assert_sqlite_error(err, SqliteStorageError::RestorePointNotFound); - Ok(()) - }) + Ok(()) + } + ) } #[tokio::test] async fn resolve_restore_target_latest_returns_current_head_metadata() -> Result<()> { - restore_matrix!("resolve_restore_target_latest_returns_current_head_metadata", |ctx, db, database_db| { - database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; - database_db.commit(vec![page(1, 0x22)], 2, 2_000).await?; - let bucket_id = BucketId::from_gas_id(test_bucket()); - let branch_id = database_branch_id(&db, bucket_id, TEST_DATABASE).await?; - let head_row = commit_row(&db, branch_id, 2).await?; + restore_matrix!( + "resolve_restore_target_latest_returns_current_head_metadata", + |ctx, db, database_db| { + database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; + database_db.commit(vec![page(1, 0x22)], 2, 2_000).await?; + let bucket_id = BucketId::from_gas_id(test_bucket()); + let branch_id = database_branch_id(&db, bucket_id, TEST_DATABASE).await?; + let head_row = commit_row(&db, branch_id, 2).await?; + + let resolved = database_db + .resolve_restore_target(SnapshotSelector::Latest) + .await?; - let resolved = database_db - .resolve_restore_target(SnapshotSelector::Latest) - .await?; + assert_eq!( + resolved, + ResolvedRestoreTarget { + database_branch_id: branch_id, + txid: 2, + versionstamp: head_row.versionstamp, + wall_clock_ms: 2_000, + kind: SnapshotKind::Latest, + restore_point: None, + } + ); - assert_eq!( - resolved, - ResolvedRestoreTarget { - database_branch_id: branch_id, - txid: 2, - versionstamp: head_row.versionstamp, - wall_clock_ms: 2_000, - kind: SnapshotKind::Latest, - restore_point: None, + Ok(()) } - ); - - Ok(()) - }) + ) } #[tokio::test] -async fn resolve_restore_target_timestamp_uses_latest_unexpired_interval_before_target() -> Result<()> { - restore_matrix!("resolve_restore_target_timestamp_uses_latest_unexpired_interval_before_target", |ctx, db, database_db| { - database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; - database_db.commit(vec![page(1, 0x22)], 2, 2_500).await?; - database_db.commit(vec![page(1, 0x33)], 2, 3_000).await?; - let bucket_id = BucketId::from_gas_id(test_bucket()); - let branch_id = database_branch_id(&db, bucket_id, TEST_DATABASE).await?; - let first = commit_row(&db, branch_id, 1).await?; - let second = commit_row(&db, branch_id, 2).await?; - let third = commit_row(&db, branch_id, 3).await?; - seed_pitr_interval( - &db, - branch_id, - 1_000, - PitrIntervalCoverage { - txid: 1, - versionstamp: first.versionstamp, - wall_clock_ms: 1_000, - expires_at_ms: i64::MAX, - }, - ) - .await?; - seed_pitr_interval( - &db, - branch_id, - 2_000, - PitrIntervalCoverage { - txid: 2, - versionstamp: second.versionstamp, - wall_clock_ms: 2_500, - expires_at_ms: i64::MAX, - }, - ) - .await?; - seed_pitr_interval( - &db, - branch_id, - 3_000, - PitrIntervalCoverage { - txid: 3, - versionstamp: third.versionstamp, - wall_clock_ms: 3_000, - expires_at_ms: i64::MAX, - }, +async fn resolve_restore_target_timestamp_uses_latest_unexpired_interval_before_target() +-> Result<()> { + restore_matrix!( + "resolve_restore_target_timestamp_uses_latest_unexpired_interval_before_target", + |ctx, db, database_db| { + database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; + database_db.commit(vec![page(1, 0x22)], 2, 2_500).await?; + database_db.commit(vec![page(1, 0x33)], 2, 3_000).await?; + let bucket_id = BucketId::from_gas_id(test_bucket()); + let branch_id = database_branch_id(&db, bucket_id, TEST_DATABASE).await?; + let first = commit_row(&db, branch_id, 1).await?; + let second = commit_row(&db, branch_id, 2).await?; + let third = commit_row(&db, branch_id, 3).await?; + seed_pitr_interval( + &db, + branch_id, + 1_000, + PitrIntervalCoverage { + txid: 1, + versionstamp: first.versionstamp, + wall_clock_ms: 1_000, + expires_at_ms: i64::MAX, + }, + ) + .await?; + seed_pitr_interval( + &db, + branch_id, + 2_000, + PitrIntervalCoverage { + txid: 2, + versionstamp: second.versionstamp, + wall_clock_ms: 2_500, + expires_at_ms: i64::MAX, + }, + ) + .await?; + seed_pitr_interval( + &db, + branch_id, + 3_000, + PitrIntervalCoverage { + txid: 3, + versionstamp: third.versionstamp, + wall_clock_ms: 3_000, + expires_at_ms: i64::MAX, + }, + ) + .await?; + + let between = database_db + .resolve_restore_target(SnapshotSelector::AtTimestamp { + timestamp_ms: 2_750, + }) + .await?; + assert_eq!(between.txid, 2); + assert_eq!(between.versionstamp, second.versionstamp); + assert_eq!(between.wall_clock_ms, 2_500); + assert_eq!(between.kind, SnapshotKind::AtTimestamp); + assert_eq!(between.restore_point, None); + + let quiet_period = database_db + .resolve_restore_target(SnapshotSelector::AtTimestamp { + timestamp_ms: 2_999, + }) + .await?; + assert_eq!(quiet_period.txid, 2); + + let walked_back = database_db + .resolve_restore_target(SnapshotSelector::AtTimestamp { + timestamp_ms: 2_100, + }) + .await?; + assert_eq!(walked_back.txid, 1); + + Ok(()) + } ) - .await?; - - let between = database_db - .resolve_restore_target(SnapshotSelector::AtTimestamp { timestamp_ms: 2_750 }) - .await?; - assert_eq!(between.txid, 2); - assert_eq!(between.versionstamp, second.versionstamp); - assert_eq!(between.wall_clock_ms, 2_500); - assert_eq!(between.kind, SnapshotKind::AtTimestamp); - assert_eq!(between.restore_point, None); - - let quiet_period = database_db - .resolve_restore_target(SnapshotSelector::AtTimestamp { timestamp_ms: 2_999 }) - .await?; - assert_eq!(quiet_period.txid, 2); - - let walked_back = database_db - .resolve_restore_target(SnapshotSelector::AtTimestamp { timestamp_ms: 2_100 }) - .await?; - assert_eq!(walked_back.txid, 1); - - Ok(()) - }) } #[tokio::test] async fn resolve_restore_target_timestamp_rejects_expired_interval_coverage() -> Result<()> { - restore_matrix!("resolve_restore_target_timestamp_rejects_expired_interval_coverage", |ctx, db, database_db| { - database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; - let bucket_id = BucketId::from_gas_id(test_bucket()); - let branch_id = database_branch_id(&db, bucket_id, TEST_DATABASE).await?; - let row = commit_row(&db, branch_id, 1).await?; - seed_pitr_interval( - &db, - branch_id, - 1_000, - PitrIntervalCoverage { - txid: 1, - versionstamp: row.versionstamp, - wall_clock_ms: 1_000, - expires_at_ms: 0, - }, - ) - .await?; + restore_matrix!( + "resolve_restore_target_timestamp_rejects_expired_interval_coverage", + |ctx, db, database_db| { + database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; + let bucket_id = BucketId::from_gas_id(test_bucket()); + let branch_id = database_branch_id(&db, bucket_id, TEST_DATABASE).await?; + let row = commit_row(&db, branch_id, 1).await?; + seed_pitr_interval( + &db, + branch_id, + 1_000, + PitrIntervalCoverage { + txid: 1, + versionstamp: row.versionstamp, + wall_clock_ms: 1_000, + expires_at_ms: 0, + }, + ) + .await?; - let err = database_db - .resolve_restore_target(SnapshotSelector::AtTimestamp { timestamp_ms: 1_000 }) - .await - .expect_err("expired interval coverage should not resolve"); + let err = database_db + .resolve_restore_target(SnapshotSelector::AtTimestamp { + timestamp_ms: 1_000, + }) + .await + .expect_err("expired interval coverage should not resolve"); - assert_sqlite_error(err, SqliteStorageError::RestoreTargetExpired); + assert_sqlite_error(err, SqliteStorageError::RestoreTargetExpired); - Ok(()) - }) + Ok(()) + } + ) } #[tokio::test] async fn restore_point_survives_after_source_interval_coverage_expires() -> Result<()> { - restore_matrix!("restore_point_survives_after_source_interval_coverage_expires", |ctx, db, database_db| { - database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; - let bucket_id = BucketId::from_gas_id(test_bucket()); - let branch_id = database_branch_id(&db, bucket_id, TEST_DATABASE).await?; - let row = commit_row(&db, branch_id, 1).await?; - seed_pitr_interval( - &db, - branch_id, - 1_000, - PitrIntervalCoverage { - txid: 1, - versionstamp: row.versionstamp, - wall_clock_ms: 1_000, - expires_at_ms: i64::MAX, - }, - ) - .await?; - let restore_point = database_db - .create_restore_point(SnapshotSelector::AtTimestamp { timestamp_ms: 1_000 }) - .await?; - - clear_value(&db, branch_pitr_interval_key(branch_id, 1_000)).await?; - let err = database_db - .resolve_restore_target(SnapshotSelector::AtTimestamp { timestamp_ms: 1_000 }) - .await - .expect_err("timestamp selector should expire with interval coverage"); - assert_sqlite_error(err, SqliteStorageError::RestoreTargetExpired); - - let resolved = database_db - .resolve_restore_target(SnapshotSelector::RestorePoint { - restore_point: restore_point.clone(), - }) - .await?; - assert_eq!(resolved.txid, 1); - assert_eq!(resolved.versionstamp, row.versionstamp); - assert_eq!( - resolved.restore_point, - Some(RestorePointRef { - restore_point, - resolved_versionstamp: Some(row.versionstamp), - }) - ); + restore_matrix!( + "restore_point_survives_after_source_interval_coverage_expires", + |ctx, db, database_db| { + database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; + let bucket_id = BucketId::from_gas_id(test_bucket()); + let branch_id = database_branch_id(&db, bucket_id, TEST_DATABASE).await?; + let row = commit_row(&db, branch_id, 1).await?; + seed_pitr_interval( + &db, + branch_id, + 1_000, + PitrIntervalCoverage { + txid: 1, + versionstamp: row.versionstamp, + wall_clock_ms: 1_000, + expires_at_ms: i64::MAX, + }, + ) + .await?; + let restore_point = database_db + .create_restore_point(SnapshotSelector::AtTimestamp { + timestamp_ms: 1_000, + }) + .await?; + + clear_value(&db, branch_pitr_interval_key(branch_id, 1_000)).await?; + let err = database_db + .resolve_restore_target(SnapshotSelector::AtTimestamp { + timestamp_ms: 1_000, + }) + .await + .expect_err("timestamp selector should expire with interval coverage"); + assert_sqlite_error(err, SqliteStorageError::RestoreTargetExpired); + + let resolved = database_db + .resolve_restore_target(SnapshotSelector::RestorePoint { + restore_point: restore_point.clone(), + }) + .await?; + assert_eq!(resolved.txid, 1); + assert_eq!(resolved.versionstamp, row.versionstamp); + assert_eq!( + resolved.restore_point, + Some(RestorePointRef { + restore_point, + resolved_versionstamp: Some(row.versionstamp), + }) + ); - Ok(()) - }) + Ok(()) + } + ) } #[tokio::test] async fn resolve_restore_target_restore_point_returns_exact_metadata() -> Result<()> { - restore_matrix!("resolve_restore_target_restore_point_returns_exact_metadata", |ctx, db, database_db| { - database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; - let restore_point = database_db.create_restore_point(SnapshotSelector::Latest).await?; - let bucket_id = BucketId::from_gas_id(test_bucket()); - let branch_id = database_branch_id(&db, bucket_id, TEST_DATABASE).await?; - let row = commit_row(&db, branch_id, 1).await?; - - let resolved = database_db - .resolve_restore_target(SnapshotSelector::RestorePoint { - restore_point: restore_point.clone(), - }) - .await?; + restore_matrix!( + "resolve_restore_target_restore_point_returns_exact_metadata", + |ctx, db, database_db| { + database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; + let restore_point = database_db + .create_restore_point(SnapshotSelector::Latest) + .await?; + let bucket_id = BucketId::from_gas_id(test_bucket()); + let branch_id = database_branch_id(&db, bucket_id, TEST_DATABASE).await?; + let row = commit_row(&db, branch_id, 1).await?; + + let resolved = database_db + .resolve_restore_target(SnapshotSelector::RestorePoint { + restore_point: restore_point.clone(), + }) + .await?; - assert_eq!( - resolved, - ResolvedRestoreTarget { - database_branch_id: branch_id, - txid: 1, - versionstamp: row.versionstamp, - wall_clock_ms: 1_000, - kind: SnapshotKind::RestorePoint, - restore_point: Some(RestorePointRef { - restore_point, - resolved_versionstamp: Some(row.versionstamp), - }), - } - ); + assert_eq!( + resolved, + ResolvedRestoreTarget { + database_branch_id: branch_id, + txid: 1, + versionstamp: row.versionstamp, + wall_clock_ms: 1_000, + kind: SnapshotKind::RestorePoint, + restore_point: Some(RestorePointRef { + restore_point, + resolved_versionstamp: Some(row.versionstamp), + }), + } + ); - Ok(()) - }) + Ok(()) + } + ) } #[tokio::test] async fn resolve_restore_target_restore_point_rejects_missing_record() -> Result<()> { - restore_matrix!("resolve_restore_target_restore_point_rejects_missing_record", |ctx, db, database_db| { - database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; - let missing = RestorePointId::format(1_010, 1)?; + restore_matrix!( + "resolve_restore_target_restore_point_rejects_missing_record", + |ctx, db, database_db| { + database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; + let missing = RestorePointId::format(1_010, 1)?; - let err = database_db - .resolve_restore_target(SnapshotSelector::RestorePoint { - restore_point: missing, - }) - .await - .expect_err("missing restore point should not resolve"); + let err = database_db + .resolve_restore_target(SnapshotSelector::RestorePoint { + restore_point: missing, + }) + .await + .expect_err("missing restore point should not resolve"); - assert_sqlite_error(err, SqliteStorageError::RestorePointNotFound); + assert_sqlite_error(err, SqliteStorageError::RestorePointNotFound); - Ok(()) - }) + Ok(()) + } + ) } #[tokio::test] async fn resolve_restore_point_prefers_exact_pinned_record() -> Result<()> { - restore_matrix!("resolve_restore_point_prefers_exact_pinned_record", |ctx, db, database_db| { - database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; - let restore_point = database_db.create_restore_point(SnapshotSelector::Latest).await?; - let bucket_id = BucketId::from_gas_id(test_bucket()); - let branch_id = database_branch_id(&db, bucket_id, TEST_DATABASE).await?; - let pinned_versionstamp = [9; 16]; - db.run({ - let restore_point = restore_point.clone(); - - move |tx| { - let restore_point = restore_point.clone(); - - async move { - let record = RestorePointRecord { - restore_point_id: restore_point.clone(), - database_branch_id: branch_id, - versionstamp: pinned_versionstamp, - status: PinStatus::Ready, - pin_object_key: None, - created_at_ms: 1_000, - updated_at_ms: 1_100, - }; - tx.informal().set( - &depot::keys::restore_point_key(TEST_DATABASE, restore_point.as_str()), - &encode_restore_point_record(record)?, - ); - Ok(()) - } - } - }) - .await?; + restore_matrix!( + "resolve_restore_point_prefers_exact_pinned_record", + |ctx, db, database_db| { + database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; + let restore_point = database_db + .create_restore_point(SnapshotSelector::Latest) + .await?; + let bucket_id = BucketId::from_gas_id(test_bucket()); + let branch_id = database_branch_id(&db, bucket_id, TEST_DATABASE).await?; + let pinned_versionstamp = [9; 16]; + db.run({ + let restore_point = restore_point.clone(); - let resolved = database_db.resolve_restore_point(restore_point.clone()).await?; + move |tx| { + let restore_point = restore_point.clone(); + + async move { + let record = RestorePointRecord { + restore_point_id: restore_point.clone(), + database_branch_id: branch_id, + versionstamp: pinned_versionstamp, + status: PinStatus::Ready, + pin_object_key: None, + created_at_ms: 1_000, + updated_at_ms: 1_100, + }; + tx.informal().set( + &depot::keys::restore_point_key(TEST_DATABASE, restore_point.as_str()), + &encode_restore_point_record(record)?, + ); + Ok(()) + } + } + }) + .await?; - assert_eq!(resolved.versionstamp, pinned_versionstamp); - assert_eq!( - resolved.restore_point, - Some(RestorePointRef { - restore_point, - resolved_versionstamp: Some(pinned_versionstamp), - }) - ); + let resolved = database_db + .resolve_restore_point(restore_point.clone()) + .await?; - Ok(()) - }) + assert_eq!(resolved.versionstamp, pinned_versionstamp); + assert_eq!( + resolved.restore_point, + Some(RestorePointRef { + restore_point, + resolved_versionstamp: Some(pinned_versionstamp), + }) + ); + + Ok(()) + } + ) } #[tokio::test] async fn resolve_restore_point_rejects_missing_record_on_forked_database() -> Result<()> { - restore_matrix!("resolve_restore_point_rejects_missing_record_on_forked_database", |ctx, db, source_database_db| { - source_database_db - .commit(vec![page(1, 0x11)], 2, 1_000) + restore_matrix!( + "resolve_restore_point_rejects_missing_record_on_forked_database", + |ctx, db, source_database_db| { + source_database_db + .commit(vec![page(1, 0x11)], 2, 1_000) + .await?; + let restore_point = source_database_db + .create_restore_point(SnapshotSelector::Latest) + .await?; + let bucket_id = BucketId::from_gas_id(test_bucket()); + let source_branch_id = database_branch_id(&db, bucket_id, TEST_DATABASE).await?; + let source_commit = commit_row(&db, source_branch_id, 1).await?; + let forked_database_id = branch::fork_database( + &db, + bucket_id, + TEST_DATABASE.to_string(), + ResolvedVersionstamp { + versionstamp: source_commit.versionstamp, + restore_point: None, + }, + bucket_id, + ) .await?; - let restore_point = source_database_db.create_restore_point(SnapshotSelector::Latest).await?; - let bucket_id = BucketId::from_gas_id(test_bucket()); - let source_branch_id = database_branch_id(&db, bucket_id, TEST_DATABASE).await?; - let source_commit = commit_row(&db, source_branch_id, 1).await?; - let forked_database_id = branch::fork_database( - &db, - bucket_id, - TEST_DATABASE.to_string(), - ResolvedVersionstamp { - versionstamp: source_commit.versionstamp, - restore_point: None, - }, - bucket_id) - .await?; - - let err = restore_point::resolve_restore_point(&db, bucket_id, forked_database_id, restore_point) + + let err = restore_point::resolve_restore_point( + &db, + bucket_id, + forked_database_id, + restore_point, + ) .await .expect_err("restore point records are scoped to the owning database id"); - assert_sqlite_error(err, SqliteStorageError::RestorePointNotFound); + assert_sqlite_error(err, SqliteStorageError::RestorePointNotFound); - Ok(()) - }) + Ok(()) + } + ) } #[tokio::test] async fn resolve_restore_point_honors_bucket_fork_versionstamp_cap() -> Result<()> { - restore_matrix!("resolve_restore_point_honors_bucket_fork_versionstamp_cap", |ctx, db, source_database_db| { - source_database_db - .commit(vec![page(1, 0x11)], 2, 1_000) + restore_matrix!( + "resolve_restore_point_honors_bucket_fork_versionstamp_cap", + |ctx, db, source_database_db| { + source_database_db + .commit(vec![page(1, 0x11)], 2, 1_000) + .await?; + let bucket_id = BucketId::from_gas_id(test_bucket()); + let source_branch_id = database_branch_id(&db, bucket_id, TEST_DATABASE).await?; + let fork_point = commit_row(&db, source_branch_id, 1).await?; + let forked_bucket = branch::fork_bucket( + &db, + bucket_id, + ResolvedVersionstamp { + versionstamp: fork_point.versionstamp, + restore_point: None, + }, + ) .await?; - let bucket_id = BucketId::from_gas_id(test_bucket()); - let source_branch_id = database_branch_id(&db, bucket_id, TEST_DATABASE).await?; - let fork_point = commit_row(&db, source_branch_id, 1).await?; - let forked_bucket = branch::fork_bucket( - &db, - bucket_id, - ResolvedVersionstamp { - versionstamp: fork_point.versionstamp, - restore_point: None, - }) - .await?; - source_database_db - .commit(vec![page(2, 0x22)], 3, 2_000) - .await?; - let post_fork_restore_point = source_database_db.create_restore_point(SnapshotSelector::Latest).await?; - - let err = restore_point::resolve_restore_point( - &db, - forked_bucket, - TEST_DATABASE.to_string(), - post_fork_restore_point, - ) - .await - .expect_err("bucket fork should not resolve source commits created after the fork"); - assert_sqlite_error(err, SqliteStorageError::BranchNotReachable); + source_database_db + .commit(vec![page(2, 0x22)], 3, 2_000) + .await?; + let post_fork_restore_point = source_database_db + .create_restore_point(SnapshotSelector::Latest) + .await?; - Ok(()) - }) + let err = restore_point::resolve_restore_point( + &db, + forked_bucket, + TEST_DATABASE.to_string(), + post_fork_restore_point, + ) + .await + .expect_err("bucket fork should not resolve source commits created after the fork"); + assert_sqlite_error(err, SqliteStorageError::BranchNotReachable); + + Ok(()) + } + ) } diff --git a/engine/packages/depot/tests/debug.rs b/engine/packages/depot/tests/debug.rs index 89e9055488..1509681fc2 100644 --- a/engine/packages/depot/tests/debug.rs +++ b/engine/packages/depot/tests/debug.rs @@ -5,8 +5,8 @@ use depot::{ debug, keys::{PAGE_SIZE, branch_commit_key}, types::{ - DatabaseBranchId, ColdManifestChunk, ColdManifestChunkRef, ColdManifestIndex, - DirtyPage, LayerEntry, LayerKind, SQLITE_STORAGE_COLD_SCHEMA_VERSION, decode_commit_row, + ColdManifestChunk, ColdManifestChunkRef, ColdManifestIndex, DatabaseBranchId, DirtyPage, + LayerEntry, LayerKind, SQLITE_STORAGE_COLD_SCHEMA_VERSION, decode_commit_row, encode_cold_manifest_chunk, encode_cold_manifest_index, }, }; @@ -38,137 +38,164 @@ async fn commit_row( #[tokio::test] async fn debug_dumps_ancestry_pins_restore_points_and_gc_pin() -> Result<()> { - common::test_matrix("depot-debug-ancestry", |_tier, ctx| Box::pin(async move { - let db = ctx.udb.clone(); - let database_db = ctx.db; - - database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; - let branch_id = debug::dump_database_ancestry(&database_db).await?[0].0; - let first_commit = commit_row(&db, branch_id, 1).await?; - let pinned = database_db.create_restore_point(depot::types::SnapshotSelector::Latest).await?; - database_db.commit(vec![page(2, 0x22)], 3, 2_000).await?; - - let ancestry = debug::dump_database_ancestry(&database_db).await?; - assert_eq!(ancestry, vec![(branch_id, None)]); - - let pins = debug::dump_branch_pins(&database_db).await?; - assert_eq!(pins.branch_id, branch_id); - assert_eq!(pins.refcount, 1); - assert_eq!(pins.restore_point_pin, first_commit.versionstamp); - - let restore_points = debug::list_restore_points(&database_db).await?; - assert!(restore_points.iter().any(|entry| { - entry.restore_point_id == pinned && entry.pin_status == depot::types::PinStatus::Ready - })); - - assert_eq!(debug::estimate_gc_pin(&database_db).await?, first_commit.versionstamp); - - Ok(()) - })) + common::test_matrix("depot-debug-ancestry", |_tier, ctx| { + Box::pin(async move { + let db = ctx.udb.clone(); + let database_db = ctx.db; + + database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; + let branch_id = debug::dump_database_ancestry(&database_db).await?[0].0; + let first_commit = commit_row(&db, branch_id, 1).await?; + let pinned = database_db + .create_restore_point(depot::types::SnapshotSelector::Latest) + .await?; + database_db.commit(vec![page(2, 0x22)], 3, 2_000).await?; + + let ancestry = debug::dump_database_ancestry(&database_db).await?; + assert_eq!(ancestry, vec![(branch_id, None)]); + + let pins = debug::dump_branch_pins(&database_db).await?; + assert_eq!(pins.branch_id, branch_id); + assert_eq!(pins.refcount, 1); + assert_eq!(pins.restore_point_pin, first_commit.versionstamp); + + let restore_points = debug::list_restore_points(&database_db).await?; + assert!(restore_points.iter().any(|entry| { + entry.restore_point_id == pinned + && entry.pin_status == depot::types::PinStatus::Ready + })); + + assert_eq!( + debug::estimate_gc_pin(&database_db).await?, + first_commit.versionstamp + ); + + Ok(()) + }) + }) .await } #[tokio::test] async fn debug_read_at_returns_page_state_for_versionstamp() -> Result<()> { - common::test_matrix("depot-debug-read-at", |_tier, ctx| Box::pin(async move { - let db = ctx.udb.clone(); - let database_db = ctx.db; - - database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; - let branch_id = debug::dump_database_ancestry(&database_db).await?[0].0; - let first_commit = commit_row(&db, branch_id, 1).await?; - database_db - .commit(vec![page(1, 0x22), page(2, 0x33)], 3, 2_000) - .await?; - let second_commit = commit_row(&db, branch_id, 2).await?; - - let first_state = debug::read_at(&database_db, first_commit.versionstamp).await?; - assert_eq!(first_state.txid, 1); - assert_eq!(first_state.db_size_pages, 2); - assert_eq!(first_state.pages[0].bytes.as_deref(), Some(&vec![0x11; PAGE_SIZE as usize][..])); - assert_eq!(first_state.pages[1].bytes.as_deref(), Some(&vec![0; PAGE_SIZE as usize][..])); - - let second_state = debug::read_at(&database_db, second_commit.versionstamp).await?; - assert_eq!(second_state.txid, 2); - assert_eq!(second_state.db_size_pages, 3); - assert_eq!(second_state.pages[0].bytes.as_deref(), Some(&vec![0x22; PAGE_SIZE as usize][..])); - assert_eq!(second_state.pages[1].bytes.as_deref(), Some(&vec![0x33; PAGE_SIZE as usize][..])); - - Ok(()) - })) + common::test_matrix("depot-debug-read-at", |_tier, ctx| { + Box::pin(async move { + let db = ctx.udb.clone(); + let database_db = ctx.db; + + database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; + let branch_id = debug::dump_database_ancestry(&database_db).await?[0].0; + let first_commit = commit_row(&db, branch_id, 1).await?; + database_db + .commit(vec![page(1, 0x22), page(2, 0x33)], 3, 2_000) + .await?; + let second_commit = commit_row(&db, branch_id, 2).await?; + + let first_state = debug::read_at(&database_db, first_commit.versionstamp).await?; + assert_eq!(first_state.txid, 1); + assert_eq!(first_state.db_size_pages, 2); + assert_eq!( + first_state.pages[0].bytes.as_deref(), + Some(&vec![0x11; PAGE_SIZE as usize][..]) + ); + assert_eq!( + first_state.pages[1].bytes.as_deref(), + Some(&vec![0; PAGE_SIZE as usize][..]) + ); + + let second_state = debug::read_at(&database_db, second_commit.versionstamp).await?; + assert_eq!(second_state.txid, 2); + assert_eq!(second_state.db_size_pages, 3); + assert_eq!( + second_state.pages[0].bytes.as_deref(), + Some(&vec![0x22; PAGE_SIZE as usize][..]) + ); + assert_eq!( + second_state.pages[1].bytes.as_deref(), + Some(&vec![0x33; PAGE_SIZE as usize][..]) + ); + + Ok(()) + }) + }) .await } #[tokio::test] async fn debug_dump_cold_manifest_reads_index_and_chunks() -> Result<()> { - common::test_matrix("depot-debug-cold-manifest", |tier, ctx| Box::pin(async move { - let database_db = ctx.db; - database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; - let branch_id = debug::dump_database_ancestry(&database_db).await?[0].0; + common::test_matrix("depot-debug-cold-manifest", |tier, ctx| { + Box::pin(async move { + let database_db = ctx.db; + database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; + let branch_id = debug::dump_database_ancestry(&database_db).await?[0].0; + + if tier == common::TierMode::Disabled { + let manifest = debug::dump_cold_manifest(&database_db).await?; + assert_eq!(manifest.branch_id, branch_id); + assert!(manifest.index.is_none()); + assert!(manifest.chunks.is_empty()); + return Ok(()); + } + + let cold_tier = ctx.cold_tier.expect("filesystem tier should be configured"); + let chunk_key = format!( + "db/{}/cold_manifest/chunks/debug.bare", + branch_id.as_uuid().simple() + ); + let index_key = format!( + "db/{}/cold_manifest/index.bare", + branch_id.as_uuid().simple() + ); + + cold_tier + .put_object( + &chunk_key, + &encode_cold_manifest_chunk(ColdManifestChunk { + schema_version: SQLITE_STORAGE_COLD_SCHEMA_VERSION, + branch_id, + pass_versionstamp: [2; 16], + layers: vec![LayerEntry { + kind: LayerKind::Delta, + shard_id: None, + min_txid: 1, + max_txid: 1, + min_versionstamp: [1; 16], + max_versionstamp: [1; 16], + byte_size: 10, + checksum: 99, + object_key: "db/layer.ltx".to_string(), + }], + restore_points: Vec::new(), + })?, + ) + .await?; + cold_tier + .put_object( + &index_key, + &encode_cold_manifest_index(ColdManifestIndex { + schema_version: SQLITE_STORAGE_COLD_SCHEMA_VERSION, + branch_id, + chunks: vec![ColdManifestChunkRef { + object_key: chunk_key, + pass_versionstamp: [2; 16], + min_versionstamp: [1; 16], + max_versionstamp: [1; 16], + byte_size: 10, + }], + last_pass_at_ms: 2_000, + last_pass_versionstamp: [2; 16], + })?, + ) + .await?; - if tier == common::TierMode::Disabled { let manifest = debug::dump_cold_manifest(&database_db).await?; assert_eq!(manifest.branch_id, branch_id); - assert!(manifest.index.is_none()); - assert!(manifest.chunks.is_empty()); - return Ok(()); - } - - let cold_tier = ctx.cold_tier.expect("filesystem tier should be configured"); - let chunk_key = format!( - "db/{}/cold_manifest/chunks/debug.bare", - branch_id.as_uuid().simple() - ); - let index_key = format!("db/{}/cold_manifest/index.bare", branch_id.as_uuid().simple()); - - cold_tier - .put_object( - &chunk_key, - &encode_cold_manifest_chunk(ColdManifestChunk { - schema_version: SQLITE_STORAGE_COLD_SCHEMA_VERSION, - branch_id, - pass_versionstamp: [2; 16], - layers: vec![LayerEntry { - kind: LayerKind::Delta, - shard_id: None, - min_txid: 1, - max_txid: 1, - min_versionstamp: [1; 16], - max_versionstamp: [1; 16], - byte_size: 10, - checksum: 99, - object_key: "db/layer.ltx".to_string(), - }], - restore_points: Vec::new(), - })?, - ) - .await?; - cold_tier - .put_object( - &index_key, - &encode_cold_manifest_index(ColdManifestIndex { - schema_version: SQLITE_STORAGE_COLD_SCHEMA_VERSION, - branch_id, - chunks: vec![ColdManifestChunkRef { - object_key: chunk_key, - pass_versionstamp: [2; 16], - min_versionstamp: [1; 16], - max_versionstamp: [1; 16], - byte_size: 10, - }], - last_pass_at_ms: 2_000, - last_pass_versionstamp: [2; 16], - })?, - ) - .await?; - - let manifest = debug::dump_cold_manifest(&database_db).await?; - assert_eq!(manifest.branch_id, branch_id); - assert!(manifest.index.is_some()); - assert_eq!(manifest.chunks.len(), 1); - assert_eq!(manifest.chunks[0].layers[0].checksum, 99); - - Ok(()) - })) + assert!(manifest.index.is_some()); + assert_eq!(manifest.chunks.len(), 1); + assert_eq!(manifest.chunks[0].layers[0].checksum, 99); + + Ok(()) + }) + }) .await } diff --git a/engine/packages/depot/tests/fault_controller.rs b/engine/packages/depot/tests/fault_controller.rs index b17e63cc53..60d983ac00 100644 --- a/engine/packages/depot/tests/fault_controller.rs +++ b/engine/packages/depot/tests/fault_controller.rs @@ -4,9 +4,8 @@ use std::time::Duration; use anyhow::Result; use depot::fault::{ - ColdTierFaultPoint, CommitFaultPoint, DepotFaultContext, DepotFaultController, - DepotFaultPoint, DepotFaultReplayEventKind, FaultBoundary, HotCompactionFaultPoint, - ReadFaultPoint, + ColdTierFaultPoint, CommitFaultPoint, DepotFaultContext, DepotFaultController, DepotFaultPoint, + DepotFaultReplayEventKind, FaultBoundary, HotCompactionFaultPoint, ReadFaultPoint, }; use depot::types::DatabaseBranchId; @@ -32,14 +31,24 @@ async fn fault_controller_matches_scope_and_invocation() -> Result<()> { seed: Some(7), ..DepotFaultContext::default() }; - assert!(controller.maybe_fire(point.clone(), wrong_scope).await?.is_none()); + assert!( + controller + .maybe_fire(point.clone(), wrong_scope) + .await? + .is_none() + ); let matching_scope = DepotFaultContext::new() .database_id("db-a") .database_branch_id(branch_id) .checkpoint("after-write") .seed(7); - assert!(controller.maybe_fire(point.clone(), matching_scope.clone()).await?.is_none()); + assert!( + controller + .maybe_fire(point.clone(), matching_scope.clone()) + .await? + .is_none() + ); let fired = controller .maybe_fire(point.clone(), matching_scope.clone()) .await? @@ -88,7 +97,10 @@ async fn fail_action_returns_error_and_records_replay() -> Result<()> { .expect_err("fail actions should return an error"); assert!(err.to_string().contains("cold object disappeared")); - assert_eq!(controller.replay_log()[0].kind, DepotFaultReplayEventKind::Fired); + assert_eq!( + controller.replay_log()[0].kind, + DepotFaultReplayEventKind::Fired + ); assert_eq!(controller.replay_log()[0].boundary, FaultBoundary::ReadOnly); Ok(()) @@ -97,7 +109,8 @@ async fn fail_action_returns_error_and_records_replay() -> Result<()> { #[tokio::test] async fn pause_action_waits_for_release() -> Result<()> { let controller = DepotFaultController::new(); - let point = DepotFaultPoint::HotCompaction(HotCompactionFaultPoint::AfterStageBeforeFinishSignal); + let point = + DepotFaultPoint::HotCompaction(HotCompactionFaultPoint::AfterStageBeforeFinishSignal); let pause = controller.pause_handle("hot-staged"); controller.at(point.clone()).once().pause("hot-staged")?; @@ -154,12 +167,21 @@ fn unfired_expected_faults_are_reported_in_replay() -> Result<()> { let err = controller .assert_expected_fired() .expect_err("unfired expected rule should fail the test"); - assert!(err.to_string().contains("expected depot faults did not fire")); + assert!( + err.to_string() + .contains("expected depot faults did not fire") + ); let replay = controller.replay_log_with_unfired(); assert_eq!(replay.len(), 1); - assert_eq!(replay[0].kind, DepotFaultReplayEventKind::ExpectedButUnfired); - assert_eq!(replay[0].boundary, FaultBoundary::AmbiguousAfterDurableCommit); + assert_eq!( + replay[0].kind, + DepotFaultReplayEventKind::ExpectedButUnfired + ); + assert_eq!( + replay[0].boundary, + FaultBoundary::AmbiguousAfterDurableCommit + ); Ok(()) } diff --git a/engine/packages/depot/tests/forced_compaction_test_driver.rs b/engine/packages/depot/tests/forced_compaction_test_driver.rs index a81abd9dc5..cad27b1c99 100644 --- a/engine/packages/depot/tests/forced_compaction_test_driver.rs +++ b/engine/packages/depot/tests/forced_compaction_test_driver.rs @@ -8,9 +8,9 @@ use depot::{ keys::PAGE_SIZE, types::{BucketId, DatabaseBranchId, DirtyPage}, workflows::compaction::{ - CompactionJobKind, DATABASE_BRANCH_ID_TAG, DbColdCompacterWorkflow, - DbHotCompacterWorkflow, DbManagerState, DbManagerWorkflow, DbReclaimerWorkflow, - DepotCompactionTestDriver, ForceCompactionWork, + CompactionJobKind, DATABASE_BRANCH_ID_TAG, DbColdCompacterWorkflow, DbHotCompacterWorkflow, + DbManagerState, DbManagerWorkflow, DbReclaimerWorkflow, DepotCompactionTestDriver, + ForceCompactionWork, }, }; use gas::{ @@ -29,7 +29,9 @@ fn test_bucket() -> Id { fn build_registry() -> Registry { let mut registry = Registry::new(); registry.register_workflow::().unwrap(); - registry.register_workflow::().unwrap(); + registry + .register_workflow::() + .unwrap(); registry .register_workflow::() .unwrap(); @@ -76,10 +78,7 @@ async fn read_database_branch_id( .await } -async fn latest_manager_state( - test_ctx: &TestCtx, - workflow_id: Id, -) -> Result { +async fn latest_manager_state(test_ctx: &TestCtx, workflow_id: Id) -> Result { let history = DatabaseDebug::get_workflow_history(test_ctx.debug_db(), workflow_id, true) .await? .context("manager workflow history not found")?; @@ -100,9 +99,7 @@ async fn test_driver_forces_noop_without_planning_timers() -> Result<()> { db.commit(vec![dirty_page(1, 0x11)], 2, 1_000).await?; let database_branch_id = read_database_branch_id(&test_ctx, TEST_DATABASE).await?; let driver = DepotCompactionTestDriver::new(&test_ctx); - let manager_workflow_id = driver - .start_manager(database_branch_id, None, true) - .await?; + let manager_workflow_id = driver.start_manager(database_branch_id, None, true).await?; let requested_work = ForceCompactionWork { hot: false, diff --git a/engine/packages/depot/tests/fork_bucket.rs b/engine/packages/depot/tests/fork_bucket.rs index 3200528191..48cd7a63dd 100644 --- a/engine/packages/depot/tests/fork_bucket.rs +++ b/engine/packages/depot/tests/fork_bucket.rs @@ -7,23 +7,23 @@ use std::sync::{ }; use anyhow::Result; -use gas::prelude::Id; use depot::{ + conveyer::branch, keys::{ - bucket_child_key, bucket_fork_pin_key, bucket_proof_epoch_key, bucket_catalog_by_db_key, - bucket_branches_restore_point_pin_key, + bucket_branches_restore_point_pin_key, bucket_catalog_by_db_key, bucket_child_key, + bucket_fork_pin_key, bucket_proof_epoch_key, }, - conveyer::branch, types::{ BucketBranchId, BucketId, ResolvedVersionstamp, decode_bucket_catalog_db_fact, decode_bucket_fork_fact, }, }; +use gas::prelude::Id; use universaldb::options::MutationType; use fork_common::{ - assert_storage_error, page, page_bytes, read_database_branch_id, read_commit, - read_bucket_branch_id_for, read_bucket_branch_record, read_value, + assert_storage_error, page, page_bytes, read_bucket_branch_id_for, read_bucket_branch_record, + read_commit, read_database_branch_id, read_value, }; fn bucket_id_to_gas_id(bucket_id: BucketId) -> Id { @@ -32,235 +32,251 @@ fn bucket_id_to_gas_id(bucket_id: BucketId) -> Id { #[tokio::test] async fn fork_bucket_covers_root_depth_one_and_deep_sources() -> Result<()> { - common::test_matrix("depot-fork-bucket-covers", |_tier, ctx| Box::pin(async move { - let db = ctx.udb.clone(); - let database_id = ctx.database_id.clone(); - let source_bucket_gas = ctx.bucket_id; - let source = ctx.make_db(source_bucket_gas, database_id.clone()); - source.commit(vec![page(1, 0x11)], 2, 1_000).await?; - let root_database_branch = - read_database_branch_id(&db, source_bucket_gas, &database_id).await?; - let root_commit = read_commit(&db, root_database_branch, 1).await?; - let mut source_bucket = BucketId::from_gas_id(source_bucket_gas); - let mut source_bucket_branch = read_bucket_branch_id_for(&db, source_bucket_gas).await?; + common::test_matrix("depot-fork-bucket-covers", |_tier, ctx| { + Box::pin(async move { + let db = ctx.udb.clone(); + let database_id = ctx.database_id.clone(); + let source_bucket_gas = ctx.bucket_id; + let source = ctx.make_db(source_bucket_gas, database_id.clone()); + source.commit(vec![page(1, 0x11)], 2, 1_000).await?; + let root_database_branch = + read_database_branch_id(&db, source_bucket_gas, &database_id).await?; + let root_commit = read_commit(&db, root_database_branch, 1).await?; + let mut source_bucket = BucketId::from_gas_id(source_bucket_gas); + let mut source_bucket_branch = + read_bucket_branch_id_for(&db, source_bucket_gas).await?; - for depth in 1..=4 { - let forked_bucket = branch::fork_bucket( - &db, - source_bucket, - ResolvedVersionstamp { - versionstamp: root_commit.versionstamp, - restore_point: None, - }) - .await?; - let forked_bucket_branch = - read_bucket_branch_id_for(&db, bucket_id_to_gas_id(forked_bucket)).await?; - let forked_record = read_bucket_branch_record(&db, forked_bucket_branch).await?; - assert_eq!(forked_record.parent, Some(source_bucket_branch)); - assert_eq!(forked_record.fork_depth, depth); + for depth in 1..=4 { + let forked_bucket = branch::fork_bucket( + &db, + source_bucket, + ResolvedVersionstamp { + versionstamp: root_commit.versionstamp, + restore_point: None, + }, + ) + .await?; + let forked_bucket_branch = + read_bucket_branch_id_for(&db, bucket_id_to_gas_id(forked_bucket)).await?; + let forked_record = read_bucket_branch_record(&db, forked_bucket_branch).await?; + assert_eq!(forked_record.parent, Some(source_bucket_branch)); + assert_eq!(forked_record.fork_depth, depth); - let forked_database_db = - ctx.make_db(bucket_id_to_gas_id(forked_bucket), database_id.clone()); - let pages = forked_database_db.get_pages(vec![1]).await?; - assert_eq!(pages[0].bytes, Some(page_bytes(0x11))); + let forked_database_db = + ctx.make_db(bucket_id_to_gas_id(forked_bucket), database_id.clone()); + let pages = forked_database_db.get_pages(vec![1]).await?; + assert_eq!(pages[0].bytes, Some(page_bytes(0x11))); - source_bucket = forked_bucket; - source_bucket_branch = forked_bucket_branch; - } + source_bucket = forked_bucket; + source_bucket_branch = forked_bucket_branch; + } - Ok(()) - })) + Ok(()) + }) + }) .await } #[tokio::test] async fn fork_bucket_writes_unresolved_proof_facts() -> Result<()> { - common::test_matrix("depot-fork-bucket-proof-facts", |_tier, ctx| Box::pin(async move { - let db = ctx.udb.clone(); - let database_id = ctx.database_id.clone(); - let source_bucket_gas = ctx.bucket_id; - let source = ctx.make_db(source_bucket_gas, database_id.clone()); - source.commit(vec![page(1, 0x11)], 2, 1_000).await?; - let root_database_branch = - read_database_branch_id(&db, source_bucket_gas, &database_id).await?; - let root_commit = read_commit(&db, root_database_branch, 1).await?; - let source_bucket_branch = read_bucket_branch_id_for(&db, source_bucket_gas).await?; + common::test_matrix("depot-fork-bucket-proof-facts", |_tier, ctx| { + Box::pin(async move { + let db = ctx.udb.clone(); + let database_id = ctx.database_id.clone(); + let source_bucket_gas = ctx.bucket_id; + let source = ctx.make_db(source_bucket_gas, database_id.clone()); + source.commit(vec![page(1, 0x11)], 2, 1_000).await?; + let root_database_branch = + read_database_branch_id(&db, source_bucket_gas, &database_id).await?; + let root_commit = read_commit(&db, root_database_branch, 1).await?; + let source_bucket_branch = read_bucket_branch_id_for(&db, source_bucket_gas).await?; - let forked_bucket = branch::fork_bucket( - &db, - BucketId::from_gas_id(source_bucket_gas), - ResolvedVersionstamp { - versionstamp: root_commit.versionstamp, - restore_point: None, - }) - .await?; - let forked_bucket_branch = - read_bucket_branch_id_for(&db, bucket_id_to_gas_id(forked_bucket)).await?; + let forked_bucket = branch::fork_bucket( + &db, + BucketId::from_gas_id(source_bucket_gas), + ResolvedVersionstamp { + versionstamp: root_commit.versionstamp, + restore_point: None, + }, + ) + .await?; + let forked_bucket_branch = + read_bucket_branch_id_for(&db, bucket_id_to_gas_id(forked_bucket)).await?; - let bucket_catalog_fact_bytes = read_value( - &db, - bucket_catalog_by_db_key(root_database_branch, source_bucket_branch), - ) - .await? - .expect("bucket catalog proof fact should exist"); - let bucket_catalog_fact = decode_bucket_catalog_db_fact(&bucket_catalog_fact_bytes)?; - assert_eq!(bucket_catalog_fact.database_branch_id, root_database_branch); - assert_eq!(bucket_catalog_fact.bucket_branch_id, source_bucket_branch); - assert!(bucket_catalog_fact.catalog_versionstamp <= root_commit.versionstamp); - assert_eq!(bucket_catalog_fact.tombstone_versionstamp, None); + let bucket_catalog_fact_bytes = read_value( + &db, + bucket_catalog_by_db_key(root_database_branch, source_bucket_branch), + ) + .await? + .expect("bucket catalog proof fact should exist"); + let bucket_catalog_fact = decode_bucket_catalog_db_fact(&bucket_catalog_fact_bytes)?; + assert_eq!(bucket_catalog_fact.database_branch_id, root_database_branch); + assert_eq!(bucket_catalog_fact.bucket_branch_id, source_bucket_branch); + assert!(bucket_catalog_fact.catalog_versionstamp <= root_commit.versionstamp); + assert_eq!(bucket_catalog_fact.tombstone_versionstamp, None); - let fork_fact_bytes = read_value( - &db, - bucket_fork_pin_key( - source_bucket_branch, - root_commit.versionstamp, - forked_bucket_branch, - ), - ) - .await? - .expect("bucket fork pin fact should exist"); - let child_fact_bytes = read_value( - &db, - bucket_child_key( - source_bucket_branch, - root_commit.versionstamp, - forked_bucket_branch, - ), - ) - .await? - .expect("bucket child fact should exist"); - let fork_fact = decode_bucket_fork_fact(&fork_fact_bytes)?; - let child_fact = decode_bucket_fork_fact(&child_fact_bytes)?; - assert_eq!(fork_fact, child_fact); - assert_eq!(fork_fact.source_bucket_branch_id, source_bucket_branch); - assert_eq!(fork_fact.target_bucket_branch_id, forked_bucket_branch); - assert_eq!(fork_fact.fork_versionstamp, root_commit.versionstamp); + let fork_fact_bytes = read_value( + &db, + bucket_fork_pin_key( + source_bucket_branch, + root_commit.versionstamp, + forked_bucket_branch, + ), + ) + .await? + .expect("bucket fork pin fact should exist"); + let child_fact_bytes = read_value( + &db, + bucket_child_key( + source_bucket_branch, + root_commit.versionstamp, + forked_bucket_branch, + ), + ) + .await? + .expect("bucket child fact should exist"); + let fork_fact = decode_bucket_fork_fact(&fork_fact_bytes)?; + let child_fact = decode_bucket_fork_fact(&child_fact_bytes)?; + assert_eq!(fork_fact, child_fact); + assert_eq!(fork_fact.source_bucket_branch_id, source_bucket_branch); + assert_eq!(fork_fact.target_bucket_branch_id, forked_bucket_branch); + assert_eq!(fork_fact.fork_versionstamp, root_commit.versionstamp); - assert!( - read_value(&db, bucket_proof_epoch_key(source_bucket_branch)) - .await? - .is_some() - ); + assert!( + read_value(&db, bucket_proof_epoch_key(source_bucket_branch)) + .await? + .is_some() + ); - Ok(()) - })) + Ok(()) + }) + }) .await } #[tokio::test] async fn fork_bucket_restore_point_pin_race_returns_out_of_retention() -> Result<()> { - common::test_matrix("depot-fork-bucket-pin-race", |_tier, ctx| Box::pin(async move { - let db = ctx.udb.clone(); - let database_id = ctx.database_id.clone(); - let source_bucket_gas = ctx.bucket_id; - let source = ctx.make_db(source_bucket_gas, database_id.clone()); - source.commit(vec![page(1, 0x11)], 2, 1_000).await?; - let root_database_branch = - read_database_branch_id(&db, source_bucket_gas, &database_id).await?; - let root_commit = read_commit(&db, root_database_branch, 1).await?; - let source_bucket_branch = read_bucket_branch_id_for(&db, source_bucket_gas).await?; - let new_branch = BucketBranchId::new_v4(); - let pin_after_fork_point = [0xff; 16]; - let raced = Arc::new(AtomicBool::new(false)); + common::test_matrix("depot-fork-bucket-pin-race", |_tier, ctx| { + Box::pin(async move { + let db = ctx.udb.clone(); + let database_id = ctx.database_id.clone(); + let source_bucket_gas = ctx.bucket_id; + let source = ctx.make_db(source_bucket_gas, database_id.clone()); + source.commit(vec![page(1, 0x11)], 2, 1_000).await?; + let root_database_branch = + read_database_branch_id(&db, source_bucket_gas, &database_id).await?; + let root_commit = read_commit(&db, root_database_branch, 1).await?; + let source_bucket_branch = read_bucket_branch_id_for(&db, source_bucket_gas).await?; + let new_branch = BucketBranchId::new_v4(); + let pin_after_fork_point = [0xff; 16]; + let raced = Arc::new(AtomicBool::new(false)); - let err = db - .run({ - let db = db.clone(); - let raced = raced.clone(); - move |tx| { + let err = db + .run({ let db = db.clone(); let raced = raced.clone(); - async move { - branch::derive_bucket_branch_at( - &tx, - source_bucket_branch, - root_commit.versionstamp, - new_branch, - None, - ) - .await?; - - if !raced.swap(true, Ordering::SeqCst) { - db.run(move |pin_tx| async move { - pin_tx.informal().atomic_op( - &bucket_branches_restore_point_pin_key(source_bucket_branch), - &pin_after_fork_point, - MutationType::ByteMin, - ); - Ok(()) - }) + move |tx| { + let db = db.clone(); + let raced = raced.clone(); + async move { + branch::derive_bucket_branch_at( + &tx, + source_bucket_branch, + root_commit.versionstamp, + new_branch, + None, + ) .await?; - } - Ok(()) + if !raced.swap(true, Ordering::SeqCst) { + db.run(move |pin_tx| async move { + pin_tx.informal().atomic_op( + &bucket_branches_restore_point_pin_key( + source_bucket_branch, + ), + &pin_after_fork_point, + MutationType::ByteMin, + ); + Ok(()) + }) + .await?; + } + + Ok(()) + } } - } - }) - .await - .expect_err("retry should observe the advanced bucket restore_point pin"); + }) + .await + .expect_err("retry should observe the advanced bucket restore_point pin"); - assert_storage_error(&err, depot::error::SqliteStorageError::ForkOutOfRetention); + assert_storage_error(&err, depot::error::SqliteStorageError::ForkOutOfRetention); - Ok(()) - })) + Ok(()) + }) + }) .await } #[tokio::test] async fn fork_bucket_allows_depth_sixteen_and_rejects_depth_seventeen() -> Result<()> { - common::test_matrix("depot-fork-bucket-depth", |_tier, ctx| Box::pin(async move { - let db = ctx.udb.clone(); - let database_id = ctx.database_id.clone(); - let source_bucket_gas = ctx.bucket_id; - let source = ctx.make_db(source_bucket_gas, database_id.clone()); - source.commit(vec![page(1, 0x11)], 2, 1_000).await?; - let root_database_branch = - read_database_branch_id(&db, source_bucket_gas, &database_id).await?; - let root_commit = read_commit(&db, root_database_branch, 1).await?; - let mut source_bucket = BucketId::from_gas_id(source_bucket_gas); - let mut source_bucket_branch = read_bucket_branch_id_for(&db, source_bucket_gas).await?; + common::test_matrix("depot-fork-bucket-depth", |_tier, ctx| { + Box::pin(async move { + let db = ctx.udb.clone(); + let database_id = ctx.database_id.clone(); + let source_bucket_gas = ctx.bucket_id; + let source = ctx.make_db(source_bucket_gas, database_id.clone()); + source.commit(vec![page(1, 0x11)], 2, 1_000).await?; + let root_database_branch = + read_database_branch_id(&db, source_bucket_gas, &database_id).await?; + let root_commit = read_commit(&db, root_database_branch, 1).await?; + let mut source_bucket = BucketId::from_gas_id(source_bucket_gas); + let mut source_bucket_branch = + read_bucket_branch_id_for(&db, source_bucket_gas).await?; - for depth in 1..=depot::constants::MAX_BUCKET_DEPTH { - let forked_bucket = branch::fork_bucket( + for depth in 1..=depot::constants::MAX_BUCKET_DEPTH { + let forked_bucket = branch::fork_bucket( + &db, + source_bucket, + ResolvedVersionstamp { + versionstamp: root_commit.versionstamp, + restore_point: None, + }, + ) + .await?; + let forked_bucket_branch = + read_bucket_branch_id_for(&db, bucket_id_to_gas_id(forked_bucket)).await?; + let forked_record = read_bucket_branch_record(&db, forked_bucket_branch).await?; + assert_eq!(forked_record.parent, Some(source_bucket_branch)); + assert_eq!(forked_record.fork_depth, depth); + + source_bucket = forked_bucket; + source_bucket_branch = forked_bucket_branch; + } + + let err = branch::fork_bucket( &db, source_bucket, ResolvedVersionstamp { versionstamp: root_commit.versionstamp, restore_point: None, - }) - .await?; - let forked_bucket_branch = - read_bucket_branch_id_for(&db, bucket_id_to_gas_id(forked_bucket)).await?; - let forked_record = read_bucket_branch_record(&db, forked_bucket_branch).await?; - assert_eq!(forked_record.parent, Some(source_bucket_branch)); - assert_eq!(forked_record.fork_depth, depth); - - source_bucket = forked_bucket; - source_bucket_branch = forked_bucket_branch; - } - - let err = branch::fork_bucket( - &db, - source_bucket, - ResolvedVersionstamp { - versionstamp: root_commit.versionstamp, - restore_point: None, - }) - .await - .expect_err("depth 17 bucket fork should be rejected"); + }, + ) + .await + .expect_err("depth 17 bucket fork should be rejected"); - assert_storage_error( - &err, - depot::error::SqliteStorageError::BucketForkChainTooDeep, - ); - assert_eq!( - read_bucket_branch_record(&db, source_bucket_branch) - .await? - .fork_depth, - depot::constants::MAX_BUCKET_DEPTH - ); + assert_storage_error( + &err, + depot::error::SqliteStorageError::BucketForkChainTooDeep, + ); + assert_eq!( + read_bucket_branch_record(&db, source_bucket_branch) + .await? + .fork_depth, + depot::constants::MAX_BUCKET_DEPTH + ); - Ok(()) - })) + Ok(()) + }) + }) .await } diff --git a/engine/packages/depot/tests/fork_common/mod.rs b/engine/packages/depot/tests/fork_common/mod.rs index 80c5322c58..bf8e1235ee 100644 --- a/engine/packages/depot/tests/fork_common/mod.rs +++ b/engine/packages/depot/tests/fork_common/mod.rs @@ -4,18 +4,18 @@ mod common; use anyhow::Result; -use gas::prelude::Id; use depot::{ keys::{ - database_pointer_cur_key, branch_commit_key, branch_meta_head_key, branches_list_key, - bucket_branches_list_key, bucket_pointer_cur_key, + branch_commit_key, branch_meta_head_key, branches_list_key, bucket_branches_list_key, + bucket_pointer_cur_key, database_pointer_cur_key, }, types::{ - DatabaseBranchId, DatabaseBranchRecord, CommitRow, DirtyPage, BucketBranchId, - BucketBranchRecord, BucketId, decode_database_branch_record, decode_database_pointer, - decode_commit_row, decode_db_head, decode_bucket_branch_record, decode_bucket_pointer, + BucketBranchId, BucketBranchRecord, BucketId, CommitRow, DatabaseBranchId, + DatabaseBranchRecord, DirtyPage, decode_bucket_branch_record, decode_bucket_pointer, + decode_commit_row, decode_database_branch_record, decode_database_pointer, decode_db_head, }, }; +use gas::prelude::Id; pub use common::read_value; @@ -114,10 +114,7 @@ pub async fn read_commit( decode_commit_row(&bytes) } -pub fn assert_storage_error( - err: &anyhow::Error, - expected: depot::error::SqliteStorageError, -) { +pub fn assert_storage_error(err: &anyhow::Error, expected: depot::error::SqliteStorageError) { assert!( err.chain().any(|cause| { cause diff --git a/engine/packages/depot/tests/fork_database.rs b/engine/packages/depot/tests/fork_database.rs index 5c4c63f028..f069b52537 100644 --- a/engine/packages/depot/tests/fork_database.rs +++ b/engine/packages/depot/tests/fork_database.rs @@ -8,376 +8,421 @@ use std::sync::{ use anyhow::Result; use depot::{ - keys::{branch_meta_head_at_fork_key, branches_restore_point_pin_key}, conveyer::branch, + keys::{branch_meta_head_at_fork_key, branches_restore_point_pin_key}, pitr_interval::write_pitr_interval_coverage, types::{ - DatabaseBranchId, BucketId, PitrIntervalCoverage, ResolvedVersionstamp, - SnapshotSelector, decode_db_head, + BucketId, DatabaseBranchId, PitrIntervalCoverage, ResolvedVersionstamp, SnapshotSelector, + decode_db_head, }, }; use universaldb::options::MutationType; use fork_common::{ - assert_storage_error, page, page_bytes, read_database_branch_id, read_database_branch_record, - read_commit, read_head_commit, read_bucket_branch_id_for, read_value, + assert_storage_error, page, page_bytes, read_bucket_branch_id_for, read_commit, + read_database_branch_id, read_database_branch_record, read_head_commit, read_value, target_bucket, }; #[tokio::test] async fn fork_database_covers_root_depth_one_deep_and_cross_bucket_sources() -> Result<()> { - common::test_matrix("depot-fork-database-covers", |_tier, ctx| Box::pin(async move { - let db = ctx.udb.clone(); - let source_bucket = ctx.bucket_id; - let root_database_id = ctx.database_id.clone(); - let source = ctx.make_db(source_bucket, root_database_id.clone()); - source.commit(vec![page(1, 0x11)], 2, 1_000).await?; - let root_branch = read_database_branch_id(&db, source_bucket, &root_database_id).await?; - let root_commit = read_commit(&db, root_branch, 1).await?; - - let target_seed = ctx.make_db(target_bucket(), "target-seed"); - target_seed.commit(vec![page(1, 0xaa)], 1, 1_100).await?; - let target_bucket_branch = read_bucket_branch_id_for(&db, target_bucket()).await?; - - let cross_bucket_database = branch::fork_database( - &db, - BucketId::from_gas_id(source_bucket), - root_database_id.clone(), - SnapshotSelector::Latest, - BucketId::from_gas_id(target_bucket())) - .await?; - let cross_bucket_branch = - read_database_branch_id(&db, target_bucket(), &cross_bucket_database).await?; - let cross_bucket_record = read_database_branch_record(&db, cross_bucket_branch).await?; - assert_eq!(cross_bucket_record.bucket_branch, target_bucket_branch); - assert_eq!(cross_bucket_record.parent, Some(root_branch)); - assert_eq!(cross_bucket_record.fork_depth, 1); - let cross_bucket_db = ctx.make_db(target_bucket(), cross_bucket_database); - let pages = cross_bucket_db.get_pages(vec![1]).await?; - assert_eq!(pages[0].bytes, Some(page_bytes(0x11))); - - let mut source_database_id = root_database_id; - let mut source_commit = root_commit; - let mut expected_pages = vec![(1, 0x11)]; - - for depth in 1..=4 { - let forked_database_id = branch::fork_database( + common::test_matrix("depot-fork-database-covers", |_tier, ctx| { + Box::pin(async move { + let db = ctx.udb.clone(); + let source_bucket = ctx.bucket_id; + let root_database_id = ctx.database_id.clone(); + let source = ctx.make_db(source_bucket, root_database_id.clone()); + source.commit(vec![page(1, 0x11)], 2, 1_000).await?; + let root_branch = + read_database_branch_id(&db, source_bucket, &root_database_id).await?; + let root_commit = read_commit(&db, root_branch, 1).await?; + + let target_seed = ctx.make_db(target_bucket(), "target-seed"); + target_seed.commit(vec![page(1, 0xaa)], 1, 1_100).await?; + let target_bucket_branch = read_bucket_branch_id_for(&db, target_bucket()).await?; + + let cross_bucket_database = branch::fork_database( &db, BucketId::from_gas_id(source_bucket), - source_database_id.clone(), - ResolvedVersionstamp { - versionstamp: source_commit.versionstamp, - restore_point: None, - }, - BucketId::from_gas_id(source_bucket)) + root_database_id.clone(), + SnapshotSelector::Latest, + BucketId::from_gas_id(target_bucket()), + ) .await?; - let forked_branch = - read_database_branch_id(&db, source_bucket, &forked_database_id).await?; - let forked_record = read_database_branch_record(&db, forked_branch).await?; - assert_eq!(forked_record.fork_depth, depth); - - let forked_db = ctx.make_db(source_bucket, forked_database_id.clone()); - let pgnos = expected_pages.iter().map(|(pgno, _)| *pgno).collect(); - let pages = forked_db.get_pages(pgnos).await?; - for (page, (_, fill)) in pages.iter().zip(expected_pages.iter()) { - assert_eq!(page.bytes, Some(page_bytes(*fill))); - } + let cross_bucket_branch = + read_database_branch_id(&db, target_bucket(), &cross_bucket_database).await?; + let cross_bucket_record = read_database_branch_record(&db, cross_bucket_branch).await?; + assert_eq!(cross_bucket_record.bucket_branch, target_bucket_branch); + assert_eq!(cross_bucket_record.parent, Some(root_branch)); + assert_eq!(cross_bucket_record.fork_depth, 1); + let cross_bucket_db = ctx.make_db(target_bucket(), cross_bucket_database); + let pages = cross_bucket_db.get_pages(vec![1]).await?; + assert_eq!(pages[0].bytes, Some(page_bytes(0x11))); + + let mut source_database_id = root_database_id; + let mut source_commit = root_commit; + let mut expected_pages = vec![(1, 0x11)]; + + for depth in 1..=4 { + let forked_database_id = branch::fork_database( + &db, + BucketId::from_gas_id(source_bucket), + source_database_id.clone(), + ResolvedVersionstamp { + versionstamp: source_commit.versionstamp, + restore_point: None, + }, + BucketId::from_gas_id(source_bucket), + ) + .await?; + let forked_branch = + read_database_branch_id(&db, source_bucket, &forked_database_id).await?; + let forked_record = read_database_branch_record(&db, forked_branch).await?; + assert_eq!(forked_record.fork_depth, depth); - if depth < 4 { - let pgno = depth as u32 + 1; - let fill = 0x20 + depth; - forked_db - .commit(vec![page(pgno, fill)], pgno + 1, 2_000 + depth as i64) - .await?; - expected_pages.push((pgno, fill)); - source_commit = read_head_commit(&db, forked_branch).await?; - source_database_id = forked_database_id; + let forked_db = ctx.make_db(source_bucket, forked_database_id.clone()); + let pgnos = expected_pages.iter().map(|(pgno, _)| *pgno).collect(); + let pages = forked_db.get_pages(pgnos).await?; + for (page, (_, fill)) in pages.iter().zip(expected_pages.iter()) { + assert_eq!(page.bytes, Some(page_bytes(*fill))); + } + + if depth < 4 { + let pgno = depth as u32 + 1; + let fill = 0x20 + depth; + forked_db + .commit(vec![page(pgno, fill)], pgno + 1, 2_000 + depth as i64) + .await?; + expected_pages.push((pgno, fill)); + source_commit = read_head_commit(&db, forked_branch).await?; + source_database_id = forked_database_id; + } } - } - Ok(()) - })) + Ok(()) + }) + }) .await } #[tokio::test] async fn fork_database_from_timestamp_selector_uses_resolved_snapshot() -> Result<()> { - common::test_matrix("depot-fork-database-timestamp", |_tier, ctx| Box::pin(async move { - let db = ctx.udb.clone(); - let source_bucket = ctx.bucket_id; - let source_database_id = ctx.database_id.clone(); - let source = ctx.make_db(source_bucket, source_database_id.clone()); - source.commit(vec![page(1, 0x11)], 2, 1_000).await?; - source.commit(vec![page(1, 0x22)], 2, 2_000).await?; - let source_branch = read_database_branch_id(&db, source_bucket, &source_database_id).await?; - let first = read_commit(&db, source_branch, 1).await?; - db.run(move |tx| async move { - write_pitr_interval_coverage( - &tx, - source_branch, - 1_000, - PitrIntervalCoverage { - txid: 1, - versionstamp: first.versionstamp, - wall_clock_ms: 1_000, - expires_at_ms: i64::MAX, + common::test_matrix("depot-fork-database-timestamp", |_tier, ctx| { + Box::pin(async move { + let db = ctx.udb.clone(); + let source_bucket = ctx.bucket_id; + let source_database_id = ctx.database_id.clone(); + let source = ctx.make_db(source_bucket, source_database_id.clone()); + source.commit(vec![page(1, 0x11)], 2, 1_000).await?; + source.commit(vec![page(1, 0x22)], 2, 2_000).await?; + let source_branch = + read_database_branch_id(&db, source_bucket, &source_database_id).await?; + let first = read_commit(&db, source_branch, 1).await?; + db.run(move |tx| async move { + write_pitr_interval_coverage( + &tx, + source_branch, + 1_000, + PitrIntervalCoverage { + txid: 1, + versionstamp: first.versionstamp, + wall_clock_ms: 1_000, + expires_at_ms: i64::MAX, + }, + )?; + Ok(()) + }) + .await?; + + let forked_database_id = branch::fork_database( + &db, + BucketId::from_gas_id(source_bucket), + source_database_id, + SnapshotSelector::AtTimestamp { + timestamp_ms: 1_500, }, - )?; + BucketId::from_gas_id(source_bucket), + ) + .await?; + let forked_branch = + read_database_branch_id(&db, source_bucket, &forked_database_id).await?; + let head_at_fork = read_value(&db, branch_meta_head_at_fork_key(forked_branch)) + .await? + .expect("forked branch head_at_fork should exist"); + assert_eq!(decode_db_head(&head_at_fork)?.head_txid, 1); + Ok(()) }) - .await?; - - let forked_database_id = branch::fork_database( - &db, - BucketId::from_gas_id(source_bucket), - source_database_id, - SnapshotSelector::AtTimestamp { timestamp_ms: 1_500 }, - BucketId::from_gas_id(source_bucket)) - .await?; - let forked_branch = read_database_branch_id(&db, source_bucket, &forked_database_id).await?; - let head_at_fork = read_value(&db, branch_meta_head_at_fork_key(forked_branch)) - .await? - .expect("forked branch head_at_fork should exist"); - assert_eq!(decode_db_head(&head_at_fork)?.head_txid, 1); - - Ok(()) - })) + }) .await } #[tokio::test] async fn fork_database_from_restore_point_selector_uses_retained_snapshot() -> Result<()> { - common::test_matrix("depot-fork-database-restore-point", |_tier, ctx| Box::pin(async move { - let db = ctx.udb.clone(); - let source_bucket = ctx.bucket_id; - let source_database_id = ctx.database_id.clone(); - let source = ctx.make_db(source_bucket, source_database_id.clone()); - source.commit(vec![page(1, 0x11)], 2, 1_000).await?; - let restore_point = source.create_restore_point(SnapshotSelector::Latest).await?; - source.commit(vec![page(1, 0x22)], 2, 2_000).await?; - - let forked_database_id = branch::fork_database( - &db, - BucketId::from_gas_id(source_bucket), - source_database_id, - SnapshotSelector::RestorePoint { restore_point }, - BucketId::from_gas_id(source_bucket)) - .await?; - let forked_branch = read_database_branch_id(&db, source_bucket, &forked_database_id).await?; - let head_at_fork = read_value(&db, branch_meta_head_at_fork_key(forked_branch)) - .await? - .expect("forked branch head_at_fork should exist"); - assert_eq!(decode_db_head(&head_at_fork)?.head_txid, 1); - - Ok(()) - })) + common::test_matrix("depot-fork-database-restore-point", |_tier, ctx| { + Box::pin(async move { + let db = ctx.udb.clone(); + let source_bucket = ctx.bucket_id; + let source_database_id = ctx.database_id.clone(); + let source = ctx.make_db(source_bucket, source_database_id.clone()); + source.commit(vec![page(1, 0x11)], 2, 1_000).await?; + let restore_point = source + .create_restore_point(SnapshotSelector::Latest) + .await?; + source.commit(vec![page(1, 0x22)], 2, 2_000).await?; + + let forked_database_id = branch::fork_database( + &db, + BucketId::from_gas_id(source_bucket), + source_database_id, + SnapshotSelector::RestorePoint { restore_point }, + BucketId::from_gas_id(source_bucket), + ) + .await?; + let forked_branch = + read_database_branch_id(&db, source_bucket, &forked_database_id).await?; + let head_at_fork = read_value(&db, branch_meta_head_at_fork_key(forked_branch)) + .await? + .expect("forked branch head_at_fork should exist"); + assert_eq!(decode_db_head(&head_at_fork)?.head_txid, 1); + + Ok(()) + }) + }) .await } #[tokio::test] async fn fork_database_immediate_reopen_isolated_from_parent_later_writes() -> Result<()> { - common::test_matrix("depot-fork-database-immediate-reopen", |_tier, ctx| Box::pin(async move { - let db = ctx.udb.clone(); - let source_bucket = ctx.bucket_id; - let source_database_id = ctx.database_id.clone(); - let source = ctx.make_db(source_bucket, source_database_id.clone()); - source - .commit(vec![page(1, 0x11), page(2, 0x22)], 3, 1_000) - .await?; - let source_branch = read_database_branch_id(&db, source_bucket, &source_database_id).await?; - let fork_point = read_commit(&db, source_branch, 1).await?; - - let forked_database_id = branch::fork_database( - &db, - BucketId::from_gas_id(source_bucket), - source_database_id.clone(), - ResolvedVersionstamp { - versionstamp: fork_point.versionstamp, - restore_point: None, - }, - BucketId::from_gas_id(source_bucket)) - .await?; - let forked_reopen = ctx.make_db(source_bucket, forked_database_id.clone()); - let pages = forked_reopen.get_pages(vec![1, 2, 3, 4]).await?; - assert_eq!(pages[0].bytes, Some(page_bytes(0x11))); - assert_eq!(pages[1].bytes, Some(page_bytes(0x22))); - assert_eq!(pages[2].bytes, Some(vec![0; depot::keys::PAGE_SIZE as usize])); - assert_eq!(pages[3].bytes, None); - - source - .commit( - vec![page(1, 0x33), page(3, 0x44), page(4, 0x55)], - 5, - 2_000, + common::test_matrix("depot-fork-database-immediate-reopen", |_tier, ctx| { + Box::pin(async move { + let db = ctx.udb.clone(); + let source_bucket = ctx.bucket_id; + let source_database_id = ctx.database_id.clone(); + let source = ctx.make_db(source_bucket, source_database_id.clone()); + source + .commit(vec![page(1, 0x11), page(2, 0x22)], 3, 1_000) + .await?; + let source_branch = + read_database_branch_id(&db, source_bucket, &source_database_id).await?; + let fork_point = read_commit(&db, source_branch, 1).await?; + + let forked_database_id = branch::fork_database( + &db, + BucketId::from_gas_id(source_bucket), + source_database_id.clone(), + ResolvedVersionstamp { + versionstamp: fork_point.versionstamp, + restore_point: None, + }, + BucketId::from_gas_id(source_bucket), ) .await?; - let parent_pages = source.get_pages(vec![1, 3, 4]).await?; - assert_eq!(parent_pages[0].bytes, Some(page_bytes(0x33))); - assert_eq!(parent_pages[1].bytes, Some(page_bytes(0x44))); - assert_eq!(parent_pages[2].bytes, Some(page_bytes(0x55))); - - let forked_reopen_after_parent_write = ctx.make_db(source_bucket, forked_database_id); - let pages = forked_reopen_after_parent_write.get_pages(vec![1, 2, 3, 4]).await?; - assert_eq!(pages[0].bytes, Some(page_bytes(0x11))); - assert_eq!(pages[1].bytes, Some(page_bytes(0x22))); - assert_eq!(pages[2].bytes, Some(vec![0; depot::keys::PAGE_SIZE as usize])); - assert_eq!(pages[3].bytes, None); - - Ok(()) - })) + let forked_reopen = ctx.make_db(source_bucket, forked_database_id.clone()); + let pages = forked_reopen.get_pages(vec![1, 2, 3, 4]).await?; + assert_eq!(pages[0].bytes, Some(page_bytes(0x11))); + assert_eq!(pages[1].bytes, Some(page_bytes(0x22))); + assert_eq!( + pages[2].bytes, + Some(vec![0; depot::keys::PAGE_SIZE as usize]) + ); + assert_eq!(pages[3].bytes, None); + + source + .commit(vec![page(1, 0x33), page(3, 0x44), page(4, 0x55)], 5, 2_000) + .await?; + let parent_pages = source.get_pages(vec![1, 3, 4]).await?; + assert_eq!(parent_pages[0].bytes, Some(page_bytes(0x33))); + assert_eq!(parent_pages[1].bytes, Some(page_bytes(0x44))); + assert_eq!(parent_pages[2].bytes, Some(page_bytes(0x55))); + + let forked_reopen_after_parent_write = ctx.make_db(source_bucket, forked_database_id); + let pages = forked_reopen_after_parent_write + .get_pages(vec![1, 2, 3, 4]) + .await?; + assert_eq!(pages[0].bytes, Some(page_bytes(0x11))); + assert_eq!(pages[1].bytes, Some(page_bytes(0x22))); + assert_eq!( + pages[2].bytes, + Some(vec![0; depot::keys::PAGE_SIZE as usize]) + ); + assert_eq!(pages[3].bytes, None); + + Ok(()) + }) + }) .await } #[tokio::test] async fn fork_database_rejects_expired_timestamp_without_target_writes() -> Result<()> { - common::test_matrix("depot-fork-database-expired", |_tier, ctx| Box::pin(async move { - let db = ctx.udb.clone(); - let source_bucket = ctx.bucket_id; - let source_database_id = ctx.database_id.clone(); - let source = ctx.make_db(source_bucket, source_database_id.clone()); - source.commit(vec![page(1, 0x11)], 2, 1_000).await?; - let target_seed = ctx.make_db(target_bucket(), "target-seed"); - target_seed.commit(vec![page(1, 0xaa)], 1, 1_100).await?; - let target_bucket_id = BucketId::from_gas_id(target_bucket()); - let before = branch::list_databases(&db, target_bucket_id).await?; - - let err = branch::fork_database( - &db, - BucketId::from_gas_id(source_bucket), - source_database_id, - SnapshotSelector::AtTimestamp { timestamp_ms: 1_500 }, - target_bucket_id) - .await - .expect_err("expired selector should reject the fork"); - - assert_storage_error(&err, depot::error::SqliteStorageError::RestoreTargetExpired); - assert_eq!(branch::list_databases(&db, target_bucket_id).await?, before); - - Ok(()) - })) + common::test_matrix("depot-fork-database-expired", |_tier, ctx| { + Box::pin(async move { + let db = ctx.udb.clone(); + let source_bucket = ctx.bucket_id; + let source_database_id = ctx.database_id.clone(); + let source = ctx.make_db(source_bucket, source_database_id.clone()); + source.commit(vec![page(1, 0x11)], 2, 1_000).await?; + let target_seed = ctx.make_db(target_bucket(), "target-seed"); + target_seed.commit(vec![page(1, 0xaa)], 1, 1_100).await?; + let target_bucket_id = BucketId::from_gas_id(target_bucket()); + let before = branch::list_databases(&db, target_bucket_id).await?; + + let err = branch::fork_database( + &db, + BucketId::from_gas_id(source_bucket), + source_database_id, + SnapshotSelector::AtTimestamp { + timestamp_ms: 1_500, + }, + target_bucket_id, + ) + .await + .expect_err("expired selector should reject the fork"); + + assert_storage_error(&err, depot::error::SqliteStorageError::RestoreTargetExpired); + assert_eq!(branch::list_databases(&db, target_bucket_id).await?, before); + + Ok(()) + }) + }) .await } #[tokio::test] async fn fork_database_restore_point_pin_race_returns_out_of_retention() -> Result<()> { - common::test_matrix("depot-fork-database-pin-race", |_tier, ctx| Box::pin(async move { - let db = ctx.udb.clone(); - let source_bucket = ctx.bucket_id; - let source_database_id = ctx.database_id.clone(); - let source = ctx.make_db(source_bucket, source_database_id.clone()); - source.commit(vec![page(1, 0x11)], 2, 1_000).await?; - let source_branch = read_database_branch_id(&db, source_bucket, &source_database_id).await?; - let bucket_branch = read_bucket_branch_id_for(&db, source_bucket).await?; - let source_commit = read_commit(&db, source_branch, 1).await?; - let new_branch = DatabaseBranchId::new_v4(); - let pin_after_fork_point = [0xff; 16]; - let raced = Arc::new(AtomicBool::new(false)); - - let err = db - .run({ - let db = db.clone(); - let raced = raced.clone(); - move |tx| { + common::test_matrix("depot-fork-database-pin-race", |_tier, ctx| { + Box::pin(async move { + let db = ctx.udb.clone(); + let source_bucket = ctx.bucket_id; + let source_database_id = ctx.database_id.clone(); + let source = ctx.make_db(source_bucket, source_database_id.clone()); + source.commit(vec![page(1, 0x11)], 2, 1_000).await?; + let source_branch = + read_database_branch_id(&db, source_bucket, &source_database_id).await?; + let bucket_branch = read_bucket_branch_id_for(&db, source_bucket).await?; + let source_commit = read_commit(&db, source_branch, 1).await?; + let new_branch = DatabaseBranchId::new_v4(); + let pin_after_fork_point = [0xff; 16]; + let raced = Arc::new(AtomicBool::new(false)); + + let err = db + .run({ let db = db.clone(); let raced = raced.clone(); - async move { - branch::derive_branch_at( - &tx, - source_branch, - source_commit.versionstamp, - new_branch, - bucket_branch, - None, - ) - .await?; - - if !raced.swap(true, Ordering::SeqCst) { - db.run(move |pin_tx| async move { - pin_tx.informal().atomic_op( - &branches_restore_point_pin_key(source_branch), - &pin_after_fork_point, - MutationType::ByteMin, - ); - Ok(()) - }) + move |tx| { + let db = db.clone(); + let raced = raced.clone(); + async move { + branch::derive_branch_at( + &tx, + source_branch, + source_commit.versionstamp, + new_branch, + bucket_branch, + None, + ) .await?; - } - Ok(()) + if !raced.swap(true, Ordering::SeqCst) { + db.run(move |pin_tx| async move { + pin_tx.informal().atomic_op( + &branches_restore_point_pin_key(source_branch), + &pin_after_fork_point, + MutationType::ByteMin, + ); + Ok(()) + }) + .await?; + } + + Ok(()) + } } - } - }) - .await - .expect_err("retry should observe the advanced restore_point pin"); + }) + .await + .expect_err("retry should observe the advanced restore_point pin"); - assert_storage_error(&err, depot::error::SqliteStorageError::ForkOutOfRetention); + assert_storage_error(&err, depot::error::SqliteStorageError::ForkOutOfRetention); - Ok(()) - })) + Ok(()) + }) + }) .await } #[tokio::test] async fn fork_database_allows_depth_sixteen_and_rejects_depth_seventeen() -> Result<()> { - common::test_matrix("depot-fork-database-depth", |_tier, ctx| Box::pin(async move { - let db = ctx.udb.clone(); - let source_bucket = ctx.bucket_id; - let mut source_database_id = ctx.database_id.clone(); - let root = ctx.make_db(source_bucket, source_database_id.clone()); - root.commit(vec![page(1, 0x11)], 2, 1_000).await?; - let mut source_branch = read_database_branch_id(&db, source_bucket, &source_database_id).await?; - let mut source_commit = read_commit(&db, source_branch, 1).await?; - - for depth in 1..=depot::constants::MAX_FORK_DEPTH { - let forked_database_id = branch::fork_database( + common::test_matrix("depot-fork-database-depth", |_tier, ctx| { + Box::pin(async move { + let db = ctx.udb.clone(); + let source_bucket = ctx.bucket_id; + let mut source_database_id = ctx.database_id.clone(); + let root = ctx.make_db(source_bucket, source_database_id.clone()); + root.commit(vec![page(1, 0x11)], 2, 1_000).await?; + let mut source_branch = + read_database_branch_id(&db, source_bucket, &source_database_id).await?; + let mut source_commit = read_commit(&db, source_branch, 1).await?; + + for depth in 1..=depot::constants::MAX_FORK_DEPTH { + let forked_database_id = branch::fork_database( + &db, + BucketId::from_gas_id(source_bucket), + source_database_id.clone(), + ResolvedVersionstamp { + versionstamp: source_commit.versionstamp, + restore_point: None, + }, + BucketId::from_gas_id(source_bucket), + ) + .await?; + let forked_branch = + read_database_branch_id(&db, source_bucket, &forked_database_id).await?; + let forked_record = read_database_branch_record(&db, forked_branch).await?; + assert_eq!(forked_record.fork_depth, depth); + + if depth < depot::constants::MAX_FORK_DEPTH { + let forked_db = ctx.make_db(source_bucket, forked_database_id.clone()); + let pgno = depth as u32 + 1; + forked_db + .commit( + vec![page(pgno, 0x30 + depth)], + pgno + 1, + 3_000 + depth as i64, + ) + .await?; + source_commit = read_head_commit(&db, forked_branch).await?; + source_database_id = forked_database_id; + source_branch = forked_branch; + } else { + source_database_id = forked_database_id; + source_branch = forked_branch; + } + } + + let err = branch::fork_database( &db, BucketId::from_gas_id(source_bucket), - source_database_id.clone(), + source_database_id, ResolvedVersionstamp { versionstamp: source_commit.versionstamp, restore_point: None, }, - BucketId::from_gas_id(source_bucket)) - .await?; - let forked_branch = - read_database_branch_id(&db, source_bucket, &forked_database_id).await?; - let forked_record = read_database_branch_record(&db, forked_branch).await?; - assert_eq!(forked_record.fork_depth, depth); + BucketId::from_gas_id(source_bucket), + ) + .await + .expect_err("depth 17 fork should be rejected"); - if depth < depot::constants::MAX_FORK_DEPTH { - let forked_db = ctx.make_db(source_bucket, forked_database_id.clone()); - let pgno = depth as u32 + 1; - forked_db - .commit(vec![page(pgno, 0x30 + depth)], pgno + 1, 3_000 + depth as i64) - .await?; - source_commit = read_head_commit(&db, forked_branch).await?; - source_database_id = forked_database_id; - source_branch = forked_branch; - } else { - source_database_id = forked_database_id; - source_branch = forked_branch; - } - } - - let err = branch::fork_database( - &db, - BucketId::from_gas_id(source_bucket), - source_database_id, - ResolvedVersionstamp { - versionstamp: source_commit.versionstamp, - restore_point: None, - }, - BucketId::from_gas_id(source_bucket)) - .await - .expect_err("depth 17 fork should be rejected"); - - assert_storage_error(&err, depot::error::SqliteStorageError::ForkChainTooDeep); - assert_eq!( - read_database_branch_record(&db, source_branch).await?.fork_depth, - depot::constants::MAX_FORK_DEPTH - ); - - Ok(()) - })) + assert_storage_error(&err, depot::error::SqliteStorageError::ForkChainTooDeep); + assert_eq!( + read_database_branch_record(&db, source_branch) + .await? + .fork_depth, + depot::constants::MAX_FORK_DEPTH + ); + + Ok(()) + }) + }) .await } diff --git a/engine/packages/depot/tests/gc.rs b/engine/packages/depot/tests/gc.rs index 30319e1224..a474dc9d3f 100644 --- a/engine/packages/depot/tests/gc.rs +++ b/engine/packages/depot/tests/gc.rs @@ -9,18 +9,20 @@ use depot::{ }, keys::{ branch_commit_key, branch_delta_chunk_key, branch_meta_head_key, branch_pidx_key, - branch_shard_key, branch_vtx_key, branches_restore_point_pin_key, branches_desc_pin_key, - branches_list_key, branches_refcount_key, db_pin_key, + branch_shard_key, branch_vtx_key, branches_desc_pin_key, branches_list_key, + branches_refcount_key, branches_restore_point_pin_key, db_pin_key, }, types::{ - DatabaseBranchId, DatabaseBranchRecord, BranchState, CommitRow, BucketBranchId, - encode_database_branch_record, encode_commit_row, + BranchState, BucketBranchId, CommitRow, DatabaseBranchId, DatabaseBranchRecord, + encode_commit_row, encode_database_branch_record, }, }; use universaldb::utils::IsolationLevel::Snapshot; fn branch_id() -> DatabaseBranchId { - DatabaseBranchId::from_uuid(uuid::Uuid::from_u128(0x1234_5678_9abc_def0_0123_4567_89ab_cdef)) + DatabaseBranchId::from_uuid(uuid::Uuid::from_u128( + 0x1234_5678_9abc_def0_0123_4567_89ab_cdef, + )) } async fn read_value(db: &universaldb::Database, key: Vec) -> Result>> { @@ -90,8 +92,10 @@ async fn write_commit(db: &universaldb::Database, txid: u64, versionstamp: [u8; post_apply_checksum: txid, })?, ); - tx.informal() - .set(&branch_vtx_key(branch_id, versionstamp), &txid.to_be_bytes()); + tx.informal().set( + &branch_vtx_key(branch_id, versionstamp), + &txid.to_be_bytes(), + ); Ok(()) }) .await @@ -99,250 +103,325 @@ async fn write_commit(db: &universaldb::Database, txid: u64, versionstamp: [u8; #[tokio::test] async fn sweeping_child_branch_releases_parent_refcount_and_fork_pin() -> Result<()> { - common::test_matrix("depot-gc-child-branch", |_tier, ctx| Box::pin(async move { - let db = ctx.udb.clone(); - let parent_branch_id = DatabaseBranchId::from_uuid(uuid::Uuid::from_u128( - 0xaaaa_0000_0000_0000_0000_0000_0000_0001, - )); - let child_branch_id = DatabaseBranchId::from_uuid(uuid::Uuid::from_u128( - 0xbbbb_0000_0000_0000_0000_0000_0000_0002, - )); - let fork_versionstamp = [2; 16]; + common::test_matrix("depot-gc-child-branch", |_tier, ctx| { + Box::pin(async move { + let db = ctx.udb.clone(); + let parent_branch_id = DatabaseBranchId::from_uuid(uuid::Uuid::from_u128( + 0xaaaa_0000_0000_0000_0000_0000_0000_0001, + )); + let child_branch_id = DatabaseBranchId::from_uuid(uuid::Uuid::from_u128( + 0xbbbb_0000_0000_0000_0000_0000_0000_0002, + )); + let fork_versionstamp = [2; 16]; - db.run(move |tx| async move { - tx.informal().set( - &branches_list_key(parent_branch_id), - &encode_database_branch_record(DatabaseBranchRecord { - branch_id: parent_branch_id, - bucket_branch: BucketBranchId::nil(), - parent: None, - parent_versionstamp: None, - root_versionstamp: [1; 16], - fork_depth: 0, - created_at_ms: 1_000, - created_from_restore_point: None, - state: BranchState::Live, - lifecycle_generation: 0, - })?, - ); - tx.informal() - .set(&branches_refcount_key(parent_branch_id), &2_i64.to_le_bytes()); - tx.informal() - .set(&branches_desc_pin_key(parent_branch_id), &fork_versionstamp); + db.run(move |tx| async move { + tx.informal().set( + &branches_list_key(parent_branch_id), + &encode_database_branch_record(DatabaseBranchRecord { + branch_id: parent_branch_id, + bucket_branch: BucketBranchId::nil(), + parent: None, + parent_versionstamp: None, + root_versionstamp: [1; 16], + fork_depth: 0, + created_at_ms: 1_000, + created_from_restore_point: None, + state: BranchState::Live, + lifecycle_generation: 0, + })?, + ); + tx.informal().set( + &branches_refcount_key(parent_branch_id), + &2_i64.to_le_bytes(), + ); + tx.informal() + .set(&branches_desc_pin_key(parent_branch_id), &fork_versionstamp); - tx.informal().set( - &branches_list_key(child_branch_id), - &encode_database_branch_record(DatabaseBranchRecord { - branch_id: child_branch_id, - bucket_branch: BucketBranchId::nil(), - parent: Some(parent_branch_id), - parent_versionstamp: Some(fork_versionstamp), - root_versionstamp: fork_versionstamp, - fork_depth: 1, - created_at_ms: 1_001, - created_from_restore_point: None, - state: BranchState::Live, - lifecycle_generation: 0, - })?, - ); - tx.informal() - .set(&branches_refcount_key(child_branch_id), &0_i64.to_le_bytes()); - history_pin::write_database_fork_pin( - &tx, - parent_branch_id, - child_branch_id, - fork_versionstamp, - 1, - 1_001, - )?; + tx.informal().set( + &branches_list_key(child_branch_id), + &encode_database_branch_record(DatabaseBranchRecord { + branch_id: child_branch_id, + bucket_branch: BucketBranchId::nil(), + parent: Some(parent_branch_id), + parent_versionstamp: Some(fork_versionstamp), + root_versionstamp: fork_versionstamp, + fork_depth: 1, + created_at_ms: 1_001, + created_from_restore_point: None, + state: BranchState::Live, + lifecycle_generation: 0, + })?, + ); + tx.informal().set( + &branches_refcount_key(child_branch_id), + &0_i64.to_le_bytes(), + ); + history_pin::write_database_fork_pin( + &tx, + parent_branch_id, + child_branch_id, + fork_versionstamp, + 1, + 1_001, + )?; - Ok(()) - }) - .await?; + Ok(()) + }) + .await?; - let outcome = sweep_unreferenced_branch(&db, child_branch_id) - .await? - .expect("child branch should be swept"); - assert!(outcome.branch_deleted); + let outcome = sweep_unreferenced_branch(&db, child_branch_id) + .await? + .expect("child branch should be swept"); + assert!(outcome.branch_deleted); - assert_eq!(read_refcount(&db, parent_branch_id).await?, 1); - assert_eq!( - read_value( - &db, - db_pin_key( - parent_branch_id, - &history_pin::database_fork_pin_id(child_branch_id), - ), - ) - .await?, - None - ); - assert_eq!( - read_value(&db, branches_desc_pin_key(parent_branch_id)).await?, - None - ); - assert_eq!(read_value(&db, branches_list_key(child_branch_id)).await?, None); + assert_eq!(read_refcount(&db, parent_branch_id).await?, 1); + assert_eq!( + read_value( + &db, + db_pin_key( + parent_branch_id, + &history_pin::database_fork_pin_id(child_branch_id), + ), + ) + .await?, + None + ); + assert_eq!( + read_value(&db, branches_desc_pin_key(parent_branch_id)).await?, + None + ); + assert_eq!( + read_value(&db, branches_list_key(child_branch_id)).await?, + None + ); - Ok(()) - })) + Ok(()) + }) + }) .await } #[tokio::test] async fn branch_gc_pin_recomputes_from_current_counters_without_ratchet() -> Result<()> { - common::test_matrix("depot-gc-pin-recompute", |_tier, ctx| Box::pin(async move { - let db = ctx.udb.clone(); - let branch_id = branch_id(); - seed_branch(&db, 1, [10; 16]).await?; + common::test_matrix("depot-gc-pin-recompute", |_tier, ctx| { + Box::pin(async move { + let db = ctx.udb.clone(); + let branch_id = branch_id(); + seed_branch(&db, 1, [10; 16]).await?; - db.run(move |tx| async move { - tx.informal() - .set(&branches_desc_pin_key(branch_id), &[4; 16]); - tx.informal().set(&branches_restore_point_pin_key(branch_id), &[8; 16]); - Ok(()) - }) - .await?; - let pin = estimate_branch_gc_pin(&db, branch_id) - .await? - .expect("branch should have a GC pin"); - assert_eq!(pin.gc_pin, [4; 16]); + db.run(move |tx| async move { + tx.informal() + .set(&branches_desc_pin_key(branch_id), &[4; 16]); + tx.informal() + .set(&branches_restore_point_pin_key(branch_id), &[8; 16]); + Ok(()) + }) + .await?; + let pin = estimate_branch_gc_pin(&db, branch_id) + .await? + .expect("branch should have a GC pin"); + assert_eq!(pin.gc_pin, [4; 16]); - db.run(move |tx| async move { - tx.informal() - .set(&branches_desc_pin_key(branch_id), &VERSIONSTAMP_INFINITY); - Ok(()) - }) - .await?; - let pin = estimate_branch_gc_pin(&db, branch_id) - .await? - .expect("branch should have a GC pin"); - assert_eq!(pin.gc_pin, [8; 16]); + db.run(move |tx| async move { + tx.informal() + .set(&branches_desc_pin_key(branch_id), &VERSIONSTAMP_INFINITY); + Ok(()) + }) + .await?; + let pin = estimate_branch_gc_pin(&db, branch_id) + .await? + .expect("branch should have a GC pin"); + assert_eq!(pin.gc_pin, [8; 16]); + + db.run(move |tx| async move { + tx.informal() + .set(&branches_refcount_key(branch_id), &0_i64.to_le_bytes()); + tx.informal().set( + &branches_restore_point_pin_key(branch_id), + &VERSIONSTAMP_INFINITY, + ); + Ok(()) + }) + .await?; + let pin = estimate_branch_gc_pin(&db, branch_id) + .await? + .expect("branch should have a GC pin"); + assert_eq!(pin.gc_pin, VERSIONSTAMP_INFINITY); - db.run(move |tx| async move { - tx.informal() - .set(&branches_refcount_key(branch_id), &0_i64.to_le_bytes()); - tx.informal() - .set(&branches_restore_point_pin_key(branch_id), &VERSIONSTAMP_INFINITY); Ok(()) }) - .await?; - let pin = estimate_branch_gc_pin(&db, branch_id) - .await? - .expect("branch should have a GC pin"); - assert_eq!(pin.gc_pin, VERSIONSTAMP_INFINITY); - - Ok(()) - })) + }) .await } #[tokio::test] async fn unreferenced_unpinned_branch_sweep_deletes_hot_branch_data() -> Result<()> { - common::test_matrix("depot-gc-unreferenced-sweep", |_tier, ctx| Box::pin(async move { - let db = ctx.udb.clone(); - let branch_id = branch_id(); - seed_branch(&db, 0, [6; 16]).await?; - write_commit(&db, 3, [3; 16]).await?; - write_commit(&db, 6, [6; 16]).await?; + common::test_matrix("depot-gc-unreferenced-sweep", |_tier, ctx| { + Box::pin(async move { + let db = ctx.udb.clone(); + let branch_id = branch_id(); + seed_branch(&db, 0, [6; 16]).await?; + write_commit(&db, 3, [3; 16]).await?; + write_commit(&db, 6, [6; 16]).await?; - db.run(move |tx| async move { - tx.informal() - .set(&branches_desc_pin_key(branch_id), &VERSIONSTAMP_INFINITY); - tx.informal().set(&branches_restore_point_pin_key(branch_id), &VERSIONSTAMP_INFINITY); - tx.informal() - .set(&branch_meta_head_key(branch_id), b"head"); - tx.informal() - .set(&branch_pidx_key(branch_id, 7), &6_u64.to_be_bytes()); - tx.informal() - .set(&branch_delta_chunk_key(branch_id, 3, 0), b"delta-three"); - tx.informal() - .set(&branch_shard_key(branch_id, 0, 6), b"shard-six"); - Ok(()) - }) - .await?; + db.run(move |tx| async move { + tx.informal() + .set(&branches_desc_pin_key(branch_id), &VERSIONSTAMP_INFINITY); + tx.informal().set( + &branches_restore_point_pin_key(branch_id), + &VERSIONSTAMP_INFINITY, + ); + tx.informal().set(&branch_meta_head_key(branch_id), b"head"); + tx.informal() + .set(&branch_pidx_key(branch_id, 7), &6_u64.to_be_bytes()); + tx.informal() + .set(&branch_delta_chunk_key(branch_id, 3, 0), b"delta-three"); + tx.informal() + .set(&branch_shard_key(branch_id, 0, 6), b"shard-six"); + Ok(()) + }) + .await?; - let outcome = sweep_unreferenced_branch(&db, branch_id) - .await? - .expect("branch should be swept"); - assert!(outcome.branch_deleted); - assert_eq!(outcome.gc_pin, VERSIONSTAMP_INFINITY); - assert!(outcome.keys_deleted >= 9); + let outcome = sweep_unreferenced_branch(&db, branch_id) + .await? + .expect("branch should be swept"); + assert!(outcome.branch_deleted); + assert_eq!(outcome.gc_pin, VERSIONSTAMP_INFINITY); + assert!(outcome.keys_deleted >= 9); - assert_eq!(read_value(&db, branches_list_key(branch_id)).await?, None); - assert_eq!(read_value(&db, branches_refcount_key(branch_id)).await?, None); - assert_eq!(read_value(&db, branches_desc_pin_key(branch_id)).await?, None); - assert_eq!(read_value(&db, branches_restore_point_pin_key(branch_id)).await?, None); - assert_eq!(read_value(&db, branch_meta_head_key(branch_id)).await?, None); - assert_eq!(read_value(&db, branch_commit_key(branch_id, 3)).await?, None); - assert_eq!(read_value(&db, branch_commit_key(branch_id, 6)).await?, None); - assert_eq!(read_value(&db, branch_vtx_key(branch_id, [3; 16])).await?, None); - assert_eq!(read_value(&db, branch_vtx_key(branch_id, [6; 16])).await?, None); - assert_eq!(read_value(&db, branch_pidx_key(branch_id, 7)).await?, None); - assert_eq!( - read_value(&db, branch_delta_chunk_key(branch_id, 3, 0)).await?, - None - ); - assert_eq!(read_value(&db, branch_shard_key(branch_id, 0, 6)).await?, None); + assert_eq!(read_value(&db, branches_list_key(branch_id)).await?, None); + assert_eq!( + read_value(&db, branches_refcount_key(branch_id)).await?, + None + ); + assert_eq!( + read_value(&db, branches_desc_pin_key(branch_id)).await?, + None + ); + assert_eq!( + read_value(&db, branches_restore_point_pin_key(branch_id)).await?, + None + ); + assert_eq!( + read_value(&db, branch_meta_head_key(branch_id)).await?, + None + ); + assert_eq!( + read_value(&db, branch_commit_key(branch_id, 3)).await?, + None + ); + assert_eq!( + read_value(&db, branch_commit_key(branch_id, 6)).await?, + None + ); + assert_eq!( + read_value(&db, branch_vtx_key(branch_id, [3; 16])).await?, + None + ); + assert_eq!( + read_value(&db, branch_vtx_key(branch_id, [6; 16])).await?, + None + ); + assert_eq!(read_value(&db, branch_pidx_key(branch_id, 7)).await?, None); + assert_eq!( + read_value(&db, branch_delta_chunk_key(branch_id, 3, 0)).await?, + None + ); + assert_eq!( + read_value(&db, branch_shard_key(branch_id, 0, 6)).await?, + None + ); - Ok(()) - })) + Ok(()) + }) + }) .await } #[tokio::test] async fn branch_hot_gc_uses_vtx_floor_for_commits_vtx_and_delta() -> Result<()> { - common::test_matrix("depot-gc-hot-history", |_tier, ctx| Box::pin(async move { - let db = ctx.udb.clone(); - let branch_id = branch_id(); - seed_branch(&db, 1, [6; 16]).await?; - write_commit(&db, 3, [3; 16]).await?; - write_commit(&db, 4, [4; 16]).await?; - write_commit(&db, 6, [6; 16]).await?; - write_commit(&db, 8, [8; 16]).await?; + common::test_matrix("depot-gc-hot-history", |_tier, ctx| { + Box::pin(async move { + let db = ctx.udb.clone(); + let branch_id = branch_id(); + seed_branch(&db, 1, [6; 16]).await?; + write_commit(&db, 3, [3; 16]).await?; + write_commit(&db, 4, [4; 16]).await?; + write_commit(&db, 6, [6; 16]).await?; + write_commit(&db, 8, [8; 16]).await?; - db.run(move |tx| async move { - tx.informal() - .set(&branch_delta_chunk_key(branch_id, 2, 0), b"delta-two"); - tx.informal() - .set(&branch_delta_chunk_key(branch_id, 5, 0), b"delta-five"); - tx.informal() - .set(&branch_delta_chunk_key(branch_id, 6, 0), b"delta-six"); - Ok(()) - }) - .await?; - - let outcome = sweep_branch_hot_history(&db, branch_id) - .await? - .expect("branch should be swept"); - assert_eq!(outcome.gc_pin, [6; 16]); - assert_eq!(outcome.txid_floor, Some(6)); - assert_eq!(outcome.commits_deleted, 2); - assert_eq!(outcome.vtx_deleted, 2); - assert_eq!(outcome.delta_chunks_deleted, 2); + db.run(move |tx| async move { + tx.informal() + .set(&branch_delta_chunk_key(branch_id, 2, 0), b"delta-two"); + tx.informal() + .set(&branch_delta_chunk_key(branch_id, 5, 0), b"delta-five"); + tx.informal() + .set(&branch_delta_chunk_key(branch_id, 6, 0), b"delta-six"); + Ok(()) + }) + .await?; - assert_eq!(read_value(&db, branch_commit_key(branch_id, 3)).await?, None); - assert_eq!(read_value(&db, branch_commit_key(branch_id, 4)).await?, None); - assert!(read_value(&db, branch_commit_key(branch_id, 6)).await?.is_some()); - assert!(read_value(&db, branch_commit_key(branch_id, 8)).await?.is_some()); - assert_eq!(read_value(&db, branch_vtx_key(branch_id, [3; 16])).await?, None); - assert_eq!(read_value(&db, branch_vtx_key(branch_id, [4; 16])).await?, None); - assert!(read_value(&db, branch_vtx_key(branch_id, [6; 16])).await?.is_some()); - assert!(read_value(&db, branch_vtx_key(branch_id, [8; 16])).await?.is_some()); - assert_eq!( - read_value(&db, branch_delta_chunk_key(branch_id, 2, 0)).await?, - None - ); - assert_eq!( - read_value(&db, branch_delta_chunk_key(branch_id, 5, 0)).await?, - None - ); - assert!( - read_value(&db, branch_delta_chunk_key(branch_id, 6, 0)) + let outcome = sweep_branch_hot_history(&db, branch_id) .await? - .is_some() - ); + .expect("branch should be swept"); + assert_eq!(outcome.gc_pin, [6; 16]); + assert_eq!(outcome.txid_floor, Some(6)); + assert_eq!(outcome.commits_deleted, 2); + assert_eq!(outcome.vtx_deleted, 2); + assert_eq!(outcome.delta_chunks_deleted, 2); - Ok(()) - })) + assert_eq!( + read_value(&db, branch_commit_key(branch_id, 3)).await?, + None + ); + assert_eq!( + read_value(&db, branch_commit_key(branch_id, 4)).await?, + None + ); + assert!( + read_value(&db, branch_commit_key(branch_id, 6)) + .await? + .is_some() + ); + assert!( + read_value(&db, branch_commit_key(branch_id, 8)) + .await? + .is_some() + ); + assert_eq!( + read_value(&db, branch_vtx_key(branch_id, [3; 16])).await?, + None + ); + assert_eq!( + read_value(&db, branch_vtx_key(branch_id, [4; 16])).await?, + None + ); + assert!( + read_value(&db, branch_vtx_key(branch_id, [6; 16])) + .await? + .is_some() + ); + assert!( + read_value(&db, branch_vtx_key(branch_id, [8; 16])) + .await? + .is_some() + ); + assert_eq!( + read_value(&db, branch_delta_chunk_key(branch_id, 2, 0)).await?, + None + ); + assert_eq!( + read_value(&db, branch_delta_chunk_key(branch_id, 5, 0)).await?, + None + ); + assert!( + read_value(&db, branch_delta_chunk_key(branch_id, 6, 0)) + .await? + .is_some() + ); + + Ok(()) + }) + }) .await } diff --git a/engine/packages/depot/tests/gc_pin_recompute_under_restore_point_delete_race.rs b/engine/packages/depot/tests/gc_pin_recompute_under_restore_point_delete_race.rs index 26205d5c3c..30b47c2444 100644 --- a/engine/packages/depot/tests/gc_pin_recompute_under_restore_point_delete_race.rs +++ b/engine/packages/depot/tests/gc_pin_recompute_under_restore_point_delete_race.rs @@ -1,16 +1,16 @@ mod common; use anyhow::{Context, Result}; -use gas::prelude::Id; use depot::{ + conveyer::branch, error::SqliteStorageError, keys::{ - branch_commit_key, branch_meta_head_at_fork_key, branch_vtx_key, branches_restore_point_pin_key, - branches_desc_pin_key, + branch_commit_key, branch_meta_head_at_fork_key, branch_vtx_key, branches_desc_pin_key, + branches_restore_point_pin_key, }, - conveyer::branch, - types::{DatabaseBranchId, DirtyPage, BucketBranchId, BucketId, decode_commit_row}, + types::{BucketBranchId, BucketId, DatabaseBranchId, DirtyPage, decode_commit_row}, }; +use gas::prelude::Id; use universaldb::utils::IsolationLevel::Serializable; const TEST_DATABASE: &str = "database-a"; @@ -50,72 +50,78 @@ fn has_storage_error(err: &anyhow::Error, expected: SqliteStorageError) -> bool #[tokio::test] async fn gc_pin_recompute_under_restore_point_delete_race() -> Result<()> { - common::test_matrix("depot-gc-restore-point-race", |_tier, ctx| Box::pin(async move { - let db = ctx.udb.clone(); - let database_db = ctx.make_db(bucket(), TEST_DATABASE); - database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; - let branch_id = database_branch_id_for(&db).await?; - let commit_bytes = common::read_value(&db, branch_commit_key(branch_id, 1)) - .await? - .context("commit row should exist")?; - let commit = decode_commit_row(&commit_bytes)?; - let restore_point = database_db.create_restore_point(depot::types::SnapshotSelector::Latest).await?; - - database_db.delete_restore_point(restore_point).await?; - let fork_before_gc = DatabaseBranchId::from_uuid(uuid::Uuid::from_u128(0x1111)); - db.run(move |tx| async move { - branch::derive_branch_at( - &tx, - branch_id, - commit.versionstamp, - fork_before_gc, - BucketBranchId::nil(), - None, - ) - .await - }) - .await?; - assert!( - common::read_value(&db, branch_meta_head_at_fork_key(fork_before_gc)) + common::test_matrix("depot-gc-restore-point-race", |_tier, ctx| { + Box::pin(async move { + let db = ctx.udb.clone(); + let database_db = ctx.make_db(bucket(), TEST_DATABASE); + database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; + let branch_id = database_branch_id_for(&db).await?; + let commit_bytes = common::read_value(&db, branch_commit_key(branch_id, 1)) .await? - .is_some(), - "fork should still succeed while the hot rows have not been GC'd" - ); + .context("commit row should exist")?; + let commit = decode_commit_row(&commit_bytes)?; + let restore_point = database_db + .create_restore_point(depot::types::SnapshotSelector::Latest) + .await?; - db.run(move |tx| async move { - tx.informal().clear(&branch_commit_key(branch_id, 1)); - tx.informal().clear(&branch_vtx_key(branch_id, commit.versionstamp)); - tx.informal().clear(&branches_desc_pin_key(branch_id)); - tx.informal().clear(&branches_restore_point_pin_key(branch_id)); - Ok(()) - }) - .await?; - let fork_after_gc = DatabaseBranchId::from_uuid(uuid::Uuid::from_u128(0x2222)); - let err = db - .run(move |tx| async move { + database_db.delete_restore_point(restore_point).await?; + let fork_before_gc = DatabaseBranchId::from_uuid(uuid::Uuid::from_u128(0x1111)); + db.run(move |tx| async move { branch::derive_branch_at( &tx, branch_id, commit.versionstamp, - fork_after_gc, + fork_before_gc, BucketBranchId::nil(), None, ) .await }) - .await - .expect_err("fork should fail once GC has removed the VTX row"); - assert!( - has_storage_error(&err, SqliteStorageError::RestoreTargetExpired), - "unexpected error: {err:?}" - ); - assert!( - common::read_value(&db, branch_meta_head_at_fork_key(fork_after_gc)) - .await? - .is_none() - ); + .await?; + assert!( + common::read_value(&db, branch_meta_head_at_fork_key(fork_before_gc)) + .await? + .is_some(), + "fork should still succeed while the hot rows have not been GC'd" + ); + + db.run(move |tx| async move { + tx.informal().clear(&branch_commit_key(branch_id, 1)); + tx.informal() + .clear(&branch_vtx_key(branch_id, commit.versionstamp)); + tx.informal().clear(&branches_desc_pin_key(branch_id)); + tx.informal() + .clear(&branches_restore_point_pin_key(branch_id)); + Ok(()) + }) + .await?; + let fork_after_gc = DatabaseBranchId::from_uuid(uuid::Uuid::from_u128(0x2222)); + let err = db + .run(move |tx| async move { + branch::derive_branch_at( + &tx, + branch_id, + commit.versionstamp, + fork_after_gc, + BucketBranchId::nil(), + None, + ) + .await + }) + .await + .expect_err("fork should fail once GC has removed the VTX row"); + assert!( + has_storage_error(&err, SqliteStorageError::RestoreTargetExpired), + "unexpected error: {err:?}" + ); + assert!( + common::read_value(&db, branch_meta_head_at_fork_key(fork_after_gc)) + .await? + .is_none() + ); - Ok(()) - })) + Ok(()) + }) + }) .await } diff --git a/engine/packages/depot/tests/inline/conveyer_ltx.rs b/engine/packages/depot/tests/inline/conveyer_ltx.rs index d3e8d3b912..dcdf552101 100644 --- a/engine/packages/depot/tests/inline/conveyer_ltx.rs +++ b/engine/packages/depot/tests/inline/conveyer_ltx.rs @@ -1,350 +1,348 @@ - use super::{ - DecodedLtx, EncodedLtx, LTX_HEADER_FLAG_NO_CHECKSUM, LTX_HEADER_SIZE, LTX_MAGIC, - LTX_PAGE_HEADER_FLAG_SIZE, LTX_PAGE_HEADER_SIZE, LTX_RESERVED_HEADER_BYTES, - LTX_TRAILER_SIZE, LTX_VERSION, LtxDecoder, LtxEncoder, LtxHeader, decode_ltx_v3, - encode_ltx_v3, - }; - use crate::types::{DirtyPage, SQLITE_PAGE_SIZE}; - - fn repeated_page(byte: u8) -> Vec { - repeated_page_with_size(byte, SQLITE_PAGE_SIZE) - } +use super::{ + DecodedLtx, EncodedLtx, LTX_HEADER_FLAG_NO_CHECKSUM, LTX_HEADER_SIZE, LTX_MAGIC, + LTX_PAGE_HEADER_FLAG_SIZE, LTX_PAGE_HEADER_SIZE, LTX_RESERVED_HEADER_BYTES, LTX_TRAILER_SIZE, + LTX_VERSION, LtxDecoder, LtxEncoder, LtxHeader, decode_ltx_v3, encode_ltx_v3, +}; +use crate::types::{DirtyPage, SQLITE_PAGE_SIZE}; + +fn repeated_page(byte: u8) -> Vec { + repeated_page_with_size(byte, SQLITE_PAGE_SIZE) +} + +fn repeated_page_with_size(byte: u8, page_size: u32) -> Vec { + vec![byte; page_size as usize] +} + +fn sample_header() -> LtxHeader { + LtxHeader::delta(7, 48, 1_713_456_789_000) +} + +fn page_index_bytes(encoded: &EncodedLtx) -> &[u8] { + let footer_offset = encoded.bytes.len() - LTX_TRAILER_SIZE - std::mem::size_of::(); + let index_size = u64::from_be_bytes( + encoded.bytes[footer_offset..footer_offset + std::mem::size_of::()] + .try_into() + .expect("page index footer should decode"), + ) as usize; + let index_start = footer_offset - index_size; + + &encoded.bytes[index_start..footer_offset] +} + +#[test] +fn delta_header_sets_v3_defaults() { + let header = sample_header(); + + assert_eq!(header.flags, LTX_HEADER_FLAG_NO_CHECKSUM); + assert_eq!(header.page_size, SQLITE_PAGE_SIZE); + assert_eq!(header.commit, 48); + assert_eq!(header.min_txid, 7); + assert_eq!(header.max_txid, 7); + assert_eq!(header.pre_apply_checksum, 0); + assert_eq!(header.wal_offset, 0); + assert_eq!(header.wal_size, 0); + assert_eq!(header.wal_salt1, 0); + assert_eq!(header.wal_salt2, 0); + assert_eq!(header.node_id, 0); + assert_eq!(LTX_VERSION, 3); +} + +#[test] +fn encodes_header_and_zeroed_trailer() { + let encoded = LtxEncoder::new(sample_header()) + .encode_with_index(&[DirtyPage { + pgno: 9, + bytes: repeated_page(0x2a), + }]) + .expect("ltx should encode"); - fn repeated_page_with_size(byte: u8, page_size: u32) -> Vec { - vec![byte; page_size as usize] - } + assert_eq!(&encoded.bytes[0..4], LTX_MAGIC); + assert_eq!( + u32::from_be_bytes(encoded.bytes[4..8].try_into().expect("flags")), + LTX_HEADER_FLAG_NO_CHECKSUM + ); + assert_eq!( + u32::from_be_bytes(encoded.bytes[8..12].try_into().expect("page size")), + SQLITE_PAGE_SIZE + ); + assert_eq!( + u32::from_be_bytes(encoded.bytes[12..16].try_into().expect("commit")), + 48 + ); + assert_eq!( + u64::from_be_bytes(encoded.bytes[16..24].try_into().expect("min txid")), + 7 + ); + assert_eq!( + u64::from_be_bytes(encoded.bytes[24..32].try_into().expect("max txid")), + 7 + ); + assert_eq!( + &encoded.bytes[LTX_HEADER_SIZE - LTX_RESERVED_HEADER_BYTES..LTX_HEADER_SIZE], + &[0u8; LTX_RESERVED_HEADER_BYTES] + ); + assert_eq!( + &encoded.bytes[encoded.bytes.len() - LTX_TRAILER_SIZE..], + &[0u8; LTX_TRAILER_SIZE] + ); +} + +#[test] +fn encodes_page_headers_with_lz4_block_size_prefixes() { + let first_page = repeated_page(0x11); + let second_page = repeated_page(0x77); + let encoded = LtxEncoder::new(sample_header()) + .encode_with_index(&[ + DirtyPage { + pgno: 4, + bytes: first_page.clone(), + }, + DirtyPage { + pgno: 12, + bytes: second_page.clone(), + }, + ]) + .expect("ltx should encode"); - fn sample_header() -> LtxHeader { - LtxHeader::delta(7, 48, 1_713_456_789_000) - } + let first_entry = &encoded.page_index[0]; + let second_entry = &encoded.page_index[1]; + let first_offset = first_entry.offset as usize; + let second_offset = second_entry.offset as usize; - fn page_index_bytes(encoded: &EncodedLtx) -> &[u8] { - let footer_offset = encoded.bytes.len() - LTX_TRAILER_SIZE - std::mem::size_of::(); - let index_size = u64::from_be_bytes( - encoded.bytes[footer_offset..footer_offset + std::mem::size_of::()] + assert_eq!(encoded.page_index.len(), 2); + assert_eq!( + u32::from_be_bytes( + encoded.bytes[first_offset..first_offset + 4] .try_into() - .expect("page index footer should decode"), - ) as usize; - let index_start = footer_offset - index_size; - - &encoded.bytes[index_start..footer_offset] - } - - #[test] - fn delta_header_sets_v3_defaults() { - let header = sample_header(); - - assert_eq!(header.flags, LTX_HEADER_FLAG_NO_CHECKSUM); - assert_eq!(header.page_size, SQLITE_PAGE_SIZE); - assert_eq!(header.commit, 48); - assert_eq!(header.min_txid, 7); - assert_eq!(header.max_txid, 7); - assert_eq!(header.pre_apply_checksum, 0); - assert_eq!(header.wal_offset, 0); - assert_eq!(header.wal_size, 0); - assert_eq!(header.wal_salt1, 0); - assert_eq!(header.wal_salt2, 0); - assert_eq!(header.node_id, 0); - assert_eq!(LTX_VERSION, 3); - } - - #[test] - fn encodes_header_and_zeroed_trailer() { - let encoded = LtxEncoder::new(sample_header()) - .encode_with_index(&[DirtyPage { - pgno: 9, - bytes: repeated_page(0x2a), - }]) - .expect("ltx should encode"); - - assert_eq!(&encoded.bytes[0..4], LTX_MAGIC); - assert_eq!( - u32::from_be_bytes(encoded.bytes[4..8].try_into().expect("flags")), - LTX_HEADER_FLAG_NO_CHECKSUM - ); - assert_eq!( - u32::from_be_bytes(encoded.bytes[8..12].try_into().expect("page size")), - SQLITE_PAGE_SIZE - ); - assert_eq!( - u32::from_be_bytes(encoded.bytes[12..16].try_into().expect("commit")), - 48 - ); - assert_eq!( - u64::from_be_bytes(encoded.bytes[16..24].try_into().expect("min txid")), - 7 - ); - assert_eq!( - u64::from_be_bytes(encoded.bytes[24..32].try_into().expect("max txid")), - 7 - ); - assert_eq!( - &encoded.bytes[LTX_HEADER_SIZE - LTX_RESERVED_HEADER_BYTES..LTX_HEADER_SIZE], - &[0u8; LTX_RESERVED_HEADER_BYTES] - ); - assert_eq!( - &encoded.bytes[encoded.bytes.len() - LTX_TRAILER_SIZE..], - &[0u8; LTX_TRAILER_SIZE] - ); - } - - #[test] - fn encodes_page_headers_with_lz4_block_size_prefixes() { - let first_page = repeated_page(0x11); - let second_page = repeated_page(0x77); - let encoded = LtxEncoder::new(sample_header()) - .encode_with_index(&[ - DirtyPage { - pgno: 4, - bytes: first_page.clone(), - }, - DirtyPage { - pgno: 12, - bytes: second_page.clone(), - }, - ]) - .expect("ltx should encode"); - - let first_entry = &encoded.page_index[0]; - let second_entry = &encoded.page_index[1]; - let first_offset = first_entry.offset as usize; - let second_offset = second_entry.offset as usize; - - assert_eq!(encoded.page_index.len(), 2); - assert_eq!( - u32::from_be_bytes( - encoded.bytes[first_offset..first_offset + 4] - .try_into() - .expect("first pgno") - ), - 4 - ); - assert_eq!( - u16::from_be_bytes( - encoded.bytes[first_offset + 4..first_offset + LTX_PAGE_HEADER_SIZE] - .try_into() - .expect("first flags") - ), - LTX_PAGE_HEADER_FLAG_SIZE - ); - - let compressed_size = u32::from_be_bytes( - encoded.bytes - [first_offset + LTX_PAGE_HEADER_SIZE..first_offset + LTX_PAGE_HEADER_SIZE + 4] + .expect("first pgno") + ), + 4 + ); + assert_eq!( + u16::from_be_bytes( + encoded.bytes[first_offset + 4..first_offset + LTX_PAGE_HEADER_SIZE] .try_into() - .expect("first compressed size"), - ) as usize; - let compressed_bytes = &encoded.bytes[first_offset + LTX_PAGE_HEADER_SIZE + 4 - ..first_offset + LTX_PAGE_HEADER_SIZE + 4 + compressed_size]; - let decoded = lz4_flex::block::decompress(compressed_bytes, SQLITE_PAGE_SIZE as usize) - .expect("page should decompress"); - - assert_eq!(decoded, first_page); - assert_eq!( - u32::from_be_bytes( - encoded.bytes[second_offset..second_offset + 4] - .try_into() - .expect("second pgno") - ), - 12 - ); - assert_eq!( - second_entry.offset, - first_entry.offset + first_entry.size, - "page frames should be tightly packed" - ); - assert_eq!(second_page.len(), SQLITE_PAGE_SIZE as usize); - } - - #[test] - fn writes_sorted_page_index_with_zero_pgno_sentinel() { - let encoded = LtxEncoder::new(sample_header()) - .encode_with_index(&[ - DirtyPage { - pgno: 33, - bytes: repeated_page(0x33), - }, - DirtyPage { - pgno: 2, - bytes: repeated_page(0x02), - }, - DirtyPage { - pgno: 17, - bytes: repeated_page(0x17), - }, - ]) - .expect("ltx should encode"); - let index_bytes = page_index_bytes(&encoded); - let mut cursor = 0usize; - - for expected in &encoded.page_index { - assert_eq!( - super::decode_uvarint(index_bytes, &mut cursor).expect("pgno"), - expected.pgno as u64 - ); - assert_eq!( - super::decode_uvarint(index_bytes, &mut cursor).expect("offset"), - expected.offset - ); - assert_eq!( - super::decode_uvarint(index_bytes, &mut cursor).expect("size"), - expected.size - ); - } + .expect("first flags") + ), + LTX_PAGE_HEADER_FLAG_SIZE + ); + + let compressed_size = u32::from_be_bytes( + encoded.bytes[first_offset + LTX_PAGE_HEADER_SIZE..first_offset + LTX_PAGE_HEADER_SIZE + 4] + .try_into() + .expect("first compressed size"), + ) as usize; + let compressed_bytes = &encoded.bytes[first_offset + LTX_PAGE_HEADER_SIZE + 4 + ..first_offset + LTX_PAGE_HEADER_SIZE + 4 + compressed_size]; + let decoded = lz4_flex::block::decompress(compressed_bytes, SQLITE_PAGE_SIZE as usize) + .expect("page should decompress"); + + assert_eq!(decoded, first_page); + assert_eq!( + u32::from_be_bytes( + encoded.bytes[second_offset..second_offset + 4] + .try_into() + .expect("second pgno") + ), + 12 + ); + assert_eq!( + second_entry.offset, + first_entry.offset + first_entry.size, + "page frames should be tightly packed" + ); + assert_eq!(second_page.len(), SQLITE_PAGE_SIZE as usize); +} + +#[test] +fn writes_sorted_page_index_with_zero_pgno_sentinel() { + let encoded = LtxEncoder::new(sample_header()) + .encode_with_index(&[ + DirtyPage { + pgno: 33, + bytes: repeated_page(0x33), + }, + DirtyPage { + pgno: 2, + bytes: repeated_page(0x02), + }, + DirtyPage { + pgno: 17, + bytes: repeated_page(0x17), + }, + ]) + .expect("ltx should encode"); + let index_bytes = page_index_bytes(&encoded); + let mut cursor = 0usize; + for expected in &encoded.page_index { assert_eq!( - encoded - .page_index - .iter() - .map(|entry| entry.pgno) - .collect::>(), - vec![2, 17, 33] + super::decode_uvarint(index_bytes, &mut cursor).expect("pgno"), + expected.pgno as u64 ); assert_eq!( - super::decode_uvarint(index_bytes, &mut cursor).expect("sentinel"), - 0 + super::decode_uvarint(index_bytes, &mut cursor).expect("offset"), + expected.offset ); - assert_eq!(cursor, index_bytes.len()); - - let sentinel_start = encoded.bytes.len() - - LTX_TRAILER_SIZE - - std::mem::size_of::() - - index_bytes.len() - - LTX_PAGE_HEADER_SIZE; assert_eq!( - &encoded.bytes[sentinel_start..sentinel_start + LTX_PAGE_HEADER_SIZE], - &[0u8; LTX_PAGE_HEADER_SIZE] + super::decode_uvarint(index_bytes, &mut cursor).expect("size"), + expected.size ); } - #[test] - fn rejects_invalid_pages() { - let encoder = LtxEncoder::new(sample_header()); - - let zero_pgno = encoder.encode(&[DirtyPage { - pgno: 0, - bytes: repeated_page(0x01), - }]); - assert!(zero_pgno.is_err()); - - let wrong_size = encoder.encode(&[DirtyPage { - pgno: 1, - bytes: vec![0u8; 128], - }]); - assert!(wrong_size.is_err()); - } - - #[test] - fn free_function_returns_complete_blob() { - let bytes = encode_ltx_v3( - sample_header(), - &[DirtyPage { - pgno: 5, - bytes: repeated_page(0x55), - }], - ) + assert_eq!( + encoded + .page_index + .iter() + .map(|entry| entry.pgno) + .collect::>(), + vec![2, 17, 33] + ); + assert_eq!( + super::decode_uvarint(index_bytes, &mut cursor).expect("sentinel"), + 0 + ); + assert_eq!(cursor, index_bytes.len()); + + let sentinel_start = encoded.bytes.len() + - LTX_TRAILER_SIZE + - std::mem::size_of::() + - index_bytes.len() + - LTX_PAGE_HEADER_SIZE; + assert_eq!( + &encoded.bytes[sentinel_start..sentinel_start + LTX_PAGE_HEADER_SIZE], + &[0u8; LTX_PAGE_HEADER_SIZE] + ); +} + +#[test] +fn rejects_invalid_pages() { + let encoder = LtxEncoder::new(sample_header()); + + let zero_pgno = encoder.encode(&[DirtyPage { + pgno: 0, + bytes: repeated_page(0x01), + }]); + assert!(zero_pgno.is_err()); + + let wrong_size = encoder.encode(&[DirtyPage { + pgno: 1, + bytes: vec![0u8; 128], + }]); + assert!(wrong_size.is_err()); +} + +#[test] +fn free_function_returns_complete_blob() { + let bytes = encode_ltx_v3( + sample_header(), + &[DirtyPage { + pgno: 5, + bytes: repeated_page(0x55), + }], + ) + .expect("ltx should encode"); + + assert!(bytes.len() > LTX_HEADER_SIZE + LTX_PAGE_HEADER_SIZE + LTX_TRAILER_SIZE); +} + +fn decode_round_trip(encoded: &[u8]) -> DecodedLtx { + LtxDecoder::new(encoded) + .decode() + .expect("ltx should decode") +} + +#[test] +fn decodes_round_trip_pages_and_header() { + let header = sample_header(); + let pages = vec![ + DirtyPage { + pgno: 8, + bytes: repeated_page(0x08), + }, + DirtyPage { + pgno: 2, + bytes: repeated_page(0x02), + }, + DirtyPage { + pgno: 44, + bytes: repeated_page(0x44), + }, + ]; + let encoded = LtxEncoder::new(header.clone()) + .encode_with_index(&pages) .expect("ltx should encode"); + let decoded = decode_round_trip(&encoded.bytes); - assert!(bytes.len() > LTX_HEADER_SIZE + LTX_PAGE_HEADER_SIZE + LTX_TRAILER_SIZE); - } - - fn decode_round_trip(encoded: &[u8]) -> DecodedLtx { - LtxDecoder::new(encoded) - .decode() - .expect("ltx should decode") - } - - #[test] - fn decodes_round_trip_pages_and_header() { - let header = sample_header(); - let pages = vec![ - DirtyPage { - pgno: 8, - bytes: repeated_page(0x08), - }, + assert_eq!(decoded.header, header); + assert_eq!(decoded.page_index, encoded.page_index); + assert_eq!( + decoded.pages, + vec![ DirtyPage { pgno: 2, bytes: repeated_page(0x02), }, + DirtyPage { + pgno: 8, + bytes: repeated_page(0x08), + }, DirtyPage { pgno: 44, bytes: repeated_page(0x44), }, - ]; + ] + ); + assert_eq!(decoded.get_page(8), Some(repeated_page(0x08).as_slice())); + assert!(decoded.get_page(99).is_none()); +} + +#[test] +fn decodes_varying_valid_page_sizes() { + for page_size in [512u32, 1024, SQLITE_PAGE_SIZE] { + let mut header = sample_header(); + header.page_size = page_size; + header.commit = page_size; + let page = DirtyPage { + pgno: 3, + bytes: repeated_page_with_size(0x5a, page_size), + }; let encoded = LtxEncoder::new(header.clone()) - .encode_with_index(&pages) + .encode(&[page.clone()]) .expect("ltx should encode"); - let decoded = decode_round_trip(&encoded.bytes); + let decoded = decode_ltx_v3(&encoded).expect("ltx should decode"); assert_eq!(decoded.header, header); - assert_eq!(decoded.page_index, encoded.page_index); - assert_eq!( - decoded.pages, - vec![ - DirtyPage { - pgno: 2, - bytes: repeated_page(0x02), - }, - DirtyPage { - pgno: 8, - bytes: repeated_page(0x08), - }, - DirtyPage { - pgno: 44, - bytes: repeated_page(0x44), - }, - ] - ); - assert_eq!(decoded.get_page(8), Some(repeated_page(0x08).as_slice())); - assert!(decoded.get_page(99).is_none()); + assert_eq!(decoded.pages, vec![page]); } +} + +#[test] +fn rejects_corrupt_trailer_or_index() { + let encoded = LtxEncoder::new(sample_header()) + .encode_with_index(&[DirtyPage { + pgno: 7, + bytes: repeated_page(0x77), + }]) + .expect("ltx should encode"); - #[test] - fn decodes_varying_valid_page_sizes() { - for page_size in [512u32, 1024, SQLITE_PAGE_SIZE] { - let mut header = sample_header(); - header.page_size = page_size; - header.commit = page_size; - let page = DirtyPage { - pgno: 3, - bytes: repeated_page_with_size(0x5a, page_size), - }; - let encoded = LtxEncoder::new(header.clone()) - .encode(&[page.clone()]) - .expect("ltx should encode"); - let decoded = decode_ltx_v3(&encoded).expect("ltx should decode"); - - assert_eq!(decoded.header, header); - assert_eq!(decoded.pages, vec![page]); - } - } - - #[test] - fn rejects_corrupt_trailer_or_index() { - let encoded = LtxEncoder::new(sample_header()) - .encode_with_index(&[DirtyPage { - pgno: 7, - bytes: repeated_page(0x77), - }]) - .expect("ltx should encode"); - - let mut bad_trailer = encoded.bytes.clone(); - let trailer_idx = bad_trailer.len() - 1; - bad_trailer[trailer_idx] = 0x01; - assert!(decode_ltx_v3(&bad_trailer).is_err()); - - let mut bad_index = encoded.bytes.clone(); - let first_page_offset = encoded.page_index[0].offset as usize; - let footer_offset = bad_index.len() - LTX_TRAILER_SIZE - std::mem::size_of::(); - let index_size = u64::from_be_bytes( - bad_index[footer_offset..footer_offset + std::mem::size_of::()] - .try_into() - .expect("index footer should decode"), - ) as usize; - let index_start = footer_offset - index_size; - bad_index[index_start + 1] ^= 0x01; - - let decoded = decode_ltx_v3(&bad_index); - assert!(decoded.is_err()); - assert_eq!(first_page_offset, encoded.page_index[0].offset as usize); - } + let mut bad_trailer = encoded.bytes.clone(); + let trailer_idx = bad_trailer.len() - 1; + bad_trailer[trailer_idx] = 0x01; + assert!(decode_ltx_v3(&bad_trailer).is_err()); + + let mut bad_index = encoded.bytes.clone(); + let first_page_offset = encoded.page_index[0].offset as usize; + let footer_offset = bad_index.len() - LTX_TRAILER_SIZE - std::mem::size_of::(); + let index_size = u64::from_be_bytes( + bad_index[footer_offset..footer_offset + std::mem::size_of::()] + .try_into() + .expect("index footer should decode"), + ) as usize; + let index_start = footer_offset - index_size; + bad_index[index_start + 1] ^= 0x01; + + let decoded = decode_ltx_v3(&bad_index); + assert!(decoded.is_err()); + assert_eq!(first_page_offset, encoded.page_index[0].offset as usize); +} diff --git a/engine/packages/depot/tests/inline/conveyer_types.rs b/engine/packages/depot/tests/inline/conveyer_types.rs index c9176a29b0..b1c59c7062 100644 --- a/engine/packages/depot/tests/inline/conveyer_types.rs +++ b/engine/packages/depot/tests/inline/conveyer_types.rs @@ -1,108 +1,105 @@ - use super::{ - BranchState, DBHead, DatabaseBranchId, DatabaseBranchRecord, MetaCompact, - BucketBranchId, SQLITE_STORAGE_META_VERSION, decode_database_branch_record, - decode_db_head, decode_meta_compact, encode_database_branch_record, encode_db_head, - encode_meta_compact, - }; +use super::{ + BranchState, BucketBranchId, DBHead, DatabaseBranchId, DatabaseBranchRecord, MetaCompact, + SQLITE_STORAGE_META_VERSION, decode_database_branch_record, decode_db_head, + decode_meta_compact, encode_database_branch_record, encode_db_head, encode_meta_compact, +}; - #[derive(serde::Serialize)] - struct LegacyDatabaseBranchRecordPayload { - branch_id: DatabaseBranchId, - bucket_branch: BucketBranchId, - parent: Option, - parent_versionstamp: Option<[u8; 16]>, - root_versionstamp: [u8; 16], - fork_depth: u8, - created_at_ms: i64, - created_from_restore_point: Option, - state: BranchState, - } +#[derive(serde::Serialize)] +struct LegacyDatabaseBranchRecordPayload { + branch_id: DatabaseBranchId, + bucket_branch: BucketBranchId, + parent: Option, + parent_versionstamp: Option<[u8; 16]>, + root_versionstamp: [u8; 16], + fork_depth: u8, + created_at_ms: i64, + created_from_restore_point: Option, + state: BranchState, +} - #[test] - fn db_head_round_trips_with_embedded_version() { - let head = DBHead { - head_txid: 42, - db_size_pages: 128, - post_apply_checksum: 9, - branch_id: super::DatabaseBranchId::nil(), - #[cfg(debug_assertions)] - generation: 7, - }; +#[test] +fn db_head_round_trips_with_embedded_version() { + let head = DBHead { + head_txid: 42, + db_size_pages: 128, + post_apply_checksum: 9, + branch_id: super::DatabaseBranchId::nil(), + #[cfg(debug_assertions)] + generation: 7, + }; - let encoded = encode_db_head(head.clone()).expect("db head should encode"); - assert_eq!( - u16::from_le_bytes([encoded[0], encoded[1]]), - SQLITE_STORAGE_META_VERSION - ); + let encoded = encode_db_head(head.clone()).expect("db head should encode"); + assert_eq!( + u16::from_le_bytes([encoded[0], encoded[1]]), + SQLITE_STORAGE_META_VERSION + ); - let decoded = decode_db_head(&encoded).expect("db head should decode"); - assert_eq!(decoded, head); - } + let decoded = decode_db_head(&encoded).expect("db head should decode"); + assert_eq!(decoded, head); +} - #[test] - fn database_branch_record_round_trips_current_version() { - let record = DatabaseBranchRecord { - branch_id: DatabaseBranchId::nil(), - bucket_branch: BucketBranchId::nil(), - parent: None, - parent_versionstamp: None, - root_versionstamp: [3; 16], - fork_depth: 2, - created_at_ms: 1_000, - created_from_restore_point: None, - state: BranchState::Live, - lifecycle_generation: 9, - }; +#[test] +fn database_branch_record_round_trips_current_version() { + let record = DatabaseBranchRecord { + branch_id: DatabaseBranchId::nil(), + bucket_branch: BucketBranchId::nil(), + parent: None, + parent_versionstamp: None, + root_versionstamp: [3; 16], + fork_depth: 2, + created_at_ms: 1_000, + created_from_restore_point: None, + state: BranchState::Live, + lifecycle_generation: 9, + }; - let encoded = encode_database_branch_record(record.clone()) - .expect("database branch record should encode"); - assert_eq!(u16::from_le_bytes([encoded[0], encoded[1]]), 1); + let encoded = encode_database_branch_record(record.clone()) + .expect("database branch record should encode"); + assert_eq!(u16::from_le_bytes([encoded[0], encoded[1]]), 1); - let decoded = - decode_database_branch_record(&encoded).expect("database branch record should decode"); - assert_eq!(decoded, record); - } + let decoded = + decode_database_branch_record(&encoded).expect("database branch record should decode"); + assert_eq!(decoded, record); +} - #[test] - fn database_branch_record_rejects_legacy_v1_without_lifecycle_generation() { - let legacy_payload = LegacyDatabaseBranchRecordPayload { - branch_id: DatabaseBranchId::nil(), - bucket_branch: BucketBranchId::nil(), - parent: None, - parent_versionstamp: None, - root_versionstamp: [3; 16], - fork_depth: 2, - created_at_ms: 1_000, - created_from_restore_point: None, - state: BranchState::Live, - }; - let mut encoded = 1_u16.to_le_bytes().to_vec(); - encoded.extend( - serde_bare::to_vec(&legacy_payload).expect("legacy bare payload should encode"), - ); +#[test] +fn database_branch_record_rejects_legacy_v1_without_lifecycle_generation() { + let legacy_payload = LegacyDatabaseBranchRecordPayload { + branch_id: DatabaseBranchId::nil(), + bucket_branch: BucketBranchId::nil(), + parent: None, + parent_versionstamp: None, + root_versionstamp: [3; 16], + fork_depth: 2, + created_at_ms: 1_000, + created_from_restore_point: None, + state: BranchState::Live, + }; + let mut encoded = 1_u16.to_le_bytes().to_vec(); + encoded.extend(serde_bare::to_vec(&legacy_payload).expect("legacy bare payload should encode")); - let err = decode_database_branch_record(&encoded) - .expect_err("legacy branch record should be rejected"); - assert!( - err.chain().any(|cause| cause - .to_string() - .contains("decode sqlite database branch record")), - "unexpected error chain: {err:#}" - ); - } + let err = decode_database_branch_record(&encoded) + .expect_err("legacy branch record should be rejected"); + assert!( + err.chain().any(|cause| cause + .to_string() + .contains("decode sqlite database branch record")), + "unexpected error chain: {err:#}" + ); +} - #[test] - fn meta_compact_round_trips_with_embedded_version() { - let compact = MetaCompact { - materialized_txid: 24, - }; +#[test] +fn meta_compact_round_trips_with_embedded_version() { + let compact = MetaCompact { + materialized_txid: 24, + }; - let encoded = encode_meta_compact(compact.clone()).expect("compact meta should encode"); - assert_eq!( - u16::from_le_bytes([encoded[0], encoded[1]]), - SQLITE_STORAGE_META_VERSION - ); + let encoded = encode_meta_compact(compact.clone()).expect("compact meta should encode"); + assert_eq!( + u16::from_le_bytes([encoded[0], encoded[1]]), + SQLITE_STORAGE_META_VERSION + ); - let decoded = decode_meta_compact(&encoded).expect("compact meta should decode"); - assert_eq!(decoded, compact); - } + let decoded = decode_meta_compact(&encoded).expect("compact meta should decode"); + assert_eq!(decoded, compact); +} diff --git a/engine/packages/depot/tests/inline/workflows_compaction.rs b/engine/packages/depot/tests/inline/workflows_compaction.rs index 001012aed6..6440c49d50 100644 --- a/engine/packages/depot/tests/inline/workflows_compaction.rs +++ b/engine/packages/depot/tests/inline/workflows_compaction.rs @@ -8,28 +8,26 @@ use universaldb::utils::IsolationLevel::Snapshot; use uuid::Uuid; use super::{ - cleanup_repair_fdb_outputs_tx, fingerprint_repair_reclaim_range, plan_cold_job, plan_hot_job, - plan_orphan_cold_object_deletes_tx, read_reclaim_input_snapshot, repair_reclaim_input_range, - ActiveColdCompactionJob, ActiveHotCompactionJob, ActiveReclaimCompactionJob, - BranchStopState, ColdInputSnapshot, ColdJobFinished, ColdJobInputRange, ColdShardBlob, - ColdShardRef, CompanionWorkflowIds, CompactionJobKind, CompactionJobStatus, CompactionRoot, - DatabaseBranchId, DatabaseBranchRecord, DbManagerInput, DbManagerState, ForceCompaction, - ForceCompactionTracker, ForceCompactionWork, HotInputSnapshot, HotJobFinished, - HotJobInputRange, HotShardOutputRef, ManagerActiveJobs, ManagerEffect, ManagerFdbSnapshot, - ManagerPlanningDeadlines, ManagerStopReason, PlannedColdCompactionJob, PlannedHotCompactionJob, - PlannedReclaimCompactionJob, ReclaimFdbJobInput, ReclaimInputSnapshot, - ReclaimJobFinished, ReclaimJobInputRange, RefreshManagerOutput, ShardCachePolicy, - StagedHotShardCleanupRef, TxidRange, manager_effect_for_requested_stop, manager_effects_after_refresh, + ActiveColdCompactionJob, ActiveHotCompactionJob, ActiveReclaimCompactionJob, BranchStopState, + ColdInputSnapshot, ColdJobFinished, ColdJobInputRange, ColdShardBlob, ColdShardRef, + CompactionJobKind, CompactionJobStatus, CompactionRoot, CompanionWorkflowIds, DatabaseBranchId, + DatabaseBranchRecord, DbManagerInput, DbManagerState, ForceCompaction, ForceCompactionTracker, + ForceCompactionWork, HotInputSnapshot, HotJobFinished, HotJobInputRange, HotShardOutputRef, + ManagerActiveJobs, ManagerEffect, ManagerFdbSnapshot, ManagerPlanningDeadlines, + ManagerStopReason, PlannedColdCompactionJob, PlannedHotCompactionJob, + PlannedReclaimCompactionJob, ReclaimFdbJobInput, ReclaimInputSnapshot, ReclaimJobFinished, + ReclaimJobInputRange, RefreshManagerOutput, ShardCachePolicy, StagedHotShardCleanupRef, + TxidRange, cleanup_repair_fdb_outputs_tx, fingerprint_repair_reclaim_range, + manager_effect_for_requested_stop, manager_effects_after_refresh, manager_effects_for_cold_job_finished, manager_effects_for_hot_job_finished, - manager_effects_for_reclaim_job_finished, + manager_effects_for_reclaim_job_finished, plan_cold_job, plan_hot_job, + plan_orphan_cold_object_deletes_tx, read_reclaim_input_snapshot, repair_reclaim_input_range, }; -use crate::{ - conveyer::{ - keys, - types::{ - BranchState, CommitRow, DBHead, BucketBranchId, encode_compaction_root, - encode_commit_row, encode_database_branch_record, - }, +use crate::conveyer::{ + keys, + types::{ + BranchState, BucketBranchId, CommitRow, DBHead, encode_commit_row, encode_compaction_root, + encode_database_branch_record, }, }; @@ -286,7 +284,11 @@ fn force_compaction_tracker_adopts_active_jobs_and_records_success() { tracker.complete_ready_requests(&active_jobs, &refresh_without_planned_work(), 101); assert_eq!(tracker.pending_requests.len(), 1); - tracker.record_job_finished(CompactionJobKind::Hot, job_id, &CompactionJobStatus::Succeeded); + tracker.record_job_finished( + CompactionJobKind::Hot, + job_id, + &CompactionJobStatus::Succeeded, + ); tracker.complete_ready_requests( &ManagerActiveJobs::default(), &refresh_without_planned_work(), @@ -334,7 +336,10 @@ fn force_compaction_tracker_records_attempted_failed_jobs() { let result = &tracker.recent_results[0]; assert_eq!(result.attempted_job_kinds, vec![CompactionJobKind::Cold]); assert_eq!(result.completed_job_ids, vec![job_id]); - assert_eq!(result.terminal_error, Some("cold upload failed".to_string())); + assert_eq!( + result.terminal_error, + Some("cold upload failed".to_string()) + ); } #[test] @@ -452,11 +457,13 @@ fn manager_effects_cover_stale_cold_cleanup_and_branch_stop() { output_refs: Vec::new(), }, ); - let [ManagerEffect::ScheduleUploadedColdOutputCleanup { - base_lifecycle_generation, - repair_action, - .. - }] = cleanup_effects.as_slice() + let [ + ManagerEffect::ScheduleUploadedColdOutputCleanup { + base_lifecycle_generation, + repair_action, + .. + }, + ] = cleanup_effects.as_slice() else { panic!("expected stale cold cleanup effect"); }; @@ -521,18 +528,21 @@ fn manager_refresh_effects_keep_cold_and_reclaim_mutually_exclusive() { }; let effects = manager_effects_after_refresh(&state, &input, &refresh, 1_500); - assert!(effects.iter().any(|effect| matches!( - effect, - ManagerEffect::RunColdJob { .. } - ))); - assert!(!effects.iter().any(|effect| matches!( - effect, - ManagerEffect::RunReclaimJob { .. } - ))); - assert!(effects.iter().any(|effect| matches!( - effect, - ManagerEffect::CompleteReadyForceCompactions { .. } - ))); + assert!( + effects + .iter() + .any(|effect| matches!(effect, ManagerEffect::RunColdJob { .. })) + ); + assert!( + !effects + .iter() + .any(|effect| matches!(effect, ManagerEffect::RunReclaimJob { .. })) + ); + assert!( + effects + .iter() + .any(|effect| matches!(effect, ManagerEffect::CompleteReadyForceCompactions { .. })) + ); } #[test] @@ -590,26 +600,18 @@ fn manager_active_jobs_store_typed_lanes_independently() { }; let mut active_jobs = ManagerActiveJobs { - hot: Some(ActiveHotCompactionJob::from_planned( - planned_hot_job( - database_branch_id, - Id::new_v1(3300), - hot_range.clone(), - ), - )), - cold: Some(ActiveColdCompactionJob::from_planned( - planned_cold_job( - database_branch_id, - Id::new_v1(3301), - cold_range.clone(), - ), - )), + hot: Some(ActiveHotCompactionJob::from_planned(planned_hot_job( + database_branch_id, + Id::new_v1(3300), + hot_range.clone(), + ))), + cold: Some(ActiveColdCompactionJob::from_planned(planned_cold_job( + database_branch_id, + Id::new_v1(3301), + cold_range.clone(), + ))), reclaim: Some(ActiveReclaimCompactionJob::from_planned( - planned_reclaim_job( - database_branch_id, - Id::new_v1(3302), - reclaim_range.clone(), - ), + planned_reclaim_job(database_branch_id, Id::new_v1(3302), reclaim_range.clone()), )), }; @@ -618,10 +620,18 @@ fn manager_active_jobs_store_typed_lanes_independently() { vec![5, 10] ); assert_eq!( - active_jobs.cold.as_ref().unwrap().input_range.min_versionstamp, + active_jobs + .cold + .as_ref() + .unwrap() + .input_range + .min_versionstamp, [1; 16] ); - assert_eq!(active_jobs.reclaim.as_ref().unwrap().input_range.max_keys, 10); + assert_eq!( + active_jobs.reclaim.as_ref().unwrap().input_range.max_keys, + 10 + ); active_jobs.hot = None; assert!(active_jobs.hot.is_none()); @@ -685,7 +695,10 @@ fn hot_planning_uses_sha256_fingerprint_and_changes_with_inputs() { update_expected_fingerprint(&mut expected, key); update_expected_fingerprint(&mut expected, value); } - assert_eq!(first_job.input_fingerprint, finish_expected_fingerprint(expected)); + assert_eq!( + first_job.input_fingerprint, + finish_expected_fingerprint(expected) + ); hot_inputs = snapshot.hot_inputs; hot_inputs.delta_chunks[0].1[0] ^= 0xff; @@ -751,7 +764,10 @@ fn cold_planning_uses_sha256_fingerprint_and_changes_with_inputs() { update_expected_fingerprint(&mut expected, &blob.key); update_expected_fingerprint(&mut expected, &blob.bytes); } - assert_eq!(first_job.input_fingerprint, finish_expected_fingerprint(expected)); + assert_eq!( + first_job.input_fingerprint, + finish_expected_fingerprint(expected) + ); cold_inputs = snapshot.cold_inputs; cold_inputs.shard_blobs[0].bytes[0] ^= 0xff; @@ -830,7 +846,13 @@ async fn repair_fdb_cleanup_lifecycle_generation_rejects_recreated_branch() -> R let stage_after = db .run(move |tx| { let stage_key = stage_key.clone(); - async move { Ok(tx.informal().get(&stage_key, Snapshot).await?.map(Vec::from)) } + async move { + Ok(tx + .informal() + .get(&stage_key, Snapshot) + .await? + .map(Vec::from)) + } }) .await?; assert_eq!(stage_after, Some(staged_blob)); @@ -914,7 +936,8 @@ async fn reclaim_input_snapshot_bounds_commit_scan_by_reclaim_ceiling() -> Resul ); let mut malformed_high_key = keys::branch_commit_key(database_branch_id, 11); malformed_high_key.push(b'/'); - tx.informal().set(&malformed_high_key, b"must-not-be-scanned"); + tx.informal() + .set(&malformed_high_key, b"must-not-be-scanned"); read_reclaim_input_snapshot( &tx, diff --git a/engine/packages/depot/tests/inspect.rs b/engine/packages/depot/tests/inspect.rs index 25c2701210..1a0e8650f4 100644 --- a/engine/packages/depot/tests/inspect.rs +++ b/engine/packages/depot/tests/inspect.rs @@ -4,7 +4,9 @@ use anyhow::{Context, Result}; use depot::{ inspect::{self, RowsQuery}, keys::{PAGE_SIZE, bucket_pointer_cur_key, database_pointer_cur_key}, - types::{BucketId, DatabaseBranchId, DirtyPage, decode_bucket_pointer, decode_database_pointer}, + types::{ + BucketId, DatabaseBranchId, DirtyPage, decode_bucket_pointer, decode_database_pointer, + }, }; use rivet_pools::NodeId; diff --git a/engine/packages/depot/tests/list_databases.rs b/engine/packages/depot/tests/list_databases.rs index 2ce9a1add2..939a9ff5f9 100644 --- a/engine/packages/depot/tests/list_databases.rs +++ b/engine/packages/depot/tests/list_databases.rs @@ -3,16 +3,19 @@ mod common; use std::collections::BTreeSet; use anyhow::Result; -use gas::prelude::Id; use depot::{ - keys::{database_pointer_cur_key, branch_commit_key, branch_meta_head_key, branches_refcount_key, bucket_pointer_cur_key}, conveyer::branch, + keys::{ + branch_commit_key, branch_meta_head_key, branches_refcount_key, bucket_pointer_cur_key, + database_pointer_cur_key, + }, types::{ - DatabaseBranchId, CommitRow, DBHead, DirtyPage, BucketBranchId, BucketId, - ResolvedVersionstamp, decode_database_pointer, decode_commit_row, decode_db_head, - decode_bucket_pointer, + BucketBranchId, BucketId, CommitRow, DBHead, DatabaseBranchId, DirtyPage, + ResolvedVersionstamp, decode_bucket_pointer, decode_commit_row, decode_database_pointer, + decode_db_head, }, }; +use gas::prelude::Id; use universaldb::utils::IsolationLevel::Snapshot; fn test_bucket() -> Id { @@ -93,140 +96,164 @@ async fn read_refcount(db: &universaldb::Database, branch_id: DatabaseBranchId) #[tokio::test] async fn delete_database_in_forked_bucket_hides_in_child_only() -> Result<()> { - common::test_matrix("depot-list-delete-forked", |_tier, ctx| Box::pin(async move { - let db = ctx.udb.clone(); - let bucket = BucketId::from_gas_id(test_bucket()); - let first_database_name = format!("{}-first", ctx.database_id); - let database_db = ctx.make_db(test_bucket(), first_database_name.clone()); - database_db.commit(vec![page(1, 0x11)], 1, 1_000).await?; - let database_id = read_database_branch_id(&db, bucket, &first_database_name).await?; - let commit = read_head_commit(&db, database_id).await?; - let forked_bucket = branch::fork_bucket( - &db, - bucket, - ResolvedVersionstamp { - versionstamp: commit.versionstamp, - restore_point: None, - }) - .await?; - - let parent_databases = branch::list_databases(&db, bucket).await?; - let child_databases = branch::list_databases(&db, forked_bucket).await?; - assert_eq!(parent_databases, vec![database_id]); - assert_eq!(child_databases, vec![database_id]); - - branch::delete_database(&db, forked_bucket, database_id).await?; - - assert_eq!(branch::list_databases(&db, forked_bucket).await?, Vec::new()); - assert_eq!(branch::list_databases(&db, bucket).await?, vec![database_id]); - assert_eq!(read_refcount(&db, database_id).await?, 0); - - Ok(()) - })) + common::test_matrix("depot-list-delete-forked", |_tier, ctx| { + Box::pin(async move { + let db = ctx.udb.clone(); + let bucket = BucketId::from_gas_id(test_bucket()); + let first_database_name = format!("{}-first", ctx.database_id); + let database_db = ctx.make_db(test_bucket(), first_database_name.clone()); + database_db.commit(vec![page(1, 0x11)], 1, 1_000).await?; + let database_id = read_database_branch_id(&db, bucket, &first_database_name).await?; + let commit = read_head_commit(&db, database_id).await?; + let forked_bucket = branch::fork_bucket( + &db, + bucket, + ResolvedVersionstamp { + versionstamp: commit.versionstamp, + restore_point: None, + }, + ) + .await?; + + let parent_databases = branch::list_databases(&db, bucket).await?; + let child_databases = branch::list_databases(&db, forked_bucket).await?; + assert_eq!(parent_databases, vec![database_id]); + assert_eq!(child_databases, vec![database_id]); + + branch::delete_database(&db, forked_bucket, database_id).await?; + + assert_eq!( + branch::list_databases(&db, forked_bucket).await?, + Vec::new() + ); + assert_eq!( + branch::list_databases(&db, bucket).await?, + vec![database_id] + ); + assert_eq!(read_refcount(&db, database_id).await?, 0); + + Ok(()) + }) + }) .await } #[tokio::test] async fn fork_bucket_filters_source_databases_created_after_fork() -> Result<()> { - common::test_matrix("depot-list-fork-filters", |_tier, ctx| Box::pin(async move { - let db = ctx.udb.clone(); - let bucket = BucketId::from_gas_id(test_bucket()); - let first_database_name = format!("{}-first", ctx.database_id); - let second_database_name = format!("{}-second", ctx.database_id); - let first_db = ctx.make_db(test_bucket(), first_database_name.clone()); - first_db.commit(vec![page(1, 0x11)], 1, 1_000).await?; - let first_database_id = read_database_branch_id(&db, bucket, &first_database_name).await?; - let first_commit = read_head_commit(&db, first_database_id).await?; - let forked_bucket = branch::fork_bucket( - &db, - bucket, - ResolvedVersionstamp { - versionstamp: first_commit.versionstamp, - restore_point: None, - }) - .await?; - - let second_db = ctx.make_db(test_bucket(), second_database_name.clone()); - second_db.commit(vec![page(1, 0x22)], 1, 2_000).await?; - let second_database_id = read_database_branch_id(&db, bucket, &second_database_name).await?; - - assert_eq!( - branch::list_databases(&db, bucket) - .await? - .into_iter() - .collect::>(), - BTreeSet::from([first_database_id, second_database_id]) - ); - assert_eq!( - branch::list_databases(&db, forked_bucket).await?, - vec![first_database_id] - ); - - Ok(()) - })) + common::test_matrix("depot-list-fork-filters", |_tier, ctx| { + Box::pin(async move { + let db = ctx.udb.clone(); + let bucket = BucketId::from_gas_id(test_bucket()); + let first_database_name = format!("{}-first", ctx.database_id); + let second_database_name = format!("{}-second", ctx.database_id); + let first_db = ctx.make_db(test_bucket(), first_database_name.clone()); + first_db.commit(vec![page(1, 0x11)], 1, 1_000).await?; + let first_database_id = + read_database_branch_id(&db, bucket, &first_database_name).await?; + let first_commit = read_head_commit(&db, first_database_id).await?; + let forked_bucket = branch::fork_bucket( + &db, + bucket, + ResolvedVersionstamp { + versionstamp: first_commit.versionstamp, + restore_point: None, + }, + ) + .await?; + + let second_db = ctx.make_db(test_bucket(), second_database_name.clone()); + second_db.commit(vec![page(1, 0x22)], 1, 2_000).await?; + let second_database_id = + read_database_branch_id(&db, bucket, &second_database_name).await?; + + assert_eq!( + branch::list_databases(&db, bucket) + .await? + .into_iter() + .collect::>(), + BTreeSet::from([first_database_id, second_database_id]) + ); + assert_eq!( + branch::list_databases(&db, forked_bucket).await?, + vec![first_database_id] + ); + + Ok(()) + }) + }) .await } #[tokio::test] async fn parent_tombstone_visibility_is_capped_across_deep_bucket_chain() -> Result<()> { - common::test_matrix("depot-list-parent-tombstone", |_tier, ctx| Box::pin(async move { - let db = ctx.udb.clone(); - let bucket = BucketId::from_gas_id(test_bucket()); - let first_database_name = format!("{}-first", ctx.database_id); - let second_database_name = format!("{}-second", ctx.database_id); - let first_db = ctx.make_db(test_bucket(), first_database_name.clone()); - first_db.commit(vec![page(1, 0x11)], 1, 1_000).await?; - let first_database_id = read_database_branch_id(&db, bucket, &first_database_name).await?; - let first_commit = read_head_commit(&db, first_database_id).await?; - - let before_delete_bucket = branch::fork_bucket( - &db, - bucket, - ResolvedVersionstamp { - versionstamp: first_commit.versionstamp, - restore_point: None, - }) - .await?; - - branch::delete_database(&db, bucket, first_database_id).await?; - - let second_db = ctx.make_db(test_bucket(), second_database_name.clone()); - second_db.commit(vec![page(1, 0x22)], 1, 2_000).await?; - let second_database_id = read_database_branch_id(&db, bucket, &second_database_name).await?; - let second_commit = read_head_commit(&db, second_database_id).await?; - - let after_delete_bucket = branch::fork_bucket( - &db, - bucket, - ResolvedVersionstamp { - versionstamp: second_commit.versionstamp, - restore_point: None, - }) - .await?; - let deep_after_delete_bucket = branch::fork_bucket( - &db, - after_delete_bucket, - ResolvedVersionstamp { - versionstamp: second_commit.versionstamp, - restore_point: None, - }) - .await?; - - assert_eq!( - branch::list_databases(&db, before_delete_bucket).await?, - vec![first_database_id] - ); - assert_eq!(branch::list_databases(&db, bucket).await?, vec![second_database_id]); - assert_eq!( - branch::list_databases(&db, after_delete_bucket).await?, - vec![second_database_id] - ); - assert_eq!( - branch::list_databases(&db, deep_after_delete_bucket).await?, - vec![second_database_id] - ); - - Ok(()) - })) + common::test_matrix("depot-list-parent-tombstone", |_tier, ctx| { + Box::pin(async move { + let db = ctx.udb.clone(); + let bucket = BucketId::from_gas_id(test_bucket()); + let first_database_name = format!("{}-first", ctx.database_id); + let second_database_name = format!("{}-second", ctx.database_id); + let first_db = ctx.make_db(test_bucket(), first_database_name.clone()); + first_db.commit(vec![page(1, 0x11)], 1, 1_000).await?; + let first_database_id = + read_database_branch_id(&db, bucket, &first_database_name).await?; + let first_commit = read_head_commit(&db, first_database_id).await?; + + let before_delete_bucket = branch::fork_bucket( + &db, + bucket, + ResolvedVersionstamp { + versionstamp: first_commit.versionstamp, + restore_point: None, + }, + ) + .await?; + + branch::delete_database(&db, bucket, first_database_id).await?; + + let second_db = ctx.make_db(test_bucket(), second_database_name.clone()); + second_db.commit(vec![page(1, 0x22)], 1, 2_000).await?; + let second_database_id = + read_database_branch_id(&db, bucket, &second_database_name).await?; + let second_commit = read_head_commit(&db, second_database_id).await?; + + let after_delete_bucket = branch::fork_bucket( + &db, + bucket, + ResolvedVersionstamp { + versionstamp: second_commit.versionstamp, + restore_point: None, + }, + ) + .await?; + let deep_after_delete_bucket = branch::fork_bucket( + &db, + after_delete_bucket, + ResolvedVersionstamp { + versionstamp: second_commit.versionstamp, + restore_point: None, + }, + ) + .await?; + + assert_eq!( + branch::list_databases(&db, before_delete_bucket).await?, + vec![first_database_id] + ); + assert_eq!( + branch::list_databases(&db, bucket).await?, + vec![second_database_id] + ); + assert_eq!( + branch::list_databases(&db, after_delete_bucket).await?, + vec![second_database_id] + ); + assert_eq!( + branch::list_databases(&db, deep_after_delete_bucket).await?, + vec![second_database_id] + ); + + Ok(()) + }) + }) .await } diff --git a/engine/packages/depot/tests/restore_points.rs b/engine/packages/depot/tests/restore_points.rs index ce7191dd1a..d6b00db2c1 100644 --- a/engine/packages/depot/tests/restore_points.rs +++ b/engine/packages/depot/tests/restore_points.rs @@ -1,21 +1,21 @@ mod common; use anyhow::{Context, Result}; -use gas::prelude::Id; use depot::{ constants::HOT_RETENTION_FLOOR_MS, + conveyer::{branch, history_pin, restore_point}, error::SqliteStorageError, keys::{ branch_commit_key, branch_manifest_last_hot_pass_txid_key, branch_meta_compact_key, branch_shard_key, branch_vtx_key, db_pin_key, }, - conveyer::{restore_point, branch, history_pin}, types::{ - DatabaseBranchId, RestorePointRef, CommitRow, DbHistoryPinKind, DirtyPage, BucketId, - PinStatus, decode_commit_row, decode_db_history_pin, decode_restore_point_record, + BucketId, CommitRow, DatabaseBranchId, DbHistoryPinKind, DirtyPage, PinStatus, + RestorePointRef, decode_commit_row, decode_db_history_pin, decode_restore_point_record, encode_meta_compact, }, }; +use gas::prelude::Id; use universaldb::utils::IsolationLevel::Serializable; const OTHER_ACTOR: &str = "restore_point-other"; @@ -82,199 +82,243 @@ fn assert_storage_error(err: anyhow::Error, expected: SqliteStorageError) { #[tokio::test] async fn restore_point_resolves_to_retained_record_versionstamp() -> Result<()> { - common::test_matrix("depot-restore-point-resolves", |_tier, ctx| Box::pin(async move { - let db = ctx.udb.clone(); - let source_bucket = ctx.bucket_id; - let database_id = ctx.database_id.clone(); - let database_db = ctx.make_db(source_bucket, database_id.clone()); - - database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; - let restore_point = database_db.create_restore_point(depot::types::SnapshotSelector::Latest).await?; - let branch_id = database_branch_id(&db, source_bucket, &database_id).await?; - let row = commit_row(&db, branch_id, 1).await?; - - let resolved = database_db.resolve_restore_point(restore_point.clone()).await?; - - assert_eq!(resolved.versionstamp, row.versionstamp); - assert_eq!( - resolved.restore_point, - Some(RestorePointRef { - restore_point, - resolved_versionstamp: Some(row.versionstamp), - }) - ); + common::test_matrix("depot-restore-point-resolves", |_tier, ctx| { + Box::pin(async move { + let db = ctx.udb.clone(); + let source_bucket = ctx.bucket_id; + let database_id = ctx.database_id.clone(); + let database_db = ctx.make_db(source_bucket, database_id.clone()); + + database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; + let restore_point = database_db + .create_restore_point(depot::types::SnapshotSelector::Latest) + .await?; + let branch_id = database_branch_id(&db, source_bucket, &database_id).await?; + let row = commit_row(&db, branch_id, 1).await?; + + let resolved = database_db + .resolve_restore_point(restore_point.clone()) + .await?; + + assert_eq!(resolved.versionstamp, row.versionstamp); + assert_eq!( + resolved.restore_point, + Some(RestorePointRef { + restore_point, + resolved_versionstamp: Some(row.versionstamp), + }) + ); - Ok(()) - })) + Ok(()) + }) + }) .await } #[tokio::test] async fn restore_point_is_ready_without_legacy_cold_handoff_or_crash_window() -> Result<()> { - common::test_matrix("depot-restore-point-ready", |_tier, ctx| Box::pin(async move { - let db = ctx.udb.clone(); - let source_bucket = ctx.bucket_id; - let database_id = ctx.database_id.clone(); - let database_db = ctx.make_db(source_bucket, database_id.clone()); - database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; - let branch_id = database_branch_id(&db, source_bucket, &database_id).await?; - let row = commit_row(&db, branch_id, 1).await?; - - db.run(move |tx| async move { - tx.informal().set( - &branch_meta_compact_key(branch_id), - &encode_meta_compact(depot::types::MetaCompact { - materialized_txid: 1, - })?, + common::test_matrix("depot-restore-point-ready", |_tier, ctx| { + Box::pin(async move { + let db = ctx.udb.clone(); + let source_bucket = ctx.bucket_id; + let database_id = ctx.database_id.clone(); + let database_db = ctx.make_db(source_bucket, database_id.clone()); + database_db.commit(vec![page(1, 0x11)], 2, 1_000).await?; + let branch_id = database_branch_id(&db, source_bucket, &database_id).await?; + let row = commit_row(&db, branch_id, 1).await?; + + db.run(move |tx| async move { + tx.informal().set( + &branch_meta_compact_key(branch_id), + &encode_meta_compact(depot::types::MetaCompact { + materialized_txid: 1, + })?, + ); + tx.informal().set( + &branch_manifest_last_hot_pass_txid_key(branch_id), + &1u64.to_be_bytes(), + ); + tx.informal() + .set(&branch_shard_key(branch_id, 0, 1), b"image-one"); + Ok(()) + }) + .await?; + + let restore_point = database_db + .create_restore_point(depot::types::SnapshotSelector::Latest) + .await?; + assert_eq!( + database_db + .restore_point_status(restore_point.clone()) + .await?, + Some(PinStatus::Ready) ); - tx.informal() - .set(&branch_manifest_last_hot_pass_txid_key(branch_id), &1u64.to_be_bytes()); - tx.informal() - .set(&branch_shard_key(branch_id, 0, 1), b"image-one"); - Ok(()) - }) - .await?; - let restore_point = database_db.create_restore_point(depot::types::SnapshotSelector::Latest).await?; - assert_eq!(database_db.restore_point_status(restore_point.clone()).await?, Some(PinStatus::Ready)); + let pinned_bytes = common::read_value( + &db, + depot::keys::restore_point_key(&database_id, restore_point.as_str()), + ) + .await? + .context("restore point record should exist")?; + let pinned = decode_restore_point_record(&pinned_bytes)?; + assert_eq!(pinned.status, PinStatus::Ready); + assert_eq!( + database_db.restore_point_status(restore_point).await?, + Some(PinStatus::Ready) + ); + assert!(pinned.pin_object_key.is_none()); + let db_pin_bytes = common::read_value( + &db, + db_pin_key( + branch_id, + &history_pin::restore_point_pin_id(&pinned.restore_point_id), + ), + ) + .await? + .context("restore point DB_PIN should exist")?; + let db_pin = decode_db_history_pin(&db_pin_bytes)?; + assert_eq!(db_pin.kind, DbHistoryPinKind::RestorePoint); + assert_eq!(db_pin.at_txid, 1); + assert_eq!(db_pin.at_versionstamp, row.versionstamp); + assert_eq!(db_pin.owner_restore_point, Some(pinned.restore_point_id)); - let pinned_bytes = common::read_value( - &db, - depot::keys::restore_point_key(&database_id, restore_point.as_str()), - ) - .await? - .context("restore point record should exist")?; - let pinned = decode_restore_point_record(&pinned_bytes)?; - assert_eq!(pinned.status, PinStatus::Ready); - assert_eq!(database_db.restore_point_status(restore_point).await?, Some(PinStatus::Ready)); - assert!(pinned.pin_object_key.is_none()); - let db_pin_bytes = common::read_value( - &db, - db_pin_key(branch_id, &history_pin::restore_point_pin_id(&pinned.restore_point_id)), - ) - .await? - .context("restore point DB_PIN should exist")?; - let db_pin = decode_db_history_pin(&db_pin_bytes)?; - assert_eq!(db_pin.kind, DbHistoryPinKind::RestorePoint); - assert_eq!(db_pin.at_txid, 1); - assert_eq!(db_pin.at_versionstamp, row.versionstamp); - assert_eq!(db_pin.owner_restore_point, Some(pinned.restore_point_id)); - - Ok(()) - })) + Ok(()) + }) + }) .await } #[tokio::test] async fn parent_bucket_restore_point_resolves_from_forked_bucket() -> Result<()> { - common::test_matrix("depot-restore-point-forked-bucket", |_tier, ctx| Box::pin(async move { - let db = ctx.udb.clone(); - let source_bucket = ctx.bucket_id; - let database_id = ctx.database_id.clone(); - let source = ctx.make_db(source_bucket, database_id.clone()); - source.commit(vec![page(1, 0x11)], 2, 1_000).await?; - let restore_point = source.create_restore_point(depot::types::SnapshotSelector::Latest).await?; - let source_branch = database_branch_id(&db, source_bucket, &database_id).await?; - let fork_point = commit_row(&db, source_branch, 1).await?; - let forked_bucket = branch::fork_bucket( - &db, - BucketId::from_gas_id(source_bucket), - depot::types::ResolvedVersionstamp { - versionstamp: fork_point.versionstamp, - restore_point: None, - }) - .await?; - - let resolved = - restore_point::resolve_restore_point(&db, forked_bucket, database_id.clone(), restore_point).await?; - - assert_eq!(resolved.versionstamp, fork_point.versionstamp); - - source.commit(vec![page(2, 0x22)], 3, 2_000).await?; - let post_fork_restore_point = source.create_restore_point(depot::types::SnapshotSelector::Latest).await?; - let err = restore_point::resolve_restore_point( - &db, - forked_bucket, - database_id, - post_fork_restore_point, - ) - .await - .expect_err("post-fork source restore_point should not be visible in forked bucket"); - assert_storage_error(err, SqliteStorageError::BranchNotReachable); - - Ok(()) - })) + common::test_matrix("depot-restore-point-forked-bucket", |_tier, ctx| { + Box::pin(async move { + let db = ctx.udb.clone(); + let source_bucket = ctx.bucket_id; + let database_id = ctx.database_id.clone(); + let source = ctx.make_db(source_bucket, database_id.clone()); + source.commit(vec![page(1, 0x11)], 2, 1_000).await?; + let restore_point = source + .create_restore_point(depot::types::SnapshotSelector::Latest) + .await?; + let source_branch = database_branch_id(&db, source_bucket, &database_id).await?; + let fork_point = commit_row(&db, source_branch, 1).await?; + let forked_bucket = branch::fork_bucket( + &db, + BucketId::from_gas_id(source_bucket), + depot::types::ResolvedVersionstamp { + versionstamp: fork_point.versionstamp, + restore_point: None, + }, + ) + .await?; + + let resolved = restore_point::resolve_restore_point( + &db, + forked_bucket, + database_id.clone(), + restore_point, + ) + .await?; + + assert_eq!(resolved.versionstamp, fork_point.versionstamp); + + source.commit(vec![page(2, 0x22)], 3, 2_000).await?; + let post_fork_restore_point = source + .create_restore_point(depot::types::SnapshotSelector::Latest) + .await?; + let err = restore_point::resolve_restore_point( + &db, + forked_bucket, + database_id, + post_fork_restore_point, + ) + .await + .expect_err("post-fork source restore_point should not be visible in forked bucket"); + assert_storage_error(err, SqliteStorageError::BranchNotReachable); + + Ok(()) + }) + }) .await } #[tokio::test] async fn restore_point_survives_hot_commit_and_vtx_reclaim() -> Result<()> { - common::test_matrix("depot-restore-point-vtx-reclaim", |_tier, ctx| Box::pin(async move { - let db = ctx.udb.clone(); - let database_id = ctx.database_id.clone(); - let database_db = ctx.make_db(nil_bucket(), database_id.clone()); - let current_ms = now_ms(); - let old_ms = current_ms - HOT_RETENTION_FLOOR_MS - 1_000; - let recent_ms = current_ms - 1_000; - - database_db.commit(vec![page(1, 0x11)], 2, old_ms).await?; - let old_restore_point = database_db - .create_restore_point(depot::types::SnapshotSelector::Latest) + common::test_matrix("depot-restore-point-vtx-reclaim", |_tier, ctx| { + Box::pin(async move { + let db = ctx.udb.clone(); + let database_id = ctx.database_id.clone(); + let database_db = ctx.make_db(nil_bucket(), database_id.clone()); + let current_ms = now_ms(); + let old_ms = current_ms - HOT_RETENTION_FLOOR_MS - 1_000; + let recent_ms = current_ms - 1_000; + + database_db.commit(vec![page(1, 0x11)], 2, old_ms).await?; + let old_restore_point = database_db + .create_restore_point(depot::types::SnapshotSelector::Latest) + .await?; + database_db + .commit(vec![page(2, 0x22)], 3, recent_ms) + .await?; + let branch_id = database_branch_id(&db, nil_bucket(), &database_id).await?; + let old_row = commit_row(&db, branch_id, 1).await?; + + db.run(move |tx| async move { + tx.informal().clear(&branch_commit_key(branch_id, 1)); + tx.informal() + .clear(&branch_vtx_key(branch_id, old_row.versionstamp)); + Ok(()) + }) .await?; - database_db.commit(vec![page(2, 0x22)], 3, recent_ms).await?; - let branch_id = database_branch_id(&db, nil_bucket(), &database_id).await?; - let old_row = commit_row(&db, branch_id, 1).await?; - db.run(move |tx| async move { - tx.informal().clear(&branch_commit_key(branch_id, 1)); - tx.informal().clear(&branch_vtx_key(branch_id, old_row.versionstamp)); + assert!( + common::read_value(&db, branch_commit_key(branch_id, 1)) + .await? + .is_none() + ); + assert!( + common::read_value(&db, branch_vtx_key(branch_id, old_row.versionstamp)) + .await? + .is_none() + ); + + let resolved = database_db.resolve_restore_point(old_restore_point).await?; + assert_eq!(resolved.versionstamp, old_row.versionstamp); + Ok(()) }) - .await?; - - assert!( - common::read_value(&db, branch_commit_key(branch_id, 1)) - .await? - .is_none() - ); - assert!( - common::read_value(&db, branch_vtx_key(branch_id, old_row.versionstamp)) - .await? - .is_none() - ); - - let resolved = database_db.resolve_restore_point(old_restore_point).await?; - assert_eq!(resolved.versionstamp, old_row.versionstamp); - - Ok(()) - })) + }) .await } #[tokio::test] async fn unrelated_database_restore_point_returns_branch_not_reachable() -> Result<()> { - common::test_matrix("depot-restore-point-unrelated", |_tier, ctx| Box::pin(async move { - let db = ctx.udb.clone(); - let source_bucket = ctx.bucket_id; - let database_id = ctx.database_id.clone(); - let source = ctx.make_db(source_bucket, database_id); - source.commit(vec![page(1, 0x11)], 2, 1_000).await?; - let restore_point = source.create_restore_point(depot::types::SnapshotSelector::Latest).await?; - let other = ctx.make_db(other_bucket(), OTHER_ACTOR); - other.commit(vec![page(1, 0x22)], 2, 1_000).await?; - - let err = restore_point::resolve_restore_point( - &db, - BucketId::from_gas_id(source_bucket), - OTHER_ACTOR.to_string(), - restore_point, - ) - .await - .expect_err("database from another bucket should not be reachable here"); - - assert_storage_error(err, SqliteStorageError::BranchNotReachable); - - Ok(()) - })) + common::test_matrix("depot-restore-point-unrelated", |_tier, ctx| { + Box::pin(async move { + let db = ctx.udb.clone(); + let source_bucket = ctx.bucket_id; + let database_id = ctx.database_id.clone(); + let source = ctx.make_db(source_bucket, database_id); + source.commit(vec![page(1, 0x11)], 2, 1_000).await?; + let restore_point = source + .create_restore_point(depot::types::SnapshotSelector::Latest) + .await?; + let other = ctx.make_db(other_bucket(), OTHER_ACTOR); + other.commit(vec![page(1, 0x22)], 2, 1_000).await?; + + let err = restore_point::resolve_restore_point( + &db, + BucketId::from_gas_id(source_bucket), + OTHER_ACTOR.to_string(), + restore_point, + ) + .await + .expect_err("database from another bucket should not be reachable here"); + + assert_storage_error(err, SqliteStorageError::BranchNotReachable); + + Ok(()) + }) + }) .await } diff --git a/engine/packages/depot/tests/takeover.rs b/engine/packages/depot/tests/takeover.rs index 35d307ece8..95107ebfc9 100644 --- a/engine/packages/depot/tests/takeover.rs +++ b/engine/packages/depot/tests/takeover.rs @@ -7,7 +7,7 @@ use depot::{ conveyer::Db, keys::{delta_chunk_key, meta_head_key, pidx_delta_key, shard_key}, takeover, - types::{DatabaseBranchId, DBHead, encode_db_head}, + types::{DBHead, DatabaseBranchId, encode_db_head}, }; use gas::prelude::Id; use rivet_pools::NodeId; @@ -38,24 +38,29 @@ async fn seed(db: &universaldb::Database, writes: Vec<(Vec, Vec)>) -> Re #[tokio::test] async fn legacy_database_scoped_rows_are_ignored() -> Result<()> { - common::test_matrix("depot-takeover-legacy-ignored", |_tier, ctx| Box::pin(async move { - let db = ctx.udb.clone(); - let database_id = ctx.database_id.clone(); - seed( - &db, - vec![ - (meta_head_key(&database_id), encode_db_head(head(1, 1))?), - (delta_chunk_key(&database_id, 99, 0), b"delta".to_vec()), - (pidx_delta_key(&database_id, 99), 99_u64.to_be_bytes().to_vec()), - (shard_key(&database_id, 99), b"shard".to_vec()), - ], - ) - .await?; + common::test_matrix("depot-takeover-legacy-ignored", |_tier, ctx| { + Box::pin(async move { + let db = ctx.udb.clone(); + let database_id = ctx.database_id.clone(); + seed( + &db, + vec![ + (meta_head_key(&database_id), encode_db_head(head(1, 1))?), + (delta_chunk_key(&database_id, 99, 0), b"delta".to_vec()), + ( + pidx_delta_key(&database_id, 99), + 99_u64.to_be_bytes().to_vec(), + ), + (shard_key(&database_id, 99), b"shard".to_vec()), + ], + ) + .await?; - takeover::reconcile(&db, &database_id).await?; + takeover::reconcile(&db, &database_id).await?; - Ok(()) - })) + Ok(()) + }) + }) .await } diff --git a/engine/packages/depot/tests/workflow_compaction_skeletons.rs b/engine/packages/depot/tests/workflow_compaction_skeletons.rs index 99b77b7872..994ed00cd9 100644 --- a/engine/packages/depot/tests/workflow_compaction_skeletons.rs +++ b/engine/packages/depot/tests/workflow_compaction_skeletons.rs @@ -1,45 +1,34 @@ -use std::{ - cell::RefCell, - future::Future, - path::Path, - rc::Rc, - sync::Arc, - time::Duration, -}; +use std::{cell::RefCell, future::Future, path::Path, rc::Rc, sync::Arc, time::Duration}; use anyhow::{Context, Result, bail}; -use futures_util::{StreamExt, TryStreamExt}; -use rivet_pools::NodeId; use depot::{ cold_tier::{ColdTier, FilesystemColdTier, cold_tier_from_config}, - debug, conveyer::{Db, branch, history_pin, metrics}, + debug, error::SqliteStorageError, keys::{ PAGE_SIZE, branch_commit_key, branch_compaction_cold_shard_key, - branch_compaction_cold_shard_prefix, - branch_compaction_retired_cold_object_key, branch_compaction_root_key, - branch_compaction_stage_hot_shard_key, branch_compaction_stage_hot_shard_prefix, - branch_delta_chunk_key, branch_manifest_last_access_bucket_key, - branch_meta_head_at_fork_key, branch_meta_head_key, branch_pidx_key, - branch_pitr_interval_key, branch_shard_key, branch_shard_prefix, branch_vtx_key, - branches_list_key, db_pin_key, bucket_child_key, bucket_fork_pin_key, - bucket_catalog_by_db_key, sqlite_cmp_dirty_key, + branch_compaction_cold_shard_prefix, branch_compaction_retired_cold_object_key, + branch_compaction_root_key, branch_compaction_stage_hot_shard_key, + branch_compaction_stage_hot_shard_prefix, branch_delta_chunk_key, + branch_manifest_last_access_bucket_key, branch_meta_head_at_fork_key, branch_meta_head_key, + branch_pidx_key, branch_pitr_interval_key, branch_shard_key, branch_shard_prefix, + branch_vtx_key, branches_list_key, bucket_catalog_by_db_key, bucket_child_key, + bucket_fork_pin_key, db_pin_key, sqlite_cmp_dirty_key, }, ltx::{LtxHeader, decode_ltx_v3, encode_ltx_v3}, policy::{set_bucket_pitr_policy, set_database_pitr_policy_override}, types::{ - RestorePointId, BranchState, ColdShardRef, CommitRow, CompactionRoot, DBHead, - DatabaseBranchId, DatabaseBranchRecord, DbHistoryPinKind, DirtyPage, FetchedPage, - BucketBranchId, BucketCatalogDbFact, BucketForkFact, BucketId, PitrIntervalCoverage, - PitrPolicy, RetiredColdObject, RetiredColdObjectDeleteState, SqliteCmpDirty, - decode_cold_shard_ref, - decode_commit_row, decode_compaction_root, decode_db_head, decode_db_history_pin, - decode_pitr_interval_coverage, decode_retired_cold_object, encode_cold_shard_ref, - encode_commit_row, encode_compaction_root, encode_database_branch_record, - encode_db_head, encode_bucket_catalog_db_fact, encode_pitr_interval_coverage, - encode_retired_cold_object, encode_bucket_fork_fact, encode_sqlite_cmp_dirty, - ResolvedVersionstamp, SnapshotKind, SnapshotSelector, + BranchState, BucketBranchId, BucketCatalogDbFact, BucketForkFact, BucketId, ColdShardRef, + CommitRow, CompactionRoot, DBHead, DatabaseBranchId, DatabaseBranchRecord, + DbHistoryPinKind, DirtyPage, FetchedPage, PitrIntervalCoverage, PitrPolicy, + ResolvedVersionstamp, RestorePointId, RetiredColdObject, RetiredColdObjectDeleteState, + SnapshotKind, SnapshotSelector, SqliteCmpDirty, decode_cold_shard_ref, decode_commit_row, + decode_compaction_root, decode_db_head, decode_db_history_pin, + decode_pitr_interval_coverage, decode_retired_cold_object, encode_bucket_catalog_db_fact, + encode_bucket_fork_fact, encode_cold_shard_ref, encode_commit_row, encode_compaction_root, + encode_database_branch_record, encode_db_head, encode_pitr_interval_coverage, + encode_retired_cold_object, encode_sqlite_cmp_dirty, }, workflows::compaction::{ BranchStopState, ColdJobFinished, CompactionJobKind, CompactionJobStatus, @@ -51,11 +40,13 @@ use depot::{ RunHotJob, RunReclaimJob, TxidRange, database_branch_tag_value, test_hooks, }, }; +use futures_util::{StreamExt, TryStreamExt}; use gas::db::{ BumpSubSubject, Database, DatabaseKv, debug::{DatabaseDebug, WorkflowState}, }; use gas::prelude::{Id, Registry, SignalTrait, TestCtx, WorkflowTrait}; +use rivet_pools::NodeId; use rivet_test_deps::TestDeps; use sha2::{Digest, Sha256}; use tempfile::Builder; @@ -135,7 +126,8 @@ fn make_test_db_for(test_ctx: &TestCtx, database_id: impl Into) -> Resul udb, test_bucket(), database_id.into(), - NodeId::new())) + NodeId::new(), + )) } fn make_test_db_with_cold_tier(test_ctx: &TestCtx, cold_tier: Arc) -> Result { @@ -146,7 +138,8 @@ fn make_test_db_with_cold_tier(test_ctx: &TestCtx, cold_tier: Arc) test_bucket(), TEST_DATABASE.to_string(), NodeId::new(), - cold_tier)) + cold_tier, + )) } async fn test_ctx_with_configured_cold_tier(root: &Path) -> Result { @@ -160,13 +153,11 @@ async fn test_ctx_with_configured_cold_tier_and_registry( let mut test_deps = TestDeps::new().await?; let mut config_root = (**test_deps.config()).clone(); config_root.sqlite = Some(rivet_config::config::Sqlite { - workflow_cold_storage: Some( - rivet_config::config::SqliteWorkflowColdStorage::FileSystem( - rivet_config::config::SqliteWorkflowColdStorageFileSystem { - root: root.display().to_string(), - }, - ), - ), + workflow_cold_storage: Some(rivet_config::config::SqliteWorkflowColdStorage::FileSystem( + rivet_config::config::SqliteWorkflowColdStorageFileSystem { + root: root.display().to_string(), + }, + )), }); test_deps.config = rivet_config::Config::from_root(config_root); TestCtx::new_with_deps(registry, test_deps).await @@ -208,7 +199,9 @@ fn dirty_page(pgno: u32, fill: u8) -> DirtyPage { fn build_registry() -> Registry { let mut registry = Registry::new(); registry.register_workflow::().unwrap(); - registry.register_workflow::().unwrap(); + registry + .register_workflow::() + .unwrap(); registry .register_workflow::() .unwrap(); @@ -229,15 +222,14 @@ fn build_registry_without_hot_compacter() -> Registry { fn build_registry_without_cold_compacter() -> Registry { let mut registry = Registry::new(); registry.register_workflow::().unwrap(); - registry.register_workflow::().unwrap(); + registry + .register_workflow::() + .unwrap(); registry.register_workflow::().unwrap(); registry } -async fn wait_until( - description: impl Into, - mut check: F, -) -> Result +async fn wait_until(description: impl Into, mut check: F) -> Result where F: FnMut() -> Fut, Fut: Future>>, @@ -384,7 +376,9 @@ async fn single_destroy_signal_for_workflow( .await?; assert_eq!(signals.len(), 1); - Ok(serde_json::from_value(signals.into_iter().next().unwrap().body)?) + Ok(serde_json::from_value( + signals.into_iter().next().unwrap().body, + )?) } async fn wait_for_reclaim_job_finished_signal( @@ -498,9 +492,10 @@ async fn wait_for_manager_state( wait_until("manager state", || { let predicate = predicate.clone(); async move { - let history = DatabaseDebug::get_workflow_history(test_ctx.debug_db(), workflow_id, true) - .await? - .ok_or_else(|| anyhow::anyhow!("manager workflow history not found"))?; + let history = + DatabaseDebug::get_workflow_history(test_ctx.debug_db(), workflow_id, true) + .await? + .ok_or_else(|| anyhow::anyhow!("manager workflow history not found"))?; for event in history.events.into_iter().rev() { if let gas::db::debug::EventData::Loop(loop_event) = event.data { @@ -566,7 +561,12 @@ async fn force_compaction_and_wait_idle( request_id: Id, requested_work: ForceCompactionWork, ) -> Result { - wait_for_manager_state(test_ctx, manager_workflow_id, manager_has_distinct_companions).await?; + wait_for_manager_state( + test_ctx, + manager_workflow_id, + manager_has_distinct_companions, + ) + .await?; let signal_id = test_ctx .signal(ForceCompaction { @@ -662,7 +662,11 @@ async fn wait_for_hot_install( .map(decode_compaction_root) .transpose()?; let pidx = read_value(test_ctx, branch_pidx_key(database_branch_id, 1)).await?; - let shard = read_value(test_ctx, branch_shard_key(database_branch_id, 0, as_of_txid)).await?; + let shard = read_value( + test_ctx, + branch_shard_key(database_branch_id, 0, as_of_txid), + ) + .await?; if let Some(root) = root { if root.manifest_generation == 1 @@ -685,7 +689,11 @@ async fn wait_for_reclaim_delete( txid: u64, ) -> Result<()> { wait_until("reclaim delete", || async { - let delta = read_value(test_ctx, branch_delta_chunk_key(database_branch_id, txid, 0)).await?; + let delta = read_value( + test_ctx, + branch_delta_chunk_key(database_branch_id, txid, 0), + ) + .await?; let commit = read_value(test_ctx, branch_commit_key(database_branch_id, txid)).await?; if delta.is_none() && commit.is_none() { return Ok(Some(())); @@ -856,9 +864,11 @@ async fn read_pitr_interval_txid( database_branch_id: DatabaseBranchId, bucket_start_ms: i64, ) -> Result> { - Ok(read_pitr_interval_coverage(test_ctx, database_branch_id, bucket_start_ms) - .await? - .map(|coverage| coverage.txid)) + Ok( + read_pitr_interval_coverage(test_ctx, database_branch_id, bucket_start_ms) + .await? + .map(|coverage| coverage.txid), + ) } async fn read_bucket_branch_id(test_ctx: &TestCtx) -> Result { @@ -875,14 +885,16 @@ async fn read_bucket_branch_id(test_ctx: &TestCtx) -> Result { .await } -async fn read_prefix_values(test_ctx: &TestCtx, prefix: Vec) -> Result, Vec)>> { +async fn read_prefix_values( + test_ctx: &TestCtx, + prefix: Vec, +) -> Result, Vec)>> { let db = test_ctx.pools().udb()?; db.run(move |tx| { let prefix = prefix.clone(); async move { - let prefix_subspace = universaldb::Subspace::from( - universaldb::tuple::Subspace::from_bytes(prefix), - ); + let prefix_subspace = + universaldb::Subspace::from(universaldb::tuple::Subspace::from_bytes(prefix)); let rows = tx .informal() .get_ranges_keyvalues( @@ -912,9 +924,8 @@ async fn seed_manager_branch( dirty: Option, ) -> Result<()> { let db = test_ctx.pools().udb()?; - let bucket_branch = BucketBranchId::from_uuid(Uuid::from_u128( - 0x9999_8888_7777_6666_5555_4444_3333_2222, - )); + let bucket_branch = + BucketBranchId::from_uuid(Uuid::from_u128(0x9999_8888_7777_6666_5555_4444_3333_2222)); db.run(move |tx| { let root = root.clone(); let dirty = dirty.clone(); @@ -958,8 +969,10 @@ async fn seed_manager_branch( post_apply_checksum: txid, })?, ); - tx.informal() - .set(&branch_vtx_key(database_branch_id, versionstamp), &txid.to_be_bytes()); + tx.informal().set( + &branch_vtx_key(database_branch_id, versionstamp), + &txid.to_be_bytes(), + ); let delta_blob = encode_ltx_v3( LtxHeader::delta(txid, 1, 1_000 + i64::try_from(txid).unwrap_or(i64::MAX)), &[DirtyPage { @@ -967,8 +980,10 @@ async fn seed_manager_branch( bytes: vec![txid as u8; PAGE_SIZE as usize], }], )?; - tx.informal() - .set(&branch_delta_chunk_key(database_branch_id, txid, 0), &delta_blob); + tx.informal().set( + &branch_delta_chunk_key(database_branch_id, txid, 0), + &delta_blob, + ); } tx.informal().set( &branch_pidx_key(database_branch_id, 1), @@ -1033,7 +1048,8 @@ async fn seed_restore_point_db_pin( database_branch_id: DatabaseBranchId, at_txid: u64, ) -> Result { - let restore_point = RestorePointId::format(1_000 + i64::try_from(at_txid).unwrap_or(i64::MAX), at_txid)?; + let restore_point = + RestorePointId::format(1_000 + i64::try_from(at_txid).unwrap_or(i64::MAX), at_txid)?; let db = test_ctx.pools().udb()?; db.run({ let restore_point = restore_point.clone(); @@ -1105,8 +1121,10 @@ async fn publish_test_shard_and_clear_pidx( bytes: vec![as_of_txid as u8; PAGE_SIZE as usize], }], )?; - tx.informal() - .set(&branch_shard_key(database_branch_id, 0, as_of_txid), &shard_blob); + tx.informal().set( + &branch_shard_key(database_branch_id, 0, as_of_txid), + &shard_blob, + ); tx.informal().clear(&branch_pidx_key(database_branch_id, 1)); Ok(()) }) @@ -1225,8 +1243,10 @@ async fn seed_workflow_cold_ref( &branch_compaction_cold_shard_key(database_branch_id, shard_id, as_of_txid), &encode_cold_shard_ref(cold_ref)?, ); - tx.informal() - .set(&branch_shard_key(database_branch_id, shard_id, as_of_txid), &bytes); + tx.informal().set( + &branch_shard_key(database_branch_id, shard_id, as_of_txid), + &bytes, + ); Ok(()) } } @@ -1298,1946 +1318,2220 @@ fn compaction_workflow_names_are_stable() { ::NAME, "db_cold_compacter" ); - assert_eq!( - ::NAME, - "db_reclaimer" - ); + assert_eq!(::NAME, "db_reclaimer"); } #[tokio::test] async fn manager_spawns_companions_and_records_deltas_available() -> Result<()> { let database_branch_id = database_branch_id(0x0011_2233_4455_6677_8899_aabb_ccdd_eeff); - workflow_matrix!("workflow-manager-spawns-companions-and-records-deltas-available", build_registry, |_tier, test_ctx| { - let tag_value = database_branch_tag_value(database_branch_id); - seed_manager_branch(&test_ctx, database_branch_id, 0, None, None).await?; - - let manager_workflow_id = test_ctx - .workflow(DbManagerInput::new(database_branch_id, None)) - .tag(DATABASE_BRANCH_ID_TAG, &tag_value) - .unique() - .dispatch() - .await?; - - let hot_workflow_id = - wait_for_workflow::(&test_ctx, database_branch_id).await?; - let cold_workflow_id = - wait_for_workflow::(&test_ctx, database_branch_id).await?; - let reclaimer_workflow_id = - wait_for_workflow::(&test_ctx, database_branch_id).await?; + workflow_matrix!( + "workflow-manager-spawns-companions-and-records-deltas-available", + build_registry, + |_tier, test_ctx| { + let tag_value = database_branch_tag_value(database_branch_id); + seed_manager_branch(&test_ctx, database_branch_id, 0, None, None).await?; + + let manager_workflow_id = test_ctx + .workflow(DbManagerInput::new(database_branch_id, None)) + .tag(DATABASE_BRANCH_ID_TAG, &tag_value) + .unique() + .dispatch() + .await?; - let signal_id = test_ctx - .signal(DeltasAvailable { - database_branch_id, - observed_head_txid: 123, - dirty_updated_at_ms: 1_714_000_000_000, - }) - .to_workflow_id(manager_workflow_id) - .send() - .await? - .expect("signal should target manager workflow"); + let hot_workflow_id = + wait_for_workflow::(&test_ctx, database_branch_id).await?; + let cold_workflow_id = + wait_for_workflow::(&test_ctx, database_branch_id).await?; + let reclaimer_workflow_id = + wait_for_workflow::(&test_ctx, database_branch_id).await?; - wait_for_signal_ack(&test_ctx, signal_id).await?; - let manager_state = wait_for_manager_cursor(&test_ctx, manager_workflow_id, 123).await?; + let signal_id = test_ctx + .signal(DeltasAvailable { + database_branch_id, + observed_head_txid: 123, + dirty_updated_at_ms: 1_714_000_000_000, + }) + .to_workflow_id(manager_workflow_id) + .send() + .await? + .expect("signal should target manager workflow"); - assert_eq!( - manager_state.companion_workflow_ids.hot_compacter_workflow_id, - hot_workflow_id - ); - assert_eq!( - manager_state - .companion_workflow_ids - .cold_compacter_workflow_id, - cold_workflow_id - ); - assert_eq!( - manager_state.companion_workflow_ids.reclaimer_workflow_id, - reclaimer_workflow_id - ); - assert_ne!(hot_workflow_id, cold_workflow_id); - assert_ne!(hot_workflow_id, reclaimer_workflow_id); - assert_ne!(cold_workflow_id, reclaimer_workflow_id); - assert!(manager_state.active_jobs.hot.is_none()); - assert!(manager_state.active_jobs.cold.is_none()); - assert!(manager_state.active_jobs.reclaim.is_none()); + wait_for_signal_ack(&test_ctx, signal_id).await?; + let manager_state = + wait_for_manager_cursor(&test_ctx, manager_workflow_id, 123).await?; - let manager_workflow = - DatabaseDebug::get_workflows(test_ctx.debug_db(), vec![manager_workflow_id]) - .await? - .into_iter() - .next() - .expect("manager workflow should exist"); - assert_eq!( - manager_workflow.tags, - serde_json::json!({ DATABASE_BRANCH_ID_TAG: tag_value }) - ); + assert_eq!( + manager_state + .companion_workflow_ids + .hot_compacter_workflow_id, + hot_workflow_id + ); + assert_eq!( + manager_state + .companion_workflow_ids + .cold_compacter_workflow_id, + cold_workflow_id + ); + assert_eq!( + manager_state.companion_workflow_ids.reclaimer_workflow_id, + reclaimer_workflow_id + ); + assert_ne!(hot_workflow_id, cold_workflow_id); + assert_ne!(hot_workflow_id, reclaimer_workflow_id); + assert_ne!(cold_workflow_id, reclaimer_workflow_id); + assert!(manager_state.active_jobs.hot.is_none()); + assert!(manager_state.active_jobs.cold.is_none()); + assert!(manager_state.active_jobs.reclaim.is_none()); + + let manager_workflow = + DatabaseDebug::get_workflows(test_ctx.debug_db(), vec![manager_workflow_id]) + .await? + .into_iter() + .next() + .expect("manager workflow should exist"); + assert_eq!( + manager_workflow.tags, + serde_json::json!({ DATABASE_BRANCH_ID_TAG: tag_value }) + ); - assert_eq!( - ::NAME, - "depot_sqlite_cmp_deltas_available" - ); + assert_eq!( + ::NAME, + "depot_sqlite_cmp_deltas_available" + ); - test_ctx.shutdown().await?; - Ok(()) - }) + test_ctx.shutdown().await?; + Ok(()) + } + ) } #[tokio::test] async fn manager_ignores_unrelated_branch_signals_without_mutating_state() -> Result<()> { let primary_branch_id = database_branch_id(0x0012_2233_4455_6677_8899_aabb_ccdd_eeff); let unrelated_branch_id = database_branch_id(0x0013_2233_4455_6677_8899_aabb_ccdd_eeff); - workflow_matrix!("workflow-manager-ignores-unrelated-branch-signals-without-mutating-state", build_registry, |_tier, test_ctx| { - let tag_value = database_branch_tag_value(primary_branch_id); - seed_manager_branch(&test_ctx, primary_branch_id, 0, None, None).await?; - - let manager_workflow_id = test_ctx - .workflow(DbManagerInput::new(primary_branch_id, None)) - .tag(DATABASE_BRANCH_ID_TAG, &tag_value) - .unique() - .dispatch() - .await?; - wait_for_manager_state(&test_ctx, manager_workflow_id, manager_has_distinct_companions).await?; + workflow_matrix!( + "workflow-manager-ignores-unrelated-branch-signals-without-mutating-state", + build_registry, + |_tier, test_ctx| { + let tag_value = database_branch_tag_value(primary_branch_id); + seed_manager_branch(&test_ctx, primary_branch_id, 0, None, None).await?; + + let manager_workflow_id = test_ctx + .workflow(DbManagerInput::new(primary_branch_id, None)) + .tag(DATABASE_BRANCH_ID_TAG, &tag_value) + .unique() + .dispatch() + .await?; + wait_for_manager_state( + &test_ctx, + manager_workflow_id, + manager_has_distinct_companions, + ) + .await?; - let signal = DeltasAvailable { - database_branch_id: unrelated_branch_id, - observed_head_txid: 999, - dirty_updated_at_ms: 1_714_000_000_000, - }; - assert_eq!( - DbManagerSignal::DeltasAvailable(signal.clone()).database_branch_id(), - unrelated_branch_id - ); - let signal_id = test_ctx - .signal(signal) - .to_workflow_id(manager_workflow_id) - .send() - .await? - .expect("signal should target manager workflow"); - wait_for_signal_ack(&test_ctx, signal_id).await?; + let signal = DeltasAvailable { + database_branch_id: unrelated_branch_id, + observed_head_txid: 999, + dirty_updated_at_ms: 1_714_000_000_000, + }; + assert_eq!( + DbManagerSignal::DeltasAvailable(signal.clone()).database_branch_id(), + unrelated_branch_id + ); + let signal_id = test_ctx + .signal(signal) + .to_workflow_id(manager_workflow_id) + .send() + .await? + .expect("signal should target manager workflow"); + wait_for_signal_ack(&test_ctx, signal_id).await?; - let manager_state = latest_manager_state(&test_ctx, manager_workflow_id).await?; - assert!(manager_state.last_dirty_cursor.is_none()); - assert!(manager_state.force_compactions.pending_requests.is_empty()); - assert!(manager_state.force_compactions.recent_results.is_empty()); - assert_eq!(manager_state.branch_stop_state, BranchStopState::Running); + let manager_state = latest_manager_state(&test_ctx, manager_workflow_id).await?; + assert!(manager_state.last_dirty_cursor.is_none()); + assert!(manager_state.force_compactions.pending_requests.is_empty()); + assert!(manager_state.force_compactions.recent_results.is_empty()); + assert_eq!(manager_state.branch_stop_state, BranchStopState::Running); - test_ctx.shutdown().await?; - Ok(()) - }) + test_ctx.shutdown().await?; + Ok(()) + } + ) } #[tokio::test] async fn companion_ignores_unrelated_branch_signals_without_mutating_state() -> Result<()> { let primary_branch_id = database_branch_id(0x0014_2233_4455_6677_8899_aabb_ccdd_eeff); let unrelated_branch_id = database_branch_id(0x0015_2233_4455_6677_8899_aabb_ccdd_eeff); - workflow_matrix!("workflow-companion-ignores-unrelated-branch-signals-without-mutating-state", build_registry, |_tier, test_ctx| { - let tag_value = database_branch_tag_value(primary_branch_id); - seed_manager_branch(&test_ctx, primary_branch_id, 0, None, None).await?; - - let _manager_workflow_id = test_ctx - .workflow(DbManagerInput::new(primary_branch_id, None)) - .tag(DATABASE_BRANCH_ID_TAG, &tag_value) - .unique() - .dispatch() - .await?; - let hot_workflow_id = - wait_for_workflow::(&test_ctx, primary_branch_id).await?; - - let signal = RunHotJob { - database_branch_id: unrelated_branch_id, - job_id: Id::new_v1(70), - job_kind: CompactionJobKind::Hot, - base_lifecycle_generation: 0, - base_manifest_generation: 0, - input_fingerprint: [0; 32], - status: CompactionJobStatus::Requested, - input_range: HotJobInputRange { - txids: TxidRange { min_txid: 1, max_txid: 1 }, - coverage_txids: vec![1], - max_pages: 1, - max_bytes: 1, - }, - }; - assert_eq!( - DbHotCompacterSignal::RunHotJob(signal.clone()).database_branch_id(), - unrelated_branch_id - ); - let signal_id = test_ctx - .signal(signal) - .to_workflow_id(hot_workflow_id) - .send() - .await? - .expect("signal should target hot companion workflow"); - wait_for_signal_ack(&test_ctx, signal_id).await?; + workflow_matrix!( + "workflow-companion-ignores-unrelated-branch-signals-without-mutating-state", + build_registry, + |_tier, test_ctx| { + let tag_value = database_branch_tag_value(primary_branch_id); + seed_manager_branch(&test_ctx, primary_branch_id, 0, None, None).await?; + + let _manager_workflow_id = test_ctx + .workflow(DbManagerInput::new(primary_branch_id, None)) + .tag(DATABASE_BRANCH_ID_TAG, &tag_value) + .unique() + .dispatch() + .await?; + let hot_workflow_id = + wait_for_workflow::(&test_ctx, primary_branch_id).await?; + + let signal = RunHotJob { + database_branch_id: unrelated_branch_id, + job_id: Id::new_v1(70), + job_kind: CompactionJobKind::Hot, + base_lifecycle_generation: 0, + base_manifest_generation: 0, + input_fingerprint: [0; 32], + status: CompactionJobStatus::Requested, + input_range: HotJobInputRange { + txids: TxidRange { + min_txid: 1, + max_txid: 1, + }, + coverage_txids: vec![1], + max_pages: 1, + max_bytes: 1, + }, + }; + assert_eq!( + DbHotCompacterSignal::RunHotJob(signal.clone()).database_branch_id(), + unrelated_branch_id + ); + let signal_id = test_ctx + .signal(signal) + .to_workflow_id(hot_workflow_id) + .send() + .await? + .expect("signal should target hot companion workflow"); + wait_for_signal_ack(&test_ctx, signal_id).await?; - let companion_state = latest_companion_state(&test_ctx, hot_workflow_id).await?; - assert_eq!(companion_state, CompanionWorkflowState::Idle); - assert!( - read_prefix_values( - &test_ctx, - branch_compaction_stage_hot_shard_prefix(unrelated_branch_id, Id::new_v1(70)), - ) - .await? - .is_empty() - ); + let companion_state = latest_companion_state(&test_ctx, hot_workflow_id).await?; + assert_eq!(companion_state, CompanionWorkflowState::Idle); + assert!( + read_prefix_values( + &test_ctx, + branch_compaction_stage_hot_shard_prefix(unrelated_branch_id, Id::new_v1(70)), + ) + .await? + .is_empty() + ); - test_ctx.shutdown().await?; - Ok(()) - }) + test_ctx.shutdown().await?; + Ok(()) + } + ) } #[tokio::test] async fn companion_destroy_signal_stops_idle_hot_cold_and_reclaim() -> Result<()> { let database_branch_id = database_branch_id(0x0016_2233_4455_6677_8899_aabb_ccdd_eeff); - workflow_matrix!("workflow-companion-destroy-signal-stops-idle-hot-cold-and-reclaim", build_registry, |_tier, test_ctx| { - let tag_value = database_branch_tag_value(database_branch_id); - seed_manager_branch(&test_ctx, database_branch_id, 0, None, None).await?; - - let _manager_workflow_id = test_ctx - .workflow(DbManagerInput::new(database_branch_id, None)) - .tag(DATABASE_BRANCH_ID_TAG, &tag_value) - .unique() - .dispatch() - .await?; - let workflow_ids = [ - wait_for_workflow::(&test_ctx, database_branch_id).await?, - wait_for_workflow::(&test_ctx, database_branch_id).await?, - wait_for_workflow::(&test_ctx, database_branch_id).await?, - ]; - - for (index, workflow_id) in workflow_ids.into_iter().enumerate() { - let signal_id = test_ctx - .signal(DestroyDatabaseBranch { - database_branch_id, - lifecycle_generation: 7, - requested_at_ms: 1_714_000_000_000 + index as i64, - reason: format!("direct idle companion destroy {index}"), - }) - .to_workflow_id(workflow_id) - .send() - .await? - .expect("signal should target companion workflow"); - wait_for_signal_ack(&test_ctx, signal_id).await?; - wait_for_workflow_state(&test_ctx, workflow_id, WorkflowState::Complete).await?; - - let companion_state = latest_companion_state(&test_ctx, workflow_id).await?; - assert_eq!( - companion_state, - CompanionWorkflowState::Stopping { - active_job: None, - lifecycle_generation: 7, - requested_at_ms: 1_714_000_000_000 + index as i64, - reason: format!("direct idle companion destroy {index}"), + workflow_matrix!( + "workflow-companion-destroy-signal-stops-idle-hot-cold-and-reclaim", + build_registry, + |_tier, test_ctx| { + let tag_value = database_branch_tag_value(database_branch_id); + seed_manager_branch(&test_ctx, database_branch_id, 0, None, None).await?; + + let _manager_workflow_id = test_ctx + .workflow(DbManagerInput::new(database_branch_id, None)) + .tag(DATABASE_BRANCH_ID_TAG, &tag_value) + .unique() + .dispatch() + .await?; + let workflow_ids = [ + wait_for_workflow::(&test_ctx, database_branch_id).await?, + wait_for_workflow::(&test_ctx, database_branch_id).await?, + wait_for_workflow::(&test_ctx, database_branch_id).await?, + ]; + + for (index, workflow_id) in workflow_ids.into_iter().enumerate() { + let signal_id = test_ctx + .signal(DestroyDatabaseBranch { + database_branch_id, + lifecycle_generation: 7, + requested_at_ms: 1_714_000_000_000 + index as i64, + reason: format!("direct idle companion destroy {index}"), + }) + .to_workflow_id(workflow_id) + .send() + .await? + .expect("signal should target companion workflow"); + wait_for_signal_ack(&test_ctx, signal_id).await?; + wait_for_workflow_state(&test_ctx, workflow_id, WorkflowState::Complete).await?; + + let companion_state = latest_companion_state(&test_ctx, workflow_id).await?; + assert_eq!( + companion_state, + CompanionWorkflowState::Stopping { + active_job: None, + lifecycle_generation: 7, + requested_at_ms: 1_714_000_000_000 + index as i64, + reason: format!("direct idle companion destroy {index}"), + } + ); } - ); - } - test_ctx.shutdown().await?; - Ok(()) - }) + test_ctx.shutdown().await?; + Ok(()) + } + ) } #[tokio::test] async fn manager_destroy_stops_idle_companions() -> Result<()> { let database_branch_id = database_branch_id(0x0d10_2233_4455_6677_8899_aabb_ccdd_eeff); - workflow_matrix!("workflow-manager-destroy-stops-idle-companions", build_registry, |_tier, test_ctx| { - let tag_value = database_branch_tag_value(database_branch_id); - seed_manager_branch(&test_ctx, database_branch_id, 0, None, None).await?; - - let manager_workflow_id = test_ctx - .workflow(DbManagerInput::new(database_branch_id, None)) - .tag(DATABASE_BRANCH_ID_TAG, &tag_value) - .unique() - .dispatch() - .await?; - let hot_workflow_id = - wait_for_workflow::(&test_ctx, database_branch_id).await?; - let cold_workflow_id = - wait_for_workflow::(&test_ctx, database_branch_id).await?; - let reclaimer_workflow_id = - wait_for_workflow::(&test_ctx, database_branch_id).await?; - - let signal_id = test_ctx - .signal(DestroyDatabaseBranch { - database_branch_id, - lifecycle_generation: 0, - requested_at_ms: 1_714_000_000_000, - reason: "test destroy".into(), - }) - .to_workflow_id(manager_workflow_id) - .send() - .await? - .expect("signal should target manager workflow"); - wait_for_signal_ack(&test_ctx, signal_id).await?; + workflow_matrix!( + "workflow-manager-destroy-stops-idle-companions", + build_registry, + |_tier, test_ctx| { + let tag_value = database_branch_tag_value(database_branch_id); + seed_manager_branch(&test_ctx, database_branch_id, 0, None, None).await?; + + let manager_workflow_id = test_ctx + .workflow(DbManagerInput::new(database_branch_id, None)) + .tag(DATABASE_BRANCH_ID_TAG, &tag_value) + .unique() + .dispatch() + .await?; + let hot_workflow_id = + wait_for_workflow::(&test_ctx, database_branch_id).await?; + let cold_workflow_id = + wait_for_workflow::(&test_ctx, database_branch_id).await?; + let reclaimer_workflow_id = + wait_for_workflow::(&test_ctx, database_branch_id).await?; + + let signal_id = test_ctx + .signal(DestroyDatabaseBranch { + database_branch_id, + lifecycle_generation: 0, + requested_at_ms: 1_714_000_000_000, + reason: "test destroy".into(), + }) + .to_workflow_id(manager_workflow_id) + .send() + .await? + .expect("signal should target manager workflow"); + wait_for_signal_ack(&test_ctx, signal_id).await?; - wait_for_workflow_state(&test_ctx, manager_workflow_id, WorkflowState::Complete).await?; - wait_for_workflow_state(&test_ctx, hot_workflow_id, WorkflowState::Complete).await?; - wait_for_workflow_state(&test_ctx, cold_workflow_id, WorkflowState::Complete).await?; - wait_for_workflow_state(&test_ctx, reclaimer_workflow_id, WorkflowState::Complete).await?; + wait_for_workflow_state(&test_ctx, manager_workflow_id, WorkflowState::Complete) + .await?; + wait_for_workflow_state(&test_ctx, hot_workflow_id, WorkflowState::Complete).await?; + wait_for_workflow_state(&test_ctx, cold_workflow_id, WorkflowState::Complete).await?; + wait_for_workflow_state(&test_ctx, reclaimer_workflow_id, WorkflowState::Complete) + .await?; - let manager_state = latest_manager_state(&test_ctx, manager_workflow_id).await?; - assert!(manager_state.active_jobs.hot.is_none()); - assert!(manager_state.active_jobs.cold.is_none()); - assert!(manager_state.active_jobs.reclaim.is_none()); - assert!(matches!( - manager_state.branch_stop_state, - BranchStopState::Stopped { .. } - )); + let manager_state = latest_manager_state(&test_ctx, manager_workflow_id).await?; + assert!(manager_state.active_jobs.hot.is_none()); + assert!(manager_state.active_jobs.cold.is_none()); + assert!(manager_state.active_jobs.reclaim.is_none()); + assert!(matches!( + manager_state.branch_stop_state, + BranchStopState::Stopped { .. } + )); - for companion_workflow_id in [hot_workflow_id, cold_workflow_id, reclaimer_workflow_id] { - let destroy = single_destroy_signal_for_workflow(&test_ctx, companion_workflow_id).await?; - assert_eq!(destroy.database_branch_id, database_branch_id); - assert_eq!(destroy.lifecycle_generation, 0); - assert_eq!(destroy.requested_at_ms, 1_714_000_000_000); - assert_eq!(destroy.reason, "test destroy"); - } + for companion_workflow_id in [hot_workflow_id, cold_workflow_id, reclaimer_workflow_id] + { + let destroy = + single_destroy_signal_for_workflow(&test_ctx, companion_workflow_id).await?; + assert_eq!(destroy.database_branch_id, database_branch_id); + assert_eq!(destroy.lifecycle_generation, 0); + assert_eq!(destroy.requested_at_ms, 1_714_000_000_000); + assert_eq!(destroy.reason, "test destroy"); + } - test_ctx.shutdown().await?; - Ok(()) - }) + test_ctx.shutdown().await?; + Ok(()) + } + ) } #[tokio::test] async fn manager_recreated_for_deleted_branch_stops_without_scheduling() -> Result<()> { let database_branch_id = database_branch_id(0x0d11_2233_4455_6677_8899_aabb_ccdd_eeff); - workflow_matrix!("workflow-manager-recreated-for-deleted-branch-stops-without-scheduling", build_registry, |_tier, test_ctx| { - let tag_value = database_branch_tag_value(database_branch_id); - seed_manager_branch( - &test_ctx, - database_branch_id, - quota_threshold_head(), - None, - Some(SqliteCmpDirty { - observed_head_txid: quota_threshold_head(), - updated_at_ms: 1_714_000_000_000, - }), - ) - .await?; - clear_branch_record(&test_ctx, database_branch_id).await?; - - let manager_workflow_id = test_ctx - .workflow(DbManagerInput::new(database_branch_id, None)) - .tag(DATABASE_BRANCH_ID_TAG, &tag_value) - .unique() - .dispatch() - .await?; - let hot_workflow_id = - wait_for_workflow::(&test_ctx, database_branch_id).await?; - let cold_workflow_id = - wait_for_workflow::(&test_ctx, database_branch_id).await?; - let reclaimer_workflow_id = - wait_for_workflow::(&test_ctx, database_branch_id).await?; + workflow_matrix!( + "workflow-manager-recreated-for-deleted-branch-stops-without-scheduling", + build_registry, + |_tier, test_ctx| { + let tag_value = database_branch_tag_value(database_branch_id); + seed_manager_branch( + &test_ctx, + database_branch_id, + quota_threshold_head(), + None, + Some(SqliteCmpDirty { + observed_head_txid: quota_threshold_head(), + updated_at_ms: 1_714_000_000_000, + }), + ) + .await?; + clear_branch_record(&test_ctx, database_branch_id).await?; - wait_for_workflow_state(&test_ctx, manager_workflow_id, WorkflowState::Complete).await?; - wait_for_workflow_state(&test_ctx, hot_workflow_id, WorkflowState::Complete).await?; - wait_for_workflow_state(&test_ctx, cold_workflow_id, WorkflowState::Complete).await?; - wait_for_workflow_state(&test_ctx, reclaimer_workflow_id, WorkflowState::Complete).await?; - let run_hot_signals = DatabaseDebug::find_signals( - test_ctx.debug_db(), - &[], - Some(hot_workflow_id), - Some(::NAME), - None, - ) - .await?; - assert!(run_hot_signals.is_empty()); - let hot_destroy = single_destroy_signal_for_workflow(&test_ctx, hot_workflow_id).await?; - let cold_destroy = single_destroy_signal_for_workflow(&test_ctx, cold_workflow_id).await?; - let reclaimer_destroy = - single_destroy_signal_for_workflow(&test_ctx, reclaimer_workflow_id).await?; - for destroy in [&hot_destroy, &cold_destroy, &reclaimer_destroy] { - assert_eq!(destroy.database_branch_id, database_branch_id); - assert_eq!(destroy.lifecycle_generation, 0); - assert_eq!(destroy.reason, "database branch is not live"); - } - assert_eq!(hot_destroy.requested_at_ms, cold_destroy.requested_at_ms); - assert_eq!( - hot_destroy.requested_at_ms, - reclaimer_destroy.requested_at_ms - ); + let manager_workflow_id = test_ctx + .workflow(DbManagerInput::new(database_branch_id, None)) + .tag(DATABASE_BRANCH_ID_TAG, &tag_value) + .unique() + .dispatch() + .await?; + let hot_workflow_id = + wait_for_workflow::(&test_ctx, database_branch_id).await?; + let cold_workflow_id = + wait_for_workflow::(&test_ctx, database_branch_id).await?; + let reclaimer_workflow_id = + wait_for_workflow::(&test_ctx, database_branch_id).await?; + + wait_for_workflow_state(&test_ctx, manager_workflow_id, WorkflowState::Complete) + .await?; + wait_for_workflow_state(&test_ctx, hot_workflow_id, WorkflowState::Complete).await?; + wait_for_workflow_state(&test_ctx, cold_workflow_id, WorkflowState::Complete).await?; + wait_for_workflow_state(&test_ctx, reclaimer_workflow_id, WorkflowState::Complete) + .await?; + let run_hot_signals = DatabaseDebug::find_signals( + test_ctx.debug_db(), + &[], + Some(hot_workflow_id), + Some(::NAME), + None, + ) + .await?; + assert!(run_hot_signals.is_empty()); + let hot_destroy = + single_destroy_signal_for_workflow(&test_ctx, hot_workflow_id).await?; + let cold_destroy = + single_destroy_signal_for_workflow(&test_ctx, cold_workflow_id).await?; + let reclaimer_destroy = + single_destroy_signal_for_workflow(&test_ctx, reclaimer_workflow_id).await?; + for destroy in [&hot_destroy, &cold_destroy, &reclaimer_destroy] { + assert_eq!(destroy.database_branch_id, database_branch_id); + assert_eq!(destroy.lifecycle_generation, 0); + assert_eq!(destroy.reason, "database branch is not live"); + } + assert_eq!(hot_destroy.requested_at_ms, cold_destroy.requested_at_ms); + assert_eq!( + hot_destroy.requested_at_ms, + reclaimer_destroy.requested_at_ms + ); - test_ctx.shutdown().await?; - Ok(()) - }) + test_ctx.shutdown().await?; + Ok(()) + } + ) } #[tokio::test] async fn manager_branch_not_live_stop_clears_active_jobs() -> Result<()> { let database_branch_id = database_branch_id(0x0d13_2233_4455_6677_8899_aabb_ccdd_eeff); - workflow_matrix!("workflow-manager-branch-not-live-stop-clears-active-jobs", build_registry_without_hot_compacter, |_tier, test_ctx| { - let tag_value = database_branch_tag_value(database_branch_id); - seed_manager_branch( - &test_ctx, - database_branch_id, - quota_threshold_head(), - None, - Some(SqliteCmpDirty { - observed_head_txid: quota_threshold_head(), - updated_at_ms: 1_714_000_000_000, - }), - ) - .await?; - - let manager_workflow_id = test_ctx - .workflow(DbManagerInput::new(database_branch_id, None)) - .tag(DATABASE_BRANCH_ID_TAG, &tag_value) - .unique() - .dispatch() - .await?; - let hot_workflow_id = - wait_for_workflow::(&test_ctx, database_branch_id).await?; - let cold_workflow_id = - wait_for_workflow::(&test_ctx, database_branch_id).await?; - let reclaimer_workflow_id = - wait_for_workflow::(&test_ctx, database_branch_id).await?; - let run_hot_job = wait_for_run_hot_job(&test_ctx, hot_workflow_id).await?; - assert_eq!(run_hot_job.database_branch_id, database_branch_id); - wait_for_manager_state(&test_ctx, manager_workflow_id, |state| { - state.active_jobs.hot.is_some() - }) - .await?; + workflow_matrix!( + "workflow-manager-branch-not-live-stop-clears-active-jobs", + build_registry_without_hot_compacter, + |_tier, test_ctx| { + let tag_value = database_branch_tag_value(database_branch_id); + seed_manager_branch( + &test_ctx, + database_branch_id, + quota_threshold_head(), + None, + Some(SqliteCmpDirty { + observed_head_txid: quota_threshold_head(), + updated_at_ms: 1_714_000_000_000, + }), + ) + .await?; - clear_branch_record(&test_ctx, database_branch_id).await?; + let manager_workflow_id = test_ctx + .workflow(DbManagerInput::new(database_branch_id, None)) + .tag(DATABASE_BRANCH_ID_TAG, &tag_value) + .unique() + .dispatch() + .await?; + let hot_workflow_id = + wait_for_workflow::(&test_ctx, database_branch_id).await?; + let cold_workflow_id = + wait_for_workflow::(&test_ctx, database_branch_id).await?; + let reclaimer_workflow_id = + wait_for_workflow::(&test_ctx, database_branch_id).await?; + let run_hot_job = wait_for_run_hot_job(&test_ctx, hot_workflow_id).await?; + assert_eq!(run_hot_job.database_branch_id, database_branch_id); + wait_for_manager_state(&test_ctx, manager_workflow_id, |state| { + state.active_jobs.hot.is_some() + }) + .await?; - wait_for_workflow_state(&test_ctx, manager_workflow_id, WorkflowState::Complete).await?; - wait_for_workflow_state(&test_ctx, cold_workflow_id, WorkflowState::Complete).await?; - wait_for_workflow_state(&test_ctx, reclaimer_workflow_id, WorkflowState::Complete).await?; + clear_branch_record(&test_ctx, database_branch_id).await?; - let manager_state = latest_manager_state(&test_ctx, manager_workflow_id).await?; - assert!(manager_state.active_jobs.hot.is_none()); - assert!(manager_state.active_jobs.cold.is_none()); - assert!(manager_state.active_jobs.reclaim.is_none()); - assert!(matches!( - manager_state.branch_stop_state, - BranchStopState::Stopped { .. } - )); - let destroy = single_destroy_signal_for_workflow(&test_ctx, hot_workflow_id).await?; - assert_eq!(destroy.database_branch_id, database_branch_id); - assert_eq!(destroy.lifecycle_generation, 0); - assert_eq!(destroy.reason, "database branch is not live"); + wait_for_workflow_state(&test_ctx, manager_workflow_id, WorkflowState::Complete) + .await?; + wait_for_workflow_state(&test_ctx, cold_workflow_id, WorkflowState::Complete).await?; + wait_for_workflow_state(&test_ctx, reclaimer_workflow_id, WorkflowState::Complete) + .await?; - test_ctx.shutdown().await?; - Ok(()) - }) + let manager_state = latest_manager_state(&test_ctx, manager_workflow_id).await?; + assert!(manager_state.active_jobs.hot.is_none()); + assert!(manager_state.active_jobs.cold.is_none()); + assert!(manager_state.active_jobs.reclaim.is_none()); + assert!(matches!( + manager_state.branch_stop_state, + BranchStopState::Stopped { .. } + )); + let destroy = single_destroy_signal_for_workflow(&test_ctx, hot_workflow_id).await?; + assert_eq!(destroy.database_branch_id, database_branch_id); + assert_eq!(destroy.lifecycle_generation, 0); + assert_eq!(destroy.reason, "database branch is not live"); + + test_ctx.shutdown().await?; + Ok(()) + } + ) } #[tokio::test] async fn manager_destroy_during_active_hot_job_completes() -> Result<()> { let database_branch_id = database_branch_id(0x0d12_2233_4455_6677_8899_aabb_ccdd_eeff); - workflow_matrix!("workflow-manager-destroy-during-active-hot-job-completes", build_registry, |_tier, test_ctx| { - let tag_value = database_branch_tag_value(database_branch_id); - seed_manager_branch( - &test_ctx, - database_branch_id, - quota_threshold_head(), - None, - Some(SqliteCmpDirty { - observed_head_txid: quota_threshold_head(), - updated_at_ms: 1_714_000_000_000, - }), - ) - .await?; + workflow_matrix!( + "workflow-manager-destroy-during-active-hot-job-completes", + build_registry, + |_tier, test_ctx| { + let tag_value = database_branch_tag_value(database_branch_id); + seed_manager_branch( + &test_ctx, + database_branch_id, + quota_threshold_head(), + None, + Some(SqliteCmpDirty { + observed_head_txid: quota_threshold_head(), + updated_at_ms: 1_714_000_000_000, + }), + ) + .await?; - let manager_workflow_id = test_ctx - .workflow(DbManagerInput::new(database_branch_id, None)) - .tag(DATABASE_BRANCH_ID_TAG, &tag_value) - .unique() - .dispatch() - .await?; - let hot_workflow_id = - wait_for_workflow::(&test_ctx, database_branch_id).await?; - let run_hot_job = wait_for_run_hot_job(&test_ctx, hot_workflow_id).await?; - assert_eq!(run_hot_job.job_kind, CompactionJobKind::Hot); - assert_eq!(run_hot_job.database_branch_id, database_branch_id); - assert_eq!(run_hot_job.input_range.txids.min_txid, 1); - assert_eq!(run_hot_job.input_range.txids.max_txid, quota_threshold_head()); + let manager_workflow_id = test_ctx + .workflow(DbManagerInput::new(database_branch_id, None)) + .tag(DATABASE_BRANCH_ID_TAG, &tag_value) + .unique() + .dispatch() + .await?; + let hot_workflow_id = + wait_for_workflow::(&test_ctx, database_branch_id).await?; + let run_hot_job = wait_for_run_hot_job(&test_ctx, hot_workflow_id).await?; + assert_eq!(run_hot_job.job_kind, CompactionJobKind::Hot); + assert_eq!(run_hot_job.database_branch_id, database_branch_id); + assert_eq!(run_hot_job.input_range.txids.min_txid, 1); + assert_eq!( + run_hot_job.input_range.txids.max_txid, + quota_threshold_head() + ); - let signal_id = test_ctx - .signal(DestroyDatabaseBranch { - database_branch_id, - lifecycle_generation: 0, - requested_at_ms: 1_714_000_000_001, - reason: "test destroy during hot".into(), - }) - .to_workflow_id(manager_workflow_id) - .send() - .await? - .expect("signal should target manager workflow"); - wait_for_signal_ack(&test_ctx, signal_id).await?; + let signal_id = test_ctx + .signal(DestroyDatabaseBranch { + database_branch_id, + lifecycle_generation: 0, + requested_at_ms: 1_714_000_000_001, + reason: "test destroy during hot".into(), + }) + .to_workflow_id(manager_workflow_id) + .send() + .await? + .expect("signal should target manager workflow"); + wait_for_signal_ack(&test_ctx, signal_id).await?; - wait_for_workflow_state(&test_ctx, manager_workflow_id, WorkflowState::Complete).await?; - wait_for_workflow_state(&test_ctx, hot_workflow_id, WorkflowState::Complete).await?; + wait_for_workflow_state(&test_ctx, manager_workflow_id, WorkflowState::Complete) + .await?; + wait_for_workflow_state(&test_ctx, hot_workflow_id, WorkflowState::Complete).await?; - test_ctx.shutdown().await?; - Ok(()) - }) + test_ctx.shutdown().await?; + Ok(()) + } + ) } #[tokio::test] async fn manager_rejects_hot_publish_after_lifecycle_generation_bump() -> Result<()> { let database_branch_id = database_branch_id(0x0d14_2233_4455_6677_8899_aabb_ccdd_eeff); - workflow_matrix!("workflow-manager-rejects-hot-publish-after-lifecycle-generation-bump", build_registry_without_hot_compacter, |_tier, test_ctx| { - let tag_value = database_branch_tag_value(database_branch_id); - seed_manager_branch( - &test_ctx, - database_branch_id, - quota_threshold_head(), - None, - Some(SqliteCmpDirty { - observed_head_txid: quota_threshold_head(), - updated_at_ms: 1_714_000_000_000, - }), - ) - .await?; + workflow_matrix!( + "workflow-manager-rejects-hot-publish-after-lifecycle-generation-bump", + build_registry_without_hot_compacter, + |_tier, test_ctx| { + let tag_value = database_branch_tag_value(database_branch_id); + seed_manager_branch( + &test_ctx, + database_branch_id, + quota_threshold_head(), + None, + Some(SqliteCmpDirty { + observed_head_txid: quota_threshold_head(), + updated_at_ms: 1_714_000_000_000, + }), + ) + .await?; - let manager_workflow_id = test_ctx - .workflow(DbManagerInput::new(database_branch_id, None)) - .tag(DATABASE_BRANCH_ID_TAG, &tag_value) - .unique() - .dispatch() - .await?; - let hot_workflow_id = - wait_for_workflow::(&test_ctx, database_branch_id).await?; - let run_hot_job = wait_for_run_hot_job(&test_ctx, hot_workflow_id).await?; - let manager_state = wait_for_manager_state(&test_ctx, manager_workflow_id, |state| { - state.active_jobs.hot.is_some() - }) - .await?; - let active_hot_job = manager_state - .active_jobs - .hot - .expect("manager should hold planned hot job active"); - assert_eq!(active_hot_job.job_id, run_hot_job.job_id); - assert_eq!(active_hot_job.base_lifecycle_generation, 0); - - let staged_blob = encode_ltx_v3( - LtxHeader::delta( - active_hot_job.input_range.txids.min_txid, - 1, - 1_002, - ), - &[DirtyPage { - pgno: 1, - bytes: page(0x14), - }], - )?; - let output_ref = HotShardOutputRef { - shard_id: 0, - as_of_txid: active_hot_job.input_range.txids.max_txid, - min_txid: active_hot_job.input_range.txids.min_txid, - max_txid: active_hot_job.input_range.txids.max_txid, - size_bytes: u64::try_from(staged_blob.len()).unwrap_or(u64::MAX), - content_hash: sha256(&staged_blob), - }; - test_ctx - .pools() - .udb()? - .run({ - let staged_blob = staged_blob.clone(); - let active_hot_job = active_hot_job.clone(); - let output_ref = output_ref.clone(); - move |tx| { - let staged_blob = staged_blob.clone(); - async move { - tx.informal().set( - &branch_compaction_stage_hot_shard_key( - database_branch_id, - active_hot_job.job_id, - output_ref.shard_id, - output_ref.as_of_txid, - 0, - ), - &staged_blob, - ); - Ok(()) - } - } - }) - .await?; - update_branch_lifecycle(&test_ctx, database_branch_id, BranchState::Live, 1).await?; + let manager_workflow_id = test_ctx + .workflow(DbManagerInput::new(database_branch_id, None)) + .tag(DATABASE_BRANCH_ID_TAG, &tag_value) + .unique() + .dispatch() + .await?; + let hot_workflow_id = + wait_for_workflow::(&test_ctx, database_branch_id).await?; + let run_hot_job = wait_for_run_hot_job(&test_ctx, hot_workflow_id).await?; + let manager_state = wait_for_manager_state(&test_ctx, manager_workflow_id, |state| { + state.active_jobs.hot.is_some() + }) + .await?; + let active_hot_job = manager_state + .active_jobs + .hot + .expect("manager should hold planned hot job active"); + assert_eq!(active_hot_job.job_id, run_hot_job.job_id); + assert_eq!(active_hot_job.base_lifecycle_generation, 0); + + let staged_blob = encode_ltx_v3( + LtxHeader::delta(active_hot_job.input_range.txids.min_txid, 1, 1_002), + &[DirtyPage { + pgno: 1, + bytes: page(0x14), + }], + )?; + let output_ref = HotShardOutputRef { + shard_id: 0, + as_of_txid: active_hot_job.input_range.txids.max_txid, + min_txid: active_hot_job.input_range.txids.min_txid, + max_txid: active_hot_job.input_range.txids.max_txid, + size_bytes: u64::try_from(staged_blob.len()).unwrap_or(u64::MAX), + content_hash: sha256(&staged_blob), + }; + test_ctx + .pools() + .udb()? + .run({ + let staged_blob = staged_blob.clone(); + let active_hot_job = active_hot_job.clone(); + let output_ref = output_ref.clone(); + move |tx| { + let staged_blob = staged_blob.clone(); + async move { + tx.informal().set( + &branch_compaction_stage_hot_shard_key( + database_branch_id, + active_hot_job.job_id, + output_ref.shard_id, + output_ref.as_of_txid, + 0, + ), + &staged_blob, + ); + Ok(()) + } + } + }) + .await?; + update_branch_lifecycle(&test_ctx, database_branch_id, BranchState::Live, 1).await?; - let signal_id = test_ctx - .signal(HotJobFinished { - database_branch_id, - job_id: active_hot_job.job_id, - job_kind: CompactionJobKind::Hot, - base_manifest_generation: active_hot_job.base_manifest_generation, - input_fingerprint: active_hot_job.input_fingerprint, - status: CompactionJobStatus::Succeeded, - output_refs: vec![output_ref], - }) - .to_workflow_id(manager_workflow_id) - .send() - .await? - .expect("signal should target manager workflow"); - wait_for_signal_ack(&test_ctx, signal_id).await?; + let signal_id = test_ctx + .signal(HotJobFinished { + database_branch_id, + job_id: active_hot_job.job_id, + job_kind: CompactionJobKind::Hot, + base_manifest_generation: active_hot_job.base_manifest_generation, + input_fingerprint: active_hot_job.input_fingerprint, + status: CompactionJobStatus::Succeeded, + output_refs: vec![output_ref], + }) + .to_workflow_id(manager_workflow_id) + .send() + .await? + .expect("signal should target manager workflow"); + wait_for_signal_ack(&test_ctx, signal_id).await?; - let manager_state = wait_for_manager_state(&test_ctx, manager_workflow_id, |state| { - state - .active_jobs - .hot - .as_ref() - .is_some_and(|job| job.base_lifecycle_generation == 1) - }) - .await?; - let rescheduled_hot_job = manager_state - .active_jobs - .hot - .expect("manager should reschedule hot work at the new generation"); - assert_eq!(rescheduled_hot_job.base_lifecycle_generation, 1); - assert!( - read_value( - &test_ctx, - branch_shard_key(database_branch_id, 0, quota_threshold_head()), - ) - .await? - .is_none() - ); - assert!( - read_value(&test_ctx, branch_pidx_key(database_branch_id, 1)) - .await? - .is_some() - ); + let manager_state = wait_for_manager_state(&test_ctx, manager_workflow_id, |state| { + state + .active_jobs + .hot + .as_ref() + .is_some_and(|job| job.base_lifecycle_generation == 1) + }) + .await?; + let rescheduled_hot_job = manager_state + .active_jobs + .hot + .expect("manager should reschedule hot work at the new generation"); + assert_eq!(rescheduled_hot_job.base_lifecycle_generation, 1); + assert!( + read_value( + &test_ctx, + branch_shard_key(database_branch_id, 0, quota_threshold_head()), + ) + .await? + .is_none() + ); + assert!( + read_value(&test_ctx, branch_pidx_key(database_branch_id, 1)) + .await? + .is_some() + ); - test_ctx.shutdown().await?; - Ok(()) - }) + test_ctx.shutdown().await?; + Ok(()) + } + ) } #[tokio::test] async fn manager_refresh_clears_idle_dirty_marker_without_planning_hot_job() -> Result<()> { let database_branch_id = database_branch_id(0x1010_2233_4455_6677_8899_aabb_ccdd_eeff); - workflow_matrix!("workflow-manager-refresh-clears-idle-dirty-marker-without-planning-hot-job", build_registry, |_tier, test_ctx| { - let tag_value = database_branch_tag_value(database_branch_id); - seed_manager_branch( - &test_ctx, - database_branch_id, - 1, - None, - Some(SqliteCmpDirty { - observed_head_txid: 1, - updated_at_ms: 1_714_000_000_000, - }), - ) - .await?; + workflow_matrix!( + "workflow-manager-refresh-clears-idle-dirty-marker-without-planning-hot-job", + build_registry, + |_tier, test_ctx| { + let tag_value = database_branch_tag_value(database_branch_id); + seed_manager_branch( + &test_ctx, + database_branch_id, + 1, + None, + Some(SqliteCmpDirty { + observed_head_txid: 1, + updated_at_ms: 1_714_000_000_000, + }), + ) + .await?; - let manager_workflow_id = test_ctx - .workflow(DbManagerInput::new(database_branch_id, None)) - .tag(DATABASE_BRANCH_ID_TAG, &tag_value) - .unique() - .dispatch() - .await?; + let manager_workflow_id = test_ctx + .workflow(DbManagerInput::new(database_branch_id, None)) + .tag(DATABASE_BRANCH_ID_TAG, &tag_value) + .unique() + .dispatch() + .await?; - wait_for_dirty_marker_cleared(&test_ctx, database_branch_id).await?; - let manager_state = - wait_for_manager_state(&test_ctx, manager_workflow_id, |state| { - state.planning_deadlines.next_hot_check_at_ms.is_some() - && state.planning_deadlines.next_cold_check_at_ms.is_some() - && state.planning_deadlines.next_reclaim_check_at_ms.is_some() - && state.planning_deadlines.final_settle_check_at_ms.is_some() - }) - .await?; + wait_for_dirty_marker_cleared(&test_ctx, database_branch_id).await?; + let manager_state = wait_for_manager_state(&test_ctx, manager_workflow_id, |state| { + state.planning_deadlines.next_hot_check_at_ms.is_some() + && state.planning_deadlines.next_cold_check_at_ms.is_some() + && state.planning_deadlines.next_reclaim_check_at_ms.is_some() + && state.planning_deadlines.final_settle_check_at_ms.is_some() + }) + .await?; - assert!(manager_state.active_jobs.hot.is_none()); + assert!(manager_state.active_jobs.hot.is_none()); - test_ctx.shutdown().await?; - Ok(()) - }) + test_ctx.shutdown().await?; + Ok(()) + } + ) } #[tokio::test] async fn manager_refresh_plans_first_hot_job_from_fdb_state() -> Result<()> { let database_branch_id = database_branch_id(0x2020_2233_4455_6677_8899_aabb_ccdd_eeff); - workflow_matrix!("workflow-manager-refresh-plans-first-hot-job-from-fdb-state", build_registry, |_tier, test_ctx| { - let tag_value = database_branch_tag_value(database_branch_id); - seed_manager_branch( - &test_ctx, - database_branch_id, - quota_threshold_head(), - None, - Some(SqliteCmpDirty { - observed_head_txid: quota_threshold_head(), - updated_at_ms: 1_714_000_000_000, - }), - ) - .await?; + workflow_matrix!( + "workflow-manager-refresh-plans-first-hot-job-from-fdb-state", + build_registry, + |_tier, test_ctx| { + let tag_value = database_branch_tag_value(database_branch_id); + seed_manager_branch( + &test_ctx, + database_branch_id, + quota_threshold_head(), + None, + Some(SqliteCmpDirty { + observed_head_txid: quota_threshold_head(), + updated_at_ms: 1_714_000_000_000, + }), + ) + .await?; - let manager_workflow_id = test_ctx - .workflow(DbManagerInput::new(database_branch_id, None)) - .tag(DATABASE_BRANCH_ID_TAG, &tag_value) - .unique() - .dispatch() - .await?; + let manager_workflow_id = test_ctx + .workflow(DbManagerInput::new(database_branch_id, None)) + .tag(DATABASE_BRANCH_ID_TAG, &tag_value) + .unique() + .dispatch() + .await?; - wait_for_hot_install(&test_ctx, database_branch_id, quota_threshold_head()).await?; - let manager_state = - wait_for_manager_state(&test_ctx, manager_workflow_id, |state| state.active_jobs.hot.is_none()) + wait_for_hot_install(&test_ctx, database_branch_id, quota_threshold_head()).await?; + let manager_state = wait_for_manager_state(&test_ctx, manager_workflow_id, |state| { + state.active_jobs.hot.is_none() + }) .await?; - assert!(manager_state.active_jobs.hot.is_none()); - assert!(read_value(&test_ctx, branch_pidx_key(database_branch_id, 1)).await?.is_none()); - assert!( - read_value( - &test_ctx, - branch_shard_key(database_branch_id, 0, quota_threshold_head()), - ) - .await? - .is_some() - ); - - test_ctx.shutdown().await?; - Ok(()) - }) + assert!(manager_state.active_jobs.hot.is_none()); + assert!( + read_value(&test_ctx, branch_pidx_key(database_branch_id, 1)) + .await? + .is_none() + ); + assert!( + read_value( + &test_ctx, + branch_shard_key(database_branch_id, 0, quota_threshold_head()), + ) + .await? + .is_some() + ); + + test_ctx.shutdown().await?; + Ok(()) + } + ) } #[tokio::test] async fn duplicate_deltas_available_does_not_create_duplicate_hot_job() -> Result<()> { let database_branch_id = database_branch_id(0x3030_2233_4455_6677_8899_aabb_ccdd_eeff); - workflow_matrix!("workflow-duplicate-deltas-available-does-not-create-duplicate-hot-job", build_registry, |_tier, test_ctx| { - let tag_value = database_branch_tag_value(database_branch_id); - seed_manager_branch( - &test_ctx, - database_branch_id, - quota_threshold_head(), - None, - Some(SqliteCmpDirty { - observed_head_txid: quota_threshold_head(), - updated_at_ms: 1_714_000_000_000, - }), - ) - .await?; - - let manager_workflow_id = test_ctx - .workflow(DbManagerInput::new(database_branch_id, None)) - .tag(DATABASE_BRANCH_ID_TAG, &tag_value) - .unique() - .dispatch() - .await?; - wait_for_hot_install(&test_ctx, database_branch_id, quota_threshold_head()).await?; - - let signal_id = test_ctx - .signal(DeltasAvailable { - database_branch_id, - observed_head_txid: 99, - dirty_updated_at_ms: 1_714_000_000_500, - }) - .to_workflow_id(manager_workflow_id) - .send() - .await? - .expect("signal should target manager workflow"); - wait_for_signal_ack(&test_ctx, signal_id).await?; - let second_state = wait_for_manager_cursor(&test_ctx, manager_workflow_id, 99).await?; - let root = read_value(&test_ctx, branch_compaction_root_key(database_branch_id)) - .await? - .as_deref() - .map(decode_compaction_root) - .transpose()? - .expect("hot install should publish compaction root"); - let shard_rows = read_prefix_values(&test_ctx, branch_shard_prefix(database_branch_id)).await?; + workflow_matrix!( + "workflow-duplicate-deltas-available-does-not-create-duplicate-hot-job", + build_registry, + |_tier, test_ctx| { + let tag_value = database_branch_tag_value(database_branch_id); + seed_manager_branch( + &test_ctx, + database_branch_id, + quota_threshold_head(), + None, + Some(SqliteCmpDirty { + observed_head_txid: quota_threshold_head(), + updated_at_ms: 1_714_000_000_000, + }), + ) + .await?; - assert!(second_state.active_jobs.hot.is_none()); - assert_eq!(root.manifest_generation, 1); - assert_eq!(root.hot_watermark_txid, quota_threshold_head()); - assert_eq!(shard_rows.len(), 1); + let manager_workflow_id = test_ctx + .workflow(DbManagerInput::new(database_branch_id, None)) + .tag(DATABASE_BRANCH_ID_TAG, &tag_value) + .unique() + .dispatch() + .await?; + wait_for_hot_install(&test_ctx, database_branch_id, quota_threshold_head()).await?; - test_ctx.shutdown().await?; - Ok(()) - }) + let signal_id = test_ctx + .signal(DeltasAvailable { + database_branch_id, + observed_head_txid: 99, + dirty_updated_at_ms: 1_714_000_000_500, + }) + .to_workflow_id(manager_workflow_id) + .send() + .await? + .expect("signal should target manager workflow"); + wait_for_signal_ack(&test_ctx, signal_id).await?; + let second_state = wait_for_manager_cursor(&test_ctx, manager_workflow_id, 99).await?; + let root = read_value(&test_ctx, branch_compaction_root_key(database_branch_id)) + .await? + .as_deref() + .map(decode_compaction_root) + .transpose()? + .expect("hot install should publish compaction root"); + let shard_rows = + read_prefix_values(&test_ctx, branch_shard_prefix(database_branch_id)).await?; + + assert!(second_state.active_jobs.hot.is_none()); + assert_eq!(root.manifest_generation, 1); + assert_eq!(root.hot_watermark_txid, quota_threshold_head()); + assert_eq!(shard_rows.len(), 1); + + test_ctx.shutdown().await?; + Ok(()) + } + ) } #[tokio::test] async fn force_compaction_noop_records_completion_result() -> Result<()> { let database_branch_id = database_branch_id(0x3131_2233_4455_6677_8899_aabb_ccdd_eeff); - workflow_matrix!("workflow-force-compaction-noop-records-completion-result", build_registry, |_tier, test_ctx| { - let tag_value = database_branch_tag_value(database_branch_id); - seed_manager_branch(&test_ctx, database_branch_id, 0, None, None).await?; - - let manager_workflow_id = test_ctx - .workflow(DbManagerInput::new(database_branch_id, None)) - .tag(DATABASE_BRANCH_ID_TAG, &tag_value) - .unique() - .dispatch() - .await?; - let request_id = Id::new_v1(42); - let result = force_compaction_and_wait_idle( - &test_ctx, - manager_workflow_id, - database_branch_id, - request_id, - ForceCompactionWork { - hot: true, - cold: true, - reclaim: true, - final_settle: true, - }, - ) - .await?; + workflow_matrix!( + "workflow-force-compaction-noop-records-completion-result", + build_registry, + |_tier, test_ctx| { + let tag_value = database_branch_tag_value(database_branch_id); + seed_manager_branch(&test_ctx, database_branch_id, 0, None, None).await?; + + let manager_workflow_id = test_ctx + .workflow(DbManagerInput::new(database_branch_id, None)) + .tag(DATABASE_BRANCH_ID_TAG, &tag_value) + .unique() + .dispatch() + .await?; + let request_id = Id::new_v1(42); + let result = force_compaction_and_wait_idle( + &test_ctx, + manager_workflow_id, + database_branch_id, + request_id, + ForceCompactionWork { + hot: true, + cold: true, + reclaim: true, + final_settle: true, + }, + ) + .await?; - assert_eq!(result.request_id, request_id); - assert!(result.attempted_job_kinds.is_empty()); - assert!(result.completed_job_ids.is_empty()); - assert!( - result - .skipped_noop_reasons - .contains(&"hot:no-actionable-lag".to_string()) - ); - assert!( - result - .skipped_noop_reasons - .contains(&"reclaim:no-actionable-work".to_string()) - ); - assert!( - result - .skipped_noop_reasons - .contains(&"final-settle:refreshed".to_string()) - ); + assert_eq!(result.request_id, request_id); + assert!(result.attempted_job_kinds.is_empty()); + assert!(result.completed_job_ids.is_empty()); + assert!( + result + .skipped_noop_reasons + .contains(&"hot:no-actionable-lag".to_string()) + ); + assert!( + result + .skipped_noop_reasons + .contains(&"reclaim:no-actionable-work".to_string()) + ); + assert!( + result + .skipped_noop_reasons + .contains(&"final-settle:refreshed".to_string()) + ); - test_ctx.shutdown().await?; - Ok(()) - }) + test_ctx.shutdown().await?; + Ok(()) + } + ) } #[tokio::test] async fn force_hot_compaction_publishes_planned_work_below_threshold() -> Result<()> { let database_branch_id = database_branch_id(0x3232_2233_4455_6677_8899_aabb_ccdd_eeff); - workflow_matrix!("workflow-force-hot-compaction-publishes-planned-work-below-threshold", build_registry, |_tier, test_ctx| { - let tag_value = database_branch_tag_value(database_branch_id); - seed_manager_branch(&test_ctx, database_branch_id, 1, None, None).await?; - - let manager_workflow_id = test_ctx - .workflow(DbManagerInput::new(database_branch_id, None)) - .tag(DATABASE_BRANCH_ID_TAG, &tag_value) - .unique() - .dispatch() - .await?; - let request_id = Id::new_v1(43); - let result = force_compaction_and_wait_idle( - &test_ctx, - manager_workflow_id, - database_branch_id, - request_id, - ForceCompactionWork { - hot: true, - cold: false, - reclaim: false, - final_settle: false, - }, - ) - .await?; + workflow_matrix!( + "workflow-force-hot-compaction-publishes-planned-work-below-threshold", + build_registry, + |_tier, test_ctx| { + let tag_value = database_branch_tag_value(database_branch_id); + seed_manager_branch(&test_ctx, database_branch_id, 1, None, None).await?; + + let manager_workflow_id = test_ctx + .workflow(DbManagerInput::new(database_branch_id, None)) + .tag(DATABASE_BRANCH_ID_TAG, &tag_value) + .unique() + .dispatch() + .await?; + let request_id = Id::new_v1(43); + let result = force_compaction_and_wait_idle( + &test_ctx, + manager_workflow_id, + database_branch_id, + request_id, + ForceCompactionWork { + hot: true, + cold: false, + reclaim: false, + final_settle: false, + }, + ) + .await?; - assert_eq!(result.attempted_job_kinds, vec![CompactionJobKind::Hot]); - assert_eq!(result.completed_job_ids.len(), 1); - assert!(result.skipped_noop_reasons.is_empty()); - assert!(result.terminal_error.is_none()); - let root = read_value(&test_ctx, branch_compaction_root_key(database_branch_id)) - .await? - .as_deref() - .map(decode_compaction_root) - .transpose()? - .expect("force hot compaction should publish root"); - assert_eq!(root.hot_watermark_txid, 1); - assert!(read_value(&test_ctx, branch_shard_key(database_branch_id, 0, 1)).await?.is_some()); - assert!(read_value(&test_ctx, branch_pidx_key(database_branch_id, 1)).await?.is_none()); + assert_eq!(result.attempted_job_kinds, vec![CompactionJobKind::Hot]); + assert_eq!(result.completed_job_ids.len(), 1); + assert!(result.skipped_noop_reasons.is_empty()); + assert!(result.terminal_error.is_none()); + let root = read_value(&test_ctx, branch_compaction_root_key(database_branch_id)) + .await? + .as_deref() + .map(decode_compaction_root) + .transpose()? + .expect("force hot compaction should publish root"); + assert_eq!(root.hot_watermark_txid, 1); + assert!( + read_value(&test_ctx, branch_shard_key(database_branch_id, 0, 1)) + .await? + .is_some() + ); + assert!( + read_value(&test_ctx, branch_pidx_key(database_branch_id, 1)) + .await? + .is_none() + ); - test_ctx.shutdown().await?; - Ok(()) - }) + test_ctx.shutdown().await?; + Ok(()) + } + ) } #[tokio::test] async fn force_hot_compaction_writes_pitr_interval_coverage() -> Result<()> { - workflow_matrix!("workflow-force-hot-pitr", build_registry, |_tier, test_ctx| { - let udb = test_ctx.pools().udb()?; - set_bucket_pitr_policy( - &*udb, - BucketId::from_gas_id(test_bucket()), - PitrPolicy { - interval_ms: 10, - retention_ms: 9_000_000_000_000, - }, - ) - .await?; - let database_db = make_test_db(&test_ctx)?; - database_db.commit(vec![dirty_page(1, 0x01)], 2, 1_000).await?; - database_db.commit(vec![dirty_page(1, 0x02)], 2, 1_004).await?; - database_db.commit(vec![dirty_page(1, 0x03)], 2, 1_012).await?; - database_db.commit(vec![dirty_page(1, 0x04)], 2, 1_018).await?; - database_db.commit(vec![dirty_page(1, 0x05)], 2, 1_029).await?; - let database_branch_id = read_database_branch_id(&test_ctx).await?; - let tag_value = database_branch_tag_value(database_branch_id); - let manager_workflow_id = test_ctx - .workflow(DbManagerInput::new(database_branch_id, None)) - .tag(DATABASE_BRANCH_ID_TAG, &tag_value) - .unique() - .dispatch() + workflow_matrix!( + "workflow-force-hot-pitr", + build_registry, + |_tier, test_ctx| { + let udb = test_ctx.pools().udb()?; + set_bucket_pitr_policy( + &*udb, + BucketId::from_gas_id(test_bucket()), + PitrPolicy { + interval_ms: 10, + retention_ms: 9_000_000_000_000, + }, + ) .await?; + let database_db = make_test_db(&test_ctx)?; + database_db + .commit(vec![dirty_page(1, 0x01)], 2, 1_000) + .await?; + database_db + .commit(vec![dirty_page(1, 0x02)], 2, 1_004) + .await?; + database_db + .commit(vec![dirty_page(1, 0x03)], 2, 1_012) + .await?; + database_db + .commit(vec![dirty_page(1, 0x04)], 2, 1_018) + .await?; + database_db + .commit(vec![dirty_page(1, 0x05)], 2, 1_029) + .await?; + let database_branch_id = read_database_branch_id(&test_ctx).await?; + let tag_value = database_branch_tag_value(database_branch_id); + let manager_workflow_id = test_ctx + .workflow(DbManagerInput::new(database_branch_id, None)) + .tag(DATABASE_BRANCH_ID_TAG, &tag_value) + .unique() + .dispatch() + .await?; - let result = force_compaction_and_wait_idle( - &test_ctx, - manager_workflow_id, - database_branch_id, - Id::new_v1(83), - ForceCompactionWork { - hot: true, - cold: false, - reclaim: false, - final_settle: false, - }, - ) - .await?; + let result = force_compaction_and_wait_idle( + &test_ctx, + manager_workflow_id, + database_branch_id, + Id::new_v1(83), + ForceCompactionWork { + hot: true, + cold: false, + reclaim: false, + final_settle: false, + }, + ) + .await?; - assert_eq!(result.attempted_job_kinds, vec![CompactionJobKind::Hot]); - assert!(result.terminal_error.is_none()); - assert_eq!( - read_pitr_interval_txid(&test_ctx, database_branch_id, 1_000).await?, - Some(2) - ); - assert_eq!( - read_pitr_interval_txid(&test_ctx, database_branch_id, 1_010).await?, - Some(4) - ); - assert_eq!( - read_pitr_interval_txid(&test_ctx, database_branch_id, 1_020).await?, - Some(5) - ); - assert_eq!( - read_pitr_interval_txid(&test_ctx, database_branch_id, 1_030).await?, - None - ); - for txid in [2, 4, 5] { - assert!( - read_value(&test_ctx, branch_shard_key(database_branch_id, 0, txid)) - .await? - .is_some() + assert_eq!(result.attempted_job_kinds, vec![CompactionJobKind::Hot]); + assert!(result.terminal_error.is_none()); + assert_eq!( + read_pitr_interval_txid(&test_ctx, database_branch_id, 1_000).await?, + Some(2) ); - } + assert_eq!( + read_pitr_interval_txid(&test_ctx, database_branch_id, 1_010).await?, + Some(4) + ); + assert_eq!( + read_pitr_interval_txid(&test_ctx, database_branch_id, 1_020).await?, + Some(5) + ); + assert_eq!( + read_pitr_interval_txid(&test_ctx, database_branch_id, 1_030).await?, + None + ); + for txid in [2, 4, 5] { + assert!( + read_value(&test_ctx, branch_shard_key(database_branch_id, 0, txid)) + .await? + .is_some() + ); + } - test_ctx.shutdown().await?; - Ok(()) - }) + test_ctx.shutdown().await?; + Ok(()) + } + ) } #[tokio::test] async fn e2e_pitr_timestamp_resolution_uses_force_compacted_interval_coverage() -> Result<()> { - workflow_matrix!("workflow-pitr-timestamp-resolution", build_registry, |_tier, test_ctx| { - let udb = test_ctx.pools().udb()?; - set_bucket_pitr_policy( - &*udb, - BucketId::from_gas_id(test_bucket()), - PitrPolicy { - interval_ms: FIVE_MINUTES_MS, - retention_ms: 9_000_000_000_000, - }, - ) - .await?; - let base_ms = 1_700_000_000_000_i64.div_euclid(FIVE_MINUTES_MS) * FIVE_MINUTES_MS; - let database_db = make_test_db(&test_ctx)?; - database_db - .commit(vec![dirty_page(1, 0x11)], 2, base_ms + 60_000) - .await?; - database_db - .commit(vec![dirty_page(1, 0x22)], 2, base_ms + 240_000) - .await?; - database_db - .commit(vec![dirty_page(1, 0x33)], 2, base_ms + 360_000) - .await?; - database_db - .commit(vec![dirty_page(1, 0x44)], 2, base_ms + 660_000) - .await?; - let database_branch_id = read_database_branch_id(&test_ctx).await?; - let manager_workflow_id = test_ctx - .workflow(DbManagerInput::new(database_branch_id, None)) - .tag(DATABASE_BRANCH_ID_TAG, &database_branch_tag_value(database_branch_id)) - .unique() - .dispatch() + workflow_matrix!( + "workflow-pitr-timestamp-resolution", + build_registry, + |_tier, test_ctx| { + let udb = test_ctx.pools().udb()?; + set_bucket_pitr_policy( + &*udb, + BucketId::from_gas_id(test_bucket()), + PitrPolicy { + interval_ms: FIVE_MINUTES_MS, + retention_ms: 9_000_000_000_000, + }, + ) .await?; + let base_ms = 1_700_000_000_000_i64.div_euclid(FIVE_MINUTES_MS) * FIVE_MINUTES_MS; + let database_db = make_test_db(&test_ctx)?; + database_db + .commit(vec![dirty_page(1, 0x11)], 2, base_ms + 60_000) + .await?; + database_db + .commit(vec![dirty_page(1, 0x22)], 2, base_ms + 240_000) + .await?; + database_db + .commit(vec![dirty_page(1, 0x33)], 2, base_ms + 360_000) + .await?; + database_db + .commit(vec![dirty_page(1, 0x44)], 2, base_ms + 660_000) + .await?; + let database_branch_id = read_database_branch_id(&test_ctx).await?; + let manager_workflow_id = test_ctx + .workflow(DbManagerInput::new(database_branch_id, None)) + .tag( + DATABASE_BRANCH_ID_TAG, + &database_branch_tag_value(database_branch_id), + ) + .unique() + .dispatch() + .await?; - let result = force_compaction_and_wait_idle( - &test_ctx, - manager_workflow_id, - database_branch_id, - Id::new_v1(92), - ForceCompactionWork { - hot: true, - cold: false, - reclaim: false, - final_settle: false, - }, - ) - .await?; + let result = force_compaction_and_wait_idle( + &test_ctx, + manager_workflow_id, + database_branch_id, + Id::new_v1(92), + ForceCompactionWork { + hot: true, + cold: false, + reclaim: false, + final_settle: false, + }, + ) + .await?; - assert_eq!(result.attempted_job_kinds, vec![CompactionJobKind::Hot]); - assert!(result.terminal_error.is_none()); - for (bucket_start_ms, expected_txid, requested_ms, expected_fill) in [ - (base_ms, 2, base_ms + 300_000, 0x22), - (base_ms + FIVE_MINUTES_MS, 3, base_ms + 600_000, 0x33), - (base_ms + 2 * FIVE_MINUTES_MS, 4, base_ms + 900_000, 0x44), - ] { - assert_eq!( - read_pitr_interval_txid(&test_ctx, database_branch_id, bucket_start_ms).await?, - Some(expected_txid) - ); - assert!( - read_value(&test_ctx, branch_shard_key(database_branch_id, 0, expected_txid)) + assert_eq!(result.attempted_job_kinds, vec![CompactionJobKind::Hot]); + assert!(result.terminal_error.is_none()); + for (bucket_start_ms, expected_txid, requested_ms, expected_fill) in [ + (base_ms, 2, base_ms + 300_000, 0x22), + (base_ms + FIVE_MINUTES_MS, 3, base_ms + 600_000, 0x33), + (base_ms + 2 * FIVE_MINUTES_MS, 4, base_ms + 900_000, 0x44), + ] { + assert_eq!( + read_pitr_interval_txid(&test_ctx, database_branch_id, bucket_start_ms).await?, + Some(expected_txid) + ); + assert!( + read_value( + &test_ctx, + branch_shard_key(database_branch_id, 0, expected_txid) + ) .await? .is_some() - ); + ); - let resolved = database_db - .resolve_restore_target(SnapshotSelector::AtTimestamp { timestamp_ms: requested_ms }) - .await?; - assert_eq!(resolved.kind, SnapshotKind::AtTimestamp); - assert_eq!(resolved.txid, expected_txid); - let state = debug::read_at(&database_db, resolved.versionstamp).await?; - assert_eq!(state.txid, expected_txid); - assert_eq!(state.pages[0].bytes.as_deref(), Some(page(expected_fill).as_slice())); - } + let resolved = database_db + .resolve_restore_target(SnapshotSelector::AtTimestamp { + timestamp_ms: requested_ms, + }) + .await?; + assert_eq!(resolved.kind, SnapshotKind::AtTimestamp); + assert_eq!(resolved.txid, expected_txid); + let state = debug::read_at(&database_db, resolved.versionstamp).await?; + assert_eq!(state.txid, expected_txid); + assert_eq!( + state.pages[0].bytes.as_deref(), + Some(page(expected_fill).as_slice()) + ); + } - test_ctx.shutdown().await?; - Ok(()) - }) + test_ctx.shutdown().await?; + Ok(()) + } + ) } #[tokio::test] async fn e2e_pitr_timestamp_resolution_uses_previous_commit_through_quiet_period() -> Result<()> { - workflow_matrix!("workflow-e2e-pitr-timestamp-resolution-uses-previous-commit-through-quiet-period", build_registry, |_tier, test_ctx| { - let udb = test_ctx.pools().udb()?; - set_bucket_pitr_policy( - &*udb, - BucketId::from_gas_id(test_bucket()), - PitrPolicy { - interval_ms: FIVE_MINUTES_MS, - retention_ms: 9_000_000_000_000, - }, - ) - .await?; - let base_ms = 1_700_000_000_000_i64.div_euclid(FIVE_MINUTES_MS) * FIVE_MINUTES_MS; - let database_db = make_test_db(&test_ctx)?; - database_db - .commit(vec![dirty_page(1, 0x51)], 2, base_ms + 60_000) - .await?; - database_db - .commit(vec![dirty_page(1, 0x52)], 2, base_ms + 17 * 60_000) - .await?; - let database_branch_id = read_database_branch_id(&test_ctx).await?; - let manager_workflow_id = test_ctx - .workflow(DbManagerInput::new(database_branch_id, None)) - .tag(DATABASE_BRANCH_ID_TAG, &database_branch_tag_value(database_branch_id)) - .unique() - .dispatch() - .await?; + workflow_matrix!( + "workflow-e2e-pitr-timestamp-resolution-uses-previous-commit-through-quiet-period", + build_registry, + |_tier, test_ctx| { + let udb = test_ctx.pools().udb()?; + set_bucket_pitr_policy( + &*udb, + BucketId::from_gas_id(test_bucket()), + PitrPolicy { + interval_ms: FIVE_MINUTES_MS, + retention_ms: 9_000_000_000_000, + }, + ) + .await?; + let base_ms = 1_700_000_000_000_i64.div_euclid(FIVE_MINUTES_MS) * FIVE_MINUTES_MS; + let database_db = make_test_db(&test_ctx)?; + database_db + .commit(vec![dirty_page(1, 0x51)], 2, base_ms + 60_000) + .await?; + database_db + .commit(vec![dirty_page(1, 0x52)], 2, base_ms + 17 * 60_000) + .await?; + let database_branch_id = read_database_branch_id(&test_ctx).await?; + let manager_workflow_id = test_ctx + .workflow(DbManagerInput::new(database_branch_id, None)) + .tag( + DATABASE_BRANCH_ID_TAG, + &database_branch_tag_value(database_branch_id), + ) + .unique() + .dispatch() + .await?; - force_compaction_and_wait_idle( - &test_ctx, - manager_workflow_id, - database_branch_id, - Id::new_v1(93), - ForceCompactionWork { - hot: true, - cold: false, - reclaim: false, - final_settle: false, - }, - ) - .await?; + force_compaction_and_wait_idle( + &test_ctx, + manager_workflow_id, + database_branch_id, + Id::new_v1(93), + ForceCompactionWork { + hot: true, + cold: false, + reclaim: false, + final_settle: false, + }, + ) + .await?; - assert_eq!( - read_pitr_interval_txid(&test_ctx, database_branch_id, base_ms).await?, - Some(1) - ); - assert_eq!( - read_pitr_interval_txid(&test_ctx, database_branch_id, base_ms + FIVE_MINUTES_MS).await?, - None - ); - assert_eq!( - read_pitr_interval_txid(&test_ctx, database_branch_id, base_ms + 2 * FIVE_MINUTES_MS).await?, - None - ); - let resolved = database_db - .resolve_restore_target(SnapshotSelector::AtTimestamp { - timestamp_ms: base_ms + 12 * 60_000, - }) - .await?; - assert_eq!(resolved.txid, 1); - let state = debug::read_at(&database_db, resolved.versionstamp).await?; - assert_eq!(state.pages[0].bytes.as_deref(), Some(page(0x51).as_slice())); + assert_eq!( + read_pitr_interval_txid(&test_ctx, database_branch_id, base_ms).await?, + Some(1) + ); + assert_eq!( + read_pitr_interval_txid(&test_ctx, database_branch_id, base_ms + FIVE_MINUTES_MS) + .await?, + None + ); + assert_eq!( + read_pitr_interval_txid( + &test_ctx, + database_branch_id, + base_ms + 2 * FIVE_MINUTES_MS + ) + .await?, + None + ); + let resolved = database_db + .resolve_restore_target(SnapshotSelector::AtTimestamp { + timestamp_ms: base_ms + 12 * 60_000, + }) + .await?; + assert_eq!(resolved.txid, 1); + let state = debug::read_at(&database_db, resolved.versionstamp).await?; + assert_eq!(state.pages[0].bytes.as_deref(), Some(page(0x51).as_slice())); - test_ctx.shutdown().await?; - Ok(()) - }) + test_ctx.shutdown().await?; + Ok(()) + } + ) } #[tokio::test] async fn e2e_pitr_timestamp_resolution_expires_after_configured_retention() -> Result<()> { - workflow_matrix!("workflow-e2e-pitr-timestamp-resolution-expires-after-configured-retention", build_registry, |_tier, test_ctx| { - let udb = test_ctx.pools().udb()?; - set_bucket_pitr_policy( - &*udb, - BucketId::from_gas_id(test_bucket()), - PitrPolicy { - interval_ms: 100, - retention_ms: 2_500, - }, - ) - .await?; - let committed_at_ms = current_time_ms()?; - let bucket_start_ms = committed_at_ms.div_euclid(100) * 100; - let database_db = make_test_db(&test_ctx)?; - database_db - .commit(vec![dirty_page(1, 0x61)], 2, committed_at_ms) - .await?; - let database_branch_id = read_database_branch_id(&test_ctx).await?; - let manager_workflow_id = test_ctx - .workflow(DbManagerInput::new(database_branch_id, None)) - .tag(DATABASE_BRANCH_ID_TAG, &database_branch_tag_value(database_branch_id)) - .unique() - .dispatch() - .await?; + workflow_matrix!( + "workflow-e2e-pitr-timestamp-resolution-expires-after-configured-retention", + build_registry, + |_tier, test_ctx| { + let udb = test_ctx.pools().udb()?; + set_bucket_pitr_policy( + &*udb, + BucketId::from_gas_id(test_bucket()), + PitrPolicy { + interval_ms: 100, + retention_ms: 2_500, + }, + ) + .await?; + let committed_at_ms = current_time_ms()?; + let bucket_start_ms = committed_at_ms.div_euclid(100) * 100; + let database_db = make_test_db(&test_ctx)?; + database_db + .commit(vec![dirty_page(1, 0x61)], 2, committed_at_ms) + .await?; + let database_branch_id = read_database_branch_id(&test_ctx).await?; + let manager_workflow_id = test_ctx + .workflow(DbManagerInput::new(database_branch_id, None)) + .tag( + DATABASE_BRANCH_ID_TAG, + &database_branch_tag_value(database_branch_id), + ) + .unique() + .dispatch() + .await?; - force_compaction_and_wait_idle( - &test_ctx, - manager_workflow_id, - database_branch_id, - Id::new_v1(94), - ForceCompactionWork { - hot: true, - cold: false, - reclaim: false, - final_settle: false, - }, - ) - .await?; + force_compaction_and_wait_idle( + &test_ctx, + manager_workflow_id, + database_branch_id, + Id::new_v1(94), + ForceCompactionWork { + hot: true, + cold: false, + reclaim: false, + final_settle: false, + }, + ) + .await?; - let coverage = read_pitr_interval_coverage(&test_ctx, database_branch_id, bucket_start_ms) - .await? - .expect("force hot compaction should publish PITR coverage"); - assert_eq!(coverage.txid, 1); - let resolved = database_db - .resolve_restore_target(SnapshotSelector::AtTimestamp { - timestamp_ms: committed_at_ms, - }) - .await?; - assert_eq!(resolved.txid, 1); - let state = debug::read_at(&database_db, resolved.versionstamp).await?; - assert_eq!(state.pages[0].bytes.as_deref(), Some(page(0x61).as_slice())); + let coverage = + read_pitr_interval_coverage(&test_ctx, database_branch_id, bucket_start_ms) + .await? + .expect("force hot compaction should publish PITR coverage"); + assert_eq!(coverage.txid, 1); + let resolved = database_db + .resolve_restore_target(SnapshotSelector::AtTimestamp { + timestamp_ms: committed_at_ms, + }) + .await?; + assert_eq!(resolved.txid, 1); + let state = debug::read_at(&database_db, resolved.versionstamp).await?; + assert_eq!(state.pages[0].bytes.as_deref(), Some(page(0x61).as_slice())); - wait_until("PITR interval expiry", || async { - if current_time_ms()? > coverage.expires_at_ms { - return Ok(Some(())); - } + wait_until("PITR interval expiry", || async { + if current_time_ms()? > coverage.expires_at_ms { + return Ok(Some(())); + } - Ok(None) - }) - .await?; - let err = database_db - .resolve_restore_target(SnapshotSelector::AtTimestamp { - timestamp_ms: committed_at_ms, - }) - .await - .expect_err("expired PITR interval should reject timestamp resolution"); - assert_storage_error(&err, SqliteStorageError::RestoreTargetExpired); - assert!( - read_pitr_interval_coverage(&test_ctx, database_branch_id, bucket_start_ms) - .await? - .is_some() - ); + Ok(None) + }) + .await?; + let err = database_db + .resolve_restore_target(SnapshotSelector::AtTimestamp { + timestamp_ms: committed_at_ms, + }) + .await + .expect_err("expired PITR interval should reject timestamp resolution"); + assert_storage_error(&err, SqliteStorageError::RestoreTargetExpired); + assert!( + read_pitr_interval_coverage(&test_ctx, database_branch_id, bucket_start_ms) + .await? + .is_some() + ); - test_ctx.shutdown().await?; - Ok(()) - }) + test_ctx.shutdown().await?; + Ok(()) + } + ) } #[tokio::test] async fn e2e_restore_point_remains_readable_after_interval_coverage_expires() -> Result<()> { - workflow_matrix!("workflow-e2e-restore-point-remains-readable-after-interval-coverage-expires", build_registry, |_tier, test_ctx| { - let udb = test_ctx.pools().udb()?; - set_bucket_pitr_policy( - &*udb, - BucketId::from_gas_id(test_bucket()), - PitrPolicy { - interval_ms: 100, - retention_ms: 2_500, - }, - ) - .await?; - let committed_at_ms = current_time_ms()?; - let bucket_start_ms = committed_at_ms.div_euclid(100) * 100; - let database_db = make_test_db(&test_ctx)?; - database_db - .commit(vec![dirty_page(1, 0x62)], 2, committed_at_ms) - .await?; - let database_branch_id = read_database_branch_id(&test_ctx).await?; - let manager_workflow_id = test_ctx - .workflow(DbManagerInput::new(database_branch_id, None)) - .tag(DATABASE_BRANCH_ID_TAG, &database_branch_tag_value(database_branch_id)) - .unique() - .dispatch() - .await?; + workflow_matrix!( + "workflow-e2e-restore-point-remains-readable-after-interval-coverage-expires", + build_registry, + |_tier, test_ctx| { + let udb = test_ctx.pools().udb()?; + set_bucket_pitr_policy( + &*udb, + BucketId::from_gas_id(test_bucket()), + PitrPolicy { + interval_ms: 100, + retention_ms: 2_500, + }, + ) + .await?; + let committed_at_ms = current_time_ms()?; + let bucket_start_ms = committed_at_ms.div_euclid(100) * 100; + let database_db = make_test_db(&test_ctx)?; + database_db + .commit(vec![dirty_page(1, 0x62)], 2, committed_at_ms) + .await?; + let database_branch_id = read_database_branch_id(&test_ctx).await?; + let manager_workflow_id = test_ctx + .workflow(DbManagerInput::new(database_branch_id, None)) + .tag( + DATABASE_BRANCH_ID_TAG, + &database_branch_tag_value(database_branch_id), + ) + .unique() + .dispatch() + .await?; - force_compaction_and_wait_idle( - &test_ctx, - manager_workflow_id, - database_branch_id, - Id::new_v1(95), - ForceCompactionWork { - hot: true, - cold: false, - reclaim: false, - final_settle: false, - }, - ) - .await?; - let restore_point = database_db - .create_restore_point(SnapshotSelector::AtTimestamp { - timestamp_ms: committed_at_ms, - }) - .await?; - let coverage = read_pitr_interval_coverage(&test_ctx, database_branch_id, bucket_start_ms) - .await? - .expect("force hot compaction should publish PITR coverage"); + force_compaction_and_wait_idle( + &test_ctx, + manager_workflow_id, + database_branch_id, + Id::new_v1(95), + ForceCompactionWork { + hot: true, + cold: false, + reclaim: false, + final_settle: false, + }, + ) + .await?; + let restore_point = database_db + .create_restore_point(SnapshotSelector::AtTimestamp { + timestamp_ms: committed_at_ms, + }) + .await?; + let coverage = + read_pitr_interval_coverage(&test_ctx, database_branch_id, bucket_start_ms) + .await? + .expect("force hot compaction should publish PITR coverage"); - wait_until("PITR interval expiry", || async { - if current_time_ms()? > coverage.expires_at_ms { - return Ok(Some(())); - } + wait_until("PITR interval expiry", || async { + if current_time_ms()? > coverage.expires_at_ms { + return Ok(Some(())); + } - Ok(None) - }) - .await?; - let err = database_db - .resolve_restore_target(SnapshotSelector::AtTimestamp { - timestamp_ms: committed_at_ms, - }) - .await - .expect_err("timestamp selector should expire without interval coverage"); - assert_storage_error(&err, SqliteStorageError::RestoreTargetExpired); - let resolved = database_db - .resolve_restore_target(SnapshotSelector::RestorePoint { - restore_point: restore_point.clone(), - }) - .await?; - assert_eq!(resolved.txid, 1); - let state = debug::read_at(&database_db, resolved.versionstamp).await?; - assert_eq!(state.pages[0].bytes.as_deref(), Some(page(0x62).as_slice())); - assert!( - read_value( - &test_ctx, - db_pin_key(database_branch_id, &history_pin::restore_point_pin_id(&restore_point)), - ) - .await? - .is_some() - ); + Ok(None) + }) + .await?; + let err = database_db + .resolve_restore_target(SnapshotSelector::AtTimestamp { + timestamp_ms: committed_at_ms, + }) + .await + .expect_err("timestamp selector should expire without interval coverage"); + assert_storage_error(&err, SqliteStorageError::RestoreTargetExpired); + let resolved = database_db + .resolve_restore_target(SnapshotSelector::RestorePoint { + restore_point: restore_point.clone(), + }) + .await?; + assert_eq!(resolved.txid, 1); + let state = debug::read_at(&database_db, resolved.versionstamp).await?; + assert_eq!(state.pages[0].bytes.as_deref(), Some(page(0x62).as_slice())); + assert!( + read_value( + &test_ctx, + db_pin_key( + database_branch_id, + &history_pin::restore_point_pin_id(&restore_point) + ), + ) + .await? + .is_some() + ); - test_ctx.shutdown().await?; - Ok(()) - }) + test_ctx.shutdown().await?; + Ok(()) + } + ) } #[tokio::test] async fn e2e_fork_and_restore_from_timestamp_selector_read_resolved_commit() -> Result<()> { - workflow_matrix!("workflow-e2e-fork-and-restore-from-timestamp-selector-read-resolved-commit", build_registry, |_tier, test_ctx| { - let udb = test_ctx.pools().udb()?; - set_bucket_pitr_policy( - &*udb, - BucketId::from_gas_id(test_bucket()), - PitrPolicy { - interval_ms: FIVE_MINUTES_MS, - retention_ms: 9_000_000_000_000, - }, - ) - .await?; - let base_ms = 1_700_000_000_000_i64.div_euclid(FIVE_MINUTES_MS) * FIVE_MINUTES_MS; - let database_db = make_test_db(&test_ctx)?; - database_db - .commit(vec![dirty_page(1, 0x71)], 2, base_ms + 60_000) - .await?; - database_db - .commit(vec![dirty_page(1, 0x72)], 2, base_ms + 360_000) - .await?; - let old_branch_id = read_database_branch_id(&test_ctx).await?; - let manager_workflow_id = test_ctx - .workflow(DbManagerInput::new(old_branch_id, None)) - .tag(DATABASE_BRANCH_ID_TAG, &database_branch_tag_value(old_branch_id)) - .unique() - .dispatch() - .await?; - - force_compaction_and_wait_idle( - &test_ctx, - manager_workflow_id, - old_branch_id, - Id::new_v1(96), - ForceCompactionWork { - hot: true, - cold: false, - reclaim: false, - final_settle: false, - }, - ) - .await?; - let selector = SnapshotSelector::AtTimestamp { - timestamp_ms: base_ms + 300_000, - }; - let resolved = database_db.resolve_restore_target(selector.clone()).await?; - assert_eq!(resolved.txid, 1); - assert_eq!( - debug::read_at(&database_db, resolved.versionstamp) - .await? - .pages[0] - .bytes - .as_deref(), - Some(page(0x71).as_slice()) - ); + workflow_matrix!( + "workflow-e2e-fork-and-restore-from-timestamp-selector-read-resolved-commit", + build_registry, + |_tier, test_ctx| { + let udb = test_ctx.pools().udb()?; + set_bucket_pitr_policy( + &*udb, + BucketId::from_gas_id(test_bucket()), + PitrPolicy { + interval_ms: FIVE_MINUTES_MS, + retention_ms: 9_000_000_000_000, + }, + ) + .await?; + let base_ms = 1_700_000_000_000_i64.div_euclid(FIVE_MINUTES_MS) * FIVE_MINUTES_MS; + let database_db = make_test_db(&test_ctx)?; + database_db + .commit(vec![dirty_page(1, 0x71)], 2, base_ms + 60_000) + .await?; + database_db + .commit(vec![dirty_page(1, 0x72)], 2, base_ms + 360_000) + .await?; + let old_branch_id = read_database_branch_id(&test_ctx).await?; + let manager_workflow_id = test_ctx + .workflow(DbManagerInput::new(old_branch_id, None)) + .tag( + DATABASE_BRANCH_ID_TAG, + &database_branch_tag_value(old_branch_id), + ) + .unique() + .dispatch() + .await?; - let forked_database_id = branch::fork_database( - &*udb, - BucketId::from_gas_id(test_bucket()), - TEST_DATABASE.to_string(), - selector.clone(), - BucketId::from_gas_id(test_bucket())) - .await?; - let forked_db = make_test_db_for(&test_ctx, forked_database_id.clone())?; - assert_eq!( - forked_db.get_pages(vec![1]).await?, - vec![FetchedPage { - pgno: 1, - bytes: Some(page(0x71)), - }] - ); - let forked_branch_id = read_named_database_branch_id(&test_ctx, &forked_database_id).await?; - let forked_head_at_fork = read_value(&test_ctx, branch_meta_head_at_fork_key(forked_branch_id)) - .await? - .expect("forked branch should store head_at_fork"); - assert_eq!(decode_db_head(&forked_head_at_fork)?.head_txid, 1); + force_compaction_and_wait_idle( + &test_ctx, + manager_workflow_id, + old_branch_id, + Id::new_v1(96), + ForceCompactionWork { + hot: true, + cold: false, + reclaim: false, + final_settle: false, + }, + ) + .await?; + let selector = SnapshotSelector::AtTimestamp { + timestamp_ms: base_ms + 300_000, + }; + let resolved = database_db.resolve_restore_target(selector.clone()).await?; + assert_eq!(resolved.txid, 1); + assert_eq!( + debug::read_at(&database_db, resolved.versionstamp) + .await? + .pages[0] + .bytes + .as_deref(), + Some(page(0x71).as_slice()) + ); - let undo_restore_point = database_db.restore_database(selector).await?; - let restored_db = make_test_db(&test_ctx)?; - assert_eq!( - restored_db.get_pages(vec![1]).await?, - vec![FetchedPage { - pgno: 1, - bytes: Some(page(0x71)), - }] - ); - let restored_branch_id = read_database_branch_id(&test_ctx).await?; - assert_ne!(restored_branch_id, old_branch_id); - let restored_head_at_fork = read_value(&test_ctx, branch_meta_head_at_fork_key(restored_branch_id)) - .await? - .expect("restored branch should store head_at_fork"); - assert_eq!(decode_db_head(&restored_head_at_fork)?.head_txid, 1); - assert!( - read_value( - &test_ctx, - db_pin_key(old_branch_id, &history_pin::restore_point_pin_id(&undo_restore_point)), - ) - .await? - .is_some() - ); + let forked_database_id = branch::fork_database( + &*udb, + BucketId::from_gas_id(test_bucket()), + TEST_DATABASE.to_string(), + selector.clone(), + BucketId::from_gas_id(test_bucket()), + ) + .await?; + let forked_db = make_test_db_for(&test_ctx, forked_database_id.clone())?; + assert_eq!( + forked_db.get_pages(vec![1]).await?, + vec![FetchedPage { + pgno: 1, + bytes: Some(page(0x71)), + }] + ); + let forked_branch_id = + read_named_database_branch_id(&test_ctx, &forked_database_id).await?; + let forked_head_at_fork = + read_value(&test_ctx, branch_meta_head_at_fork_key(forked_branch_id)) + .await? + .expect("forked branch should store head_at_fork"); + assert_eq!(decode_db_head(&forked_head_at_fork)?.head_txid, 1); - test_ctx.shutdown().await?; - Ok(()) - }) + let undo_restore_point = database_db.restore_database(selector).await?; + let restored_db = make_test_db(&test_ctx)?; + assert_eq!( + restored_db.get_pages(vec![1]).await?, + vec![FetchedPage { + pgno: 1, + bytes: Some(page(0x71)), + }] + ); + let restored_branch_id = read_database_branch_id(&test_ctx).await?; + assert_ne!(restored_branch_id, old_branch_id); + let restored_head_at_fork = + read_value(&test_ctx, branch_meta_head_at_fork_key(restored_branch_id)) + .await? + .expect("restored branch should store head_at_fork"); + assert_eq!(decode_db_head(&restored_head_at_fork)?.head_txid, 1); + assert!( + read_value( + &test_ctx, + db_pin_key( + old_branch_id, + &history_pin::restore_point_pin_id(&undo_restore_point) + ), + ) + .await? + .is_some() + ); + + test_ctx.shutdown().await?; + Ok(()) + } + ) } #[tokio::test] async fn hot_compacter_rejects_stale_pitr_interval_selection() -> Result<()> { - workflow_matrix!("workflow-hot-compacter-rejects-stale-pitr-interval-selection", build_registry, |_tier, test_ctx| { - let udb = test_ctx.pools().udb()?; - set_bucket_pitr_policy( - &*udb, - BucketId::from_gas_id(test_bucket()), - PitrPolicy { - interval_ms: 5, - retention_ms: 9_000_000_000_000, - }, - ) - .await?; - let database_db = make_test_db(&test_ctx)?; - database_db.commit(vec![dirty_page(1, 0x01)], 2, 1_000).await?; - database_db.commit(vec![dirty_page(1, 0x02)], 2, 1_004).await?; - database_db.commit(vec![dirty_page(1, 0x03)], 2, 1_012).await?; - database_db.commit(vec![dirty_page(1, 0x04)], 2, 1_018).await?; - let database_branch_id = read_database_branch_id(&test_ctx).await?; - let tag_value = database_branch_tag_value(database_branch_id); - let manager_workflow_id = test_ctx - .workflow(DbManagerInput::new(database_branch_id, None)) - .tag(DATABASE_BRANCH_ID_TAG, &tag_value) - .unique() - .dispatch() - .await?; - let hot_workflow_id = - wait_for_workflow::(&test_ctx, database_branch_id).await?; - set_database_pitr_policy_override( - &*udb, - BucketId::from_gas_id(test_bucket()), - TEST_DATABASE, - PitrPolicy { - interval_ms: 10, - retention_ms: 9_000_000_000_000, - }, - ) - .await?; - let stale_job_id = Id::new_v1(84); - - let signal_id = test_ctx - .signal(RunHotJob { - database_branch_id, - job_id: stale_job_id, - job_kind: CompactionJobKind::Hot, - base_lifecycle_generation: 0, - base_manifest_generation: 0, - input_fingerprint: [9; 32], - status: CompactionJobStatus::Requested, - input_range: HotJobInputRange { - txids: TxidRange { - min_txid: 1, - max_txid: 4, + workflow_matrix!( + "workflow-hot-compacter-rejects-stale-pitr-interval-selection", + build_registry, + |_tier, test_ctx| { + let udb = test_ctx.pools().udb()?; + set_bucket_pitr_policy( + &*udb, + BucketId::from_gas_id(test_bucket()), + PitrPolicy { + interval_ms: 5, + retention_ms: 9_000_000_000_000, }, - coverage_txids: vec![2, 3, 4], - max_pages: 1, - max_bytes: 1, - }, - }) - .to_workflow_id(hot_workflow_id) - .send() - .await? - .expect("signal should target hot compacter workflow"); - wait_for_signal_ack(&test_ctx, signal_id).await?; - let finished = - wait_for_hot_job_finished_signal(&test_ctx, manager_workflow_id, stale_job_id).await?; - - assert_eq!(finished.job_id, stale_job_id); - assert!(matches!( - finished.status, - CompactionJobStatus::Rejected { ref reason } - if reason == "hot compaction coverage targets changed" - )); - assert!(finished.output_refs.is_empty()); + ) + .await?; + let database_db = make_test_db(&test_ctx)?; + database_db + .commit(vec![dirty_page(1, 0x01)], 2, 1_000) + .await?; + database_db + .commit(vec![dirty_page(1, 0x02)], 2, 1_004) + .await?; + database_db + .commit(vec![dirty_page(1, 0x03)], 2, 1_012) + .await?; + database_db + .commit(vec![dirty_page(1, 0x04)], 2, 1_018) + .await?; + let database_branch_id = read_database_branch_id(&test_ctx).await?; + let tag_value = database_branch_tag_value(database_branch_id); + let manager_workflow_id = test_ctx + .workflow(DbManagerInput::new(database_branch_id, None)) + .tag(DATABASE_BRANCH_ID_TAG, &tag_value) + .unique() + .dispatch() + .await?; + let hot_workflow_id = + wait_for_workflow::(&test_ctx, database_branch_id).await?; + set_database_pitr_policy_override( + &*udb, + BucketId::from_gas_id(test_bucket()), + TEST_DATABASE, + PitrPolicy { + interval_ms: 10, + retention_ms: 9_000_000_000_000, + }, + ) + .await?; + let stale_job_id = Id::new_v1(84); - test_ctx.shutdown().await?; - Ok(()) - }) + let signal_id = test_ctx + .signal(RunHotJob { + database_branch_id, + job_id: stale_job_id, + job_kind: CompactionJobKind::Hot, + base_lifecycle_generation: 0, + base_manifest_generation: 0, + input_fingerprint: [9; 32], + status: CompactionJobStatus::Requested, + input_range: HotJobInputRange { + txids: TxidRange { + min_txid: 1, + max_txid: 4, + }, + coverage_txids: vec![2, 3, 4], + max_pages: 1, + max_bytes: 1, + }, + }) + .to_workflow_id(hot_workflow_id) + .send() + .await? + .expect("signal should target hot compacter workflow"); + wait_for_signal_ack(&test_ctx, signal_id).await?; + let finished = + wait_for_hot_job_finished_signal(&test_ctx, manager_workflow_id, stale_job_id) + .await?; + + assert_eq!(finished.job_id, stale_job_id); + assert!(matches!( + finished.status, + CompactionJobStatus::Rejected { ref reason } + if reason == "hot compaction coverage targets changed" + )); + assert!(finished.output_refs.is_empty()); + + test_ctx.shutdown().await?; + Ok(()) + } + ) } #[tokio::test] async fn hot_install_rejects_staged_output_after_concurrent_commit() -> Result<()> { - workflow_matrix!("workflow-hot-install-rejects-staged-output-after-concurrent-commit", build_registry, |_tier, test_ctx| { - let database_db = make_test_db(&test_ctx)?; - database_db.commit(vec![dirty_page(1, 0xa1)], 2, 1_001).await?; - let database_branch_id = read_database_branch_id(&test_ctx).await?; - let tag_value = database_branch_tag_value(database_branch_id); - let manager_workflow_id = test_ctx - .workflow(DbManagerInput::new(database_branch_id, None)) - .tag(DATABASE_BRANCH_ID_TAG, &tag_value) - .unique() - .dispatch() - .await?; - wait_for_manager_state(&test_ctx, manager_workflow_id, manager_has_distinct_companions).await?; - - let (_pause_guard, reached_hot_stage, release_hot_stage) = - test_hooks::pause_after_hot_stage(database_branch_id); - let request_id = Id::new_v1(64); - let signal_id = test_ctx - .signal(ForceCompaction { - database_branch_id, - request_id, - requested_work: ForceCompactionWork { - hot: true, - cold: false, - reclaim: false, - final_settle: false, - }, - }) - .to_workflow_id(manager_workflow_id) - .send() - .await? - .expect("signal should target manager workflow"); - wait_for_signal_ack(&test_ctx, signal_id).await?; + workflow_matrix!( + "workflow-hot-install-rejects-staged-output-after-concurrent-commit", + build_registry, + |_tier, test_ctx| { + let database_db = make_test_db(&test_ctx)?; + database_db + .commit(vec![dirty_page(1, 0xa1)], 2, 1_001) + .await?; + let database_branch_id = read_database_branch_id(&test_ctx).await?; + let tag_value = database_branch_tag_value(database_branch_id); + let manager_workflow_id = test_ctx + .workflow(DbManagerInput::new(database_branch_id, None)) + .tag(DATABASE_BRANCH_ID_TAG, &tag_value) + .unique() + .dispatch() + .await?; + wait_for_manager_state( + &test_ctx, + manager_workflow_id, + manager_has_distinct_companions, + ) + .await?; - tokio::time::timeout(Duration::from_secs(5), reached_hot_stage.notified()).await?; - let active_hot_job = wait_for_manager_state(&test_ctx, manager_workflow_id, |state| { - state.active_jobs.hot.is_some() - }) - .await? - .active_jobs - .hot - .expect("manager should hold the staged hot job active"); - assert_eq!(active_hot_job.input_range.txids.max_txid, 1); - let staged_rows = - wait_for_staged_hot_rows(&test_ctx, database_branch_id, active_hot_job.job_id).await?; - assert!(!staged_rows.is_empty()); - - database_db.commit(vec![dirty_page(1, 0xa2)], 2, 1_002).await?; - release_hot_stage.notify_one(); - drop(_pause_guard); + let (_pause_guard, reached_hot_stage, release_hot_stage) = + test_hooks::pause_after_hot_stage(database_branch_id); + let request_id = Id::new_v1(64); + let signal_id = test_ctx + .signal(ForceCompaction { + database_branch_id, + request_id, + requested_work: ForceCompactionWork { + hot: true, + cold: false, + reclaim: false, + final_settle: false, + }, + }) + .to_workflow_id(manager_workflow_id) + .send() + .await? + .expect("signal should target manager workflow"); + wait_for_signal_ack(&test_ctx, signal_id).await?; - wait_for_manager_state(&test_ctx, manager_workflow_id, |state| { - state.active_jobs.hot.is_none() - }) - .await?; - assert!( - read_value(&test_ctx, branch_shard_key(database_branch_id, 0, 1)) + tokio::time::timeout(Duration::from_secs(5), reached_hot_stage.notified()).await?; + let active_hot_job = wait_for_manager_state(&test_ctx, manager_workflow_id, |state| { + state.active_jobs.hot.is_some() + }) .await? - .is_none() - ); - assert_eq!( - database_db.get_pages(vec![1]).await?, - vec![FetchedPage { - pgno: 1, - bytes: Some(page(0xa2)), - }] - ); + .active_jobs + .hot + .expect("manager should hold the staged hot job active"); + assert_eq!(active_hot_job.input_range.txids.max_txid, 1); + let staged_rows = + wait_for_staged_hot_rows(&test_ctx, database_branch_id, active_hot_job.job_id) + .await?; + assert!(!staged_rows.is_empty()); + + database_db + .commit(vec![dirty_page(1, 0xa2)], 2, 1_002) + .await?; + release_hot_stage.notify_one(); + drop(_pause_guard); - test_ctx.shutdown().await?; - Ok(()) - }) + wait_for_manager_state(&test_ctx, manager_workflow_id, |state| { + state.active_jobs.hot.is_none() + }) + .await?; + assert!( + read_value(&test_ctx, branch_shard_key(database_branch_id, 0, 1)) + .await? + .is_none() + ); + assert_eq!( + database_db.get_pages(vec![1]).await?, + vec![FetchedPage { + pgno: 1, + bytes: Some(page(0xa2)), + }] + ); + + test_ctx.shutdown().await?; + Ok(()) + } + ) } #[tokio::test] async fn force_reclaim_waits_for_reclaim_completion() -> Result<()> { let database_branch_id = database_branch_id(0x3333_2233_4455_6677_8899_aabb_ccdd_eeff); - workflow_matrix!("workflow-force-reclaim-waits-for-reclaim-completion", build_registry, |_tier, test_ctx| { - let tag_value = database_branch_tag_value(database_branch_id); - seed_manager_branch( - &test_ctx, - database_branch_id, - 1, - Some(CompactionRoot { - schema_version: 1, - manifest_generation: 1, - hot_watermark_txid: 1, - cold_watermark_txid: 0, - cold_watermark_versionstamp: [0; 16], - }), - None, - ) - .await?; - publish_test_shard_and_clear_pidx(&test_ctx, database_branch_id, 1).await?; + workflow_matrix!( + "workflow-force-reclaim-waits-for-reclaim-completion", + build_registry, + |_tier, test_ctx| { + let tag_value = database_branch_tag_value(database_branch_id); + seed_manager_branch( + &test_ctx, + database_branch_id, + 1, + Some(CompactionRoot { + schema_version: 1, + manifest_generation: 1, + hot_watermark_txid: 1, + cold_watermark_txid: 0, + cold_watermark_versionstamp: [0; 16], + }), + None, + ) + .await?; + publish_test_shard_and_clear_pidx(&test_ctx, database_branch_id, 1).await?; - let manager_workflow_id = test_ctx - .workflow(DbManagerInput::new(database_branch_id, None)) - .tag(DATABASE_BRANCH_ID_TAG, &tag_value) - .unique() - .dispatch() - .await?; - let request_id = Id::new_v1(44); - let result = force_compaction_and_wait_idle( - &test_ctx, - manager_workflow_id, - database_branch_id, - request_id, - ForceCompactionWork { - hot: false, - cold: false, - reclaim: true, - final_settle: false, - }, - ) - .await?; + let manager_workflow_id = test_ctx + .workflow(DbManagerInput::new(database_branch_id, None)) + .tag(DATABASE_BRANCH_ID_TAG, &tag_value) + .unique() + .dispatch() + .await?; + let request_id = Id::new_v1(44); + let result = force_compaction_and_wait_idle( + &test_ctx, + manager_workflow_id, + database_branch_id, + request_id, + ForceCompactionWork { + hot: false, + cold: false, + reclaim: true, + final_settle: false, + }, + ) + .await?; - assert_eq!(result.attempted_job_kinds, vec![CompactionJobKind::Reclaim]); - assert_eq!(result.completed_job_ids.len(), 1); - assert!(result.terminal_error.is_none()); - assert!( - read_value(&test_ctx, branch_delta_chunk_key(database_branch_id, 1, 0)) - .await? - .is_none() - ); - assert!( - read_value(&test_ctx, branch_commit_key(database_branch_id, 1)) - .await? - .is_none() - ); + assert_eq!(result.attempted_job_kinds, vec![CompactionJobKind::Reclaim]); + assert_eq!(result.completed_job_ids.len(), 1); + assert!(result.terminal_error.is_none()); + assert!( + read_value(&test_ctx, branch_delta_chunk_key(database_branch_id, 1, 0)) + .await? + .is_none() + ); + assert!( + read_value(&test_ctx, branch_commit_key(database_branch_id, 1)) + .await? + .is_none() + ); - test_ctx.shutdown().await?; - Ok(()) - }) + test_ctx.shutdown().await?; + Ok(()) + } + ) } #[tokio::test] async fn force_reclaim_reports_pidx_safety_gate() -> Result<()> { let database_branch_id = database_branch_id(0x3334_2233_4455_6677_8899_aabb_ccdd_eeff); - workflow_matrix!("workflow-force-reclaim-reports-pidx-safety-gate", build_registry, |_tier, test_ctx| { - let tag_value = database_branch_tag_value(database_branch_id); - seed_manager_branch( - &test_ctx, - database_branch_id, - 1, - Some(CompactionRoot { - schema_version: 1, - manifest_generation: 1, - hot_watermark_txid: 1, - cold_watermark_txid: 0, - cold_watermark_versionstamp: [0; 16], - }), - None, - ) - .await?; + workflow_matrix!( + "workflow-force-reclaim-reports-pidx-safety-gate", + build_registry, + |_tier, test_ctx| { + let tag_value = database_branch_tag_value(database_branch_id); + seed_manager_branch( + &test_ctx, + database_branch_id, + 1, + Some(CompactionRoot { + schema_version: 1, + manifest_generation: 1, + hot_watermark_txid: 1, + cold_watermark_txid: 0, + cold_watermark_versionstamp: [0; 16], + }), + None, + ) + .await?; - let manager_workflow_id = test_ctx - .workflow(DbManagerInput::new(database_branch_id, None)) - .tag(DATABASE_BRANCH_ID_TAG, &tag_value) - .unique() - .dispatch() - .await?; - let result = force_compaction_and_wait_idle( - &test_ctx, - manager_workflow_id, - database_branch_id, - Id::new_v1(46), - ForceCompactionWork { - hot: false, - cold: false, - reclaim: true, - final_settle: false, - }, - ) - .await?; + let manager_workflow_id = test_ctx + .workflow(DbManagerInput::new(database_branch_id, None)) + .tag(DATABASE_BRANCH_ID_TAG, &tag_value) + .unique() + .dispatch() + .await?; + let result = force_compaction_and_wait_idle( + &test_ctx, + manager_workflow_id, + database_branch_id, + Id::new_v1(46), + ForceCompactionWork { + hot: false, + cold: false, + reclaim: true, + final_settle: false, + }, + ) + .await?; - assert!(result.attempted_job_kinds.is_empty()); - assert!(result.completed_job_ids.is_empty()); - assert_eq!( - result.skipped_noop_reasons, - vec!["reclaim:pidx-dependencies".to_string()] - ); - assert!(result.terminal_error.is_none()); + assert!(result.attempted_job_kinds.is_empty()); + assert!(result.completed_job_ids.is_empty()); + assert_eq!( + result.skipped_noop_reasons, + vec!["reclaim:pidx-dependencies".to_string()] + ); + assert!(result.terminal_error.is_none()); - test_ctx.shutdown().await?; - Ok(()) - }) -} + test_ctx.shutdown().await?; + Ok(()) + } + ) +} #[tokio::test] async fn e2e_force_hot_compaction_preserves_reads_after_pidx_clear() -> Result<()> { - workflow_matrix!("workflow-e2e-force-hot-compaction-preserves-reads-after-pidx-clear", build_registry, |_tier, test_ctx| { - let database_db = make_test_db(&test_ctx)?; - database_db - .commit(vec![dirty_page(1, 0x11), dirty_page(2, 0x22)], 3, 1_001) - .await?; - let database_branch_id = read_database_branch_id(&test_ctx).await?; - let tag_value = database_branch_tag_value(database_branch_id); - let manager_workflow_id = test_ctx - .workflow(DbManagerInput::new(database_branch_id, None)) - .tag(DATABASE_BRANCH_ID_TAG, &tag_value) - .unique() - .dispatch() - .await?; + workflow_matrix!( + "workflow-e2e-force-hot-compaction-preserves-reads-after-pidx-clear", + build_registry, + |_tier, test_ctx| { + let database_db = make_test_db(&test_ctx)?; + database_db + .commit(vec![dirty_page(1, 0x11), dirty_page(2, 0x22)], 3, 1_001) + .await?; + let database_branch_id = read_database_branch_id(&test_ctx).await?; + let tag_value = database_branch_tag_value(database_branch_id); + let manager_workflow_id = test_ctx + .workflow(DbManagerInput::new(database_branch_id, None)) + .tag(DATABASE_BRANCH_ID_TAG, &tag_value) + .unique() + .dispatch() + .await?; - let result = force_compaction_and_wait_idle( - &test_ctx, - manager_workflow_id, - database_branch_id, - Id::new_v1(45), - ForceCompactionWork { - hot: true, - cold: false, - reclaim: false, - final_settle: false, - }, - ) - .await?; + let result = force_compaction_and_wait_idle( + &test_ctx, + manager_workflow_id, + database_branch_id, + Id::new_v1(45), + ForceCompactionWork { + hot: true, + cold: false, + reclaim: false, + final_settle: false, + }, + ) + .await?; - assert_eq!(result.attempted_job_kinds, vec![CompactionJobKind::Hot]); - assert!(result.terminal_error.is_none()); - assert_eq!( - database_db.get_pages(vec![1, 2]).await?, - vec![ - FetchedPage { - pgno: 1, - bytes: Some(page(0x11)), - }, - FetchedPage { - pgno: 2, - bytes: Some(page(0x22)), - }, - ] - ); - assert!(read_value(&test_ctx, branch_pidx_key(database_branch_id, 1)).await?.is_none()); - assert!(read_value(&test_ctx, branch_pidx_key(database_branch_id, 2)).await?.is_none()); - assert!(read_value(&test_ctx, branch_shard_key(database_branch_id, 0, 1)).await?.is_some()); - let root = read_value(&test_ctx, branch_compaction_root_key(database_branch_id)) - .await? - .as_deref() - .map(decode_compaction_root) - .transpose()? - .expect("hot force compaction should publish a root"); - assert_eq!(root.hot_watermark_txid, 1); + assert_eq!(result.attempted_job_kinds, vec![CompactionJobKind::Hot]); + assert!(result.terminal_error.is_none()); + assert_eq!( + database_db.get_pages(vec![1, 2]).await?, + vec![ + FetchedPage { + pgno: 1, + bytes: Some(page(0x11)), + }, + FetchedPage { + pgno: 2, + bytes: Some(page(0x22)), + }, + ] + ); + assert!( + read_value(&test_ctx, branch_pidx_key(database_branch_id, 1)) + .await? + .is_none() + ); + assert!( + read_value(&test_ctx, branch_pidx_key(database_branch_id, 2)) + .await? + .is_none() + ); + assert!( + read_value(&test_ctx, branch_shard_key(database_branch_id, 0, 1)) + .await? + .is_some() + ); + let root = read_value(&test_ctx, branch_compaction_root_key(database_branch_id)) + .await? + .as_deref() + .map(decode_compaction_root) + .transpose()? + .expect("hot force compaction should publish a root"); + assert_eq!(root.hot_watermark_txid, 1); - test_ctx.shutdown().await?; - Ok(()) - }) + test_ctx.shutdown().await?; + Ok(()) + } + ) } #[tokio::test] async fn e2e_force_reclaim_removes_hot_rows_and_keeps_reads() -> Result<()> { - workflow_matrix!("workflow-e2e-force-reclaim-removes-hot-rows-and-keeps-reads", build_registry, |_tier, test_ctx| { - let database_db = make_test_db(&test_ctx)?; - database_db.commit(vec![dirty_page(1, 0x33)], 2, 1_001).await?; - let database_branch_id = read_database_branch_id(&test_ctx).await?; - let commit = read_value(&test_ctx, branch_commit_key(database_branch_id, 1)) - .await? - .as_deref() - .map(decode_commit_row) - .transpose()? - .expect("commit should exist before reclaim"); - let tag_value = database_branch_tag_value(database_branch_id); - let manager_workflow_id = test_ctx - .workflow(DbManagerInput::new(database_branch_id, None)) - .tag(DATABASE_BRANCH_ID_TAG, &tag_value) - .unique() - .dispatch() - .await?; - - force_compaction_and_wait_idle( - &test_ctx, - manager_workflow_id, - database_branch_id, - Id::new_v1(46), - ForceCompactionWork { - hot: true, - cold: false, - reclaim: false, - final_settle: false, - }, - ) - .await?; - let result = force_compaction_and_wait_idle( - &test_ctx, - manager_workflow_id, - database_branch_id, - Id::new_v1(47), - ForceCompactionWork { - hot: false, - cold: false, - reclaim: true, - final_settle: false, - }, - ) - .await?; - - assert!( - result.attempted_job_kinds.is_empty() - || result.attempted_job_kinds == vec![CompactionJobKind::Reclaim] - ); - assert!(result.terminal_error.is_none()); - assert_eq!( - database_db.get_pages(vec![1]).await?, - vec![FetchedPage { - pgno: 1, - bytes: Some(page(0x33)), - }] - ); - assert!( - read_value(&test_ctx, branch_delta_chunk_key(database_branch_id, 1, 0)) - .await? - .is_none() - ); - assert!(read_value(&test_ctx, branch_commit_key(database_branch_id, 1)).await?.is_none()); - assert!( - read_value(&test_ctx, branch_vtx_key(database_branch_id, commit.versionstamp)) - .await? - .is_none() - ); - assert!(read_value(&test_ctx, branch_shard_key(database_branch_id, 0, 1)).await?.is_some()); - - test_ctx.shutdown().await?; - Ok(()) - }) -} + workflow_matrix!( + "workflow-e2e-force-reclaim-removes-hot-rows-and-keeps-reads", + build_registry, + |_tier, test_ctx| { + let database_db = make_test_db(&test_ctx)?; + database_db + .commit(vec![dirty_page(1, 0x33)], 2, 1_001) + .await?; + let database_branch_id = read_database_branch_id(&test_ctx).await?; + let commit = read_value(&test_ctx, branch_commit_key(database_branch_id, 1)) + .await? + .as_deref() + .map(decode_commit_row) + .transpose()? + .expect("commit should exist before reclaim"); + let tag_value = database_branch_tag_value(database_branch_id); + let manager_workflow_id = test_ctx + .workflow(DbManagerInput::new(database_branch_id, None)) + .tag(DATABASE_BRANCH_ID_TAG, &tag_value) + .unique() + .dispatch() + .await?; -#[tokio::test] -async fn e2e_force_compaction_preserves_exact_restore_point_txid() -> Result<()> { - workflow_matrix!("workflow-e2e-force-compaction-preserves-exact-restore-point-txid", build_registry, |_tier, test_ctx| { - let database_db = make_test_db(&test_ctx)?; - database_db.commit(vec![dirty_page(1, 0x41)], 2, 1_001).await?; - let restore_point = database_db.create_restore_point(depot::types::SnapshotSelector::Latest).await?; - database_db.commit(vec![dirty_page(1, 0x42)], 2, 1_002).await?; - let database_branch_id = read_database_branch_id(&test_ctx).await?; - let tag_value = database_branch_tag_value(database_branch_id); - let manager_workflow_id = test_ctx - .workflow(DbManagerInput::new(database_branch_id, None)) - .tag(DATABASE_BRANCH_ID_TAG, &tag_value) - .unique() - .dispatch() - .await?; + force_compaction_and_wait_idle( + &test_ctx, + manager_workflow_id, + database_branch_id, + Id::new_v1(46), + ForceCompactionWork { + hot: true, + cold: false, + reclaim: false, + final_settle: false, + }, + ) + .await?; + let result = force_compaction_and_wait_idle( + &test_ctx, + manager_workflow_id, + database_branch_id, + Id::new_v1(47), + ForceCompactionWork { + hot: false, + cold: false, + reclaim: true, + final_settle: false, + }, + ) + .await?; - force_compaction_and_wait_idle( - &test_ctx, - manager_workflow_id, - database_branch_id, - Id::new_v1(48), - ForceCompactionWork { - hot: true, - cold: false, - reclaim: false, - final_settle: false, - }, - ) - .await?; - force_compaction_and_wait_idle( - &test_ctx, - manager_workflow_id, - database_branch_id, - Id::new_v1(49), - ForceCompactionWork { - hot: false, - cold: false, - reclaim: true, - final_settle: false, - }, - ) - .await?; + assert!( + result.attempted_job_kinds.is_empty() + || result.attempted_job_kinds == vec![CompactionJobKind::Reclaim] + ); + assert!(result.terminal_error.is_none()); + assert_eq!( + database_db.get_pages(vec![1]).await?, + vec![FetchedPage { + pgno: 1, + bytes: Some(page(0x33)), + }] + ); + assert!( + read_value(&test_ctx, branch_delta_chunk_key(database_branch_id, 1, 0)) + .await? + .is_none() + ); + assert!( + read_value(&test_ctx, branch_commit_key(database_branch_id, 1)) + .await? + .is_none() + ); + assert!( + read_value( + &test_ctx, + branch_vtx_key(database_branch_id, commit.versionstamp) + ) + .await? + .is_none() + ); + assert!( + read_value(&test_ctx, branch_shard_key(database_branch_id, 0, 1)) + .await? + .is_some() + ); - assert_eq!( - database_db.get_pages(vec![1]).await?, - vec![FetchedPage { - pgno: 1, - bytes: Some(page(0x42)), - }] - ); - let pinned_shard = read_value(&test_ctx, branch_shard_key(database_branch_id, 0, 1)) - .await? - .expect("pinned txid shard should be published exactly"); - let latest_shard = read_value(&test_ctx, branch_shard_key(database_branch_id, 0, 2)) - .await? - .expect("latest txid shard should be published"); - assert_eq!(decode_ltx_v3(&pinned_shard)?.get_page(1), Some(page(0x41).as_slice())); - assert_eq!(decode_ltx_v3(&latest_shard)?.get_page(1), Some(page(0x42).as_slice())); - assert!(read_value(&test_ctx, branch_delta_chunk_key(database_branch_id, 1, 0)).await?.is_some()); - assert!(read_value(&test_ctx, branch_commit_key(database_branch_id, 1)).await?.is_some()); - let pin_bytes = read_value( - &test_ctx, - db_pin_key(database_branch_id, &history_pin::restore_point_pin_id(&restore_point)), + test_ctx.shutdown().await?; + Ok(()) + } ) - .await? - .expect("restore_point DB_PIN should exist"); - let pin = decode_db_history_pin(&pin_bytes)?; - assert_eq!(pin.kind, DbHistoryPinKind::RestorePoint); - assert_eq!(pin.at_txid, 1); - - test_ctx.shutdown().await?; - Ok(()) - }) } #[tokio::test] -async fn e2e_force_reclaim_materializes_bucket_fork_pin() -> Result<()> { - workflow_matrix!("workflow-e2e-force-reclaim-materializes-bucket-fork-pin", build_registry, |_tier, test_ctx| { - let database_db = make_test_db(&test_ctx)?; - database_db.commit(vec![dirty_page(1, 0x51)], 2, 1_001).await?; - let database_branch_id = read_database_branch_id(&test_ctx).await?; - let source_bucket_branch_id = read_bucket_branch_id(&test_ctx).await?; - let fork_commit = read_value(&test_ctx, branch_commit_key(database_branch_id, 1)) - .await? - .as_deref() - .map(decode_commit_row) - .transpose()? - .expect("fork-point commit should exist"); - let udb_pool = test_ctx.pools().udb()?; - let udb = Arc::new((*udb_pool).clone()); - let forked_bucket = branch::fork_bucket( - udb.as_ref(), - BucketId::from_gas_id(test_bucket()), - ResolvedVersionstamp { - versionstamp: fork_commit.versionstamp, - restore_point: None, - }) - .await?; - database_db.commit(vec![dirty_page(1, 0x52)], 2, 1_002).await?; - let tag_value = database_branch_tag_value(database_branch_id); - let manager_workflow_id = test_ctx - .workflow(DbManagerInput::new(database_branch_id, None)) - .tag(DATABASE_BRANCH_ID_TAG, &tag_value) - .unique() - .dispatch() - .await?; +async fn e2e_force_compaction_preserves_exact_restore_point_txid() -> Result<()> { + workflow_matrix!( + "workflow-e2e-force-compaction-preserves-exact-restore-point-txid", + build_registry, + |_tier, test_ctx| { + let database_db = make_test_db(&test_ctx)?; + database_db + .commit(vec![dirty_page(1, 0x41)], 2, 1_001) + .await?; + let restore_point = database_db + .create_restore_point(depot::types::SnapshotSelector::Latest) + .await?; + database_db + .commit(vec![dirty_page(1, 0x42)], 2, 1_002) + .await?; + let database_branch_id = read_database_branch_id(&test_ctx).await?; + let tag_value = database_branch_tag_value(database_branch_id); + let manager_workflow_id = test_ctx + .workflow(DbManagerInput::new(database_branch_id, None)) + .tag(DATABASE_BRANCH_ID_TAG, &tag_value) + .unique() + .dispatch() + .await?; - force_compaction_and_wait_idle( - &test_ctx, - manager_workflow_id, - database_branch_id, - Id::new_v1(50), - ForceCompactionWork { - hot: true, - cold: false, - reclaim: false, - final_settle: false, - }, - ) - .await?; - let result = force_compaction_and_wait_idle( - &test_ctx, - manager_workflow_id, - database_branch_id, - Id::new_v1(51), - ForceCompactionWork { - hot: false, - cold: false, - reclaim: true, - final_settle: false, - }, - ) - .await?; + force_compaction_and_wait_idle( + &test_ctx, + manager_workflow_id, + database_branch_id, + Id::new_v1(48), + ForceCompactionWork { + hot: true, + cold: false, + reclaim: false, + final_settle: false, + }, + ) + .await?; + force_compaction_and_wait_idle( + &test_ctx, + manager_workflow_id, + database_branch_id, + Id::new_v1(49), + ForceCompactionWork { + hot: false, + cold: false, + reclaim: true, + final_settle: false, + }, + ) + .await?; - assert!( - result.attempted_job_kinds.is_empty() - || result.attempted_job_kinds == vec![CompactionJobKind::Reclaim] - ); - assert!(result.terminal_error.is_none()); - assert_eq!( - database_db.get_pages(vec![1]).await?, - vec![FetchedPage { - pgno: 1, - bytes: Some(page(0x52)), - }] - ); - let forked_bucket_branch_id = udb - .run(move |tx| async move { - branch::resolve_bucket_branch( - &tx, - forked_bucket, - universaldb::utils::IsolationLevel::Serializable, + assert_eq!( + database_db.get_pages(vec![1]).await?, + vec![FetchedPage { + pgno: 1, + bytes: Some(page(0x42)), + }] + ); + let pinned_shard = read_value(&test_ctx, branch_shard_key(database_branch_id, 0, 1)) + .await? + .expect("pinned txid shard should be published exactly"); + let latest_shard = read_value(&test_ctx, branch_shard_key(database_branch_id, 0, 2)) + .await? + .expect("latest txid shard should be published"); + assert_eq!( + decode_ltx_v3(&pinned_shard)?.get_page(1), + Some(page(0x41).as_slice()) + ); + assert_eq!( + decode_ltx_v3(&latest_shard)?.get_page(1), + Some(page(0x42).as_slice()) + ); + assert!( + read_value(&test_ctx, branch_delta_chunk_key(database_branch_id, 1, 0)) + .await? + .is_some() + ); + assert!( + read_value(&test_ctx, branch_commit_key(database_branch_id, 1)) + .await? + .is_some() + ); + let pin_bytes = read_value( + &test_ctx, + db_pin_key( + database_branch_id, + &history_pin::restore_point_pin_id(&restore_point), + ), ) .await? - .ok_or_else(|| anyhow::anyhow!("forked bucket branch should exist")) - }) - .await?; - assert!( - read_value( - &test_ctx, - bucket_fork_pin_key( - source_bucket_branch_id, - fork_commit.versionstamp, - forked_bucket_branch_id, - ), - ) - .await? - .is_some() - ); - let pin_bytes = read_value( - &test_ctx, - db_pin_key( - database_branch_id, - &history_pin::bucket_fork_pin_id(forked_bucket_branch_id), - ), + .expect("restore_point DB_PIN should exist"); + let pin = decode_db_history_pin(&pin_bytes)?; + assert_eq!(pin.kind, DbHistoryPinKind::RestorePoint); + assert_eq!(pin.at_txid, 1); + + test_ctx.shutdown().await?; + Ok(()) + } ) - .await? - .expect("bucket-derived DB_PIN should be materialized"); - let pin = decode_db_history_pin(&pin_bytes)?; - assert_eq!(pin.kind, DbHistoryPinKind::BucketFork); - assert_eq!(pin.at_txid, 1); - assert!(read_value(&test_ctx, branch_delta_chunk_key(database_branch_id, 1, 0)).await?.is_some()); +} - test_ctx.shutdown().await?; - Ok(()) - }) +#[tokio::test] +async fn e2e_force_reclaim_materializes_bucket_fork_pin() -> Result<()> { + workflow_matrix!( + "workflow-e2e-force-reclaim-materializes-bucket-fork-pin", + build_registry, + |_tier, test_ctx| { + let database_db = make_test_db(&test_ctx)?; + database_db + .commit(vec![dirty_page(1, 0x51)], 2, 1_001) + .await?; + let database_branch_id = read_database_branch_id(&test_ctx).await?; + let source_bucket_branch_id = read_bucket_branch_id(&test_ctx).await?; + let fork_commit = read_value(&test_ctx, branch_commit_key(database_branch_id, 1)) + .await? + .as_deref() + .map(decode_commit_row) + .transpose()? + .expect("fork-point commit should exist"); + let udb_pool = test_ctx.pools().udb()?; + let udb = Arc::new((*udb_pool).clone()); + let forked_bucket = branch::fork_bucket( + udb.as_ref(), + BucketId::from_gas_id(test_bucket()), + ResolvedVersionstamp { + versionstamp: fork_commit.versionstamp, + restore_point: None, + }, + ) + .await?; + database_db + .commit(vec![dirty_page(1, 0x52)], 2, 1_002) + .await?; + let tag_value = database_branch_tag_value(database_branch_id); + let manager_workflow_id = test_ctx + .workflow(DbManagerInput::new(database_branch_id, None)) + .tag(DATABASE_BRANCH_ID_TAG, &tag_value) + .unique() + .dispatch() + .await?; + + force_compaction_and_wait_idle( + &test_ctx, + manager_workflow_id, + database_branch_id, + Id::new_v1(50), + ForceCompactionWork { + hot: true, + cold: false, + reclaim: false, + final_settle: false, + }, + ) + .await?; + let result = force_compaction_and_wait_idle( + &test_ctx, + manager_workflow_id, + database_branch_id, + Id::new_v1(51), + ForceCompactionWork { + hot: false, + cold: false, + reclaim: true, + final_settle: false, + }, + ) + .await?; + + assert!( + result.attempted_job_kinds.is_empty() + || result.attempted_job_kinds == vec![CompactionJobKind::Reclaim] + ); + assert!(result.terminal_error.is_none()); + assert_eq!( + database_db.get_pages(vec![1]).await?, + vec![FetchedPage { + pgno: 1, + bytes: Some(page(0x52)), + }] + ); + let forked_bucket_branch_id = udb + .run(move |tx| async move { + branch::resolve_bucket_branch( + &tx, + forked_bucket, + universaldb::utils::IsolationLevel::Serializable, + ) + .await? + .ok_or_else(|| anyhow::anyhow!("forked bucket branch should exist")) + }) + .await?; + assert!( + read_value( + &test_ctx, + bucket_fork_pin_key( + source_bucket_branch_id, + fork_commit.versionstamp, + forked_bucket_branch_id, + ), + ) + .await? + .is_some() + ); + let pin_bytes = read_value( + &test_ctx, + db_pin_key( + database_branch_id, + &history_pin::bucket_fork_pin_id(forked_bucket_branch_id), + ), + ) + .await? + .expect("bucket-derived DB_PIN should be materialized"); + let pin = decode_db_history_pin(&pin_bytes)?; + assert_eq!(pin.kind, DbHistoryPinKind::BucketFork); + assert_eq!(pin.at_txid, 1); + assert!( + read_value(&test_ctx, branch_delta_chunk_key(database_branch_id, 1, 0)) + .await? + .is_some() + ); + + test_ctx.shutdown().await?; + Ok(()) + } + ) } #[tokio::test] async fn force_cold_compaction_is_noop_when_cold_storage_disabled() -> Result<()> { let mut test_ctx = TestCtx::new(build_registry()).await?; let database_db = make_test_db(&test_ctx)?; - database_db.commit(vec![dirty_page(1, 0x41)], 2, 1_001).await?; - let _restore_point = database_db.create_restore_point(depot::types::SnapshotSelector::Latest).await?; + database_db + .commit(vec![dirty_page(1, 0x41)], 2, 1_001) + .await?; + let _restore_point = database_db + .create_restore_point(depot::types::SnapshotSelector::Latest) + .await?; let database_branch_id = read_database_branch_id(&test_ctx).await?; let tag_value = database_branch_tag_value(database_branch_id); let manager_workflow_id = test_ctx @@ -3298,7 +3592,9 @@ async fn force_cold_compaction_is_noop_when_cold_storage_disabled() -> Result<() async fn cold_disabled_read_missing_fdb_shard_returns_error() -> Result<()> { let mut test_ctx = TestCtx::new(build_registry()).await?; let database_db = make_test_db(&test_ctx)?; - database_db.commit(vec![dirty_page(1, 0x43)], 2, 1_001).await?; + database_db + .commit(vec![dirty_page(1, 0x43)], 2, 1_001) + .await?; let database_branch_id = read_database_branch_id(&test_ctx).await?; let tag_value = database_branch_tag_value(database_branch_id); let manager_workflow_id = test_ctx @@ -3342,7 +3638,8 @@ async fn cold_disabled_read_missing_fdb_shard_returns_error() -> Result<()> { .await?; let db = test_ctx.pools().udb()?; db.run(move |tx| async move { - tx.informal().clear(&branch_shard_key(database_branch_id, 0, 1)); + tx.informal() + .clear(&branch_shard_key(database_branch_id, 0, 1)); Ok(()) }) .await?; @@ -3369,8 +3666,12 @@ async fn configured_cold_storage_publishes_and_reads_workflow_cold_refs() -> Res .await? .expect("configured cold tier should be enabled"); let database_db = configured_test_db(&test_ctx, tier.clone())?; - database_db.commit(vec![dirty_page(1, 0x42)], 2, 1_001).await?; - let _restore_point = database_db.create_restore_point(depot::types::SnapshotSelector::Latest).await?; + database_db + .commit(vec![dirty_page(1, 0x42)], 2, 1_001) + .await?; + let _restore_point = database_db + .create_restore_point(depot::types::SnapshotSelector::Latest) + .await?; let database_branch_id = read_database_branch_id(&test_ctx).await?; let tag_value = database_branch_tag_value(database_branch_id); let manager_workflow_id = test_ctx @@ -3499,8 +3800,12 @@ async fn reclaimer_eviction_preserves_future_pin_reads_via_cold_ref() -> Result< let tier = Arc::new(FilesystemColdTier::new(cold_root.path())); let mut test_ctx = test_ctx_with_configured_cold_tier(cold_root.path()).await?; let database_db = make_test_db_with_cold_tier(&test_ctx, tier.clone())?; - database_db.commit(vec![dirty_page(1, 0x71)], 2, 1_001).await?; - database_db.commit(vec![dirty_page(2, 0x72)], 2, 1_002).await?; + database_db + .commit(vec![dirty_page(1, 0x71)], 2, 1_001) + .await?; + database_db + .commit(vec![dirty_page(2, 0x72)], 2, 1_002) + .await?; let restore_point = database_db .create_restore_point(depot::types::SnapshotSelector::Latest) .await?; @@ -3556,7 +3861,10 @@ async fn reclaimer_eviction_preserves_future_pin_reads_via_cold_ref() -> Result< assert!( read_value( &test_ctx, - db_pin_key(database_branch_id, &history_pin::restore_point_pin_id(&restore_point)), + db_pin_key( + database_branch_id, + &history_pin::restore_point_pin_id(&restore_point) + ), ) .await? .as_deref() @@ -3918,8 +4226,12 @@ async fn e2e_force_cold_publish_reads_after_hot_rows_removed() -> Result<()> { let mut test_ctx = test_ctx_with_configured_cold_tier(cold_root.path()).await?; let database_db = make_test_db_with_cold_tier(&test_ctx, tier.clone())?; - database_db.commit(vec![dirty_page(1, 0x61)], 2, 1_001).await?; - let _restore_point = database_db.create_restore_point(depot::types::SnapshotSelector::Latest).await?; + database_db + .commit(vec![dirty_page(1, 0x61)], 2, 1_001) + .await?; + let _restore_point = database_db + .create_restore_point(depot::types::SnapshotSelector::Latest) + .await?; let database_branch_id = read_database_branch_id(&test_ctx).await?; let tag_value = database_branch_tag_value(database_branch_id); let manager_workflow_id = test_ctx @@ -3980,7 +4292,11 @@ async fn e2e_force_cold_publish_reads_after_hot_rows_removed() -> Result<()> { .is_some() ); database_db.wait_for_shard_cache_fill_idle_for_test().await; - assert!(read_value(&test_ctx, branch_shard_key(database_branch_id, 0, 1)).await?.is_some()); + assert!( + read_value(&test_ctx, branch_shard_key(database_branch_id, 0, 1)) + .await? + .is_some() + ); test_ctx.shutdown().await?; Ok(()) @@ -3995,14 +4311,19 @@ async fn e2e_dual_purpose_shard_cache_eviction_reads_and_refills() -> Result<()> let mut test_ctx = test_ctx_with_configured_cold_tier(cold_root.path()).await?; let database_db = make_test_db_with_cold_tier(&test_ctx, tier.clone())?; - database_db.commit(vec![dirty_page(1, 0xa3)], 2, 1_001).await?; + database_db + .commit(vec![dirty_page(1, 0xa3)], 2, 1_001) + .await?; let restore_point = database_db .create_restore_point(depot::types::SnapshotSelector::Latest) .await?; let database_branch_id = read_database_branch_id(&test_ctx).await?; let manager_workflow_id = test_ctx .workflow(DbManagerInput::new(database_branch_id, None)) - .tag(DATABASE_BRANCH_ID_TAG, &database_branch_tag_value(database_branch_id)) + .tag( + DATABASE_BRANCH_ID_TAG, + &database_branch_tag_value(database_branch_id), + ) .unique() .dispatch() .await?; @@ -4033,7 +4354,11 @@ async fn e2e_dual_purpose_shard_cache_eviction_reads_and_refills() -> Result<()> }, ) .await?; - assert_eq!(cold.attempted_job_kinds, vec![CompactionJobKind::Cold], "{cold:?}"); + assert_eq!( + cold.attempted_job_kinds, + vec![CompactionJobKind::Cold], + "{cold:?}" + ); assert!(cold.terminal_error.is_none(), "{cold:?}"); let cold_ref = wait_for_cold_publish(&test_ctx, database_branch_id, 1).await?; @@ -4063,7 +4388,10 @@ async fn e2e_dual_purpose_shard_cache_eviction_reads_and_refills() -> Result<()> ) .await?; - assert_eq!(reclaim.attempted_job_kinds, vec![CompactionJobKind::Reclaim]); + assert_eq!( + reclaim.attempted_job_kinds, + vec![CompactionJobKind::Reclaim] + ); assert!(reclaim.terminal_error.is_none()); assert!( read_value(&test_ctx, branch_shard_key(database_branch_id, 0, 1)) @@ -4095,7 +4423,10 @@ async fn e2e_dual_purpose_shard_cache_eviction_reads_and_refills() -> Result<()> let refilled_shard = read_value(&test_ctx, branch_shard_key(database_branch_id, 0, 1)) .await? .expect("cold read should refill the FDB shard cache"); - assert_eq!(decode_ltx_v3(&refilled_shard)?.get_page(1), Some(page(0xa3).as_slice())); + assert_eq!( + decode_ltx_v3(&refilled_shard)?.get_page(1), + Some(page(0xa3).as_slice()) + ); assert!( read_value( &test_ctx, @@ -4113,11 +4444,16 @@ async fn e2e_dual_purpose_shard_cache_eviction_reads_and_refills() -> Result<()> async fn e2e_cold_disabled_keeps_fdb_shard_and_skips_cold_work() -> Result<()> { let mut test_ctx = TestCtx::new(build_registry()).await?; let database_db = make_test_db(&test_ctx)?; - database_db.commit(vec![dirty_page(1, 0xa4)], 2, 1_001).await?; + database_db + .commit(vec![dirty_page(1, 0xa4)], 2, 1_001) + .await?; let database_branch_id = read_database_branch_id(&test_ctx).await?; let manager_workflow_id = test_ctx .workflow(DbManagerInput::new(database_branch_id, None)) - .tag(DATABASE_BRANCH_ID_TAG, &database_branch_tag_value(database_branch_id)) + .tag( + DATABASE_BRANCH_ID_TAG, + &database_branch_tag_value(database_branch_id), + ) .unique() .dispatch() .await?; @@ -4207,14 +4543,19 @@ async fn e2e_cold_upload_failure_keeps_fdb_shard_readable() -> Result<()> { tokio::fs::write(&invalid_root, b"not a directory").await?; let mut test_ctx = test_ctx_with_configured_cold_tier(&invalid_root).await?; let database_db = make_test_db(&test_ctx)?; - database_db.commit(vec![dirty_page(1, 0xa5)], 2, 1_001).await?; + database_db + .commit(vec![dirty_page(1, 0xa5)], 2, 1_001) + .await?; let _restore_point = database_db .create_restore_point(depot::types::SnapshotSelector::Latest) .await?; let database_branch_id = read_database_branch_id(&test_ctx).await?; let manager_workflow_id = test_ctx .workflow(DbManagerInput::new(database_branch_id, None)) - .tag(DATABASE_BRANCH_ID_TAG, &database_branch_tag_value(database_branch_id)) + .tag( + DATABASE_BRANCH_ID_TAG, + &database_branch_tag_value(database_branch_id), + ) .unique() .dispatch() .await?; @@ -4246,235 +4587,255 @@ async fn e2e_cold_upload_failure_keeps_fdb_shard_readable() -> Result<()> { final_settle: false, }, }) - .to_workflow_id(manager_workflow_id) - .send() - .await? - .expect("signal should target manager workflow"); - wait_for_signal_ack(&test_ctx, signal_id).await?; - let run_cold_job = wait_for_run_cold_job(&test_ctx, cold_workflow_id).await?; - let cold = wait_for_cold_job_finished_signal( - &test_ctx, - manager_workflow_id, - run_cold_job.job_id, - ) - .await?; - - let root = read_value(&test_ctx, branch_compaction_root_key(database_branch_id)) - .await? - .as_deref() - .map(decode_compaction_root) - .transpose()? - .expect("hot compaction should publish root"); - assert!(matches!( - cold.status, - CompactionJobStatus::Rejected { ref reason } - if reason == "cold shard upload failed" - )); - assert!(cold.output_refs.is_empty()); - assert_eq!(root.hot_watermark_txid, 1); - assert_eq!(root.cold_watermark_txid, 0); - assert!( - read_value( - &test_ctx, - branch_compaction_cold_shard_key(database_branch_id, 0, 1), - ) - .await? - .is_none() - ); - assert!( - read_value(&test_ctx, branch_shard_key(database_branch_id, 0, 1)) - .await? - .is_some() - ); - assert_eq!( - database_db.get_pages(vec![1]).await?, - vec![FetchedPage { - pgno: 1, - bytes: Some(page(0xa5)), - }] - ); - - test_ctx.shutdown().await?; - Ok(()) -} - -#[tokio::test] -async fn stale_pidx_missing_delta_falls_back_to_fdb_shard() -> Result<()> { - workflow_matrix!("workflow-stale-pidx-missing-delta-falls-back-to-fdb-shard", build_registry, |_tier, test_ctx| { - let database_db = make_test_db(&test_ctx)?; - database_db.commit(vec![dirty_page(1, 0xa6)], 2, 1_001).await?; - let database_branch_id = read_database_branch_id(&test_ctx).await?; - let manager_workflow_id = test_ctx - .workflow(DbManagerInput::new(database_branch_id, None)) - .tag(DATABASE_BRANCH_ID_TAG, &database_branch_tag_value(database_branch_id)) - .unique() - .dispatch() - .await?; - - force_compaction_and_wait_idle( - &test_ctx, - manager_workflow_id, - database_branch_id, - Id::new_v1(201), - ForceCompactionWork { - hot: true, - cold: false, - reclaim: false, - final_settle: false, - }, - ) - .await?; - set_test_pidx(&test_ctx, database_branch_id, 1).await?; - test_ctx - .pools() - .udb()? - .run(move |tx| async move { - tx.informal() - .clear(&branch_delta_chunk_key(database_branch_id, 1, 0)); - Ok(()) - }) - .await?; - - assert!( - read_value(&test_ctx, branch_pidx_key(database_branch_id, 1)) - .await? - .is_some() - ); - assert!( - read_value(&test_ctx, branch_delta_chunk_key(database_branch_id, 1, 0)) - .await? - .is_none() - ); - assert!( - read_value(&test_ctx, branch_shard_key(database_branch_id, 0, 1)) - .await? - .is_some() - ); - assert_eq!( - database_db.get_pages(vec![1]).await?, - vec![FetchedPage { - pgno: 1, - bytes: Some(page(0xa6)), - }] - ); - - test_ctx.shutdown().await?; - Ok(()) - }) -} - -#[tokio::test] -async fn e2e_workflow_compacts_reclaims_multiple_deltas_and_keeps_reads() -> Result<()> { - workflow_matrix!("workflow-e2e-workflow-compacts-reclaims-multiple-deltas-and-keeps-reads", build_registry, |_tier, test_ctx| { - let database_db = make_test_db(&test_ctx)?; - let mut commits = Vec::new(); - for txid in 1..=3 { - database_db - .commit( - vec![dirty_page(1, 0x70 + u8::try_from(txid).unwrap_or(u8::MAX))], - 2, - 1_000 + i64::try_from(txid).unwrap_or(i64::MAX), - ) - .await?; - } - let database_branch_id = read_database_branch_id(&test_ctx).await?; - for txid in 1..=3 { - let commit = read_value(&test_ctx, branch_commit_key(database_branch_id, txid)) - .await? - .as_deref() - .map(decode_commit_row) - .transpose()? - .expect("commit row should exist before reclaim"); - commits.push((txid, commit)); - } - let tag_value = database_branch_tag_value(database_branch_id); - let manager_workflow_id = test_ctx - .workflow(DbManagerInput::new(database_branch_id, None)) - .tag(DATABASE_BRANCH_ID_TAG, &tag_value) - .unique() - .dispatch() - .await?; - - let hot_result = force_compaction_and_wait_idle( - &test_ctx, - manager_workflow_id, - database_branch_id, - Id::new_v1(60), - ForceCompactionWork { - hot: true, - cold: false, - reclaim: false, - final_settle: false, - }, - ) - .await?; - let reclaim_result = force_compaction_and_wait_idle( - &test_ctx, - manager_workflow_id, - database_branch_id, - Id::new_v1(61), - ForceCompactionWork { - hot: false, - cold: false, - reclaim: true, - final_settle: false, - }, - ) - .await?; + .to_workflow_id(manager_workflow_id) + .send() + .await? + .expect("signal should target manager workflow"); + wait_for_signal_ack(&test_ctx, signal_id).await?; + let run_cold_job = wait_for_run_cold_job(&test_ctx, cold_workflow_id).await?; + let cold = + wait_for_cold_job_finished_signal(&test_ctx, manager_workflow_id, run_cold_job.job_id) + .await?; - assert_eq!(hot_result.attempted_job_kinds, vec![CompactionJobKind::Hot]); - assert!(hot_result.terminal_error.is_none()); - assert!( - reclaim_result.attempted_job_kinds.is_empty() - || reclaim_result.attempted_job_kinds == vec![CompactionJobKind::Reclaim] - ); - assert!(reclaim_result.terminal_error.is_none()); - assert_eq!( - database_db.get_pages(vec![1]).await?, - vec![FetchedPage { - pgno: 1, - bytes: Some(page(0x73)), - }] - ); - for (txid, commit) in commits { - assert!( - read_value(&test_ctx, branch_delta_chunk_key(database_branch_id, txid, 0)) - .await? - .is_none() - ); - assert!( - read_value(&test_ctx, branch_commit_key(database_branch_id, txid)) - .await? - .is_none() - ); - assert!( - read_value(&test_ctx, branch_vtx_key(database_branch_id, commit.versionstamp)) - .await? - .is_none() - ); - } let root = read_value(&test_ctx, branch_compaction_root_key(database_branch_id)) .await? .as_deref() .map(decode_compaction_root) .transpose()? - .expect("hot compaction should publish a root"); - assert_eq!(root.hot_watermark_txid, 3); + .expect("hot compaction should publish root"); + assert!(matches!( + cold.status, + CompactionJobStatus::Rejected { ref reason } + if reason == "cold shard upload failed" + )); + assert!(cold.output_refs.is_empty()); + assert_eq!(root.hot_watermark_txid, 1); assert_eq!(root.cold_watermark_txid, 0); - assert_eq!( - read_prefix_values( + assert!( + read_value( &test_ctx, - branch_compaction_cold_shard_prefix(database_branch_id), + branch_compaction_cold_shard_key(database_branch_id, 0, 1), ) .await? - .len(), - 0 + .is_none() + ); + assert!( + read_value(&test_ctx, branch_shard_key(database_branch_id, 0, 1)) + .await? + .is_some() + ); + assert_eq!( + database_db.get_pages(vec![1]).await?, + vec![FetchedPage { + pgno: 1, + bytes: Some(page(0xa5)), + }] ); - assert!(read_value(&test_ctx, branch_shard_key(database_branch_id, 0, 3)).await?.is_some()); test_ctx.shutdown().await?; Ok(()) - }) +} + +#[tokio::test] +async fn stale_pidx_missing_delta_falls_back_to_fdb_shard() -> Result<()> { + workflow_matrix!( + "workflow-stale-pidx-missing-delta-falls-back-to-fdb-shard", + build_registry, + |_tier, test_ctx| { + let database_db = make_test_db(&test_ctx)?; + database_db + .commit(vec![dirty_page(1, 0xa6)], 2, 1_001) + .await?; + let database_branch_id = read_database_branch_id(&test_ctx).await?; + let manager_workflow_id = test_ctx + .workflow(DbManagerInput::new(database_branch_id, None)) + .tag( + DATABASE_BRANCH_ID_TAG, + &database_branch_tag_value(database_branch_id), + ) + .unique() + .dispatch() + .await?; + + force_compaction_and_wait_idle( + &test_ctx, + manager_workflow_id, + database_branch_id, + Id::new_v1(201), + ForceCompactionWork { + hot: true, + cold: false, + reclaim: false, + final_settle: false, + }, + ) + .await?; + set_test_pidx(&test_ctx, database_branch_id, 1).await?; + test_ctx + .pools() + .udb()? + .run(move |tx| async move { + tx.informal() + .clear(&branch_delta_chunk_key(database_branch_id, 1, 0)); + Ok(()) + }) + .await?; + + assert!( + read_value(&test_ctx, branch_pidx_key(database_branch_id, 1)) + .await? + .is_some() + ); + assert!( + read_value(&test_ctx, branch_delta_chunk_key(database_branch_id, 1, 0)) + .await? + .is_none() + ); + assert!( + read_value(&test_ctx, branch_shard_key(database_branch_id, 0, 1)) + .await? + .is_some() + ); + assert_eq!( + database_db.get_pages(vec![1]).await?, + vec![FetchedPage { + pgno: 1, + bytes: Some(page(0xa6)), + }] + ); + + test_ctx.shutdown().await?; + Ok(()) + } + ) +} + +#[tokio::test] +async fn e2e_workflow_compacts_reclaims_multiple_deltas_and_keeps_reads() -> Result<()> { + workflow_matrix!( + "workflow-e2e-workflow-compacts-reclaims-multiple-deltas-and-keeps-reads", + build_registry, + |_tier, test_ctx| { + let database_db = make_test_db(&test_ctx)?; + let mut commits = Vec::new(); + for txid in 1..=3 { + database_db + .commit( + vec![dirty_page(1, 0x70 + u8::try_from(txid).unwrap_or(u8::MAX))], + 2, + 1_000 + i64::try_from(txid).unwrap_or(i64::MAX), + ) + .await?; + } + let database_branch_id = read_database_branch_id(&test_ctx).await?; + for txid in 1..=3 { + let commit = read_value(&test_ctx, branch_commit_key(database_branch_id, txid)) + .await? + .as_deref() + .map(decode_commit_row) + .transpose()? + .expect("commit row should exist before reclaim"); + commits.push((txid, commit)); + } + let tag_value = database_branch_tag_value(database_branch_id); + let manager_workflow_id = test_ctx + .workflow(DbManagerInput::new(database_branch_id, None)) + .tag(DATABASE_BRANCH_ID_TAG, &tag_value) + .unique() + .dispatch() + .await?; + + let hot_result = force_compaction_and_wait_idle( + &test_ctx, + manager_workflow_id, + database_branch_id, + Id::new_v1(60), + ForceCompactionWork { + hot: true, + cold: false, + reclaim: false, + final_settle: false, + }, + ) + .await?; + let reclaim_result = force_compaction_and_wait_idle( + &test_ctx, + manager_workflow_id, + database_branch_id, + Id::new_v1(61), + ForceCompactionWork { + hot: false, + cold: false, + reclaim: true, + final_settle: false, + }, + ) + .await?; + + assert_eq!(hot_result.attempted_job_kinds, vec![CompactionJobKind::Hot]); + assert!(hot_result.terminal_error.is_none()); + assert!( + reclaim_result.attempted_job_kinds.is_empty() + || reclaim_result.attempted_job_kinds == vec![CompactionJobKind::Reclaim] + ); + assert!(reclaim_result.terminal_error.is_none()); + assert_eq!( + database_db.get_pages(vec![1]).await?, + vec![FetchedPage { + pgno: 1, + bytes: Some(page(0x73)), + }] + ); + for (txid, commit) in commits { + assert!( + read_value( + &test_ctx, + branch_delta_chunk_key(database_branch_id, txid, 0) + ) + .await? + .is_none() + ); + assert!( + read_value(&test_ctx, branch_commit_key(database_branch_id, txid)) + .await? + .is_none() + ); + assert!( + read_value( + &test_ctx, + branch_vtx_key(database_branch_id, commit.versionstamp) + ) + .await? + .is_none() + ); + } + let root = read_value(&test_ctx, branch_compaction_root_key(database_branch_id)) + .await? + .as_deref() + .map(decode_compaction_root) + .transpose()? + .expect("hot compaction should publish a root"); + assert_eq!(root.hot_watermark_txid, 3); + assert_eq!(root.cold_watermark_txid, 0); + assert_eq!( + read_prefix_values( + &test_ctx, + branch_compaction_cold_shard_prefix(database_branch_id), + ) + .await? + .len(), + 0 + ); + assert!( + read_value(&test_ctx, branch_shard_key(database_branch_id, 0, 3)) + .await? + .is_some() + ); + + test_ctx.shutdown().await?; + Ok(()) + } + ) } #[tokio::test] @@ -4486,8 +4847,12 @@ async fn e2e_workflow_cold_publish_reclaim_retires_obsolete_object() -> Result<( let mut test_ctx = test_ctx_with_configured_cold_tier(cold_root.path()).await?; let database_db = make_test_db_with_cold_tier(&test_ctx, tier.clone())?; - database_db.commit(vec![dirty_page(1, 0x81)], 2, 1_001).await?; - let old_restore_point = database_db.create_restore_point(depot::types::SnapshotSelector::Latest).await?; + database_db + .commit(vec![dirty_page(1, 0x81)], 2, 1_001) + .await?; + let old_restore_point = database_db + .create_restore_point(depot::types::SnapshotSelector::Latest) + .await?; let database_branch_id = read_database_branch_id(&test_ctx).await?; let tag_value = database_branch_tag_value(database_branch_id); let manager_workflow_id = test_ctx @@ -4527,8 +4892,12 @@ async fn e2e_workflow_cold_publish_reclaim_retires_obsolete_object() -> Result<( assert!(tier.get_object(&old_ref.object_key).await?.is_some()); database_db.delete_restore_point(old_restore_point).await?; - database_db.commit(vec![dirty_page(1, 0x82)], 2, 1_002).await?; - let current_restore_point = database_db.create_restore_point(depot::types::SnapshotSelector::Latest).await?; + database_db + .commit(vec![dirty_page(1, 0x82)], 2, 1_002) + .await?; + let current_restore_point = database_db + .create_restore_point(depot::types::SnapshotSelector::Latest) + .await?; force_compaction_and_wait_idle( &test_ctx, manager_workflow_id, @@ -4558,7 +4927,9 @@ async fn e2e_workflow_cold_publish_reclaim_retires_obsolete_object() -> Result<( let current_ref = wait_for_cold_publish(&test_ctx, database_branch_id, 2).await?; assert_ne!(old_ref.object_key, current_ref.object_key); assert!(tier.get_object(¤t_ref.object_key).await?.is_some()); - database_db.delete_restore_point(current_restore_point).await?; + database_db + .delete_restore_point(current_restore_point) + .await?; let reclaim_result = force_compaction_and_wait_idle( &test_ctx, @@ -4616,379 +4987,402 @@ async fn e2e_workflow_cold_publish_reclaim_retires_obsolete_object() -> Result<( #[tokio::test] async fn e2e_workflow_rejects_stale_hot_work_then_stops_on_branch_deletion() -> Result<()> { - workflow_matrix!("workflow-e2e-workflow-rejects-stale-hot-work-then-stops-on-branch-deletion", build_registry, |_tier, test_ctx| { - let database_db = make_test_db(&test_ctx)?; - database_db.commit(vec![dirty_page(1, 0x91)], 2, 1_001).await?; - let database_branch_id = read_database_branch_id(&test_ctx).await?; - update_branch_lifecycle(&test_ctx, database_branch_id, BranchState::Live, 1).await?; - let tag_value = database_branch_tag_value(database_branch_id); - let manager_workflow_id = test_ctx - .workflow(DbManagerInput::new(database_branch_id, None)) - .tag(DATABASE_BRANCH_ID_TAG, &tag_value) - .unique() - .dispatch() - .await?; - let hot_workflow_id = - wait_for_workflow::(&test_ctx, database_branch_id).await?; - let cold_workflow_id = - wait_for_workflow::(&test_ctx, database_branch_id).await?; - let reclaimer_workflow_id = - wait_for_workflow::(&test_ctx, database_branch_id).await?; - let stale_job_id = Id::new_v1(67); - - let signal_id = test_ctx - .signal(RunHotJob { - database_branch_id, - job_id: stale_job_id, - job_kind: CompactionJobKind::Hot, - base_lifecycle_generation: 0, - base_manifest_generation: 0, - input_fingerprint: [0x67; 32], - status: CompactionJobStatus::Requested, - input_range: HotJobInputRange { - txids: TxidRange { - min_txid: 1, - max_txid: 1, - }, - coverage_txids: vec![1], - max_pages: 1, - max_bytes: 1, - }, - }) - .to_workflow_id(hot_workflow_id) - .send() - .await? - .expect("signal should target hot compacter workflow"); - wait_for_signal_ack(&test_ctx, signal_id).await?; + workflow_matrix!( + "workflow-e2e-workflow-rejects-stale-hot-work-then-stops-on-branch-deletion", + build_registry, + |_tier, test_ctx| { + let database_db = make_test_db(&test_ctx)?; + database_db + .commit(vec![dirty_page(1, 0x91)], 2, 1_001) + .await?; + let database_branch_id = read_database_branch_id(&test_ctx).await?; + update_branch_lifecycle(&test_ctx, database_branch_id, BranchState::Live, 1).await?; + let tag_value = database_branch_tag_value(database_branch_id); + let manager_workflow_id = test_ctx + .workflow(DbManagerInput::new(database_branch_id, None)) + .tag(DATABASE_BRANCH_ID_TAG, &tag_value) + .unique() + .dispatch() + .await?; + let hot_workflow_id = + wait_for_workflow::(&test_ctx, database_branch_id).await?; + let cold_workflow_id = + wait_for_workflow::(&test_ctx, database_branch_id).await?; + let reclaimer_workflow_id = + wait_for_workflow::(&test_ctx, database_branch_id).await?; + let stale_job_id = Id::new_v1(67); + + let signal_id = test_ctx + .signal(RunHotJob { + database_branch_id, + job_id: stale_job_id, + job_kind: CompactionJobKind::Hot, + base_lifecycle_generation: 0, + base_manifest_generation: 0, + input_fingerprint: [0x67; 32], + status: CompactionJobStatus::Requested, + input_range: HotJobInputRange { + txids: TxidRange { + min_txid: 1, + max_txid: 1, + }, + coverage_txids: vec![1], + max_pages: 1, + max_bytes: 1, + }, + }) + .to_workflow_id(hot_workflow_id) + .send() + .await? + .expect("signal should target hot compacter workflow"); + wait_for_signal_ack(&test_ctx, signal_id).await?; - let staged_rows = read_prefix_values( - &test_ctx, - branch_compaction_stage_hot_shard_prefix(database_branch_id, stale_job_id), - ) - .await?; - assert!(staged_rows.is_empty()); + let staged_rows = read_prefix_values( + &test_ctx, + branch_compaction_stage_hot_shard_prefix(database_branch_id, stale_job_id), + ) + .await?; + assert!(staged_rows.is_empty()); - clear_branch_record(&test_ctx, database_branch_id).await?; - let signal_id = test_ctx - .signal(DestroyDatabaseBranch { - database_branch_id, - lifecycle_generation: 1, - requested_at_ms: 1_714_000_000_002, - reason: "test e2e branch deletion".into(), - }) - .to_workflow_id(manager_workflow_id) - .send() - .await? - .expect("signal should target manager workflow"); - wait_for_signal_ack(&test_ctx, signal_id).await?; + clear_branch_record(&test_ctx, database_branch_id).await?; + let signal_id = test_ctx + .signal(DestroyDatabaseBranch { + database_branch_id, + lifecycle_generation: 1, + requested_at_ms: 1_714_000_000_002, + reason: "test e2e branch deletion".into(), + }) + .to_workflow_id(manager_workflow_id) + .send() + .await? + .expect("signal should target manager workflow"); + wait_for_signal_ack(&test_ctx, signal_id).await?; - wait_for_workflow_state(&test_ctx, manager_workflow_id, WorkflowState::Complete).await?; - wait_for_workflow_state(&test_ctx, hot_workflow_id, WorkflowState::Complete).await?; - wait_for_workflow_state(&test_ctx, cold_workflow_id, WorkflowState::Complete).await?; - wait_for_workflow_state(&test_ctx, reclaimer_workflow_id, WorkflowState::Complete).await?; - assert!( - read_value(&test_ctx, branch_shard_key(database_branch_id, 0, 1)) - .await? - .is_none() - ); + wait_for_workflow_state(&test_ctx, manager_workflow_id, WorkflowState::Complete) + .await?; + wait_for_workflow_state(&test_ctx, hot_workflow_id, WorkflowState::Complete).await?; + wait_for_workflow_state(&test_ctx, cold_workflow_id, WorkflowState::Complete).await?; + wait_for_workflow_state(&test_ctx, reclaimer_workflow_id, WorkflowState::Complete) + .await?; + assert!( + read_value(&test_ctx, branch_shard_key(database_branch_id, 0, 1)) + .await? + .is_none() + ); - test_ctx.shutdown().await?; - Ok(()) - }) + test_ctx.shutdown().await?; + Ok(()) + } + ) } #[tokio::test] async fn hot_compacter_writes_idempotent_staged_shard_output() -> Result<()> { let database_branch_id = database_branch_id(0x4040_2233_4455_6677_8899_aabb_ccdd_eeff); - workflow_matrix!("workflow-hot-compacter-writes-idempotent-staged-shard-output", build_registry, |_tier, test_ctx| { - let tag_value = database_branch_tag_value(database_branch_id); - seed_manager_branch( - &test_ctx, - database_branch_id, - quota_threshold_head(), - None, - Some(SqliteCmpDirty { - observed_head_txid: quota_threshold_head(), - updated_at_ms: 1_714_000_000_000, - }), - ) - .await?; - - let _manager_workflow_id = test_ctx - .workflow(DbManagerInput::new(database_branch_id, None)) - .tag(DATABASE_BRANCH_ID_TAG, &tag_value) - .unique() - .dispatch() - .await?; - let hot_workflow_id = - wait_for_workflow::(&test_ctx, database_branch_id).await?; - let run_hot_job = wait_for_run_hot_job(&test_ctx, hot_workflow_id).await?; - let first_staged_rows = - wait_for_staged_hot_rows(&test_ctx, database_branch_id, run_hot_job.job_id).await?; - - assert_eq!(first_staged_rows.len(), 1); - wait_for_hot_install(&test_ctx, database_branch_id, quota_threshold_head()).await?; - assert_eq!( - read_value(&test_ctx, branch_pidx_key(database_branch_id, 1)).await?, - None - ); - - let signal_id = test_ctx - .signal(run_hot_job.clone()) - .to_workflow_id(hot_workflow_id) - .send() - .await? - .expect("signal should target hot compacter workflow"); - wait_for_signal_ack(&test_ctx, signal_id).await?; - - let second_staged_rows = read_prefix_values( - &test_ctx, - branch_compaction_stage_hot_shard_prefix(database_branch_id, run_hot_job.job_id), - ) - .await?; - assert_eq!(second_staged_rows, first_staged_rows); + workflow_matrix!( + "workflow-hot-compacter-writes-idempotent-staged-shard-output", + build_registry, + |_tier, test_ctx| { + let tag_value = database_branch_tag_value(database_branch_id); + seed_manager_branch( + &test_ctx, + database_branch_id, + quota_threshold_head(), + None, + Some(SqliteCmpDirty { + observed_head_txid: quota_threshold_head(), + updated_at_ms: 1_714_000_000_000, + }), + ) + .await?; - test_ctx.shutdown().await?; - Ok(()) - }) -} + let _manager_workflow_id = test_ctx + .workflow(DbManagerInput::new(database_branch_id, None)) + .tag(DATABASE_BRANCH_ID_TAG, &tag_value) + .unique() + .dispatch() + .await?; + let hot_workflow_id = + wait_for_workflow::(&test_ctx, database_branch_id).await?; + let run_hot_job = wait_for_run_hot_job(&test_ctx, hot_workflow_id).await?; + let first_staged_rows = + wait_for_staged_hot_rows(&test_ctx, database_branch_id, run_hot_job.job_id).await?; + + assert_eq!(first_staged_rows.len(), 1); + wait_for_hot_install(&test_ctx, database_branch_id, quota_threshold_head()).await?; + assert_eq!( + read_value(&test_ctx, branch_pidx_key(database_branch_id, 1)).await?, + None + ); -#[tokio::test] -async fn hot_compacter_rejects_stale_base_generation_without_staging() -> Result<()> { - let database_branch_id = database_branch_id(0x5050_2233_4455_6677_8899_aabb_ccdd_eeff); - workflow_matrix!("workflow-hot-compacter-rejects-stale-base-generation-without-staging", build_registry, |_tier, test_ctx| { - let tag_value = database_branch_tag_value(database_branch_id); - seed_manager_branch( - &test_ctx, - database_branch_id, - 1, - Some(CompactionRoot { - schema_version: 1, - manifest_generation: 2, - hot_watermark_txid: 0, - cold_watermark_txid: 0, - cold_watermark_versionstamp: [0; 16], - }), - None, - ) - .await?; + let signal_id = test_ctx + .signal(run_hot_job.clone()) + .to_workflow_id(hot_workflow_id) + .send() + .await? + .expect("signal should target hot compacter workflow"); + wait_for_signal_ack(&test_ctx, signal_id).await?; - let _manager_workflow_id = test_ctx - .workflow(DbManagerInput::new(database_branch_id, None)) - .tag(DATABASE_BRANCH_ID_TAG, &tag_value) - .unique() - .dispatch() - .await?; - let hot_workflow_id = - wait_for_workflow::(&test_ctx, database_branch_id).await?; - let stale_job_id = Id::new_v1(42); - let signal_id = test_ctx - .signal(RunHotJob { - database_branch_id, - job_id: stale_job_id, - job_kind: CompactionJobKind::Hot, - base_lifecycle_generation: 0, - base_manifest_generation: 1, - input_fingerprint: [7; 32], - status: CompactionJobStatus::Requested, - input_range: HotJobInputRange { - txids: TxidRange { - min_txid: 1, - max_txid: 1, - }, - coverage_txids: vec![1], - max_pages: 1, - max_bytes: 1, - }, - }) - .to_workflow_id(hot_workflow_id) - .send() - .await? - .expect("signal should target hot compacter workflow"); - wait_for_signal_ack(&test_ctx, signal_id).await?; + let second_staged_rows = read_prefix_values( + &test_ctx, + branch_compaction_stage_hot_shard_prefix(database_branch_id, run_hot_job.job_id), + ) + .await?; + assert_eq!(second_staged_rows, first_staged_rows); - let staged_rows = read_prefix_values( - &test_ctx, - branch_compaction_stage_hot_shard_prefix(database_branch_id, stale_job_id), + test_ctx.shutdown().await?; + Ok(()) + } ) - .await?; - assert!(staged_rows.is_empty()); +} - test_ctx.shutdown().await?; - Ok(()) - }) +#[tokio::test] +async fn hot_compacter_rejects_stale_base_generation_without_staging() -> Result<()> { + let database_branch_id = database_branch_id(0x5050_2233_4455_6677_8899_aabb_ccdd_eeff); + workflow_matrix!( + "workflow-hot-compacter-rejects-stale-base-generation-without-staging", + build_registry, + |_tier, test_ctx| { + let tag_value = database_branch_tag_value(database_branch_id); + seed_manager_branch( + &test_ctx, + database_branch_id, + 1, + Some(CompactionRoot { + schema_version: 1, + manifest_generation: 2, + hot_watermark_txid: 0, + cold_watermark_txid: 0, + cold_watermark_versionstamp: [0; 16], + }), + None, + ) + .await?; + + let _manager_workflow_id = test_ctx + .workflow(DbManagerInput::new(database_branch_id, None)) + .tag(DATABASE_BRANCH_ID_TAG, &tag_value) + .unique() + .dispatch() + .await?; + let hot_workflow_id = + wait_for_workflow::(&test_ctx, database_branch_id).await?; + let stale_job_id = Id::new_v1(42); + let signal_id = test_ctx + .signal(RunHotJob { + database_branch_id, + job_id: stale_job_id, + job_kind: CompactionJobKind::Hot, + base_lifecycle_generation: 0, + base_manifest_generation: 1, + input_fingerprint: [7; 32], + status: CompactionJobStatus::Requested, + input_range: HotJobInputRange { + txids: TxidRange { + min_txid: 1, + max_txid: 1, + }, + coverage_txids: vec![1], + max_pages: 1, + max_bytes: 1, + }, + }) + .to_workflow_id(hot_workflow_id) + .send() + .await? + .expect("signal should target hot compacter workflow"); + wait_for_signal_ack(&test_ctx, signal_id).await?; + + let staged_rows = read_prefix_values( + &test_ctx, + branch_compaction_stage_hot_shard_prefix(database_branch_id, stale_job_id), + ) + .await?; + assert!(staged_rows.is_empty()); + + test_ctx.shutdown().await?; + Ok(()) + } + ) } #[tokio::test] async fn hot_compacter_rejects_stale_lifecycle_generation_without_staging() -> Result<()> { let database_branch_id = database_branch_id(0x5051_2233_4455_6677_8899_aabb_ccdd_eeff); - workflow_matrix!("workflow-hot-compacter-rejects-stale-lifecycle-generation-without-staging", build_registry, |_tier, test_ctx| { - let tag_value = database_branch_tag_value(database_branch_id); - seed_manager_branch( - &test_ctx, - database_branch_id, - 1, - Some(CompactionRoot { - schema_version: 1, - manifest_generation: 0, - hot_watermark_txid: 0, - cold_watermark_txid: 0, - cold_watermark_versionstamp: [0; 16], - }), - None, - ) - .await?; - update_branch_lifecycle(&test_ctx, database_branch_id, BranchState::Live, 1).await?; + workflow_matrix!( + "workflow-hot-compacter-rejects-stale-lifecycle-generation-without-staging", + build_registry, + |_tier, test_ctx| { + let tag_value = database_branch_tag_value(database_branch_id); + seed_manager_branch( + &test_ctx, + database_branch_id, + 1, + Some(CompactionRoot { + schema_version: 1, + manifest_generation: 0, + hot_watermark_txid: 0, + cold_watermark_txid: 0, + cold_watermark_versionstamp: [0; 16], + }), + None, + ) + .await?; + update_branch_lifecycle(&test_ctx, database_branch_id, BranchState::Live, 1).await?; - let _manager_workflow_id = test_ctx - .workflow(DbManagerInput::new(database_branch_id, None)) - .tag(DATABASE_BRANCH_ID_TAG, &tag_value) - .unique() - .dispatch() - .await?; - let hot_workflow_id = - wait_for_workflow::(&test_ctx, database_branch_id).await?; - let stale_job_id = Id::new_v1(43); - let signal_id = test_ctx - .signal(RunHotJob { - database_branch_id, - job_id: stale_job_id, - job_kind: CompactionJobKind::Hot, - base_lifecycle_generation: 0, - base_manifest_generation: 0, - input_fingerprint: [8; 32], - status: CompactionJobStatus::Requested, - input_range: HotJobInputRange { - txids: TxidRange { - min_txid: 1, - max_txid: 1, - }, - coverage_txids: vec![1], - max_pages: 1, - max_bytes: 1, - }, - }) - .to_workflow_id(hot_workflow_id) - .send() - .await? - .expect("signal should target hot compacter workflow"); - wait_for_signal_ack(&test_ctx, signal_id).await?; + let _manager_workflow_id = test_ctx + .workflow(DbManagerInput::new(database_branch_id, None)) + .tag(DATABASE_BRANCH_ID_TAG, &tag_value) + .unique() + .dispatch() + .await?; + let hot_workflow_id = + wait_for_workflow::(&test_ctx, database_branch_id).await?; + let stale_job_id = Id::new_v1(43); + let signal_id = test_ctx + .signal(RunHotJob { + database_branch_id, + job_id: stale_job_id, + job_kind: CompactionJobKind::Hot, + base_lifecycle_generation: 0, + base_manifest_generation: 0, + input_fingerprint: [8; 32], + status: CompactionJobStatus::Requested, + input_range: HotJobInputRange { + txids: TxidRange { + min_txid: 1, + max_txid: 1, + }, + coverage_txids: vec![1], + max_pages: 1, + max_bytes: 1, + }, + }) + .to_workflow_id(hot_workflow_id) + .send() + .await? + .expect("signal should target hot compacter workflow"); + wait_for_signal_ack(&test_ctx, signal_id).await?; - let staged_rows = read_prefix_values( - &test_ctx, - branch_compaction_stage_hot_shard_prefix(database_branch_id, stale_job_id), - ) - .await?; - assert!(staged_rows.is_empty()); + let staged_rows = read_prefix_values( + &test_ctx, + branch_compaction_stage_hot_shard_prefix(database_branch_id, stale_job_id), + ) + .await?; + assert!(staged_rows.is_empty()); - test_ctx.shutdown().await?; - Ok(()) - }) + test_ctx.shutdown().await?; + Ok(()) + } + ) } #[tokio::test] async fn manager_schedules_cleanup_for_stale_hot_output() -> Result<()> { let database_branch_id = database_branch_id(0x5052_2233_4455_6677_8899_aabb_ccdd_eeff); - workflow_matrix!("workflow-manager-schedules-cleanup-for-stale-hot-output", build_registry, |_tier, test_ctx| { - let tag_value = database_branch_tag_value(database_branch_id); - seed_manager_branch( - &test_ctx, - database_branch_id, - 0, - Some(CompactionRoot { - schema_version: 1, - manifest_generation: 1, - hot_watermark_txid: 0, - cold_watermark_txid: 0, - cold_watermark_versionstamp: [0; 16], - }), - None, - ) - .await?; - update_branch_lifecycle(&test_ctx, database_branch_id, BranchState::Live, 7).await?; - - let manager_workflow_id = test_ctx - .workflow(DbManagerInput::new(database_branch_id, None)) - .tag(DATABASE_BRANCH_ID_TAG, &tag_value) - .unique() - .dispatch() - .await?; - let reclaimer_workflow_id = - wait_for_workflow::(&test_ctx, database_branch_id).await?; - wait_for_manager_state(&test_ctx, manager_workflow_id, |state| { - state.last_observed_branch_lifecycle_generation == Some(7) - }) - .await?; - let stale_job_id = Id::new_v1(52); - let staged_blob = encode_ltx_v3( - LtxHeader::delta(1, 1, 1_001), - &[DirtyPage { - pgno: 1, - bytes: page(9), - }], - )?; - let output_ref = HotShardOutputRef { - shard_id: 0, - as_of_txid: 1, - min_txid: 1, - max_txid: 1, - size_bytes: u64::try_from(staged_blob.len()).unwrap_or(u64::MAX), - content_hash: sha256(&staged_blob), - }; - test_ctx - .pools() - .udb()? - .run({ - let staged_blob = staged_blob.clone(); - move |tx| { - let staged_blob = staged_blob.clone(); - async move { - tx.informal().set( - &branch_compaction_stage_hot_shard_key( - database_branch_id, - stale_job_id, - 0, - 1, - 0, - ), - &staged_blob, - ); - Ok(()) - } - } - }) - .await?; + workflow_matrix!( + "workflow-manager-schedules-cleanup-for-stale-hot-output", + build_registry, + |_tier, test_ctx| { + let tag_value = database_branch_tag_value(database_branch_id); + seed_manager_branch( + &test_ctx, + database_branch_id, + 0, + Some(CompactionRoot { + schema_version: 1, + manifest_generation: 1, + hot_watermark_txid: 0, + cold_watermark_txid: 0, + cold_watermark_versionstamp: [0; 16], + }), + None, + ) + .await?; + update_branch_lifecycle(&test_ctx, database_branch_id, BranchState::Live, 7).await?; - let signal_id = test_ctx - .signal(HotJobFinished { - database_branch_id, - job_id: stale_job_id, - job_kind: CompactionJobKind::Hot, - base_manifest_generation: 1, - input_fingerprint: [5; 32], - status: CompactionJobStatus::Succeeded, - output_refs: vec![output_ref], - }) - .to_workflow_id(manager_workflow_id) - .send() - .await? - .expect("signal should target manager workflow"); - wait_for_signal_ack(&test_ctx, signal_id).await?; - let repair_job = wait_for_run_reclaim_job(&test_ctx, reclaimer_workflow_id).await?; - assert_eq!(repair_job.base_lifecycle_generation, 7); - assert_eq!(repair_job.input_range.staged_hot_shards.len(), 1); + let manager_workflow_id = test_ctx + .workflow(DbManagerInput::new(database_branch_id, None)) + .tag(DATABASE_BRANCH_ID_TAG, &tag_value) + .unique() + .dispatch() + .await?; + let reclaimer_workflow_id = + wait_for_workflow::(&test_ctx, database_branch_id).await?; + wait_for_manager_state(&test_ctx, manager_workflow_id, |state| { + state.last_observed_branch_lifecycle_generation == Some(7) + }) + .await?; + let stale_job_id = Id::new_v1(52); + let staged_blob = encode_ltx_v3( + LtxHeader::delta(1, 1, 1_001), + &[DirtyPage { + pgno: 1, + bytes: page(9), + }], + )?; + let output_ref = HotShardOutputRef { + shard_id: 0, + as_of_txid: 1, + min_txid: 1, + max_txid: 1, + size_bytes: u64::try_from(staged_blob.len()).unwrap_or(u64::MAX), + content_hash: sha256(&staged_blob), + }; + test_ctx + .pools() + .udb()? + .run({ + let staged_blob = staged_blob.clone(); + move |tx| { + let staged_blob = staged_blob.clone(); + async move { + tx.informal().set( + &branch_compaction_stage_hot_shard_key( + database_branch_id, + stale_job_id, + 0, + 1, + 0, + ), + &staged_blob, + ); + Ok(()) + } + } + }) + .await?; - wait_for_stage_row_cleared(&test_ctx, database_branch_id, stale_job_id).await?; - let manager_state = - wait_for_manager_state(&test_ctx, manager_workflow_id, |state| { - state.active_jobs.reclaim.is_none() - }) - .await?; - assert!(manager_state.active_jobs.reclaim.is_none()); + let signal_id = test_ctx + .signal(HotJobFinished { + database_branch_id, + job_id: stale_job_id, + job_kind: CompactionJobKind::Hot, + base_manifest_generation: 1, + input_fingerprint: [5; 32], + status: CompactionJobStatus::Succeeded, + output_refs: vec![output_ref], + }) + .to_workflow_id(manager_workflow_id) + .send() + .await? + .expect("signal should target manager workflow"); + wait_for_signal_ack(&test_ctx, signal_id).await?; + let repair_job = wait_for_run_reclaim_job(&test_ctx, reclaimer_workflow_id).await?; + assert_eq!(repair_job.base_lifecycle_generation, 7); + assert_eq!(repair_job.input_range.staged_hot_shards.len(), 1); + + wait_for_stage_row_cleared(&test_ctx, database_branch_id, stale_job_id).await?; + let manager_state = wait_for_manager_state(&test_ctx, manager_workflow_id, |state| { + state.active_jobs.reclaim.is_none() + }) + .await?; + assert!(manager_state.active_jobs.reclaim.is_none()); - test_ctx.shutdown().await?; - Ok(()) - }) + test_ctx.shutdown().await?; + Ok(()) + } + ) } #[tokio::test] @@ -5074,11 +5468,10 @@ async fn manager_schedules_cleanup_for_stale_cold_output() -> Result<()> { assert_eq!(repair_job.input_range.orphan_cold_objects.len(), 1); wait_for_cold_object_deleted(tier.as_ref(), &object_key).await?; - let manager_state = - wait_for_manager_state(&test_ctx, manager_workflow_id, |state| { - state.active_jobs.reclaim.is_none() - }) - .await?; + let manager_state = wait_for_manager_state(&test_ctx, manager_workflow_id, |state| { + state.active_jobs.reclaim.is_none() + }) + .await?; assert!(manager_state.active_jobs.reclaim.is_none()); test_ctx.shutdown().await?; @@ -5165,11 +5558,10 @@ async fn manager_schedules_cold_cleanup_intent_when_cold_storage_disabled() -> R object_key ); - let manager_state = - wait_for_manager_state(&test_ctx, manager_workflow_id, |state| { - state.active_jobs.reclaim.is_none() - }) - .await?; + let manager_state = wait_for_manager_state(&test_ctx, manager_workflow_id, |state| { + state.active_jobs.reclaim.is_none() + }) + .await?; assert!(manager_state.active_jobs.reclaim.is_none()); test_ctx.shutdown().await?; @@ -5246,8 +5638,7 @@ async fn manager_cleans_uploaded_cold_output_when_active_publish_rejects() -> Re .expect("compaction root should exist"); let mut root = decode_compaction_root(&root_bytes)?; root.manifest_generation = root.manifest_generation.saturating_add(1); - tx.informal() - .set(&root_key, &encode_compaction_root(root)?); + tx.informal().set(&root_key, &encode_compaction_root(root)?); Ok(()) }) .await?; @@ -5276,83 +5667,83 @@ async fn manager_cleans_uploaded_cold_output_when_active_publish_rejects() -> Re output_refs: vec![cold_ref], }) .to_workflow_id(manager_workflow_id) - .send() - .await? - .expect("signal should target manager workflow"); - wait_for_signal_ack(&test_ctx, signal_id).await?; - - let repair_job = wait_for_run_reclaim_job(&test_ctx, reclaimer_workflow_id).await?; - assert_eq!(repair_job.input_range.orphan_cold_objects.len(), 1); - assert_eq!( - repair_job.input_range.orphan_cold_objects[0].object_key, - object_key - ); - wait_for_cold_object_deleted(tier.as_ref(), &object_key).await?; - let manager_state = - wait_for_manager_state(&test_ctx, manager_workflow_id, |state| { - state.active_jobs.reclaim.is_none() - }) - .await?; - assert!(manager_state.active_jobs.reclaim.is_none()); - - test_ctx.shutdown().await?; - Ok(()) -} - -#[tokio::test] -async fn manager_publishes_hot_output_and_reads_through_shard_after_pidx_clear() -> Result<()> { - workflow_matrix!("workflow-manager-publishes-hot-output-and-reads-through-shard-after-pidx-clear", build_registry, |_tier, test_ctx| { - let udb_pool = test_ctx.pools().udb()?; - let udb = Arc::new((*udb_pool).clone()); - let database_db = Db::new( - udb, - test_bucket(), - TEST_DATABASE.to_string(), - NodeId::new()); - - for txid in 1..=quota_threshold_head() { - database_db - .commit( - vec![dirty_page(1, u8::try_from(txid).unwrap_or(u8::MAX))], - 1, - 1_000 + i64::try_from(txid).unwrap_or(i64::MAX), - ) - .await?; - } - let database_branch_id = read_database_branch_id(&test_ctx).await?; - let tag_value = database_branch_tag_value(database_branch_id); - - let manager_workflow_id = test_ctx - .workflow(DbManagerInput::new(database_branch_id, None)) - .tag(DATABASE_BRANCH_ID_TAG, &tag_value) - .unique() - .dispatch() - .await?; - - wait_for_hot_install(&test_ctx, database_branch_id, quota_threshold_head()).await?; - let manager_state = - wait_for_manager_state(&test_ctx, manager_workflow_id, |state| state.active_jobs.hot.is_none()) - .await?; + .send() + .await? + .expect("signal should target manager workflow"); + wait_for_signal_ack(&test_ctx, signal_id).await?; - assert!(manager_state.active_jobs.hot.is_none()); + let repair_job = wait_for_run_reclaim_job(&test_ctx, reclaimer_workflow_id).await?; + assert_eq!(repair_job.input_range.orphan_cold_objects.len(), 1); assert_eq!( - database_db.get_pages(vec![1]).await?, - vec![FetchedPage { - pgno: 1, - bytes: Some(page(u8::try_from(quota_threshold_head()).unwrap_or(u8::MAX))), - }] + repair_job.input_range.orphan_cold_objects[0].object_key, + object_key ); + wait_for_cold_object_deleted(tier.as_ref(), &object_key).await?; + let manager_state = wait_for_manager_state(&test_ctx, manager_workflow_id, |state| { + state.active_jobs.reclaim.is_none() + }) + .await?; + assert!(manager_state.active_jobs.reclaim.is_none()); test_ctx.shutdown().await?; Ok(()) - }) +} + +#[tokio::test] +async fn manager_publishes_hot_output_and_reads_through_shard_after_pidx_clear() -> Result<()> { + workflow_matrix!( + "workflow-manager-publishes-hot-output-and-reads-through-shard-after-pidx-clear", + build_registry, + |_tier, test_ctx| { + let udb_pool = test_ctx.pools().udb()?; + let udb = Arc::new((*udb_pool).clone()); + let database_db = Db::new(udb, test_bucket(), TEST_DATABASE.to_string(), NodeId::new()); + + for txid in 1..=quota_threshold_head() { + database_db + .commit( + vec![dirty_page(1, u8::try_from(txid).unwrap_or(u8::MAX))], + 1, + 1_000 + i64::try_from(txid).unwrap_or(i64::MAX), + ) + .await?; + } + let database_branch_id = read_database_branch_id(&test_ctx).await?; + let tag_value = database_branch_tag_value(database_branch_id); + + let manager_workflow_id = test_ctx + .workflow(DbManagerInput::new(database_branch_id, None)) + .tag(DATABASE_BRANCH_ID_TAG, &tag_value) + .unique() + .dispatch() + .await?; + + wait_for_hot_install(&test_ctx, database_branch_id, quota_threshold_head()).await?; + let manager_state = wait_for_manager_state(&test_ctx, manager_workflow_id, |state| { + state.active_jobs.hot.is_none() + }) + .await?; + + assert!(manager_state.active_jobs.hot.is_none()); + assert_eq!( + database_db.get_pages(vec![1]).await?, + vec![FetchedPage { + pgno: 1, + bytes: Some(page( + u8::try_from(quota_threshold_head()).unwrap_or(u8::MAX) + )), + }] + ); + + test_ctx.shutdown().await?; + Ok(()) + } + ) } #[tokio::test] async fn manager_publishes_cold_output_and_reads_through_cold_ref() -> Result<()> { - let cold_root = Builder::new() - .prefix("depot-workflow-cold-") - .tempdir()?; + let cold_root = Builder::new().prefix("depot-workflow-cold-").tempdir()?; let tier = Arc::new(FilesystemColdTier::new(cold_root.path())); let mut test_ctx = test_ctx_with_configured_cold_tier(cold_root.path()).await?; @@ -5363,7 +5754,8 @@ async fn manager_publishes_cold_output_and_reads_through_cold_ref() -> Result<() test_bucket(), TEST_DATABASE.to_string(), NodeId::new(), - tier.clone()); + tier.clone(), + ); database_db.commit(vec![dirty_page(1, 1)], 1, 1_001).await?; let database_branch_id = read_database_branch_id(&test_ctx).await?; @@ -5415,10 +5807,12 @@ async fn manager_publishes_cold_output_and_reads_through_cold_ref() -> Result<() run_cold_job.input_range.txids.max_txid, cold_threshold_head() ); - let cold_ref = wait_for_cold_publish(&test_ctx, database_branch_id, cold_threshold_head()).await?; - let manager_state = - wait_for_manager_state(&test_ctx, manager_workflow_id, |state| state.active_jobs.cold.is_none()) - .await?; + let cold_ref = + wait_for_cold_publish(&test_ctx, database_branch_id, cold_threshold_head()).await?; + let manager_state = wait_for_manager_state(&test_ctx, manager_workflow_id, |state| { + state.active_jobs.cold.is_none() + }) + .await?; assert!(manager_state.active_jobs.cold.is_none()); assert_eq!(run_cold_job.job_id, cold_ref.object_generation_id); @@ -5592,11 +5986,10 @@ async fn reclaimer_retires_cold_object_before_grace_delete_and_cleanup() -> Resu .await?; assert_eq!(deleted.object_key, old_ref.object_key); assert!(tier.get_object(¤t_key).await?.is_some()); - let manager_state = - wait_for_manager_state(&test_ctx, manager_workflow_id, |state| { - state.active_jobs.reclaim.is_none() - }) - .await?; + let manager_state = wait_for_manager_state(&test_ctx, manager_workflow_id, |state| { + state.active_jobs.reclaim.is_none() + }) + .await?; assert!(manager_state.active_jobs.reclaim.is_none()); test_ctx.shutdown().await?; @@ -5756,263 +6149,19 @@ async fn reclaimer_logs_and_retains_live_cold_ref_for_delete_issued_object() -> ), &encode_retired_cold_object(RetiredColdObject { object_key, - object_generation_id: cold_ref.object_generation_id, - content_hash: cold_ref.content_hash, - retired_manifest_generation: 3, - retired_at_ms: 1_001, - delete_after_ms: 1_002, - delete_state: RetiredColdObjectDeleteState::DeleteIssued, - })?, - ); - Ok(()) - } - } - }) - .await?; - - let manager_workflow_id = test_ctx - .workflow(DbManagerInput::new(database_branch_id, None)) - .tag(DATABASE_BRANCH_ID_TAG, &tag_value) - .unique() - .dispatch() - .await?; - let reclaimer_workflow_id = - wait_for_workflow::(&test_ctx, database_branch_id).await?; - let _run_reclaim_job = wait_for_run_reclaim_job(&test_ctx, reclaimer_workflow_id).await?; - wait_for_reclaim_job_finished_signal(&test_ctx, manager_workflow_id).await?; - - assert!( - read_value( - &test_ctx, - branch_compaction_cold_shard_key(database_branch_id, 0, 1), - ) - .await? - .is_some() - ); - let retired = wait_for_retired_cold_object_state( - &test_ctx, - database_branch_id, - &object_key, - RetiredColdObjectDeleteState::DeleteIssued, - ) - .await?; - assert_eq!(retired.object_key, object_key); - assert!(tier.get_object(&retired.object_key).await?.is_some()); - - test_ctx.shutdown().await?; - Ok(()) -} - -#[tokio::test] -async fn cold_compacter_rejects_stale_base_generation_without_publish() -> Result<()> { - let cold_root = Builder::new() - .prefix("depot-workflow-cold-stale-") - .tempdir()?; - let tier = Arc::new(FilesystemColdTier::new(cold_root.path())); - - let database_branch_id = database_branch_id(0xd0d0_2233_4455_6677_8899_aabb_ccdd_eeff); - let tag_value = database_branch_tag_value(database_branch_id); - let mut test_ctx = test_ctx_with_configured_cold_tier(cold_root.path()).await?; - seed_manager_branch( - &test_ctx, - database_branch_id, - 1, - Some(CompactionRoot { - schema_version: 1, - manifest_generation: 2, - hot_watermark_txid: 1, - cold_watermark_txid: 0, - cold_watermark_versionstamp: [0; 16], - }), - None, - ) - .await?; - publish_test_shard_and_clear_pidx(&test_ctx, database_branch_id, 1).await?; - - let _manager_workflow_id = test_ctx - .workflow(DbManagerInput::new(database_branch_id, None)) - .tag(DATABASE_BRANCH_ID_TAG, &tag_value) - .unique() - .dispatch() - .await?; - let cold_workflow_id = - wait_for_workflow::(&test_ctx, database_branch_id).await?; - let mut versionstamp = [0; 16]; - versionstamp[8..16].copy_from_slice(&1_u64.to_be_bytes()); - let signal_id = test_ctx - .signal(RunColdJob { - database_branch_id, - job_id: Id::new_v1(42), - job_kind: CompactionJobKind::Cold, - base_lifecycle_generation: 0, - base_manifest_generation: 1, - input_fingerprint: [9; 32], - status: CompactionJobStatus::Requested, - input_range: depot::workflows::compaction::ColdJobInputRange { - txids: TxidRange { - min_txid: 1, - max_txid: 1, - }, - min_versionstamp: versionstamp, - max_versionstamp: versionstamp, - max_bytes: 1, - }, - }) - .to_workflow_id(cold_workflow_id) - .send() - .await? - .expect("signal should target cold compacter workflow"); - wait_for_signal_ack(&test_ctx, signal_id).await?; - - assert!( - read_value( - &test_ctx, - branch_compaction_cold_shard_key(database_branch_id, 0, 1), - ) - .await? - .is_none() - ); - assert!(tier.list_prefix("").await?.is_empty()); - - test_ctx.shutdown().await?; - Ok(()) -} - -#[tokio::test] -async fn manager_hot_planning_materializes_exact_pinned_txid() -> Result<()> { - let database_branch_id = database_branch_id(0x6060_2233_4455_6677_8899_aabb_ccdd_eeff); - workflow_matrix!("workflow-manager-hot-planning-materializes-exact-pinned-txid", build_registry, |_tier, test_ctx| { - let tag_value = database_branch_tag_value(database_branch_id); - seed_manager_branch( - &test_ctx, - database_branch_id, - 100, - None, - Some(SqliteCmpDirty { - observed_head_txid: 100, - updated_at_ms: 1_714_000_000_000, - }), - ) - .await?; - let _restore_point = seed_restore_point_db_pin(&test_ctx, database_branch_id, 50).await?; - - let _manager_workflow_id = test_ctx - .workflow(DbManagerInput::new(database_branch_id, None)) - .tag(DATABASE_BRANCH_ID_TAG, &tag_value) - .unique() - .dispatch() - .await?; - let hot_workflow_id = - wait_for_workflow::(&test_ctx, database_branch_id).await?; - let run_hot_job = wait_for_run_hot_job(&test_ctx, hot_workflow_id).await?; - - assert_eq!(run_hot_job.input_range.txids.max_txid, 100); - assert_eq!(run_hot_job.input_range.coverage_txids, vec![50, 100]); - - wait_for_hot_install(&test_ctx, database_branch_id, 100).await?; - let pinned_shard = read_value(&test_ctx, branch_shard_key(database_branch_id, 0, 50)) - .await? - .expect("pinned txid shard should be published"); - let latest_shard = read_value(&test_ctx, branch_shard_key(database_branch_id, 0, 100)) - .await? - .expect("latest head shard should be published"); - - let pinned_decoded = decode_ltx_v3(&pinned_shard)?; - let latest_decoded = decode_ltx_v3(&latest_shard)?; - assert_eq!(pinned_decoded.header.max_txid, 50); - assert_eq!(latest_decoded.header.max_txid, 100); - assert_eq!(pinned_decoded.get_page(1), Some(page(50).as_slice())); - assert_eq!(latest_decoded.get_page(1), Some(page(100).as_slice())); - assert_eq!( - read_value(&test_ctx, branch_shard_key(database_branch_id, 0, 99)).await?, - None - ); - - test_ctx.shutdown().await?; - Ok(()) - }) -} - -#[tokio::test] -async fn reclaimer_deletes_obsolete_fdb_rows_after_hot_coverage() -> Result<()> { - let database_branch_id = database_branch_id(0x7070_2233_4455_6677_8899_aabb_ccdd_eeff); - workflow_matrix!("workflow-reclaimer-deletes-obsolete-fdb-rows-after-hot-coverage", build_registry, |_tier, test_ctx| { - let tag_value = database_branch_tag_value(database_branch_id); - seed_manager_branch( - &test_ctx, - database_branch_id, - quota_threshold_head(), - None, - Some(SqliteCmpDirty { - observed_head_txid: quota_threshold_head(), - updated_at_ms: 1_714_000_000_000, - }), - ) - .await?; - - let _manager_workflow_id = test_ctx - .workflow(DbManagerInput::new(database_branch_id, None)) - .tag(DATABASE_BRANCH_ID_TAG, &tag_value) - .unique() - .dispatch() - .await?; - let reclaimer_workflow_id = - wait_for_workflow::(&test_ctx, database_branch_id).await?; - - wait_for_hot_install(&test_ctx, database_branch_id, quota_threshold_head()).await?; - let run_reclaim_job = wait_for_run_reclaim_job(&test_ctx, reclaimer_workflow_id).await?; - assert_eq!(run_reclaim_job.job_kind, CompactionJobKind::Reclaim); - assert_eq!(run_reclaim_job.database_branch_id, database_branch_id); - assert_eq!(run_reclaim_job.input_range.txids.min_txid, 1); - assert_eq!( - run_reclaim_job.input_range.txids.max_txid, - quota_threshold_head() - ); - assert!(!run_reclaim_job.input_range.txid_refs.is_empty()); - wait_for_reclaim_delete(&test_ctx, database_branch_id, quota_threshold_head()).await?; - - let mut versionstamp = [0; 16]; - versionstamp[8..16].copy_from_slice("a_threshold_head().to_be_bytes()); - assert!( - read_value(&test_ctx, branch_vtx_key(database_branch_id, versionstamp)) - .await? - .is_none() - ); - assert!( - read_value( - &test_ctx, - branch_shard_key(database_branch_id, 0, quota_threshold_head()), - ) - .await? - .is_some() - ); - - test_ctx.shutdown().await?; - Ok(()) - }) -} - -#[tokio::test] -async fn reclaimer_retains_rows_when_pidx_still_references_deleted_txid() -> Result<()> { - let database_branch_id = database_branch_id(0x8080_2233_4455_6677_8899_aabb_ccdd_eeff); - workflow_matrix!("workflow-reclaimer-retains-rows-when-pidx-still-references-deleted-txid", build_registry, |_tier, test_ctx| { - let tag_value = database_branch_tag_value(database_branch_id); - seed_manager_branch( - &test_ctx, - database_branch_id, - quota_threshold_head(), - Some(CompactionRoot { - schema_version: 1, - manifest_generation: 1, - hot_watermark_txid: quota_threshold_head(), - cold_watermark_txid: 0, - cold_watermark_versionstamp: [0; 16], - }), - None, - ) - .await?; - publish_test_shard_and_clear_pidx(&test_ctx, database_branch_id, quota_threshold_head()).await?; - set_test_pidx(&test_ctx, database_branch_id, 1).await?; + object_generation_id: cold_ref.object_generation_id, + content_hash: cold_ref.content_hash, + retired_manifest_generation: 3, + retired_at_ms: 1_001, + delete_after_ms: 1_002, + delete_state: RetiredColdObjectDeleteState::DeleteIssued, + })?, + ); + Ok(()) + } + } + }) + .await?; let manager_workflow_id = test_ctx .workflow(DbManagerInput::new(database_branch_id, None)) @@ -6020,41 +6169,50 @@ async fn reclaimer_retains_rows_when_pidx_still_references_deleted_txid() -> Res .unique() .dispatch() .await?; - let manager_state = - wait_for_manager_state(&test_ctx, manager_workflow_id, |state| { - state.planning_deadlines.next_reclaim_check_at_ms.is_some() - }) - .await?; + let reclaimer_workflow_id = + wait_for_workflow::(&test_ctx, database_branch_id).await?; + let _run_reclaim_job = wait_for_run_reclaim_job(&test_ctx, reclaimer_workflow_id).await?; + wait_for_reclaim_job_finished_signal(&test_ctx, manager_workflow_id).await?; - assert!(manager_state.active_jobs.reclaim.is_none()); - assert!( - read_value(&test_ctx, branch_delta_chunk_key(database_branch_id, 1, 0)) - .await? - .is_some() - ); assert!( - read_value(&test_ctx, branch_commit_key(database_branch_id, 1)) - .await? - .is_some() + read_value( + &test_ctx, + branch_compaction_cold_shard_key(database_branch_id, 0, 1), + ) + .await? + .is_some() ); + let retired = wait_for_retired_cold_object_state( + &test_ctx, + database_branch_id, + &object_key, + RetiredColdObjectDeleteState::DeleteIssued, + ) + .await?; + assert_eq!(retired.object_key, object_key); + assert!(tier.get_object(&retired.object_key).await?.is_some()); test_ctx.shutdown().await?; Ok(()) - }) } #[tokio::test] -async fn reclaimer_rejects_stale_manifest_generation() -> Result<()> { - let database_branch_id = database_branch_id(0x9090_2233_4455_6677_8899_aabb_ccdd_eeff); - workflow_matrix!("workflow-reclaimer-rejects-stale-manifest-generation", build_registry, |_tier, test_ctx| { +async fn cold_compacter_rejects_stale_base_generation_without_publish() -> Result<()> { + let cold_root = Builder::new() + .prefix("depot-workflow-cold-stale-") + .tempdir()?; + let tier = Arc::new(FilesystemColdTier::new(cold_root.path())); + + let database_branch_id = database_branch_id(0xd0d0_2233_4455_6677_8899_aabb_ccdd_eeff); let tag_value = database_branch_tag_value(database_branch_id); + let mut test_ctx = test_ctx_with_configured_cold_tier(cold_root.path()).await?; seed_manager_branch( &test_ctx, database_branch_id, 1, Some(CompactionRoot { schema_version: 1, - manifest_generation: 1, + manifest_generation: 2, hot_watermark_txid: 1, cold_watermark_txid: 0, cold_watermark_versionstamp: [0; 16], @@ -6063,7 +6221,6 @@ async fn reclaimer_rejects_stale_manifest_generation() -> Result<()> { ) .await?; publish_test_shard_and_clear_pidx(&test_ctx, database_branch_id, 1).await?; - set_test_pidx(&test_ctx, database_branch_id, 1).await?; let _manager_workflow_id = test_ctx .workflow(DbManagerInput::new(database_branch_id, None)) @@ -6071,375 +6228,666 @@ async fn reclaimer_rejects_stale_manifest_generation() -> Result<()> { .unique() .dispatch() .await?; - let reclaimer_workflow_id = - wait_for_workflow::(&test_ctx, database_branch_id).await?; + let cold_workflow_id = + wait_for_workflow::(&test_ctx, database_branch_id).await?; let mut versionstamp = [0; 16]; versionstamp[8..16].copy_from_slice(&1_u64.to_be_bytes()); let signal_id = test_ctx - .signal(RunReclaimJob { + .signal(RunColdJob { database_branch_id, job_id: Id::new_v1(42), - job_kind: CompactionJobKind::Reclaim, + job_kind: CompactionJobKind::Cold, base_lifecycle_generation: 0, - base_manifest_generation: 0, - input_fingerprint: [3; 32], + base_manifest_generation: 1, + input_fingerprint: [9; 32], status: CompactionJobStatus::Requested, - input_range: depot::workflows::compaction::ReclaimJobInputRange { + input_range: depot::workflows::compaction::ColdJobInputRange { txids: TxidRange { min_txid: 1, max_txid: 1, }, - txid_refs: vec![depot::workflows::compaction::ReclaimTxidRef { - txid: 1, - versionstamp, - }], - cold_objects: Vec::new(), - shard_cache_evictions: Vec::new(), - staged_hot_shards: Vec::new(), - orphan_cold_objects: Vec::new(), - max_keys: 500, - max_bytes: 2 * 1024 * 1024, + min_versionstamp: versionstamp, + max_versionstamp: versionstamp, + max_bytes: 1, }, }) - .to_workflow_id(reclaimer_workflow_id) + .to_workflow_id(cold_workflow_id) .send() .await? - .expect("signal should target reclaimer workflow"); + .expect("signal should target cold compacter workflow"); wait_for_signal_ack(&test_ctx, signal_id).await?; assert!( - read_value(&test_ctx, branch_delta_chunk_key(database_branch_id, 1, 0)) - .await? - .is_some() - ); - assert!( - read_value(&test_ctx, branch_commit_key(database_branch_id, 1)) - .await? - .is_some() + read_value( + &test_ctx, + branch_compaction_cold_shard_key(database_branch_id, 0, 1), + ) + .await? + .is_none() ); + assert!(tier.list_prefix("").await?.is_empty()); test_ctx.shutdown().await?; Ok(()) - }) } #[tokio::test] -async fn reclaimer_retains_pinned_txid_history() -> Result<()> { - let database_branch_id = database_branch_id(0xa0a0_2233_4455_6677_8899_aabb_ccdd_eeff); - workflow_matrix!("workflow-reclaimer-retains-pinned-txid-history", build_registry, |_tier, test_ctx| { - let tag_value = database_branch_tag_value(database_branch_id); - seed_manager_branch( - &test_ctx, - database_branch_id, - 100, - Some(CompactionRoot { - schema_version: 1, - manifest_generation: 1, - hot_watermark_txid: 100, - cold_watermark_txid: 0, - cold_watermark_versionstamp: [0; 16], - }), - None, +async fn manager_hot_planning_materializes_exact_pinned_txid() -> Result<()> { + let database_branch_id = database_branch_id(0x6060_2233_4455_6677_8899_aabb_ccdd_eeff); + workflow_matrix!( + "workflow-manager-hot-planning-materializes-exact-pinned-txid", + build_registry, + |_tier, test_ctx| { + let tag_value = database_branch_tag_value(database_branch_id); + seed_manager_branch( + &test_ctx, + database_branch_id, + 100, + None, + Some(SqliteCmpDirty { + observed_head_txid: 100, + updated_at_ms: 1_714_000_000_000, + }), + ) + .await?; + let _restore_point = + seed_restore_point_db_pin(&test_ctx, database_branch_id, 50).await?; + + let _manager_workflow_id = test_ctx + .workflow(DbManagerInput::new(database_branch_id, None)) + .tag(DATABASE_BRANCH_ID_TAG, &tag_value) + .unique() + .dispatch() + .await?; + let hot_workflow_id = + wait_for_workflow::(&test_ctx, database_branch_id).await?; + let run_hot_job = wait_for_run_hot_job(&test_ctx, hot_workflow_id).await?; + + assert_eq!(run_hot_job.input_range.txids.max_txid, 100); + assert_eq!(run_hot_job.input_range.coverage_txids, vec![50, 100]); + + wait_for_hot_install(&test_ctx, database_branch_id, 100).await?; + let pinned_shard = read_value(&test_ctx, branch_shard_key(database_branch_id, 0, 50)) + .await? + .expect("pinned txid shard should be published"); + let latest_shard = read_value(&test_ctx, branch_shard_key(database_branch_id, 0, 100)) + .await? + .expect("latest head shard should be published"); + + let pinned_decoded = decode_ltx_v3(&pinned_shard)?; + let latest_decoded = decode_ltx_v3(&latest_shard)?; + assert_eq!(pinned_decoded.header.max_txid, 50); + assert_eq!(latest_decoded.header.max_txid, 100); + assert_eq!(pinned_decoded.get_page(1), Some(page(50).as_slice())); + assert_eq!(latest_decoded.get_page(1), Some(page(100).as_slice())); + assert_eq!( + read_value(&test_ctx, branch_shard_key(database_branch_id, 0, 99)).await?, + None + ); + + test_ctx.shutdown().await?; + Ok(()) + } ) - .await?; - publish_test_shard_and_clear_pidx(&test_ctx, database_branch_id, 100).await?; - let _restore_point = seed_restore_point_db_pin(&test_ctx, database_branch_id, 50).await?; +} - let _manager_workflow_id = test_ctx - .workflow(DbManagerInput::new(database_branch_id, None)) - .tag(DATABASE_BRANCH_ID_TAG, &tag_value) - .unique() - .dispatch() - .await?; +#[tokio::test] +async fn reclaimer_deletes_obsolete_fdb_rows_after_hot_coverage() -> Result<()> { + let database_branch_id = database_branch_id(0x7070_2233_4455_6677_8899_aabb_ccdd_eeff); + workflow_matrix!( + "workflow-reclaimer-deletes-obsolete-fdb-rows-after-hot-coverage", + build_registry, + |_tier, test_ctx| { + let tag_value = database_branch_tag_value(database_branch_id); + seed_manager_branch( + &test_ctx, + database_branch_id, + quota_threshold_head(), + None, + Some(SqliteCmpDirty { + observed_head_txid: quota_threshold_head(), + updated_at_ms: 1_714_000_000_000, + }), + ) + .await?; - wait_for_reclaim_delete(&test_ctx, database_branch_id, 49).await?; - assert!( - read_value(&test_ctx, branch_delta_chunk_key(database_branch_id, 50, 0)) - .await? - .is_some() - ); - assert!( - read_value(&test_ctx, branch_commit_key(database_branch_id, 50)) - .await? - .is_some() - ); + let _manager_workflow_id = test_ctx + .workflow(DbManagerInput::new(database_branch_id, None)) + .tag(DATABASE_BRANCH_ID_TAG, &tag_value) + .unique() + .dispatch() + .await?; + let reclaimer_workflow_id = + wait_for_workflow::(&test_ctx, database_branch_id).await?; + + wait_for_hot_install(&test_ctx, database_branch_id, quota_threshold_head()).await?; + let run_reclaim_job = + wait_for_run_reclaim_job(&test_ctx, reclaimer_workflow_id).await?; + assert_eq!(run_reclaim_job.job_kind, CompactionJobKind::Reclaim); + assert_eq!(run_reclaim_job.database_branch_id, database_branch_id); + assert_eq!(run_reclaim_job.input_range.txids.min_txid, 1); + assert_eq!( + run_reclaim_job.input_range.txids.max_txid, + quota_threshold_head() + ); + assert!(!run_reclaim_job.input_range.txid_refs.is_empty()); + wait_for_reclaim_delete(&test_ctx, database_branch_id, quota_threshold_head()).await?; - test_ctx.shutdown().await?; - Ok(()) - }) + let mut versionstamp = [0; 16]; + versionstamp[8..16].copy_from_slice("a_threshold_head().to_be_bytes()); + assert!( + read_value(&test_ctx, branch_vtx_key(database_branch_id, versionstamp)) + .await? + .is_none() + ); + assert!( + read_value( + &test_ctx, + branch_shard_key(database_branch_id, 0, quota_threshold_head()), + ) + .await? + .is_some() + ); + + test_ctx.shutdown().await?; + Ok(()) + } + ) } #[tokio::test] -async fn reclaimer_retains_unexpired_pitr_interval_history() -> Result<()> { - let database_branch_id = database_branch_id(0xa1a1_2233_4455_6677_8899_aabb_ccdd_eeff); - workflow_matrix!("workflow-reclaimer-retains-unexpired-pitr-interval-history", build_registry, |_tier, test_ctx| { - let tag_value = database_branch_tag_value(database_branch_id); - seed_manager_branch( - &test_ctx, - database_branch_id, - 100, - Some(CompactionRoot { - schema_version: 1, - manifest_generation: 1, - hot_watermark_txid: 100, - cold_watermark_txid: 0, - cold_watermark_versionstamp: [0; 16], - }), - None, +async fn reclaimer_retains_rows_when_pidx_still_references_deleted_txid() -> Result<()> { + let database_branch_id = database_branch_id(0x8080_2233_4455_6677_8899_aabb_ccdd_eeff); + workflow_matrix!( + "workflow-reclaimer-retains-rows-when-pidx-still-references-deleted-txid", + build_registry, + |_tier, test_ctx| { + let tag_value = database_branch_tag_value(database_branch_id); + seed_manager_branch( + &test_ctx, + database_branch_id, + quota_threshold_head(), + Some(CompactionRoot { + schema_version: 1, + manifest_generation: 1, + hot_watermark_txid: quota_threshold_head(), + cold_watermark_txid: 0, + cold_watermark_versionstamp: [0; 16], + }), + None, + ) + .await?; + publish_test_shard_and_clear_pidx( + &test_ctx, + database_branch_id, + quota_threshold_head(), + ) + .await?; + set_test_pidx(&test_ctx, database_branch_id, 1).await?; + + let manager_workflow_id = test_ctx + .workflow(DbManagerInput::new(database_branch_id, None)) + .tag(DATABASE_BRANCH_ID_TAG, &tag_value) + .unique() + .dispatch() + .await?; + let manager_state = wait_for_manager_state(&test_ctx, manager_workflow_id, |state| { + state.planning_deadlines.next_reclaim_check_at_ms.is_some() + }) + .await?; + + assert!(manager_state.active_jobs.reclaim.is_none()); + assert!( + read_value(&test_ctx, branch_delta_chunk_key(database_branch_id, 1, 0)) + .await? + .is_some() + ); + assert!( + read_value(&test_ctx, branch_commit_key(database_branch_id, 1)) + .await? + .is_some() + ); + + test_ctx.shutdown().await?; + Ok(()) + } ) - .await?; - publish_test_shard_and_clear_pidx(&test_ctx, database_branch_id, 100).await?; - seed_pitr_interval_coverage(&test_ctx, database_branch_id, 5_000, 50, i64::MAX).await?; +} - let _manager_workflow_id = test_ctx - .workflow(DbManagerInput::new(database_branch_id, None)) - .tag(DATABASE_BRANCH_ID_TAG, &tag_value) - .unique() - .dispatch() - .await?; +#[tokio::test] +async fn reclaimer_rejects_stale_manifest_generation() -> Result<()> { + let database_branch_id = database_branch_id(0x9090_2233_4455_6677_8899_aabb_ccdd_eeff); + workflow_matrix!( + "workflow-reclaimer-rejects-stale-manifest-generation", + build_registry, + |_tier, test_ctx| { + let tag_value = database_branch_tag_value(database_branch_id); + seed_manager_branch( + &test_ctx, + database_branch_id, + 1, + Some(CompactionRoot { + schema_version: 1, + manifest_generation: 1, + hot_watermark_txid: 1, + cold_watermark_txid: 0, + cold_watermark_versionstamp: [0; 16], + }), + None, + ) + .await?; + publish_test_shard_and_clear_pidx(&test_ctx, database_branch_id, 1).await?; + set_test_pidx(&test_ctx, database_branch_id, 1).await?; + + let _manager_workflow_id = test_ctx + .workflow(DbManagerInput::new(database_branch_id, None)) + .tag(DATABASE_BRANCH_ID_TAG, &tag_value) + .unique() + .dispatch() + .await?; + let reclaimer_workflow_id = + wait_for_workflow::(&test_ctx, database_branch_id).await?; + let mut versionstamp = [0; 16]; + versionstamp[8..16].copy_from_slice(&1_u64.to_be_bytes()); + let signal_id = test_ctx + .signal(RunReclaimJob { + database_branch_id, + job_id: Id::new_v1(42), + job_kind: CompactionJobKind::Reclaim, + base_lifecycle_generation: 0, + base_manifest_generation: 0, + input_fingerprint: [3; 32], + status: CompactionJobStatus::Requested, + input_range: depot::workflows::compaction::ReclaimJobInputRange { + txids: TxidRange { + min_txid: 1, + max_txid: 1, + }, + txid_refs: vec![depot::workflows::compaction::ReclaimTxidRef { + txid: 1, + versionstamp, + }], + cold_objects: Vec::new(), + shard_cache_evictions: Vec::new(), + staged_hot_shards: Vec::new(), + orphan_cold_objects: Vec::new(), + max_keys: 500, + max_bytes: 2 * 1024 * 1024, + }, + }) + .to_workflow_id(reclaimer_workflow_id) + .send() + .await? + .expect("signal should target reclaimer workflow"); + wait_for_signal_ack(&test_ctx, signal_id).await?; + + assert!( + read_value(&test_ctx, branch_delta_chunk_key(database_branch_id, 1, 0)) + .await? + .is_some() + ); + assert!( + read_value(&test_ctx, branch_commit_key(database_branch_id, 1)) + .await? + .is_some() + ); + + test_ctx.shutdown().await?; + Ok(()) + } + ) +} + +#[tokio::test] +async fn reclaimer_retains_pinned_txid_history() -> Result<()> { + let database_branch_id = database_branch_id(0xa0a0_2233_4455_6677_8899_aabb_ccdd_eeff); + workflow_matrix!( + "workflow-reclaimer-retains-pinned-txid-history", + build_registry, + |_tier, test_ctx| { + let tag_value = database_branch_tag_value(database_branch_id); + seed_manager_branch( + &test_ctx, + database_branch_id, + 100, + Some(CompactionRoot { + schema_version: 1, + manifest_generation: 1, + hot_watermark_txid: 100, + cold_watermark_txid: 0, + cold_watermark_versionstamp: [0; 16], + }), + None, + ) + .await?; + publish_test_shard_and_clear_pidx(&test_ctx, database_branch_id, 100).await?; + let _restore_point = + seed_restore_point_db_pin(&test_ctx, database_branch_id, 50).await?; + + let _manager_workflow_id = test_ctx + .workflow(DbManagerInput::new(database_branch_id, None)) + .tag(DATABASE_BRANCH_ID_TAG, &tag_value) + .unique() + .dispatch() + .await?; + + wait_for_reclaim_delete(&test_ctx, database_branch_id, 49).await?; + assert!( + read_value(&test_ctx, branch_delta_chunk_key(database_branch_id, 50, 0)) + .await? + .is_some() + ); + assert!( + read_value(&test_ctx, branch_commit_key(database_branch_id, 50)) + .await? + .is_some() + ); + + test_ctx.shutdown().await?; + Ok(()) + } + ) +} - wait_for_reclaim_delete(&test_ctx, database_branch_id, 49).await?; - assert!( - read_value(&test_ctx, branch_delta_chunk_key(database_branch_id, 50, 0)) - .await? - .is_some() - ); - assert!( - read_value(&test_ctx, branch_commit_key(database_branch_id, 50)) - .await? - .is_some() - ); - assert_eq!(read_pitr_interval_txid(&test_ctx, database_branch_id, 5_000).await?, Some(50)); +#[tokio::test] +async fn reclaimer_retains_unexpired_pitr_interval_history() -> Result<()> { + let database_branch_id = database_branch_id(0xa1a1_2233_4455_6677_8899_aabb_ccdd_eeff); + workflow_matrix!( + "workflow-reclaimer-retains-unexpired-pitr-interval-history", + build_registry, + |_tier, test_ctx| { + let tag_value = database_branch_tag_value(database_branch_id); + seed_manager_branch( + &test_ctx, + database_branch_id, + 100, + Some(CompactionRoot { + schema_version: 1, + manifest_generation: 1, + hot_watermark_txid: 100, + cold_watermark_txid: 0, + cold_watermark_versionstamp: [0; 16], + }), + None, + ) + .await?; + publish_test_shard_and_clear_pidx(&test_ctx, database_branch_id, 100).await?; + seed_pitr_interval_coverage(&test_ctx, database_branch_id, 5_000, 50, i64::MAX).await?; + + let _manager_workflow_id = test_ctx + .workflow(DbManagerInput::new(database_branch_id, None)) + .tag(DATABASE_BRANCH_ID_TAG, &tag_value) + .unique() + .dispatch() + .await?; - test_ctx.shutdown().await?; - Ok(()) - }) + wait_for_reclaim_delete(&test_ctx, database_branch_id, 49).await?; + assert!( + read_value(&test_ctx, branch_delta_chunk_key(database_branch_id, 50, 0)) + .await? + .is_some() + ); + assert!( + read_value(&test_ctx, branch_commit_key(database_branch_id, 50)) + .await? + .is_some() + ); + assert_eq!( + read_pitr_interval_txid(&test_ctx, database_branch_id, 5_000).await?, + Some(50) + ); + + test_ctx.shutdown().await?; + Ok(()) + } + ) } #[tokio::test] async fn reclaimer_deletes_expired_pitr_interval_and_reclaims_history() -> Result<()> { let database_branch_id = database_branch_id(0xa2a2_2233_4455_6677_8899_aabb_ccdd_eeff); - workflow_matrix!("workflow-reclaimer-deletes-expired-pitr-interval-and-reclaims-history", build_registry, |_tier, test_ctx| { - let tag_value = database_branch_tag_value(database_branch_id); - seed_manager_branch( - &test_ctx, - database_branch_id, - 100, - Some(CompactionRoot { - schema_version: 1, - manifest_generation: 1, - hot_watermark_txid: 100, - cold_watermark_txid: 0, - cold_watermark_versionstamp: [0; 16], - }), - None, - ) - .await?; - publish_test_shard_and_clear_pidx(&test_ctx, database_branch_id, 100).await?; - seed_pitr_interval_coverage(&test_ctx, database_branch_id, 5_000, 50, 0).await?; - - let _manager_workflow_id = test_ctx - .workflow(DbManagerInput::new(database_branch_id, None)) - .tag(DATABASE_BRANCH_ID_TAG, &tag_value) - .unique() - .dispatch() - .await?; + workflow_matrix!( + "workflow-reclaimer-deletes-expired-pitr-interval-and-reclaims-history", + build_registry, + |_tier, test_ctx| { + let tag_value = database_branch_tag_value(database_branch_id); + seed_manager_branch( + &test_ctx, + database_branch_id, + 100, + Some(CompactionRoot { + schema_version: 1, + manifest_generation: 1, + hot_watermark_txid: 100, + cold_watermark_txid: 0, + cold_watermark_versionstamp: [0; 16], + }), + None, + ) + .await?; + publish_test_shard_and_clear_pidx(&test_ctx, database_branch_id, 100).await?; + seed_pitr_interval_coverage(&test_ctx, database_branch_id, 5_000, 50, 0).await?; + + let _manager_workflow_id = test_ctx + .workflow(DbManagerInput::new(database_branch_id, None)) + .tag(DATABASE_BRANCH_ID_TAG, &tag_value) + .unique() + .dispatch() + .await?; - wait_for_reclaim_delete(&test_ctx, database_branch_id, 100).await?; - assert_eq!(read_pitr_interval_txid(&test_ctx, database_branch_id, 5_000).await?, None); + wait_for_reclaim_delete(&test_ctx, database_branch_id, 100).await?; + assert_eq!( + read_pitr_interval_txid(&test_ctx, database_branch_id, 5_000).await?, + None + ); - test_ctx.shutdown().await?; - Ok(()) - }) + test_ctx.shutdown().await?; + Ok(()) + } + ) } #[tokio::test] async fn reclaimer_keeps_restore_point_after_pitr_interval_expires() -> Result<()> { let database_branch_id = database_branch_id(0xa3a3_2233_4455_6677_8899_aabb_ccdd_eeff); - workflow_matrix!("workflow-reclaimer-keeps-restore-point-after-pitr-interval-expires", build_registry, |_tier, test_ctx| { - let tag_value = database_branch_tag_value(database_branch_id); - seed_manager_branch( - &test_ctx, - database_branch_id, - 100, - Some(CompactionRoot { - schema_version: 1, - manifest_generation: 1, - hot_watermark_txid: 100, - cold_watermark_txid: 0, - cold_watermark_versionstamp: [0; 16], - }), - None, - ) - .await?; - publish_test_shard_and_clear_pidx(&test_ctx, database_branch_id, 100).await?; - let restore_point = seed_restore_point_db_pin(&test_ctx, database_branch_id, 50).await?; - seed_pitr_interval_coverage(&test_ctx, database_branch_id, 5_000, 50, 0).await?; - - let _manager_workflow_id = test_ctx - .workflow(DbManagerInput::new(database_branch_id, None)) - .tag(DATABASE_BRANCH_ID_TAG, &tag_value) - .unique() - .dispatch() - .await?; + workflow_matrix!( + "workflow-reclaimer-keeps-restore-point-after-pitr-interval-expires", + build_registry, + |_tier, test_ctx| { + let tag_value = database_branch_tag_value(database_branch_id); + seed_manager_branch( + &test_ctx, + database_branch_id, + 100, + Some(CompactionRoot { + schema_version: 1, + manifest_generation: 1, + hot_watermark_txid: 100, + cold_watermark_txid: 0, + cold_watermark_versionstamp: [0; 16], + }), + None, + ) + .await?; + publish_test_shard_and_clear_pidx(&test_ctx, database_branch_id, 100).await?; + let restore_point = + seed_restore_point_db_pin(&test_ctx, database_branch_id, 50).await?; + seed_pitr_interval_coverage(&test_ctx, database_branch_id, 5_000, 50, 0).await?; + + let _manager_workflow_id = test_ctx + .workflow(DbManagerInput::new(database_branch_id, None)) + .tag(DATABASE_BRANCH_ID_TAG, &tag_value) + .unique() + .dispatch() + .await?; - wait_for_reclaim_delete(&test_ctx, database_branch_id, 49).await?; - assert!( - read_value(&test_ctx, branch_delta_chunk_key(database_branch_id, 50, 0)) - .await? - .is_some() - ); - assert!( - read_value(&test_ctx, branch_commit_key(database_branch_id, 50)) - .await? - .is_some() - ); - assert_eq!(read_pitr_interval_txid(&test_ctx, database_branch_id, 5_000).await?, None); - assert!( - read_value( - &test_ctx, - db_pin_key(database_branch_id, &history_pin::restore_point_pin_id(&restore_point)), - ) - .await? - .is_some() - ); + wait_for_reclaim_delete(&test_ctx, database_branch_id, 49).await?; + assert!( + read_value(&test_ctx, branch_delta_chunk_key(database_branch_id, 50, 0)) + .await? + .is_some() + ); + assert!( + read_value(&test_ctx, branch_commit_key(database_branch_id, 50)) + .await? + .is_some() + ); + assert_eq!( + read_pitr_interval_txid(&test_ctx, database_branch_id, 5_000).await?, + None + ); + assert!( + read_value( + &test_ctx, + db_pin_key( + database_branch_id, + &history_pin::restore_point_pin_id(&restore_point) + ), + ) + .await? + .is_some() + ); - test_ctx.shutdown().await?; - Ok(()) - }) + test_ctx.shutdown().await?; + Ok(()) + } + ) } #[tokio::test] async fn reclaimer_materializes_bucket_fork_pin_before_delete() -> Result<()> { let database_branch_id = database_branch_id(0xb0b0_2233_4455_6677_8899_aabb_ccdd_eeff); - let source_bucket_branch_id = BucketBranchId::from_uuid(Uuid::from_u128( - 0x1111_2222_3333_4444_5555_6666_7777_8888, - )); - let target_bucket_branch_id = BucketBranchId::from_uuid(Uuid::from_u128( - 0x9999_aaaa_bbbb_cccc_dddd_eeee_ffff_0001, - )); - workflow_matrix!("workflow-reclaimer-materializes-bucket-fork-pin-before-delete", build_registry, |_tier, test_ctx| { - let tag_value = database_branch_tag_value(database_branch_id); - seed_manager_branch( - &test_ctx, - database_branch_id, - 100, - Some(CompactionRoot { - schema_version: 1, - manifest_generation: 1, - hot_watermark_txid: 100, - cold_watermark_txid: 0, - cold_watermark_versionstamp: [0; 16], - }), - None, - ) - .await?; - publish_test_shard_and_clear_pidx(&test_ctx, database_branch_id, 100).await?; - seed_bucket_fork_proof( - &test_ctx, - database_branch_id, - source_bucket_branch_id, - target_bucket_branch_id, - 50, - true, - ) - .await?; + let source_bucket_branch_id = + BucketBranchId::from_uuid(Uuid::from_u128(0x1111_2222_3333_4444_5555_6666_7777_8888)); + let target_bucket_branch_id = + BucketBranchId::from_uuid(Uuid::from_u128(0x9999_aaaa_bbbb_cccc_dddd_eeee_ffff_0001)); + workflow_matrix!( + "workflow-reclaimer-materializes-bucket-fork-pin-before-delete", + build_registry, + |_tier, test_ctx| { + let tag_value = database_branch_tag_value(database_branch_id); + seed_manager_branch( + &test_ctx, + database_branch_id, + 100, + Some(CompactionRoot { + schema_version: 1, + manifest_generation: 1, + hot_watermark_txid: 100, + cold_watermark_txid: 0, + cold_watermark_versionstamp: [0; 16], + }), + None, + ) + .await?; + publish_test_shard_and_clear_pidx(&test_ctx, database_branch_id, 100).await?; + seed_bucket_fork_proof( + &test_ctx, + database_branch_id, + source_bucket_branch_id, + target_bucket_branch_id, + 50, + true, + ) + .await?; - let _manager_workflow_id = test_ctx - .workflow(DbManagerInput::new(database_branch_id, None)) - .tag(DATABASE_BRANCH_ID_TAG, &tag_value) - .unique() - .dispatch() - .await?; + let _manager_workflow_id = test_ctx + .workflow(DbManagerInput::new(database_branch_id, None)) + .tag(DATABASE_BRANCH_ID_TAG, &tag_value) + .unique() + .dispatch() + .await?; - wait_for_reclaim_delete(&test_ctx, database_branch_id, 49).await?; - let pin_bytes = read_value( - &test_ctx, - db_pin_key( - database_branch_id, - &history_pin::bucket_fork_pin_id(target_bucket_branch_id), - ), - ) - .await? - .expect("bucket-derived DB_PIN should be materialized"); - let pin = decode_db_history_pin(&pin_bytes)?; - assert_eq!(pin.kind, DbHistoryPinKind::BucketFork); - assert_eq!(pin.owner_bucket_branch_id, Some(target_bucket_branch_id)); - assert_eq!(pin.at_txid, 50); - assert!( - read_value(&test_ctx, branch_delta_chunk_key(database_branch_id, 50, 0)) + wait_for_reclaim_delete(&test_ctx, database_branch_id, 49).await?; + let pin_bytes = read_value( + &test_ctx, + db_pin_key( + database_branch_id, + &history_pin::bucket_fork_pin_id(target_bucket_branch_id), + ), + ) .await? - .is_some() - ); + .expect("bucket-derived DB_PIN should be materialized"); + let pin = decode_db_history_pin(&pin_bytes)?; + assert_eq!(pin.kind, DbHistoryPinKind::BucketFork); + assert_eq!(pin.owner_bucket_branch_id, Some(target_bucket_branch_id)); + assert_eq!(pin.at_txid, 50); + assert!( + read_value(&test_ctx, branch_delta_chunk_key(database_branch_id, 50, 0)) + .await? + .is_some() + ); - test_ctx.shutdown().await?; - Ok(()) - }) + test_ctx.shutdown().await?; + Ok(()) + } + ) } #[tokio::test] async fn reclaimer_retains_history_when_bucket_proof_is_ambiguous() -> Result<()> { let database_branch_id = database_branch_id(0xc0c0_2233_4455_6677_8899_aabb_ccdd_eeff); - let source_bucket_branch_id = BucketBranchId::from_uuid(Uuid::from_u128( - 0x2222_3333_4444_5555_6666_7777_8888_9999, - )); - let target_bucket_branch_id = BucketBranchId::from_uuid(Uuid::from_u128( - 0xaaaa_bbbb_cccc_dddd_eeee_ffff_0001_0002, - )); - workflow_matrix!("workflow-reclaimer-retains-history-when-bucket-proof-is-ambiguous", build_registry, |_tier, test_ctx| { - let tag_value = database_branch_tag_value(database_branch_id); - seed_manager_branch( - &test_ctx, - database_branch_id, - 100, - Some(CompactionRoot { - schema_version: 1, - manifest_generation: 1, - hot_watermark_txid: 100, - cold_watermark_txid: 0, - cold_watermark_versionstamp: [0; 16], - }), - None, - ) - .await?; - publish_test_shard_and_clear_pidx(&test_ctx, database_branch_id, 100).await?; - seed_bucket_fork_proof( - &test_ctx, - database_branch_id, - source_bucket_branch_id, - target_bucket_branch_id, - 50, - false, - ) - .await?; + let source_bucket_branch_id = + BucketBranchId::from_uuid(Uuid::from_u128(0x2222_3333_4444_5555_6666_7777_8888_9999)); + let target_bucket_branch_id = + BucketBranchId::from_uuid(Uuid::from_u128(0xaaaa_bbbb_cccc_dddd_eeee_ffff_0001_0002)); + workflow_matrix!( + "workflow-reclaimer-retains-history-when-bucket-proof-is-ambiguous", + build_registry, + |_tier, test_ctx| { + let tag_value = database_branch_tag_value(database_branch_id); + seed_manager_branch( + &test_ctx, + database_branch_id, + 100, + Some(CompactionRoot { + schema_version: 1, + manifest_generation: 1, + hot_watermark_txid: 100, + cold_watermark_txid: 0, + cold_watermark_versionstamp: [0; 16], + }), + None, + ) + .await?; + publish_test_shard_and_clear_pidx(&test_ctx, database_branch_id, 100).await?; + seed_bucket_fork_proof( + &test_ctx, + database_branch_id, + source_bucket_branch_id, + target_bucket_branch_id, + 50, + false, + ) + .await?; - let manager_workflow_id = test_ctx - .workflow(DbManagerInput::new(database_branch_id, None)) - .tag(DATABASE_BRANCH_ID_TAG, &tag_value) - .unique() - .dispatch() - .await?; - let manager_state = - wait_for_manager_state(&test_ctx, manager_workflow_id, |state| { - state.planning_deadlines.next_reclaim_check_at_ms.is_some() - }) - .await?; + let manager_workflow_id = test_ctx + .workflow(DbManagerInput::new(database_branch_id, None)) + .tag(DATABASE_BRANCH_ID_TAG, &tag_value) + .unique() + .dispatch() + .await?; + let manager_state = wait_for_manager_state(&test_ctx, manager_workflow_id, |state| { + state.planning_deadlines.next_reclaim_check_at_ms.is_some() + }) + .await?; - assert!(manager_state.active_jobs.reclaim.is_none()); - assert!( - read_value(&test_ctx, branch_delta_chunk_key(database_branch_id, 1, 0)) - .await? - .is_some() - ); - assert!( - read_value(&test_ctx, branch_commit_key(database_branch_id, 1)) - .await? - .is_some() - ); + assert!(manager_state.active_jobs.reclaim.is_none()); + assert!( + read_value(&test_ctx, branch_delta_chunk_key(database_branch_id, 1, 0)) + .await? + .is_some() + ); + assert!( + read_value(&test_ctx, branch_commit_key(database_branch_id, 1)) + .await? + .is_some() + ); - test_ctx.shutdown().await?; - Ok(()) - }) + test_ctx.shutdown().await?; + Ok(()) + } + ) } fn quota_threshold_head() -> u64 { diff --git a/engine/packages/engine/tests/actor_v2_2_1_migration.rs b/engine/packages/engine/tests/actor_v2_2_1_migration.rs index 8ce50dce3b..5f5e689581 100644 --- a/engine/packages/engine/tests/actor_v2_2_1_migration.rs +++ b/engine/packages/engine/tests/actor_v2_2_1_migration.rs @@ -1,17 +1,17 @@ use std::{collections::HashMap, sync::Arc}; use anyhow::{Context, Result, ensure}; +use depot::{ + conveyer::{Db, branch as depot_branch}, + keys::{branch_meta_head_key, meta_head_key}, + types::{BucketId, SQLITE_PAGE_SIZE, decode_db_head}, +}; use gas::prelude::*; use pegboard::actor_kv::Recipient; use rivet_envoy_protocol as protocol; use rivet_pools::NodeId; use rusqlite::Connection; use serde::Deserialize; -use depot::{ - keys::{branch_meta_head_key, meta_head_key}, - conveyer::{branch as depot_branch, Db}, - types::{BucketId, SQLITE_PAGE_SIZE, decode_db_head}, -}; use test_snapshot::SnapshotTestCtx; const SNAPSHOT_NAME: &str = "actor-v2-2-1-baseline"; @@ -81,7 +81,9 @@ async fn actor_v2_2_1_baseline_migrates_to_current_layout() -> Result<()> { .await?; assert_eq!( - query_sqlite_notes(&load_v2_sqlite_bytes(&db, namespace.namespace_id, actor.actor_id).await?)?, + query_sqlite_notes( + &load_v2_sqlite_bytes(&db, namespace.namespace_id, actor.actor_id).await? + )?, vec!["sqlite-from-v2.2.1"] ); diff --git a/engine/packages/engine/tests/common/ctx.rs b/engine/packages/engine/tests/common/ctx.rs index 3693bd79b0..d9de9b2cdd 100644 --- a/engine/packages/engine/tests/common/ctx.rs +++ b/engine/packages/engine/tests/common/ctx.rs @@ -95,7 +95,8 @@ impl TestCtx { opts.auth_admin_token.clone(), ) }); - let mut dcs: Vec = futures_util::future::try_join_all(setup_futures).await?; + let mut dcs: Vec = + futures_util::future::try_join_all(setup_futures).await?; dcs.sort_by_key(|dc| dc.config.dc_label()); Ok(Self { dcs, opts }) diff --git a/engine/packages/engine/tests/common/test_envoy.rs b/engine/packages/engine/tests/common/test_envoy.rs index 186a3c7b73..ef4e74c263 100644 --- a/engine/packages/engine/tests/common/test_envoy.rs +++ b/engine/packages/engine/tests/common/test_envoy.rs @@ -245,10 +245,10 @@ impl rivet_test_envoy::EnvoyCallbacks for TestEnvoyCallbacks { &self, handle: EnvoyHandle, actor_id: String, - generation: u32, - config: ep::ActorConfig, - _preloaded_kv: Option, - ) -> BoxFuture> { + generation: u32, + config: ep::ActorConfig, + _preloaded_kv: Option, + ) -> BoxFuture> { let inner = self.inner.clone(); Box::pin(async move { let factory = inner diff --git a/engine/packages/engine/tests/envoy/actors_alarm.rs b/engine/packages/engine/tests/envoy/actors_alarm.rs index a34c4e5e52..36b71fd5e6 100644 --- a/engine/packages/engine/tests/envoy/actors_alarm.rs +++ b/engine/packages/engine/tests/envoy/actors_alarm.rs @@ -846,55 +846,58 @@ fn alarm_in_the_past() { #[test] fn alarm_with_null_timestamp() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _) = common::setup_test_namespace(ctx.leader_dc()).await; + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _) = common::setup_test_namespace(ctx.leader_dc()).await; - let (ready_tx, ready_rx) = tokio::sync::oneshot::channel(); - let ready_tx = Arc::new(Mutex::new(Some(ready_tx))); + let (ready_tx, ready_rx) = tokio::sync::oneshot::channel(); + let ready_tx = Arc::new(Mutex::new(Some(ready_tx))); - let runner = common::setup_envoy(ctx.leader_dc(), &namespace, |builder| { - builder.with_actor_behavior("alarm-actor", move |_| { - let ready_tx = ready_tx.clone(); - Box::new(SetClearAlarmAndSleepActor::new(ready_tx)) + let runner = common::setup_envoy(ctx.leader_dc(), &namespace, |builder| { + builder.with_actor_behavior("alarm-actor", move |_| { + let ready_tx = ready_tx.clone(); + Box::new(SetClearAlarmAndSleepActor::new(ready_tx)) + }) }) - }) - .await; + .await; - let res = common::create_actor( - ctx.leader_dc().guard_port(), - &namespace, - "alarm-actor", - runner.pool_name(), - rivet_types::actors::CrashPolicy::Sleep, - ) - .await; + let res = common::create_actor( + ctx.leader_dc().guard_port(), + &namespace, + "alarm-actor", + runner.pool_name(), + rivet_types::actors::CrashPolicy::Sleep, + ) + .await; - let actor_id = res.actor.actor_id.to_string(); + let actor_id = res.actor.actor_id.to_string(); - // Wait for actor to be ready - ready_rx.await.expect("actor should send ready signal"); + // Wait for actor to be ready + ready_rx.await.expect("actor should send ready signal"); - // Verify actor is sleeping - wait_for_actor_sleep(ctx.leader_dc().guard_port(), &actor_id, &namespace, 5) - .await - .expect("actor is not sleeping"); + // Verify actor is sleeping + wait_for_actor_sleep(ctx.leader_dc().guard_port(), &actor_id, &namespace, 5) + .await + .expect("actor is not sleeping"); - // Wait past alarm time - tokio::time::sleep(tokio::time::Duration::from_secs(3)).await; + // Wait past alarm time + tokio::time::sleep(tokio::time::Duration::from_secs(3)).await; - // Verify actor is still sleeping - let actor = common::try_get_actor(ctx.leader_dc().guard_port(), &actor_id, &namespace) - .await - .expect("failed to get actor") - .expect("actor should exist"); + // Verify actor is still sleeping + let actor = common::try_get_actor(ctx.leader_dc().guard_port(), &actor_id, &namespace) + .await + .expect("failed to get actor") + .expect("actor should exist"); - assert!( - actor.sleep_ts.is_some(), - "actor should still be sleeping after alarm was cleared with null" - ); + assert!( + actor.sleep_ts.is_some(), + "actor should still be sleeping after alarm was cleared with null" + ); - tracing::info!(?actor_id, "null alarm_ts successfully cleared alarm"); - }); + tracing::info!(?actor_id, "null alarm_ts successfully cleared alarm"); + }, + ); } // MARK: Edge Cases @@ -1254,105 +1257,108 @@ fn multiple_actors_with_different_alarm_times() { // Broken legacy Pegboard Runner test: times out waiting for all same-deadline // actors to wake in the combined Envoy+Runner full engine sweep. fn many_actors_same_alarm_time() { - common::run(common::TestOpts::new(1).with_timeout(45), |ctx| async move { - let (namespace, _) = common::setup_test_namespace(ctx.leader_dc()).await; + common::run( + common::TestOpts::new(1).with_timeout(45), + |ctx| async move { + let (namespace, _) = common::setup_test_namespace(ctx.leader_dc()).await; - let num_actors = 10; - let alarm_offset = 2000; // All wake at same time - let mut actor_ids = Vec::new(); + let num_actors = 10; + let alarm_offset = 2000; // All wake at same time + let mut actor_ids = Vec::new(); - let runner = common::setup_envoy(ctx.leader_dc(), &namespace, |builder| { - builder.with_actor_behavior("alarm-actor", move |_| { - let (ready_tx, _) = tokio::sync::oneshot::channel(); - let ready_tx = Arc::new(Mutex::new(Some(ready_tx))); - Box::new(AlarmAndSleepActor::new(alarm_offset, ready_tx)) + let runner = common::setup_envoy(ctx.leader_dc(), &namespace, |builder| { + builder.with_actor_behavior("alarm-actor", move |_| { + let (ready_tx, _) = tokio::sync::oneshot::channel(); + let ready_tx = Arc::new(Mutex::new(Some(ready_tx))); + Box::new(AlarmAndSleepActor::new(alarm_offset, ready_tx)) + }) }) - }) - .await; - - let mut lifecycle_rx = runner.subscribe_lifecycle_events(); - - // Create actors - for _idx in 0..num_actors { - let res = common::create_actor( - ctx.leader_dc().guard_port(), - &namespace, - "alarm-actor", - runner.pool_name(), - rivet_types::actors::CrashPolicy::Sleep, - ) .await; - actor_ids.push(res.actor.actor_id.to_string()); - } - - tracing::info!(num_actors, "created actors with same alarm time (+2s)"); - let actor_id_set: HashSet = actor_ids.iter().cloned().collect(); + let mut lifecycle_rx = runner.subscribe_lifecycle_events(); + + // Create actors + for _idx in 0..num_actors { + let res = common::create_actor( + ctx.leader_dc().guard_port(), + &namespace, + "alarm-actor", + runner.pool_name(), + rivet_types::actors::CrashPolicy::Sleep, + ) + .await; + actor_ids.push(res.actor.actor_id.to_string()); + } - // Same-time alarms can wake early actors before a sequential API poll reaches - // later ones, so use the Envoy lifecycle stream to prove every actor stopped - // for sleep at generation 1. - let sleep_deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); - let mut slept_actor_ids = HashSet::new(); - while slept_actor_ids.len() < num_actors { - let remaining = sleep_deadline.saturating_duration_since(std::time::Instant::now()); - let event = tokio::time::timeout(remaining, lifecycle_rx.recv()) - .await - .expect("timed out waiting for actors to sleep") - .expect("lifecycle stream closed"); - - if let ActorLifecycleEvent::Stopped { - actor_id, - generation, - } = event - { - if generation == 1 && actor_id_set.contains(&actor_id) { - slept_actor_ids.insert(actor_id); + tracing::info!(num_actors, "created actors with same alarm time (+2s)"); + + let actor_id_set: HashSet = actor_ids.iter().cloned().collect(); + + // Same-time alarms can wake early actors before a sequential API poll reaches + // later ones, so use the Envoy lifecycle stream to prove every actor stopped + // for sleep at generation 1. + let sleep_deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + let mut slept_actor_ids = HashSet::new(); + while slept_actor_ids.len() < num_actors { + let remaining = sleep_deadline.saturating_duration_since(std::time::Instant::now()); + let event = tokio::time::timeout(remaining, lifecycle_rx.recv()) + .await + .expect("timed out waiting for actors to sleep") + .expect("lifecycle stream closed"); + + if let ActorLifecycleEvent::Stopped { + actor_id, + generation, + } = event + { + if generation == 1 && actor_id_set.contains(&actor_id) { + slept_actor_ids.insert(actor_id); + } } } - } - - tracing::info!("all actors sleeping"); - - let alarm_start = std::time::Instant::now(); - // Verify all actors wake within a reasonable time window. - let wake_deadline = std::time::Instant::now() + std::time::Duration::from_secs(4); - let mut woke_actor_ids = HashSet::new(); - while woke_actor_ids.len() < num_actors { - let remaining = wake_deadline.saturating_duration_since(std::time::Instant::now()); - let event = tokio::time::timeout(remaining, lifecycle_rx.recv()) - .await - .expect("timed out waiting for actors to wake") - .expect("lifecycle stream closed"); - - if let ActorLifecycleEvent::Started { - actor_id, - generation, - } = event - { - if generation == 2 && actor_id_set.contains(&actor_id) { - tracing::info!(actor_id, "actor woke"); - woke_actor_ids.insert(actor_id); + tracing::info!("all actors sleeping"); + + let alarm_start = std::time::Instant::now(); + + // Verify all actors wake within a reasonable time window. + let wake_deadline = std::time::Instant::now() + std::time::Duration::from_secs(4); + let mut woke_actor_ids = HashSet::new(); + while woke_actor_ids.len() < num_actors { + let remaining = wake_deadline.saturating_duration_since(std::time::Instant::now()); + let event = tokio::time::timeout(remaining, lifecycle_rx.recv()) + .await + .expect("timed out waiting for actors to wake") + .expect("lifecycle stream closed"); + + if let ActorLifecycleEvent::Started { + actor_id, + generation, + } = event + { + if generation == 2 && actor_id_set.contains(&actor_id) { + tracing::info!(actor_id, "actor woke"); + woke_actor_ids.insert(actor_id); + } } } - } - let total_duration = alarm_start.elapsed(); + let total_duration = alarm_start.elapsed(); - // All 10 actors should wake within a 500ms window around the alarm time - assert!( - total_duration <= std::time::Duration::from_millis(3000), - "all actors should wake within 3s, actual: {:?}", - total_duration - ); + // All 10 actors should wake within a 500ms window around the alarm time + assert!( + total_duration <= std::time::Duration::from_millis(3000), + "all actors should wake within 3s, actual: {:?}", + total_duration + ); - tracing::info!( - num_actors, - ?total_duration, - "all actors woke concurrently at same alarm time" - ); - }); + tracing::info!( + num_actors, + ?total_duration, + "all actors woke concurrently at same alarm time" + ); + }, + ); } /// Regression test for the alarm-during-sleep-transition race. @@ -1372,7 +1378,7 @@ fn many_actors_same_alarm_time() { #[test] fn alarm_overdue_during_sleep_transition_fires_via_reallocation() { common::run( - common::TestOpts::new(1).with_timeout(30), + common::TestOpts::new(1).with_timeout(30), |ctx| async move { let (namespace, _) = common::setup_test_namespace(ctx.leader_dc()).await; diff --git a/engine/packages/engine/tests/envoy/actors_kv_crud.rs b/engine/packages/engine/tests/envoy/actors_kv_crud.rs index 74999e181d..5ba81edf1c 100644 --- a/engine/packages/engine/tests/envoy/actors_kv_crud.rs +++ b/engine/packages/engine/tests/envoy/actors_kv_crud.rs @@ -587,42 +587,45 @@ fn kv_delete_existing_key() { #[test] fn kv_delete_nonexistent_key() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _) = common::setup_test_namespace(ctx.leader_dc()).await; - - let (notify_tx, notify_rx) = tokio::sync::oneshot::channel(); - let notify_tx = Arc::new(Mutex::new(Some(notify_tx))); - - let runner = common::setup_envoy(ctx.leader_dc(), &namespace, |builder| { - builder.with_actor_behavior("kv-delete-nonexistent", move |_| { - Box::new(DeleteNonexistentKeyActor::new(notify_tx.clone())) + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _) = common::setup_test_namespace(ctx.leader_dc()).await; + + let (notify_tx, notify_rx) = tokio::sync::oneshot::channel(); + let notify_tx = Arc::new(Mutex::new(Some(notify_tx))); + + let runner = common::setup_envoy(ctx.leader_dc(), &namespace, |builder| { + builder.with_actor_behavior("kv-delete-nonexistent", move |_| { + Box::new(DeleteNonexistentKeyActor::new(notify_tx.clone())) + }) }) - }) - .await; + .await; - let res = common::create_actor( - ctx.leader_dc().guard_port(), - &namespace, - "kv-delete-nonexistent", - runner.pool_name(), - rivet_types::actors::CrashPolicy::Sleep, - ) - .await; + let res = common::create_actor( + ctx.leader_dc().guard_port(), + &namespace, + "kv-delete-nonexistent", + runner.pool_name(), + rivet_types::actors::CrashPolicy::Sleep, + ) + .await; - let actor_id = res.actor.actor_id.to_string(); + let actor_id = res.actor.actor_id.to_string(); - // Wait for actor to complete KV operations - let result = notify_rx.await.expect("actor should send test result"); + // Wait for actor to complete KV operations + let result = notify_rx.await.expect("actor should send test result"); - match result { - KvTestResult::Success => { - tracing::info!(?actor_id, "delete nonexistent key test succeeded"); - } - KvTestResult::Failure(msg) => { - panic!("delete nonexistent key test failed: {}", msg); + match result { + KvTestResult::Success => { + tracing::info!(?actor_id, "delete nonexistent key test succeeded"); + } + KvTestResult::Failure(msg) => { + panic!("delete nonexistent key test failed: {}", msg); + } } - } - }); + }, + ); } // MARK: Batch Operations Tests @@ -880,41 +883,44 @@ impl Actor for BatchDeleteActor { #[test] fn kv_put_multiple_keys() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _) = common::setup_test_namespace(ctx.leader_dc()).await; - - let (notify_tx, notify_rx) = tokio::sync::oneshot::channel(); - let notify_tx = Arc::new(Mutex::new(Some(notify_tx))); - - let runner = common::setup_envoy(ctx.leader_dc(), &namespace, |builder| { - builder.with_actor_behavior("kv-batch-put", move |_| { - Box::new(BatchPutActor::new(notify_tx.clone())) + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _) = common::setup_test_namespace(ctx.leader_dc()).await; + + let (notify_tx, notify_rx) = tokio::sync::oneshot::channel(); + let notify_tx = Arc::new(Mutex::new(Some(notify_tx))); + + let runner = common::setup_envoy(ctx.leader_dc(), &namespace, |builder| { + builder.with_actor_behavior("kv-batch-put", move |_| { + Box::new(BatchPutActor::new(notify_tx.clone())) + }) }) - }) - .await; + .await; - let res = common::create_actor( - ctx.leader_dc().guard_port(), - &namespace, - "kv-batch-put", - runner.pool_name(), - rivet_types::actors::CrashPolicy::Sleep, - ) - .await; + let res = common::create_actor( + ctx.leader_dc().guard_port(), + &namespace, + "kv-batch-put", + runner.pool_name(), + rivet_types::actors::CrashPolicy::Sleep, + ) + .await; - let actor_id = res.actor.actor_id.to_string(); + let actor_id = res.actor.actor_id.to_string(); - let result = notify_rx.await.expect("actor should send test result"); + let result = notify_rx.await.expect("actor should send test result"); - match result { - KvTestResult::Success => { - tracing::info!(?actor_id, "batch put test succeeded"); - } - KvTestResult::Failure(msg) => { - panic!("batch put test failed: {}", msg); + match result { + KvTestResult::Success => { + tracing::info!(?actor_id, "batch put test succeeded"); + } + KvTestResult::Failure(msg) => { + panic!("batch put test failed: {}", msg); + } } - } - }); + }, + ); } #[test] diff --git a/engine/packages/engine/tests/envoy/actors_kv_delete_range.rs b/engine/packages/engine/tests/envoy/actors_kv_delete_range.rs index 4b156a1647..61c9761b0b 100644 --- a/engine/packages/engine/tests/envoy/actors_kv_delete_range.rs +++ b/engine/packages/engine/tests/envoy/actors_kv_delete_range.rs @@ -96,31 +96,34 @@ impl TestActor for DeleteRangeActor { #[test] fn kv_delete_range_removes_half_open_range() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _) = common::setup_test_namespace(ctx.leader_dc()).await; - - let (notify_tx, notify_rx) = tokio::sync::oneshot::channel(); - let notify_tx = Arc::new(Mutex::new(Some(notify_tx))); - - let runner = common::setup_envoy(ctx.leader_dc(), &namespace, |builder| { - builder.with_actor_behavior("kv-delete-range", move |_| { - Box::new(DeleteRangeActor::new(notify_tx.clone())) + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _) = common::setup_test_namespace(ctx.leader_dc()).await; + + let (notify_tx, notify_rx) = tokio::sync::oneshot::channel(); + let notify_tx = Arc::new(Mutex::new(Some(notify_tx))); + + let runner = common::setup_envoy(ctx.leader_dc(), &namespace, |builder| { + builder.with_actor_behavior("kv-delete-range", move |_| { + Box::new(DeleteRangeActor::new(notify_tx.clone())) + }) }) - }) - .await; - - common::create_actor( - ctx.leader_dc().guard_port(), - &namespace, - "kv-delete-range", - runner.pool_name(), - rivet_types::actors::CrashPolicy::Sleep, - ) - .await; - - match notify_rx.await.expect("actor should send test result") { - KvTestResult::Success => {} - KvTestResult::Failure(msg) => panic!("kv delete range test failed: {}", msg), - } - }); + .await; + + common::create_actor( + ctx.leader_dc().guard_port(), + &namespace, + "kv-delete-range", + runner.pool_name(), + rivet_types::actors::CrashPolicy::Sleep, + ) + .await; + + match notify_rx.await.expect("actor should send test result") { + KvTestResult::Success => {} + KvTestResult::Failure(msg) => panic!("kv delete range test failed: {}", msg), + } + }, + ); } diff --git a/engine/packages/engine/tests/envoy/actors_kv_list.rs b/engine/packages/engine/tests/envoy/actors_kv_list.rs index 6acf4f4c4f..09d6e7d87d 100644 --- a/engine/packages/engine/tests/envoy/actors_kv_list.rs +++ b/engine/packages/engine/tests/envoy/actors_kv_list.rs @@ -867,119 +867,128 @@ fn kv_list_all_with_limit() { #[test] fn kv_list_all_reverse() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _) = common::setup_test_namespace(ctx.leader_dc()).await; - - let (notify_tx, notify_rx) = tokio::sync::oneshot::channel(); - let notify_tx = Arc::new(Mutex::new(Some(notify_tx))); - - let runner = common::setup_envoy(ctx.leader_dc(), &namespace, |builder| { - builder.with_actor_behavior("kv-list-reverse", move |_| { - Box::new(ListAllReverseActor::new(notify_tx.clone())) + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _) = common::setup_test_namespace(ctx.leader_dc()).await; + + let (notify_tx, notify_rx) = tokio::sync::oneshot::channel(); + let notify_tx = Arc::new(Mutex::new(Some(notify_tx))); + + let runner = common::setup_envoy(ctx.leader_dc(), &namespace, |builder| { + builder.with_actor_behavior("kv-list-reverse", move |_| { + Box::new(ListAllReverseActor::new(notify_tx.clone())) + }) }) - }) - .await; + .await; - let res = common::create_actor( - ctx.leader_dc().guard_port(), - &namespace, - "kv-list-reverse", - runner.pool_name(), - rivet_types::actors::CrashPolicy::Sleep, - ) - .await; + let res = common::create_actor( + ctx.leader_dc().guard_port(), + &namespace, + "kv-list-reverse", + runner.pool_name(), + rivet_types::actors::CrashPolicy::Sleep, + ) + .await; - let actor_id = res.actor.actor_id.to_string(); + let actor_id = res.actor.actor_id.to_string(); - let result = notify_rx.await.expect("actor should send test result"); + let result = notify_rx.await.expect("actor should send test result"); - match result { - KvTestResult::Success => { - tracing::info!(?actor_id, "list all reverse test succeeded"); - } - KvTestResult::Failure(msg) => { - panic!("list all reverse test failed: {}", msg); + match result { + KvTestResult::Success => { + tracing::info!(?actor_id, "list all reverse test succeeded"); + } + KvTestResult::Failure(msg) => { + panic!("list all reverse test failed: {}", msg); + } } - } - }); + }, + ); } #[test] fn kv_list_range_inclusive() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _) = common::setup_test_namespace(ctx.leader_dc()).await; - - let (notify_tx, notify_rx) = tokio::sync::oneshot::channel(); - let notify_tx = Arc::new(Mutex::new(Some(notify_tx))); - - let runner = common::setup_envoy(ctx.leader_dc(), &namespace, |builder| { - builder.with_actor_behavior("kv-range-inclusive", move |_| { - Box::new(ListRangeInclusiveActor::new(notify_tx.clone())) + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _) = common::setup_test_namespace(ctx.leader_dc()).await; + + let (notify_tx, notify_rx) = tokio::sync::oneshot::channel(); + let notify_tx = Arc::new(Mutex::new(Some(notify_tx))); + + let runner = common::setup_envoy(ctx.leader_dc(), &namespace, |builder| { + builder.with_actor_behavior("kv-range-inclusive", move |_| { + Box::new(ListRangeInclusiveActor::new(notify_tx.clone())) + }) }) - }) - .await; + .await; - let res = common::create_actor( - ctx.leader_dc().guard_port(), - &namespace, - "kv-range-inclusive", - runner.pool_name(), - rivet_types::actors::CrashPolicy::Sleep, - ) - .await; + let res = common::create_actor( + ctx.leader_dc().guard_port(), + &namespace, + "kv-range-inclusive", + runner.pool_name(), + rivet_types::actors::CrashPolicy::Sleep, + ) + .await; - let actor_id = res.actor.actor_id.to_string(); + let actor_id = res.actor.actor_id.to_string(); - let result = notify_rx.await.expect("actor should send test result"); + let result = notify_rx.await.expect("actor should send test result"); - match result { - KvTestResult::Success => { - tracing::info!(?actor_id, "list range inclusive test succeeded"); - } - KvTestResult::Failure(msg) => { - panic!("list range inclusive test failed: {}", msg); + match result { + KvTestResult::Success => { + tracing::info!(?actor_id, "list range inclusive test succeeded"); + } + KvTestResult::Failure(msg) => { + panic!("list range inclusive test failed: {}", msg); + } } - } - }); + }, + ); } #[test] fn kv_list_range_exclusive() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _) = common::setup_test_namespace(ctx.leader_dc()).await; - - let (notify_tx, notify_rx) = tokio::sync::oneshot::channel(); - let notify_tx = Arc::new(Mutex::new(Some(notify_tx))); - - let runner = common::setup_envoy(ctx.leader_dc(), &namespace, |builder| { - builder.with_actor_behavior("kv-range-exclusive", move |_| { - Box::new(ListRangeExclusiveActor::new(notify_tx.clone())) + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _) = common::setup_test_namespace(ctx.leader_dc()).await; + + let (notify_tx, notify_rx) = tokio::sync::oneshot::channel(); + let notify_tx = Arc::new(Mutex::new(Some(notify_tx))); + + let runner = common::setup_envoy(ctx.leader_dc(), &namespace, |builder| { + builder.with_actor_behavior("kv-range-exclusive", move |_| { + Box::new(ListRangeExclusiveActor::new(notify_tx.clone())) + }) }) - }) - .await; + .await; - let res = common::create_actor( - ctx.leader_dc().guard_port(), - &namespace, - "kv-range-exclusive", - runner.pool_name(), - rivet_types::actors::CrashPolicy::Sleep, - ) - .await; + let res = common::create_actor( + ctx.leader_dc().guard_port(), + &namespace, + "kv-range-exclusive", + runner.pool_name(), + rivet_types::actors::CrashPolicy::Sleep, + ) + .await; - let actor_id = res.actor.actor_id.to_string(); + let actor_id = res.actor.actor_id.to_string(); - let result = notify_rx.await.expect("actor should send test result"); + let result = notify_rx.await.expect("actor should send test result"); - match result { - KvTestResult::Success => { - tracing::info!(?actor_id, "list range exclusive test succeeded"); - } - KvTestResult::Failure(msg) => { - panic!("list range exclusive test failed: {}", msg); + match result { + KvTestResult::Success => { + tracing::info!(?actor_id, "list range exclusive test succeeded"); + } + KvTestResult::Failure(msg) => { + panic!("list range exclusive test failed: {}", msg); + } } - } - }); + }, + ); } #[test] @@ -1023,39 +1032,42 @@ fn kv_list_prefix_match() { #[test] fn kv_list_prefix_no_matches() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _) = common::setup_test_namespace(ctx.leader_dc()).await; - - let (notify_tx, notify_rx) = tokio::sync::oneshot::channel(); - let notify_tx = Arc::new(Mutex::new(Some(notify_tx))); - - let runner = common::setup_envoy(ctx.leader_dc(), &namespace, |builder| { - builder.with_actor_behavior("kv-prefix-no-match", move |_| { - Box::new(ListPrefixNoMatchActor::new(notify_tx.clone())) + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _) = common::setup_test_namespace(ctx.leader_dc()).await; + + let (notify_tx, notify_rx) = tokio::sync::oneshot::channel(); + let notify_tx = Arc::new(Mutex::new(Some(notify_tx))); + + let runner = common::setup_envoy(ctx.leader_dc(), &namespace, |builder| { + builder.with_actor_behavior("kv-prefix-no-match", move |_| { + Box::new(ListPrefixNoMatchActor::new(notify_tx.clone())) + }) }) - }) - .await; + .await; - let res = common::create_actor( - ctx.leader_dc().guard_port(), - &namespace, - "kv-prefix-no-match", - runner.pool_name(), - rivet_types::actors::CrashPolicy::Sleep, - ) - .await; + let res = common::create_actor( + ctx.leader_dc().guard_port(), + &namespace, + "kv-prefix-no-match", + runner.pool_name(), + rivet_types::actors::CrashPolicy::Sleep, + ) + .await; - let actor_id = res.actor.actor_id.to_string(); + let actor_id = res.actor.actor_id.to_string(); - let result = notify_rx.await.expect("actor should send test result"); + let result = notify_rx.await.expect("actor should send test result"); - match result { - KvTestResult::Success => { - tracing::info!(?actor_id, "list prefix no matches test succeeded"); - } - KvTestResult::Failure(msg) => { - panic!("list prefix no matches test failed: {}", msg); + match result { + KvTestResult::Success => { + tracing::info!(?actor_id, "list prefix no matches test succeeded"); + } + KvTestResult::Failure(msg) => { + panic!("list prefix no matches test failed: {}", msg); + } } - } - }); + }, + ); } diff --git a/engine/packages/engine/tests/envoy/actors_kv_misc.rs b/engine/packages/engine/tests/envoy/actors_kv_misc.rs index f0fab278ea..79062f282a 100644 --- a/engine/packages/engine/tests/envoy/actors_kv_misc.rs +++ b/engine/packages/engine/tests/envoy/actors_kv_misc.rs @@ -197,8 +197,8 @@ impl Actor for LargeValueActor { tracing::info!(actor_id = ?config.actor_id, generation = config.generation, "large value actor starting"); let result = async { - let key = make_key("large-value-key"); - let value: Vec = (0..128 * 1024).map(|i| (i % 256) as u8).collect(); + let key = make_key("large-value-key"); + let value: Vec = (0..128 * 1024).map(|i| (i % 256) as u8).collect(); tracing::info!(value_size = value.len(), "putting large value"); @@ -513,38 +513,38 @@ impl Actor for ManyKeysActor { tracing::info!(actor_id = ?config.actor_id, generation = config.generation, "many keys actor starting"); let result = async { - let mut keys = Vec::new(); - let mut values = Vec::new(); - for i in 0..128 { - keys.push(make_key(&format!("many-key-{:04}", i))); - values.push(make_value(&format!("many-value-{}", i))); - } + let mut keys = Vec::new(); + let mut values = Vec::new(); + for i in 0..128 { + keys.push(make_key(&format!("many-key-{:04}", i))); + values.push(make_value(&format!("many-value-{}", i))); + } - config - .send_kv_put(keys.clone(), values.clone()) - .await - .context("failed to put 128 keys")?; + config + .send_kv_put(keys.clone(), values.clone()) + .await + .context("failed to put 128 keys")?; - tracing::info!("put 128 keys"); + tracing::info!("put 128 keys"); // Call listAll let response = config - .send_kv_list(rp::KvListQuery::KvListAllQuery, None, None) - .await - .context("failed to list all 128 keys")?; + .send_kv_list(rp::KvListQuery::KvListAllQuery, None, None) + .await + .context("failed to list all 128 keys")?; - if response.keys.len() != 128 { - bail!("expected 128 keys, got {}", response.keys.len()); - } + if response.keys.len() != 128 { + bail!("expected 128 keys, got {}", response.keys.len()); + } - if response.values.len() != 128 { - bail!("expected 128 values, got {}", response.values.len()); - } + if response.values.len() != 128 { + bail!("expected 128 values, got {}", response.values.len()); + } - tracing::info!("verified 128 keys present in list"); + tracing::info!("verified 128 keys present in list"); // Get random sample of keys to verify values - for i in &[0, 32, 64, 96, 127] { + for i in &[0, 32, 64, 96, 127] { let key = make_key(&format!("many-key-{:04}", i)); let expected_value = make_value(&format!("many-value-{}", i)); @@ -595,275 +595,296 @@ impl Actor for ManyKeysActor { // Elapsed(())`. #[test] fn kv_binary_keys_and_values() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _) = common::setup_test_namespace(ctx.leader_dc()).await; - - let (notify_tx, notify_rx) = tokio::sync::oneshot::channel(); - let notify_tx = Arc::new(Mutex::new(Some(notify_tx))); - - let runner = common::setup_envoy(ctx.leader_dc(), &namespace, |builder| { - builder.with_actor_behavior("kv-binary", move |_| { - Box::new(BinaryDataActor::new(notify_tx.clone())) + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _) = common::setup_test_namespace(ctx.leader_dc()).await; + + let (notify_tx, notify_rx) = tokio::sync::oneshot::channel(); + let notify_tx = Arc::new(Mutex::new(Some(notify_tx))); + + let runner = common::setup_envoy(ctx.leader_dc(), &namespace, |builder| { + builder.with_actor_behavior("kv-binary", move |_| { + Box::new(BinaryDataActor::new(notify_tx.clone())) + }) }) - }) - .await; + .await; - let res = common::create_actor( - ctx.leader_dc().guard_port(), - &namespace, - "kv-binary", - runner.pool_name(), - rivet_types::actors::CrashPolicy::Sleep, - ) - .await; + let res = common::create_actor( + ctx.leader_dc().guard_port(), + &namespace, + "kv-binary", + runner.pool_name(), + rivet_types::actors::CrashPolicy::Sleep, + ) + .await; - let actor_id = res.actor.actor_id.to_string(); + let actor_id = res.actor.actor_id.to_string(); - let result = notify_rx.await.expect("actor should send test result"); + let result = notify_rx.await.expect("actor should send test result"); - match result { - KvTestResult::Success => { - tracing::info!(?actor_id, "binary data test succeeded"); - } - KvTestResult::Failure(msg) => { - panic!("binary data test failed: {}", msg); + match result { + KvTestResult::Success => { + tracing::info!(?actor_id, "binary data test succeeded"); + } + KvTestResult::Failure(msg) => { + panic!("binary data test failed: {}", msg); + } } - } - }); + }, + ); } #[test] fn kv_empty_value() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _) = common::setup_test_namespace(ctx.leader_dc()).await; - - let (notify_tx, notify_rx) = tokio::sync::oneshot::channel(); - let notify_tx = Arc::new(Mutex::new(Some(notify_tx))); - - let runner = common::setup_envoy(ctx.leader_dc(), &namespace, |builder| { - builder.with_actor_behavior("kv-empty-value", move |_| { - Box::new(EmptyValueActor::new(notify_tx.clone())) + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _) = common::setup_test_namespace(ctx.leader_dc()).await; + + let (notify_tx, notify_rx) = tokio::sync::oneshot::channel(); + let notify_tx = Arc::new(Mutex::new(Some(notify_tx))); + + let runner = common::setup_envoy(ctx.leader_dc(), &namespace, |builder| { + builder.with_actor_behavior("kv-empty-value", move |_| { + Box::new(EmptyValueActor::new(notify_tx.clone())) + }) }) - }) - .await; + .await; - let res = common::create_actor( - ctx.leader_dc().guard_port(), - &namespace, - "kv-empty-value", - runner.pool_name(), - rivet_types::actors::CrashPolicy::Sleep, - ) - .await; + let res = common::create_actor( + ctx.leader_dc().guard_port(), + &namespace, + "kv-empty-value", + runner.pool_name(), + rivet_types::actors::CrashPolicy::Sleep, + ) + .await; - let actor_id = res.actor.actor_id.to_string(); + let actor_id = res.actor.actor_id.to_string(); - let result = notify_rx.await.expect("actor should send test result"); + let result = notify_rx.await.expect("actor should send test result"); - match result { - KvTestResult::Success => { - tracing::info!(?actor_id, "empty value test succeeded"); - } - KvTestResult::Failure(msg) => { - panic!("empty value test failed: {}", msg); + match result { + KvTestResult::Success => { + tracing::info!(?actor_id, "empty value test succeeded"); + } + KvTestResult::Failure(msg) => { + panic!("empty value test failed: {}", msg); + } } - } - }); + }, + ); } #[test] fn kv_large_value() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _) = common::setup_test_namespace(ctx.leader_dc()).await; - - let (notify_tx, notify_rx) = tokio::sync::oneshot::channel(); - let notify_tx = Arc::new(Mutex::new(Some(notify_tx))); - - let runner = common::setup_envoy(ctx.leader_dc(), &namespace, |builder| { - builder.with_actor_behavior("kv-large-value", move |_| { - Box::new(LargeValueActor::new(notify_tx.clone())) + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _) = common::setup_test_namespace(ctx.leader_dc()).await; + + let (notify_tx, notify_rx) = tokio::sync::oneshot::channel(); + let notify_tx = Arc::new(Mutex::new(Some(notify_tx))); + + let runner = common::setup_envoy(ctx.leader_dc(), &namespace, |builder| { + builder.with_actor_behavior("kv-large-value", move |_| { + Box::new(LargeValueActor::new(notify_tx.clone())) + }) }) - }) - .await; + .await; - let res = common::create_actor( - ctx.leader_dc().guard_port(), - &namespace, - "kv-large-value", - runner.pool_name(), - rivet_types::actors::CrashPolicy::Sleep, - ) - .await; + let res = common::create_actor( + ctx.leader_dc().guard_port(), + &namespace, + "kv-large-value", + runner.pool_name(), + rivet_types::actors::CrashPolicy::Sleep, + ) + .await; - let actor_id = res.actor.actor_id.to_string(); + let actor_id = res.actor.actor_id.to_string(); - let result = notify_rx.await.expect("actor should send test result"); + let result = notify_rx.await.expect("actor should send test result"); - match result { - KvTestResult::Success => { - tracing::info!(?actor_id, "large value test succeeded"); - } - KvTestResult::Failure(msg) => { - panic!("large value test failed: {}", msg); + match result { + KvTestResult::Success => { + tracing::info!(?actor_id, "large value test succeeded"); + } + KvTestResult::Failure(msg) => { + panic!("large value test failed: {}", msg); + } } - } - }); + }, + ); } #[test] fn kv_get_with_empty_keys_array() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _) = common::setup_test_namespace(ctx.leader_dc()).await; - - let (notify_tx, notify_rx) = tokio::sync::oneshot::channel(); - let notify_tx = Arc::new(Mutex::new(Some(notify_tx))); - - let runner = common::setup_envoy(ctx.leader_dc(), &namespace, |builder| { - builder.with_actor_behavior("kv-get-empty", move |_| { - Box::new(GetEmptyKeysActor::new(notify_tx.clone())) + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _) = common::setup_test_namespace(ctx.leader_dc()).await; + + let (notify_tx, notify_rx) = tokio::sync::oneshot::channel(); + let notify_tx = Arc::new(Mutex::new(Some(notify_tx))); + + let runner = common::setup_envoy(ctx.leader_dc(), &namespace, |builder| { + builder.with_actor_behavior("kv-get-empty", move |_| { + Box::new(GetEmptyKeysActor::new(notify_tx.clone())) + }) }) - }) - .await; + .await; - let res = common::create_actor( - ctx.leader_dc().guard_port(), - &namespace, - "kv-get-empty", - runner.pool_name(), - rivet_types::actors::CrashPolicy::Sleep, - ) - .await; + let res = common::create_actor( + ctx.leader_dc().guard_port(), + &namespace, + "kv-get-empty", + runner.pool_name(), + rivet_types::actors::CrashPolicy::Sleep, + ) + .await; - let actor_id = res.actor.actor_id.to_string(); + let actor_id = res.actor.actor_id.to_string(); - let result = notify_rx.await.expect("actor should send test result"); + let result = notify_rx.await.expect("actor should send test result"); - match result { - KvTestResult::Success => { - tracing::info!(?actor_id, "get empty keys test succeeded"); - } - KvTestResult::Failure(msg) => { - panic!("get empty keys test failed: {}", msg); + match result { + KvTestResult::Success => { + tracing::info!(?actor_id, "get empty keys test succeeded"); + } + KvTestResult::Failure(msg) => { + panic!("get empty keys test failed: {}", msg); + } } - } - }); + }, + ); } #[test] fn kv_list_with_limit_zero() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _) = common::setup_test_namespace(ctx.leader_dc()).await; - - let (notify_tx, notify_rx) = tokio::sync::oneshot::channel(); - let notify_tx = Arc::new(Mutex::new(Some(notify_tx))); - - let runner = common::setup_envoy(ctx.leader_dc(), &namespace, |builder| { - builder.with_actor_behavior("kv-list-limit-zero", move |_| { - Box::new(ListLimitZeroActor::new(notify_tx.clone())) + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _) = common::setup_test_namespace(ctx.leader_dc()).await; + + let (notify_tx, notify_rx) = tokio::sync::oneshot::channel(); + let notify_tx = Arc::new(Mutex::new(Some(notify_tx))); + + let runner = common::setup_envoy(ctx.leader_dc(), &namespace, |builder| { + builder.with_actor_behavior("kv-list-limit-zero", move |_| { + Box::new(ListLimitZeroActor::new(notify_tx.clone())) + }) }) - }) - .await; + .await; - let res = common::create_actor( - ctx.leader_dc().guard_port(), - &namespace, - "kv-list-limit-zero", - runner.pool_name(), - rivet_types::actors::CrashPolicy::Sleep, - ) - .await; + let res = common::create_actor( + ctx.leader_dc().guard_port(), + &namespace, + "kv-list-limit-zero", + runner.pool_name(), + rivet_types::actors::CrashPolicy::Sleep, + ) + .await; - let actor_id = res.actor.actor_id.to_string(); + let actor_id = res.actor.actor_id.to_string(); - let result = notify_rx.await.expect("actor should send test result"); + let result = notify_rx.await.expect("actor should send test result"); - match result { - KvTestResult::Success => { - tracing::info!(?actor_id, "list limit zero test succeeded"); - } - KvTestResult::Failure(msg) => { - panic!("list limit zero test failed: {}", msg); + match result { + KvTestResult::Success => { + tracing::info!(?actor_id, "list limit zero test succeeded"); + } + KvTestResult::Failure(msg) => { + panic!("list limit zero test failed: {}", msg); + } } - } - }); + }, + ); } #[test] // Broken legacy Pegboard Runner test: full engine sweep timed out in // `kv_key_ordering_lexicographic`. fn kv_key_ordering_lexicographic() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _) = common::setup_test_namespace(ctx.leader_dc()).await; - - let (notify_tx, notify_rx) = tokio::sync::oneshot::channel(); - let notify_tx = Arc::new(Mutex::new(Some(notify_tx))); - - let runner = common::setup_envoy(ctx.leader_dc(), &namespace, |builder| { - builder.with_actor_behavior("kv-key-ordering", move |_| { - Box::new(KeyOrderingActor::new(notify_tx.clone())) + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _) = common::setup_test_namespace(ctx.leader_dc()).await; + + let (notify_tx, notify_rx) = tokio::sync::oneshot::channel(); + let notify_tx = Arc::new(Mutex::new(Some(notify_tx))); + + let runner = common::setup_envoy(ctx.leader_dc(), &namespace, |builder| { + builder.with_actor_behavior("kv-key-ordering", move |_| { + Box::new(KeyOrderingActor::new(notify_tx.clone())) + }) }) - }) - .await; + .await; - let res = common::create_actor( - ctx.leader_dc().guard_port(), - &namespace, - "kv-key-ordering", - runner.pool_name(), - rivet_types::actors::CrashPolicy::Sleep, - ) - .await; + let res = common::create_actor( + ctx.leader_dc().guard_port(), + &namespace, + "kv-key-ordering", + runner.pool_name(), + rivet_types::actors::CrashPolicy::Sleep, + ) + .await; - let actor_id = res.actor.actor_id.to_string(); + let actor_id = res.actor.actor_id.to_string(); - let result = notify_rx.await.expect("actor should send test result"); + let result = notify_rx.await.expect("actor should send test result"); - match result { - KvTestResult::Success => { - tracing::info!(?actor_id, "key ordering test succeeded"); - } - KvTestResult::Failure(msg) => { - panic!("key ordering test failed: {}", msg); + match result { + KvTestResult::Success => { + tracing::info!(?actor_id, "key ordering test succeeded"); + } + KvTestResult::Failure(msg) => { + panic!("key ordering test failed: {}", msg); + } } - } - }); + }, + ); } #[test] fn kv_many_keys_storage() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _) = common::setup_test_namespace(ctx.leader_dc()).await; - - let (notify_tx, notify_rx) = tokio::sync::oneshot::channel(); - let notify_tx = Arc::new(Mutex::new(Some(notify_tx))); - - let runner = common::setup_envoy(ctx.leader_dc(), &namespace, |builder| { - builder.with_actor_behavior("kv-many-keys", move |_| { - Box::new(ManyKeysActor::new(notify_tx.clone())) + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _) = common::setup_test_namespace(ctx.leader_dc()).await; + + let (notify_tx, notify_rx) = tokio::sync::oneshot::channel(); + let notify_tx = Arc::new(Mutex::new(Some(notify_tx))); + + let runner = common::setup_envoy(ctx.leader_dc(), &namespace, |builder| { + builder.with_actor_behavior("kv-many-keys", move |_| { + Box::new(ManyKeysActor::new(notify_tx.clone())) + }) }) - }) - .await; + .await; - let res = common::create_actor( - ctx.leader_dc().guard_port(), - &namespace, - "kv-many-keys", - runner.pool_name(), - rivet_types::actors::CrashPolicy::Sleep, - ) - .await; + let res = common::create_actor( + ctx.leader_dc().guard_port(), + &namespace, + "kv-many-keys", + runner.pool_name(), + rivet_types::actors::CrashPolicy::Sleep, + ) + .await; - let actor_id = res.actor.actor_id.to_string(); + let actor_id = res.actor.actor_id.to_string(); - let result = notify_rx.await.expect("actor should send test result"); + let result = notify_rx.await.expect("actor should send test result"); - match result { - KvTestResult::Success => { - tracing::info!(?actor_id, "many keys storage test succeeded"); - } - KvTestResult::Failure(msg) => { - panic!("many keys storage test failed: {}", msg); + match result { + KvTestResult::Success => { + tracing::info!(?actor_id, "many keys storage test succeeded"); + } + KvTestResult::Failure(msg) => { + panic!("many keys storage test failed: {}", msg); + } } - } - }); + }, + ); } diff --git a/engine/packages/engine/tests/envoy/actors_lifecycle.rs b/engine/packages/engine/tests/envoy/actors_lifecycle.rs index 042e0ae6d9..4563779c63 100644 --- a/engine/packages/engine/tests/envoy/actors_lifecycle.rs +++ b/engine/packages/engine/tests/envoy/actors_lifecycle.rs @@ -70,82 +70,86 @@ fn envoy_actor_basic_create() { #[test] fn envoy_create_actor_with_input() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _) = common::setup_test_namespace(ctx.leader_dc()).await; - - // Generate test input data (base64-encoded String) - let input_data = common::generate_test_input_data(); - - // Decode the base64 data to get the actual bytes the actor will receive - // The API automatically decodes base64 input before sending to the envoy - let input_data_bytes = - base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &input_data) - .expect("failed to decode base64 input"); - - // Create envoy with VerifyInputActor that will validate the input - let envoy = common::setup_envoy(ctx.leader_dc(), &namespace, |builder| { - builder.with_actor_behavior("test-actor", move |_| { - Box::new(common::test_envoy::VerifyInputActor::new( - input_data_bytes.clone(), - )) + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _) = common::setup_test_namespace(ctx.leader_dc()).await; + + // Generate test input data (base64-encoded String) + let input_data = common::generate_test_input_data(); + + // Decode the base64 data to get the actual bytes the actor will receive + // The API automatically decodes base64 input before sending to the envoy + let input_data_bytes = + base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &input_data) + .expect("failed to decode base64 input"); + + // Create envoy with VerifyInputActor that will validate the input + let envoy = common::setup_envoy(ctx.leader_dc(), &namespace, |builder| { + builder.with_actor_behavior("test-actor", move |_| { + Box::new(common::test_envoy::VerifyInputActor::new( + input_data_bytes.clone(), + )) + }) }) - }) - .await; + .await; - // Create actor with input data - let res = common::api::public::actors_create( - ctx.leader_dc().guard_port(), - common::api_types::actors::create::CreateQuery { - namespace: namespace.clone(), - }, - common::api_types::actors::create::CreateRequest { - datacenter: None, - name: "test-actor".to_string(), - key: None, - input: Some(input_data.clone()), - runner_name_selector: envoy.pool_name().to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, - }, - ) - .await - .expect("failed to create actor"); + // Create actor with input data + let res = common::api::public::actors_create( + ctx.leader_dc().guard_port(), + common::api_types::actors::create::CreateQuery { + namespace: namespace.clone(), + }, + common::api_types::actors::create::CreateRequest { + datacenter: None, + name: "test-actor".to_string(), + key: None, + input: Some(input_data.clone()), + runner_name_selector: envoy.pool_name().to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await + .expect("failed to create actor"); - let actor_id = res.actor.actor_id.to_string(); + let actor_id = res.actor.actor_id.to_string(); - // Poll for actor to become connectable - // If input verification fails, the actor will crash and never become connectable - let actor = loop { - let actor = common::try_get_actor(ctx.leader_dc().guard_port(), &actor_id, &namespace) - .await - .expect("failed to get actor") - .expect("actor should exist"); + // Poll for actor to become connectable + // If input verification fails, the actor will crash and never become connectable + let actor = loop { + let actor = + common::try_get_actor(ctx.leader_dc().guard_port(), &actor_id, &namespace) + .await + .expect("failed to get actor") + .expect("actor should exist"); - // Check if actor crashed (input verification failed) - if actor.destroy_ts.is_some() { - panic!( - "actor crashed during input verification (input data was not received correctly)" - ); - } + // Check if actor crashed (input verification failed) + if actor.destroy_ts.is_some() { + panic!( + "actor crashed during input verification (input data was not received correctly)" + ); + } - // Check if actor is connectable (input verification succeeded) - if actor.connectable_ts.is_some() { - break actor; - } + // Check if actor is connectable (input verification succeeded) + if actor.connectable_ts.is_some() { + break actor; + } - tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; - }; + tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; + }; - assert!( - actor.connectable_ts.is_some(), - "actor should be connectable after successful input verification" - ); + assert!( + actor.connectable_ts.is_some(), + "actor should be connectable after successful input verification" + ); - tracing::info!( - ?actor_id, - input_size = input_data.len(), - "actor successfully verified input data" - ); - }); + tracing::info!( + ?actor_id, + input_size = input_data.len(), + "actor successfully verified input data" + ); + }, + ); } // MARK: Running State Management @@ -211,93 +215,107 @@ fn envoy_actor_starts_and_connectable_via_guard_http() { #[test] fn envoy_http_tunnel_round_trips_request_and_errors() { - common::run(common::TestOpts::new(1).with_timeout(20), |ctx| async move { - let (namespace, _) = common::setup_test_namespace(ctx.leader_dc()).await; + common::run( + common::TestOpts::new(1).with_timeout(20), + |ctx| async move { + let (namespace, _) = common::setup_test_namespace(ctx.leader_dc()).await; - let envoy = common::setup_envoy(ctx.leader_dc(), &namespace, |builder| { - builder.with_actor_behavior("test-actor", |_| { - Box::new(common::test_envoy::EchoActor::new()) + let envoy = common::setup_envoy(ctx.leader_dc(), &namespace, |builder| { + builder.with_actor_behavior("test-actor", |_| { + Box::new(common::test_envoy::EchoActor::new()) + }) }) - }) - .await; - - let res = common::create_actor( - ctx.leader_dc().guard_port(), - &namespace, - "test-actor", - envoy.pool_name(), - rivet_types::actors::CrashPolicy::Sleep, - ) - .await; - let actor_id = res.actor.actor_id.to_string(); - wait_for_envoy_actor(&envoy, &actor_id).await; + .await; - let client = reqwest::Client::new(); - let body = "hello over envoy".as_bytes().to_vec(); - let response = client - .post(format!("http://127.0.0.1:{}/echo", ctx.leader_dc().guard_port())) - .header("X-Rivet-Target", "actor") - .header("X-Rivet-Actor", &actor_id) - .header("X-Test-Header", "from-client") - .body(body.clone()) - .send() - .await - .expect("failed to send HTTP tunnel request"); - - assert_eq!(response.status(), reqwest::StatusCode::CREATED); - assert_eq!( - response - .headers() - .get("x-envoy-test") - .and_then(|v| v.to_str().ok()), - Some("ok") - ); - let payload: serde_json::Value = response.json().await.expect("invalid echo response"); - assert_eq!(payload["actorId"], actor_id); - assert_eq!(payload["method"], "POST"); - assert_eq!(payload["path"], "/echo"); - assert_eq!(payload["testHeader"], "from-client"); - assert_eq!(payload["body"], "hello over envoy"); - assert_eq!(payload["bodyLen"], body.len()); - - let large_body = vec![b'x'; 128 * 1024]; - let large_response = client - .put(format!("http://127.0.0.1:{}/echo", ctx.leader_dc().guard_port())) - .header("X-Rivet-Target", "actor") - .header("X-Rivet-Actor", &actor_id) - .body(large_body.clone()) - .send() - .await - .expect("failed to send large HTTP tunnel request"); - assert_eq!(large_response.status(), reqwest::StatusCode::CREATED); - let large_payload: serde_json::Value = - large_response.json().await.expect("invalid large echo response"); - assert_eq!(large_payload["method"], "PUT"); - assert_eq!(large_payload["bodyLen"], large_body.len()); - - let error_response = client - .get(format!( - "http://127.0.0.1:{}/actor-error", - ctx.leader_dc().guard_port() - )) - .header("X-Rivet-Target", "actor") - .header("X-Rivet-Actor", &actor_id) - .send() - .await - .expect("failed to send actor error request"); - assert!( - !error_response.status().is_success(), - "actor fetch error should map to an HTTP error" - ); - assert_eq!(error_response.status(), reqwest::StatusCode::INTERNAL_SERVER_ERROR); - assert_eq!( - error_response - .headers() - .get("x-rivet-error") - .and_then(|v| v.to_str().ok()), - Some("envoy.fetch_failed") - ); - }); + let res = common::create_actor( + ctx.leader_dc().guard_port(), + &namespace, + "test-actor", + envoy.pool_name(), + rivet_types::actors::CrashPolicy::Sleep, + ) + .await; + let actor_id = res.actor.actor_id.to_string(); + wait_for_envoy_actor(&envoy, &actor_id).await; + + let client = reqwest::Client::new(); + let body = "hello over envoy".as_bytes().to_vec(); + let response = client + .post(format!( + "http://127.0.0.1:{}/echo", + ctx.leader_dc().guard_port() + )) + .header("X-Rivet-Target", "actor") + .header("X-Rivet-Actor", &actor_id) + .header("X-Test-Header", "from-client") + .body(body.clone()) + .send() + .await + .expect("failed to send HTTP tunnel request"); + + assert_eq!(response.status(), reqwest::StatusCode::CREATED); + assert_eq!( + response + .headers() + .get("x-envoy-test") + .and_then(|v| v.to_str().ok()), + Some("ok") + ); + let payload: serde_json::Value = response.json().await.expect("invalid echo response"); + assert_eq!(payload["actorId"], actor_id); + assert_eq!(payload["method"], "POST"); + assert_eq!(payload["path"], "/echo"); + assert_eq!(payload["testHeader"], "from-client"); + assert_eq!(payload["body"], "hello over envoy"); + assert_eq!(payload["bodyLen"], body.len()); + + let large_body = vec![b'x'; 128 * 1024]; + let large_response = client + .put(format!( + "http://127.0.0.1:{}/echo", + ctx.leader_dc().guard_port() + )) + .header("X-Rivet-Target", "actor") + .header("X-Rivet-Actor", &actor_id) + .body(large_body.clone()) + .send() + .await + .expect("failed to send large HTTP tunnel request"); + assert_eq!(large_response.status(), reqwest::StatusCode::CREATED); + let large_payload: serde_json::Value = large_response + .json() + .await + .expect("invalid large echo response"); + assert_eq!(large_payload["method"], "PUT"); + assert_eq!(large_payload["bodyLen"], large_body.len()); + + let error_response = client + .get(format!( + "http://127.0.0.1:{}/actor-error", + ctx.leader_dc().guard_port() + )) + .header("X-Rivet-Target", "actor") + .header("X-Rivet-Actor", &actor_id) + .send() + .await + .expect("failed to send actor error request"); + assert!( + !error_response.status().is_success(), + "actor fetch error should map to an HTTP error" + ); + assert_eq!( + error_response.status(), + reqwest::StatusCode::INTERNAL_SERVER_ERROR + ); + assert_eq!( + error_response + .headers() + .get("x-rivet-error") + .and_then(|v| v.to_str().ok()), + Some("envoy.fetch_failed") + ); + }, + ); } #[test] @@ -361,231 +379,246 @@ fn envoy_actor_connectable_via_guard_websocket() { #[test] fn query_get_or_create_from_dc_without_runner_forwards_to_runner_dc() { - common::run(common::TestOpts::new(2).with_timeout(45), |ctx| async move { - let (namespace, _, _envoy) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - let wrong_dc = ctx.get_dc(2); - - let client = reqwest::Client::new(); - let response = client - .get(format!( - "http://127.0.0.1:{}/gateway/test-actor/ping", - wrong_dc.guard_port() - )) - .query(&[ - ("rvt-namespace", namespace.as_str()), - ("rvt-method", "getOrCreate"), - ("rvt-runner", common::TEST_RUNNER_NAME), - ("rvt-key", "geo-routed-key"), - ]) - .send() - .await - .expect("failed to send query gateway request"); + common::run( + common::TestOpts::new(2).with_timeout(45), + |ctx| async move { + let (namespace, _, _envoy) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + let wrong_dc = ctx.get_dc(2); + + let client = reqwest::Client::new(); + let response = client + .get(format!( + "http://127.0.0.1:{}/gateway/test-actor/ping", + wrong_dc.guard_port() + )) + .query(&[ + ("rvt-namespace", namespace.as_str()), + ("rvt-method", "getOrCreate"), + ("rvt-runner", common::TEST_RUNNER_NAME), + ("rvt-key", "geo-routed-key"), + ]) + .send() + .await + .expect("failed to send query gateway request"); - assert_eq!( - response.status(), - reqwest::StatusCode::OK, - "query gateway should forward to the runner dc" - ); + assert_eq!( + response.status(), + reqwest::StatusCode::OK, + "query gateway should forward to the runner dc" + ); - let body: serde_json::Value = response.json().await.expect("invalid ping response"); - let actor_id = body["actorId"].as_str().expect("missing actor id"); - assert_eq!(body["status"], "ok"); - common::assert_actor_in_dc(actor_id, ctx.leader_dc().config.dc_label()).await; - }); + let body: serde_json::Value = response.json().await.expect("invalid ping response"); + let actor_id = body["actorId"].as_str().expect("missing actor id"); + assert_eq!(body["status"], "ok"); + common::assert_actor_in_dc(actor_id, ctx.leader_dc().config.dc_label()).await; + }, + ); } #[test] fn public_get_or_create_with_unavailable_datacenter_returns_typed_error() { - common::run(common::TestOpts::new(2).with_timeout(45), |ctx| async move { - let (namespace, _, _envoy) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - let wrong_dc = ctx.get_dc(2); - - let request = common::api::public::build_actors_get_or_create_request( - ctx.leader_dc().guard_port(), - common::api::public::GetOrCreateQuery { - namespace: namespace.clone(), - }, - common::api::public::GetOrCreateRequest { - datacenter: Some(wrong_dc.config.dc_name().unwrap().to_string()), - name: "test-actor".to_string(), - key: "public-explicit-wrong-dc-key".to_string(), - input: None, - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, - }, - ) - .await - .expect("failed to build request"); - let response = request.send().await.expect("failed to send request"); - - assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST); - let body: serde_json::Value = response.json().await.expect("invalid error response"); - assert_eq!(body["group"], "actor"); - assert_eq!(body["code"], "no_runner_config_configured"); - }); + common::run( + common::TestOpts::new(2).with_timeout(45), + |ctx| async move { + let (namespace, _, _envoy) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + let wrong_dc = ctx.get_dc(2); + + let request = common::api::public::build_actors_get_or_create_request( + ctx.leader_dc().guard_port(), + common::api::public::GetOrCreateQuery { + namespace: namespace.clone(), + }, + common::api::public::GetOrCreateRequest { + datacenter: Some(wrong_dc.config.dc_name().unwrap().to_string()), + name: "test-actor".to_string(), + key: "public-explicit-wrong-dc-key".to_string(), + input: None, + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await + .expect("failed to build request"); + let response = request.send().await.expect("failed to send request"); + + assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST); + let body: serde_json::Value = response.json().await.expect("invalid error response"); + assert_eq!(body["group"], "actor"); + assert_eq!(body["code"], "no_runner_config_configured"); + }, + ); } #[test] fn public_create_with_unavailable_datacenter_returns_typed_error() { - common::run(common::TestOpts::new(2).with_timeout(45), |ctx| async move { - let (namespace, _, _envoy) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - let wrong_dc = ctx.get_dc(2); - - let request = common::api::public::build_actors_create_request( - ctx.leader_dc().guard_port(), - common::api_types::actors::create::CreateQuery { - namespace: namespace.clone(), - }, - common::api_types::actors::create::CreateRequest { - datacenter: Some(wrong_dc.config.dc_name().unwrap().to_string()), - name: "test-actor".to_string(), - key: Some("public-create-explicit-wrong-dc-key".to_string()), - input: None, - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, - }, - ) - .await - .expect("failed to build request"); - let response = request.send().await.expect("failed to send request"); - - assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST); - let body: serde_json::Value = response.json().await.expect("invalid error response"); - assert_eq!(body["group"], "actor"); - assert_eq!(body["code"], "no_runner_config_configured"); - }); + common::run( + common::TestOpts::new(2).with_timeout(45), + |ctx| async move { + let (namespace, _, _envoy) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + let wrong_dc = ctx.get_dc(2); + + let request = common::api::public::build_actors_create_request( + ctx.leader_dc().guard_port(), + common::api_types::actors::create::CreateQuery { + namespace: namespace.clone(), + }, + common::api_types::actors::create::CreateRequest { + datacenter: Some(wrong_dc.config.dc_name().unwrap().to_string()), + name: "test-actor".to_string(), + key: Some("public-create-explicit-wrong-dc-key".to_string()), + input: None, + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await + .expect("failed to build request"); + let response = request.send().await.expect("failed to send request"); + + assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST); + let body: serde_json::Value = response.json().await.expect("invalid error response"); + assert_eq!(body["group"], "actor"); + assert_eq!(body["code"], "no_runner_config_configured"); + }, + ); } #[test] fn envoy_websocket_actor_close_round_trip() { - common::run(common::TestOpts::new(1).with_timeout(20), |ctx| async move { - use futures_util::{SinkExt, StreamExt}; - use tokio_tungstenite::{ - connect_async, - tungstenite::{Message, client::IntoClientRequest}, - }; - - let (namespace, _) = common::setup_test_namespace(ctx.leader_dc()).await; - - let envoy = common::setup_envoy(ctx.leader_dc(), &namespace, |builder| { - builder.with_actor_behavior("test-actor", |_| { - Box::new(common::test_envoy::EchoActor::new()) + common::run( + common::TestOpts::new(1).with_timeout(20), + |ctx| async move { + use futures_util::{SinkExt, StreamExt}; + use tokio_tungstenite::{ + connect_async, + tungstenite::{Message, client::IntoClientRequest}, + }; + + let (namespace, _) = common::setup_test_namespace(ctx.leader_dc()).await; + + let envoy = common::setup_envoy(ctx.leader_dc(), &namespace, |builder| { + builder.with_actor_behavior("test-actor", |_| { + Box::new(common::test_envoy::EchoActor::new()) + }) }) - }) - .await; - - let res = common::create_actor( - ctx.leader_dc().guard_port(), - &namespace, - "test-actor", - envoy.pool_name(), - rivet_types::actors::CrashPolicy::Sleep, - ) - .await; - let actor_id = res.actor.actor_id.to_string(); - wait_for_envoy_actor(&envoy, &actor_id).await; + .await; - let mut request = format!("ws://127.0.0.1:{}/ws", ctx.leader_dc().guard_port()) - .into_client_request() - .expect("failed to create WebSocket request"); - request.headers_mut().insert( - "Sec-WebSocket-Protocol", - format!( - "rivet, rivet_target.actor, rivet_actor.{}", - urlencoding::encode(&actor_id) + let res = common::create_actor( + ctx.leader_dc().guard_port(), + &namespace, + "test-actor", + envoy.pool_name(), + rivet_types::actors::CrashPolicy::Sleep, ) - .parse() - .unwrap(), - ); + .await; + let actor_id = res.actor.actor_id.to_string(); + wait_for_envoy_actor(&envoy, &actor_id).await; + + let mut request = format!("ws://127.0.0.1:{}/ws", ctx.leader_dc().guard_port()) + .into_client_request() + .expect("failed to create WebSocket request"); + request.headers_mut().insert( + "Sec-WebSocket-Protocol", + format!( + "rivet, rivet_target.actor, rivet_actor.{}", + urlencoding::encode(&actor_id) + ) + .parse() + .unwrap(), + ); - let (ws_stream, response) = connect_async(request) - .await - .expect("failed to connect WebSocket through guard"); - assert_eq!(response.status(), 101); - let (mut write, mut read) = ws_stream.split(); + let (ws_stream, response) = connect_async(request) + .await + .expect("failed to connect WebSocket through guard"); + assert_eq!(response.status(), 101); + let (mut write, mut read) = ws_stream.split(); - write - .send(Message::Text("close-from-actor".to_string().into())) - .await - .expect("failed to send close request"); + write + .send(Message::Text("close-from-actor".to_string().into())) + .await + .expect("failed to send close request"); - let close = tokio::time::timeout(std::time::Duration::from_secs(5), read.next()) - .await - .expect("timed out waiting for actor close") - .expect("websocket should yield close frame") - .expect("websocket close should not error"); - - match close { - Message::Close(Some(frame)) => { - assert_eq!(u16::from(frame.code), 4001); - assert_eq!(frame.reason, "actor.requested_close"); + let close = tokio::time::timeout(std::time::Duration::from_secs(5), read.next()) + .await + .expect("timed out waiting for actor close") + .expect("websocket should yield close frame") + .expect("websocket close should not error"); + + match close { + Message::Close(Some(frame)) => { + assert_eq!(u16::from(frame.code), 4001); + assert_eq!(frame.reason, "actor.requested_close"); + } + other => panic!("expected close frame, got {other:?}"), } - other => panic!("expected close frame, got {other:?}"), - } - }); + }, + ); } // MARK: Stopping and Graceful Shutdown #[test] fn envoy_actor_graceful_stop_with_destroy_policy() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _) = common::setup_test_namespace(ctx.leader_dc()).await; - - // Create envoy client with stop immediately actor - let envoy = common::setup_envoy(ctx.leader_dc(), &namespace, |builder| { - builder.with_actor_behavior("stop-actor", move |_| { - Box::new(common::test_envoy::StopImmediatelyActor::new()) + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _) = common::setup_test_namespace(ctx.leader_dc()).await; + + // Create envoy client with stop immediately actor + let envoy = common::setup_envoy(ctx.leader_dc(), &namespace, |builder| { + builder.with_actor_behavior("stop-actor", move |_| { + Box::new(common::test_envoy::StopImmediatelyActor::new()) + }) }) - }) - .await; + .await; - tracing::info!("envoy client ready, creating actor that will stop gracefully"); + tracing::info!("envoy client ready, creating actor that will stop gracefully"); - // Create actor with destroy crash policy - let res = common::create_actor( - ctx.leader_dc().guard_port(), - &namespace, - "stop-actor", - envoy.pool_name(), - rivet_types::actors::CrashPolicy::Sleep, - ) - .await; + // Create actor with destroy crash policy + let res = common::create_actor( + ctx.leader_dc().guard_port(), + &namespace, + "stop-actor", + envoy.pool_name(), + rivet_types::actors::CrashPolicy::Sleep, + ) + .await; - let actor_id_str = res.actor.actor_id.to_string(); + let actor_id_str = res.actor.actor_id.to_string(); - tracing::info!(?actor_id_str, "actor created, will send stop intent"); + tracing::info!(?actor_id_str, "actor created, will send stop intent"); - // Poll for actor to be destroyed after graceful stop - let actor = loop { - let actor = - common::try_get_actor(ctx.leader_dc().guard_port(), &actor_id_str, &namespace) - .await - .expect("failed to get actor") - .expect("actor should exist"); + // Poll for actor to be destroyed after graceful stop + let actor = loop { + let actor = + common::try_get_actor(ctx.leader_dc().guard_port(), &actor_id_str, &namespace) + .await + .expect("failed to get actor") + .expect("actor should exist"); - if actor.destroy_ts.is_some() { - break actor; - } + if actor.destroy_ts.is_some() { + break actor; + } - tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; - }; + tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; + }; - assert!( - actor.destroy_ts.is_some(), - "actor should be destroyed after graceful stop with destroy policy" - ); + assert!( + actor.destroy_ts.is_some(), + "actor should be destroyed after graceful stop with destroy policy" + ); - // Verify envoy slot freed (actor no longer on envoy) - assert!( - !envoy.has_actor(&actor_id_str).await, - "actor should be removed from envoy after destroy" - ); + // Verify envoy slot freed (actor no longer on envoy) + assert!( + !envoy.has_actor(&actor_id_str).await, + "actor should be removed from envoy after destroy" + ); - tracing::info!(?actor_id_str, "actor gracefully stopped and destroyed"); - }); + tracing::info!(?actor_id_str, "actor gracefully stopped and destroyed"); + }, + ); } #[test] @@ -667,237 +700,246 @@ fn envoy_actor_explicit_destroy() { #[test] fn envoy_reconnect_replays_pending_start_once() { - common::run(common::TestOpts::new(1).with_timeout(20), |ctx| async move { - let (namespace, _) = common::setup_test_namespace(ctx.leader_dc()).await; - - let start_count = Arc::new(AtomicUsize::new(0)); - let actor_start_count = start_count.clone(); - let envoy = common::setup_envoy(ctx.leader_dc(), &namespace, |builder| { - builder.with_actor_behavior("replay-actor", move |_| { - let actor_start_count = actor_start_count.clone(); - Box::new( - common::test_envoy::CustomActorBuilder::new() - .on_start(move |_| { - let actor_start_count = actor_start_count.clone(); - Box::pin(async move { - actor_start_count.fetch_add(1, Ordering::SeqCst); - Ok(common::test_envoy::ActorStartResult::Running) + common::run( + common::TestOpts::new(1).with_timeout(20), + |ctx| async move { + let (namespace, _) = common::setup_test_namespace(ctx.leader_dc()).await; + + let start_count = Arc::new(AtomicUsize::new(0)); + let actor_start_count = start_count.clone(); + let envoy = common::setup_envoy(ctx.leader_dc(), &namespace, |builder| { + builder.with_actor_behavior("replay-actor", move |_| { + let actor_start_count = actor_start_count.clone(); + Box::new( + common::test_envoy::CustomActorBuilder::new() + .on_start(move |_| { + let actor_start_count = actor_start_count.clone(); + Box::pin(async move { + actor_start_count.fetch_add(1, Ordering::SeqCst); + Ok(common::test_envoy::ActorStartResult::Running) + }) }) - }) - .build(), - ) + .build(), + ) + }) }) - }) - .await; - envoy.shutdown().await; + .await; + envoy.shutdown().await; - let res = common::create_actor( - ctx.leader_dc().guard_port(), - &namespace, - "replay-actor", - envoy.pool_name(), - rivet_types::actors::CrashPolicy::Sleep, - ) - .await; - let actor_id = res.actor.actor_id.to_string(); + let res = common::create_actor( + ctx.leader_dc().guard_port(), + &namespace, + "replay-actor", + envoy.pool_name(), + rivet_types::actors::CrashPolicy::Sleep, + ) + .await; + let actor_id = res.actor.actor_id.to_string(); - tokio::time::timeout(std::time::Duration::from_secs(5), async { - loop { - let actor = - common::try_get_actor(ctx.leader_dc().guard_port(), &actor_id, &namespace) - .await - .expect("failed to get actor") - .expect("actor should exist"); - if matches!(&actor.error, Some(rivet_types::actor::ActorError::NoEnvoys)) { - break; + tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + let actor = + common::try_get_actor(ctx.leader_dc().guard_port(), &actor_id, &namespace) + .await + .expect("failed to get actor") + .expect("actor should exist"); + if matches!(&actor.error, Some(rivet_types::actor::ActorError::NoEnvoys)) { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; } - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - } - }) - .await - .expect("actor should wait for envoy while disconnected"); + }) + .await + .expect("actor should wait for envoy while disconnected"); - envoy.start().await.expect("failed to restart envoy"); - envoy.wait_ready().await; - wait_for_envoy_actor(&envoy, &actor_id).await; + envoy.start().await.expect("failed to restart envoy"); + envoy.wait_ready().await; + wait_for_envoy_actor(&envoy, &actor_id).await; - assert_eq!( - start_count.load(Ordering::SeqCst), - 1, - "reconnected envoy should receive the missed start exactly once" - ); - tokio::time::sleep(std::time::Duration::from_millis(500)).await; - assert_eq!( - start_count.load(Ordering::SeqCst), - 1, - "start command should not be replayed twice after reconnect" - ); - }); + assert_eq!( + start_count.load(Ordering::SeqCst), + 1, + "reconnected envoy should receive the missed start exactly once" + ); + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + assert_eq!( + start_count.load(Ordering::SeqCst), + 1, + "start command should not be replayed twice after reconnect" + ); + }, + ); } #[test] fn envoy_actor_stop_waits_for_completion_before_destroy() { - common::run(common::TestOpts::new(1).with_timeout(20), |ctx| async move { - let (namespace, _) = common::setup_test_namespace(ctx.leader_dc()).await; - - let (stop_started_tx, stop_started_rx) = tokio::sync::oneshot::channel(); - let stop_started_tx = Arc::new(Mutex::new(Some(stop_started_tx))); - let envoy = common::setup_envoy(ctx.leader_dc(), &namespace, |builder| { - builder.with_actor_behavior("delayed-stop-actor", move |_| { - let stop_started_tx = stop_started_tx.clone(); - Box::new( - common::test_envoy::CustomActorBuilder::new() - .on_stop(move || { - let stop_started_tx = stop_started_tx.clone(); - Box::pin(async move { - if let Some(tx) = - stop_started_tx.lock().expect("stop tx lock").take() - { - let _ = tx.send(()); - } - tokio::time::sleep(std::time::Duration::from_secs(3)).await; - Ok(common::test_envoy::ActorStopResult::Success) + common::run( + common::TestOpts::new(1).with_timeout(20), + |ctx| async move { + let (namespace, _) = common::setup_test_namespace(ctx.leader_dc()).await; + + let (stop_started_tx, stop_started_rx) = tokio::sync::oneshot::channel(); + let stop_started_tx = Arc::new(Mutex::new(Some(stop_started_tx))); + let envoy = common::setup_envoy(ctx.leader_dc(), &namespace, |builder| { + builder.with_actor_behavior("delayed-stop-actor", move |_| { + let stop_started_tx = stop_started_tx.clone(); + Box::new( + common::test_envoy::CustomActorBuilder::new() + .on_stop(move || { + let stop_started_tx = stop_started_tx.clone(); + Box::pin(async move { + if let Some(tx) = + stop_started_tx.lock().expect("stop tx lock").take() + { + let _ = tx.send(()); + } + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + Ok(common::test_envoy::ActorStopResult::Success) + }) }) - }) - .build(), - ) + .build(), + ) + }) }) - }) - .await; - - let res = common::create_actor( - ctx.leader_dc().guard_port(), - &namespace, - "delayed-stop-actor", - envoy.pool_name(), - rivet_types::actors::CrashPolicy::Sleep, - ) - .await; - let actor_id = res.actor.actor_id.to_string(); - wait_for_envoy_actor(&envoy, &actor_id).await; + .await; - let guard_port = ctx.leader_dc().guard_port(); - let delete_actor_id = actor_id.clone(); - let delete_namespace = namespace.clone(); - let delete_task = tokio::spawn(async move { - common::api::public::actors_delete( - guard_port, - common::api_types::actors::delete::DeletePath { - actor_id: delete_actor_id.parse().expect("failed to parse actor_id"), - }, - common::api_types::actors::delete::DeleteQuery { - namespace: delete_namespace, - }, + let res = common::create_actor( + ctx.leader_dc().guard_port(), + &namespace, + "delayed-stop-actor", + envoy.pool_name(), + rivet_types::actors::CrashPolicy::Sleep, ) - .await - .expect("failed to delete actor"); - }); - - stop_started_rx - .await - .expect("envoy should begin graceful stop"); + .await; + let actor_id = res.actor.actor_id.to_string(); + wait_for_envoy_actor(&envoy, &actor_id).await; + + let guard_port = ctx.leader_dc().guard_port(); + let delete_actor_id = actor_id.clone(); + let delete_namespace = namespace.clone(); + let delete_task = tokio::spawn(async move { + common::api::public::actors_delete( + guard_port, + common::api_types::actors::delete::DeletePath { + actor_id: delete_actor_id.parse().expect("failed to parse actor_id"), + }, + common::api_types::actors::delete::DeleteQuery { + namespace: delete_namespace, + }, + ) + .await + .expect("failed to delete actor"); + }); - let actor_during_stop = - common::try_get_actor(ctx.leader_dc().guard_port(), &actor_id, &namespace) + stop_started_rx .await - .expect("failed to get actor") - .expect("actor should exist during stop"); - assert!( - actor_during_stop.destroy_ts.is_none(), - "actor should not be destroyed before Envoy stop completion" - ); + .expect("envoy should begin graceful stop"); - delete_task.await.expect("delete task should not panic"); + let actor_during_stop = + common::try_get_actor(ctx.leader_dc().guard_port(), &actor_id, &namespace) + .await + .expect("failed to get actor") + .expect("actor should exist during stop"); + assert!( + actor_during_stop.destroy_ts.is_none(), + "actor should not be destroyed before Envoy stop completion" + ); - tokio::time::timeout(std::time::Duration::from_secs(5), async { - loop { - let actor = - common::try_get_actor(ctx.leader_dc().guard_port(), &actor_id, &namespace) - .await - .expect("failed to get actor") - .expect("actor should exist"); - if actor.destroy_ts.is_some() { - break; + delete_task.await.expect("delete task should not panic"); + + tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + let actor = + common::try_get_actor(ctx.leader_dc().guard_port(), &actor_id, &namespace) + .await + .expect("failed to get actor") + .expect("actor should exist"); + if actor.destroy_ts.is_some() { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; } - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - } - }) - .await - .expect("actor should be destroyed after Envoy stop completion"); - }); + }) + .await + .expect("actor should be destroyed after Envoy stop completion"); + }, + ); } // MARK: 5. Crash Handling and Policies #[test] fn envoy_crash_policy_sleep() { - common::run(common::TestOpts::new(1).with_timeout(75), |ctx| async move { - let (namespace, _) = common::setup_test_namespace(ctx.leader_dc()).await; - - // Create channel to be notified when actor crashes - let (crash_tx, crash_rx) = tokio::sync::oneshot::channel(); - let crash_tx = Arc::new(Mutex::new(Some(crash_tx))); - - // Create envoy client with crashing actor - let envoy = common::setup_envoy(ctx.leader_dc(), &namespace, |builder| { - builder.with_actor_behavior("crash-actor", move |_| { - Box::new(common::test_envoy::CrashOnStartActor::new_with_notify( - 1, - crash_tx.clone(), - )) + common::run( + common::TestOpts::new(1).with_timeout(75), + |ctx| async move { + let (namespace, _) = common::setup_test_namespace(ctx.leader_dc()).await; + + // Create channel to be notified when actor crashes + let (crash_tx, crash_rx) = tokio::sync::oneshot::channel(); + let crash_tx = Arc::new(Mutex::new(Some(crash_tx))); + + // Create envoy client with crashing actor + let envoy = common::setup_envoy(ctx.leader_dc(), &namespace, |builder| { + builder.with_actor_behavior("crash-actor", move |_| { + Box::new(common::test_envoy::CrashOnStartActor::new_with_notify( + 1, + crash_tx.clone(), + )) + }) }) - }) - .await; + .await; - tracing::info!("envoy client ready, creating actor with sleep policy"); + tracing::info!("envoy client ready, creating actor with sleep policy"); - // Create actor with sleep crash policy - let res = common::create_actor( - ctx.leader_dc().guard_port(), - &namespace, - "crash-actor", - envoy.pool_name(), - rivet_types::actors::CrashPolicy::Sleep, - ) - .await; + // Create actor with sleep crash policy + let res = common::create_actor( + ctx.leader_dc().guard_port(), + &namespace, + "crash-actor", + envoy.pool_name(), + rivet_types::actors::CrashPolicy::Sleep, + ) + .await; - let actor_id_str = res.actor.actor_id.to_string(); + let actor_id_str = res.actor.actor_id.to_string(); - tracing::info!(?actor_id_str, "actor created with sleep policy"); + tracing::info!(?actor_id_str, "actor created with sleep policy"); - // Wait for crash notification - crash_rx - .await - .expect("actor should have sent crash notification"); + // Wait for crash notification + crash_rx + .await + .expect("actor should have sent crash notification"); - // Poll for sleep_ts to be set (system needs to process the crash) - let actor = loop { - let actor = - common::try_get_actor(ctx.leader_dc().guard_port(), &actor_id_str, &namespace) - .await - .expect("failed to get actor") - .expect("actor should exist"); + // Poll for sleep_ts to be set (system needs to process the crash) + let actor = loop { + let actor = + common::try_get_actor(ctx.leader_dc().guard_port(), &actor_id_str, &namespace) + .await + .expect("failed to get actor") + .expect("actor should exist"); - if actor.sleep_ts.is_some() { - break actor; - } + if actor.sleep_ts.is_some() { + break actor; + } - tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; - }; + tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; + }; - assert!( - actor.sleep_ts.is_some(), - "actor should be sleeping after crash with sleep policy" - ); - assert!( - actor.connectable_ts.is_none(), - "actor should not be connectable while sleeping" - ); + assert!( + actor.sleep_ts.is_some(), + "actor should be sleeping after crash with sleep policy" + ); + assert!( + actor.connectable_ts.is_none(), + "actor should not be connectable while sleeping" + ); - tracing::info!( - ?actor_id_str, - "actor correctly entered sleep state after crash" - ); - }); + tracing::info!( + ?actor_id_str, + "actor correctly entered sleep state after crash" + ); + }, + ); } // MARK: 6. Sleep and Wake @@ -1035,10 +1077,7 @@ fn envoy_actor_pending_allocation_no_envoys() { "actor should not be connectable yet" ); assert!( - matches!( - &actor.error, - Some(rivet_types::actor::ActorError::NoEnvoys) - ), + matches!(&actor.error, Some(rivet_types::actor::ActorError::NoEnvoys)), "actor should report no connected envoys before allocation, got {:?}", actor.error ); @@ -1074,87 +1113,93 @@ fn envoy_actor_pending_allocation_no_envoys() { #[test] fn envoy_multiple_pending_allocations_start_after_envoy_reconnect() { - common::run(common::TestOpts::new(1).with_timeout(45), |ctx| async move { - let (namespace, _) = common::setup_test_namespace(ctx.leader_dc()).await; + common::run( + common::TestOpts::new(1).with_timeout(45), + |ctx| async move { + let (namespace, _) = common::setup_test_namespace(ctx.leader_dc()).await; + + // Prime the pool's Envoy protocol version, then disconnect so all actors are + // created as actor2 with no active envoys available. + let envoy = common::setup_envoy(ctx.leader_dc(), &namespace, |builder| { + builder + .with_actor_behavior("test-actor-0", |_| { + Box::new(common::test_envoy::EchoActor::new()) + }) + .with_actor_behavior("test-actor-1", |_| { + Box::new(common::test_envoy::EchoActor::new()) + }) + .with_actor_behavior("test-actor-2", |_| { + Box::new(common::test_envoy::EchoActor::new()) + }) + }) + .await; + envoy.shutdown().await; + + tracing::info!("envoy protocol version primed, envoy disconnected"); + + // Create 3 actors while no envoy is connected. + let mut actor_ids = Vec::new(); + for i in 0..3 { + let name = format!("test-actor-{}", i); + let res = common::create_actor( + ctx.leader_dc().guard_port(), + &namespace, + &name, + envoy.pool_name(), + rivet_types::actors::CrashPolicy::Sleep, + ) + .await; + + let actor_id = res.actor.actor_id.to_string(); + tokio::time::timeout(tokio::time::Duration::from_secs(5), async { + loop { + let actor = common::try_get_actor( + ctx.leader_dc().guard_port(), + &actor_id, + &namespace, + ) + .await + .expect("failed to get actor") + .expect("actor should exist"); - // Prime the pool's Envoy protocol version, then disconnect so all actors are - // created as actor2 with no active envoys available. - let envoy = common::setup_envoy(ctx.leader_dc(), &namespace, |builder| { - builder - .with_actor_behavior("test-actor-0", |_| { - Box::new(common::test_envoy::EchoActor::new()) - }) - .with_actor_behavior("test-actor-1", |_| { - Box::new(common::test_envoy::EchoActor::new()) - }) - .with_actor_behavior("test-actor-2", |_| { - Box::new(common::test_envoy::EchoActor::new()) + assert!( + actor.connectable_ts.is_none(), + "actor should not be connectable before envoy reconnect" + ); + if matches!(&actor.error, Some(rivet_types::actor::ActorError::NoEnvoys)) { + break; + } + + tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; + } }) - }) - .await; - envoy.shutdown().await; + .await + .expect("actor should report no connected envoys before allocation"); - tracing::info!("envoy protocol version primed, envoy disconnected"); + actor_ids.push(actor_id); + } - // Create 3 actors while no envoy is connected. - let mut actor_ids = Vec::new(); - for i in 0..3 { - let name = format!("test-actor-{}", i); - let res = common::create_actor( - ctx.leader_dc().guard_port(), - &namespace, - &name, - envoy.pool_name(), - rivet_types::actors::CrashPolicy::Sleep, - ) - .await; + envoy.start().await.expect("failed to restart envoy"); + envoy.wait_ready().await; - let actor_id = res.actor.actor_id.to_string(); - tokio::time::timeout(tokio::time::Duration::from_secs(5), async { - loop { - let actor = - common::try_get_actor(ctx.leader_dc().guard_port(), &actor_id, &namespace) - .await - .expect("failed to get actor") - .expect("actor should exist"); - - assert!( - actor.connectable_ts.is_none(), - "actor should not be connectable before envoy reconnect" - ); - if matches!(&actor.error, Some(rivet_types::actor::ActorError::NoEnvoys)) { + // Poll for all pending actors to be allocated. + loop { + let mut all_allocated = true; + for actor_id in &actor_ids { + if !envoy.has_actor(actor_id).await { + all_allocated = false; break; } - - tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; } - }) - .await - .expect("actor should report no connected envoys before allocation"); - - actor_ids.push(actor_id); - } - - envoy.start().await.expect("failed to restart envoy"); - envoy.wait_ready().await; - - // Poll for all pending actors to be allocated. - loop { - let mut all_allocated = true; - for actor_id in &actor_ids { - if !envoy.has_actor(actor_id).await { - all_allocated = false; + if all_allocated { break; } + tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; } - if all_allocated { - break; - } - tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; - } - tracing::info!("all pending actors allocated after envoy reconnect"); - }); + tracing::info!("all pending actors allocated after envoy reconnect"); + }, + ); } // MARK: Resource Limits diff --git a/engine/packages/engine/tests/envoy/api_actors_create.rs b/engine/packages/engine/tests/envoy/api_actors_create.rs index f68878a08a..a365061353 100644 --- a/engine/packages/engine/tests/envoy/api_actors_create.rs +++ b/engine/packages/engine/tests/envoy/api_actors_create.rs @@ -3,258 +3,283 @@ use super::super::common; // MARK: Basic #[test] fn create_actor_valid_namespace() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _, runner) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - - let res = common::api::public::actors_create( - ctx.leader_dc().guard_port(), - common::api_types::actors::create::CreateQuery { - namespace: namespace.clone(), - }, - common::api_types::actors::create::CreateRequest { - datacenter: None, - name: "test-actor".to_string(), - key: None, - input: None, - runner_name_selector: runner.pool_name().to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, - }, - ) - .await - .expect("failed to create actor"); - let actor_id = res.actor.actor_id.to_string(); - - common::assert_actor_exists(ctx.leader_dc().guard_port(), &actor_id, &namespace).await; - - // TODO: Hook into engine instead of sleep - tokio::time::sleep(std::time::Duration::from_secs(1)).await; - - assert!( - runner.has_actor(&actor_id).await, - "runner should have the actor" - ); - }); + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _, runner) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + + let res = common::api::public::actors_create( + ctx.leader_dc().guard_port(), + common::api_types::actors::create::CreateQuery { + namespace: namespace.clone(), + }, + common::api_types::actors::create::CreateRequest { + datacenter: None, + name: "test-actor".to_string(), + key: None, + input: None, + runner_name_selector: runner.pool_name().to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await + .expect("failed to create actor"); + let actor_id = res.actor.actor_id.to_string(); + + common::assert_actor_exists(ctx.leader_dc().guard_port(), &actor_id, &namespace).await; + + // TODO: Hook into engine instead of sleep + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + + assert!( + runner.has_actor(&actor_id).await, + "runner should have the actor" + ); + }, + ); } #[test] fn create_actor_with_key() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - - let key = common::generate_unique_key(); - let res = common::api::public::actors_create( - ctx.leader_dc().guard_port(), - common::api_types::actors::create::CreateQuery { - namespace: namespace.clone(), - }, - common::api_types::actors::create::CreateRequest { - datacenter: None, - name: "test-actor".to_string(), - key: Some(key.clone()), - input: None, - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, - }, - ) - .await - .expect("failed to create actor"); - let actor_id = res.actor.actor_id.to_string(); - - assert!(!actor_id.is_empty(), "actor ID should not be empty"); - - // Verify actor exists - let actor = - common::assert_actor_exists(ctx.leader_dc().guard_port(), &actor_id, &namespace).await; - assert_eq!(actor.key, Some(key)); - }); + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + + let key = common::generate_unique_key(); + let res = common::api::public::actors_create( + ctx.leader_dc().guard_port(), + common::api_types::actors::create::CreateQuery { + namespace: namespace.clone(), + }, + common::api_types::actors::create::CreateRequest { + datacenter: None, + name: "test-actor".to_string(), + key: Some(key.clone()), + input: None, + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await + .expect("failed to create actor"); + let actor_id = res.actor.actor_id.to_string(); + + assert!(!actor_id.is_empty(), "actor ID should not be empty"); + + // Verify actor exists + let actor = + common::assert_actor_exists(ctx.leader_dc().guard_port(), &actor_id, &namespace) + .await; + assert_eq!(actor.key, Some(key)); + }, + ); } #[test] fn create_actor_with_input() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - - let input_data = common::generate_test_input_data(); - let res = common::api::public::actors_create( - ctx.leader_dc().guard_port(), - common::api_types::actors::create::CreateQuery { - namespace: namespace.clone(), - }, - common::api_types::actors::create::CreateRequest { - datacenter: None, - name: "test-actor".to_string(), - key: None, - input: Some(input_data.clone()), - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, - }, - ) - .await - .expect("failed to create actor"); - let actor_id = res.actor.actor_id.to_string(); - - assert!(!actor_id.is_empty(), "actor ID should not be empty"); - }); + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + + let input_data = common::generate_test_input_data(); + let res = common::api::public::actors_create( + ctx.leader_dc().guard_port(), + common::api_types::actors::create::CreateQuery { + namespace: namespace.clone(), + }, + common::api_types::actors::create::CreateRequest { + datacenter: None, + name: "test-actor".to_string(), + key: None, + input: Some(input_data.clone()), + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await + .expect("failed to create actor"); + let actor_id = res.actor.actor_id.to_string(); + + assert!(!actor_id.is_empty(), "actor ID should not be empty"); + }, + ); } #[test] fn create_actor_sleep_crash_policy() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - - let res = common::api::public::actors_create( - ctx.leader_dc().guard_port(), - common::api_types::actors::create::CreateQuery { - namespace: namespace.clone(), - }, - common::api_types::actors::create::CreateRequest { - datacenter: None, - name: "test-actor".to_string(), - key: None, - input: None, - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, - }, - ) - .await - .expect("failed to create actor"); - let actor_id = res.actor.actor_id.to_string(); - - assert!(!actor_id.is_empty(), "actor ID should not be empty"); - - let actor = - common::assert_actor_exists(ctx.leader_dc().guard_port(), &actor_id, &namespace).await; - assert_eq!( - actor.crash_policy, - rivet_types::actors::CrashPolicy::Sleep - ); - }); + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + + let res = common::api::public::actors_create( + ctx.leader_dc().guard_port(), + common::api_types::actors::create::CreateQuery { + namespace: namespace.clone(), + }, + common::api_types::actors::create::CreateRequest { + datacenter: None, + name: "test-actor".to_string(), + key: None, + input: None, + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await + .expect("failed to create actor"); + let actor_id = res.actor.actor_id.to_string(); + + assert!(!actor_id.is_empty(), "actor ID should not be empty"); + + let actor = + common::assert_actor_exists(ctx.leader_dc().guard_port(), &actor_id, &namespace) + .await; + assert_eq!(actor.crash_policy, rivet_types::actors::CrashPolicy::Sleep); + }, + ); } #[test] // Broken legacy Pegboard Runner test: full engine sweep timed out in // `create_actor_specific_datacenter`. fn create_actor_specific_datacenter() { - common::run(common::TestOpts::new(2).with_timeout(45), |ctx| async move { - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - - let res = common::api::public::actors_create( - ctx.leader_dc().guard_port(), - common::api_types::actors::create::CreateQuery { - namespace: namespace.clone(), - }, - common::api_types::actors::create::CreateRequest { - datacenter: Some("dc-2".to_string()), - name: "test-actor".to_string(), - key: None, - input: None, - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, - }, - ) - .await - .expect("failed to create actor"); - let actor_id = res.actor.actor_id.to_string(); - - assert!(!actor_id.is_empty(), "actor ID should not be empty"); - - let actor = - common::assert_actor_exists(ctx.leader_dc().guard_port(), &actor_id, &namespace).await; - common::assert_actor_in_dc(&actor.actor_id.to_string(), 2).await; - }); + common::run( + common::TestOpts::new(2).with_timeout(45), + |ctx| async move { + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + + let res = common::api::public::actors_create( + ctx.leader_dc().guard_port(), + common::api_types::actors::create::CreateQuery { + namespace: namespace.clone(), + }, + common::api_types::actors::create::CreateRequest { + datacenter: Some("dc-2".to_string()), + name: "test-actor".to_string(), + key: None, + input: None, + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await + .expect("failed to create actor"); + let actor_id = res.actor.actor_id.to_string(); + + assert!(!actor_id.is_empty(), "actor ID should not be empty"); + + let actor = + common::assert_actor_exists(ctx.leader_dc().guard_port(), &actor_id, &namespace) + .await; + common::assert_actor_in_dc(&actor.actor_id.to_string(), 2).await; + }, + ); } // MARK: Error cases #[test] fn create_actor_non_existent_namespace() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let res = common::api::public::actors_create( - ctx.leader_dc().guard_port(), - common::api_types::actors::create::CreateQuery { - namespace: "non-existent-namespace".to_string(), - }, - common::api_types::actors::create::CreateRequest { - datacenter: None, - name: "test-actor".to_string(), - key: None, - input: None, - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, - }, - ) - .await; - - assert!( - res.is_err(), - "should fail to create actor with non-existent namespace" - ); - }); + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let res = common::api::public::actors_create( + ctx.leader_dc().guard_port(), + common::api_types::actors::create::CreateQuery { + namespace: "non-existent-namespace".to_string(), + }, + common::api_types::actors::create::CreateRequest { + datacenter: None, + name: "test-actor".to_string(), + key: None, + input: None, + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await; + + assert!( + res.is_err(), + "should fail to create actor with non-existent namespace" + ); + }, + ); } #[test] fn create_actor_invalid_datacenter() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - - let res = common::api::public::actors_create( - ctx.leader_dc().guard_port(), - common::api_types::actors::create::CreateQuery { - namespace: namespace.clone(), - }, - common::api_types::actors::create::CreateRequest { - datacenter: Some("invalid-dc".to_string()), - name: "test-actor".to_string(), - key: None, - input: None, - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, - }, - ) - .await; - - assert!( - res.is_err(), - "should fail to create actor with invalid datacenter" - ); - }); + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + + let res = common::api::public::actors_create( + ctx.leader_dc().guard_port(), + common::api_types::actors::create::CreateQuery { + namespace: namespace.clone(), + }, + common::api_types::actors::create::CreateRequest { + datacenter: Some("invalid-dc".to_string()), + name: "test-actor".to_string(), + key: None, + input: None, + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await; + + assert!( + res.is_err(), + "should fail to create actor with invalid datacenter" + ); + }, + ); } // MARK: Cross-datacenter tests #[test] fn create_actor_remote_datacenter_verify() { - common::run(common::TestOpts::new(2).with_timeout(45), |ctx| async move { - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - - let res = common::api::public::actors_create( - ctx.leader_dc().guard_port(), - common::api_types::actors::create::CreateQuery { - namespace: namespace.clone(), - }, - common::api_types::actors::create::CreateRequest { - datacenter: Some("dc-2".to_string()), - name: "test-actor".to_string(), - key: None, - input: None, - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, - }, - ) - .await - .expect("failed to create actor"); - - let actor_id = res.actor.actor_id.to_string(); - - let actor = - common::assert_actor_exists(ctx.get_dc(2).guard_port(), &actor_id, &namespace).await; - common::assert_actor_in_dc(&actor.actor_id.to_string(), 2).await; - }); + common::run( + common::TestOpts::new(2).with_timeout(45), + |ctx| async move { + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + + let res = common::api::public::actors_create( + ctx.leader_dc().guard_port(), + common::api_types::actors::create::CreateQuery { + namespace: namespace.clone(), + }, + common::api_types::actors::create::CreateRequest { + datacenter: Some("dc-2".to_string()), + name: "test-actor".to_string(), + key: None, + input: None, + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await + .expect("failed to create actor"); + + let actor_id = res.actor.actor_id.to_string(); + + let actor = + common::assert_actor_exists(ctx.get_dc(2).guard_port(), &actor_id, &namespace) + .await; + common::assert_actor_in_dc(&actor.actor_id.to_string(), 2).await; + }, + ); } // MARK: Input validation tests @@ -264,160 +289,176 @@ fn create_actor_remote_datacenter_verify() { #[test] fn create_actor_input_large() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - - // Create a large input (1 MiB) that should succeed - let input_size = 1024 * 1024; - let input_data = "a".repeat(input_size); - - let res = common::api::public::actors_create( - ctx.leader_dc().guard_port(), - common::api_types::actors::create::CreateQuery { - namespace: namespace.clone(), - }, - common::api_types::actors::create::CreateRequest { - datacenter: None, - name: "test-actor".to_string(), - key: None, - input: Some(input_data), - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, - }, - ) - .await - .expect("should succeed with large input"); - - let actor_id = res.actor.actor_id.to_string(); - assert!(!actor_id.is_empty(), "actor ID should not be empty"); - }); + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + + // Create a large input (1 MiB) that should succeed + let input_size = 1024 * 1024; + let input_data = "a".repeat(input_size); + + let res = common::api::public::actors_create( + ctx.leader_dc().guard_port(), + common::api_types::actors::create::CreateQuery { + namespace: namespace.clone(), + }, + common::api_types::actors::create::CreateRequest { + datacenter: None, + name: "test-actor".to_string(), + key: None, + input: Some(input_data), + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await + .expect("should succeed with large input"); + + let actor_id = res.actor.actor_id.to_string(); + assert!(!actor_id.is_empty(), "actor ID should not be empty"); + }, + ); } #[test] fn create_actor_input_exceeds_max_size() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - - // Create input exceeding 4 MiB - let max_input_size = 4 * 1024 * 1024; - let input_data = "a".repeat(max_input_size + 1); - - let res = common::api::public::actors_create( - ctx.leader_dc().guard_port(), - common::api_types::actors::create::CreateQuery { - namespace: namespace.clone(), - }, - common::api_types::actors::create::CreateRequest { - datacenter: None, - name: "test-actor".to_string(), - key: None, - input: Some(input_data), - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, - }, - ) - .await; - - assert!( - res.is_err(), - "should fail to create actor with input exceeding max size" - ); - }); + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + + // Create input exceeding 4 MiB + let max_input_size = 4 * 1024 * 1024; + let input_data = "a".repeat(max_input_size + 1); + + let res = common::api::public::actors_create( + ctx.leader_dc().guard_port(), + common::api_types::actors::create::CreateQuery { + namespace: namespace.clone(), + }, + common::api_types::actors::create::CreateRequest { + datacenter: None, + name: "test-actor".to_string(), + key: None, + input: Some(input_data), + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await; + + assert!( + res.is_err(), + "should fail to create actor with input exceeding max size" + ); + }, + ); } // MARK: Key validation tests #[test] fn create_actor_empty_key() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - - let res = common::api::public::actors_create( - ctx.leader_dc().guard_port(), - common::api_types::actors::create::CreateQuery { - namespace: namespace.clone(), - }, - common::api_types::actors::create::CreateRequest { - datacenter: None, - name: "test-actor".to_string(), - key: Some("".to_string()), - input: None, - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, - }, - ) - .await; - - assert!(res.is_err(), "should fail to create actor with empty key"); - }); + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + + let res = common::api::public::actors_create( + ctx.leader_dc().guard_port(), + common::api_types::actors::create::CreateQuery { + namespace: namespace.clone(), + }, + common::api_types::actors::create::CreateRequest { + datacenter: None, + name: "test-actor".to_string(), + key: Some("".to_string()), + input: None, + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await; + + assert!(res.is_err(), "should fail to create actor with empty key"); + }, + ); } #[test] fn create_actor_key_at_max_size() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - - // Create key of exactly 1024 bytes - let key = "a".repeat(1024); - - let res = common::api::public::actors_create( - ctx.leader_dc().guard_port(), - common::api_types::actors::create::CreateQuery { - namespace: namespace.clone(), - }, - common::api_types::actors::create::CreateRequest { - datacenter: None, - name: "test-actor".to_string(), - key: Some(key.clone()), - input: None, - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, - }, - ) - .await - .expect("should succeed with key at max size"); - - let actor_id = res.actor.actor_id.to_string(); - assert!(!actor_id.is_empty(), "actor ID should not be empty"); - - // Verify actor exists with correct key - let actor = - common::assert_actor_exists(ctx.leader_dc().guard_port(), &actor_id, &namespace).await; - assert_eq!(actor.key, Some(key)); - }); + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + + // Create key of exactly 1024 bytes + let key = "a".repeat(1024); + + let res = common::api::public::actors_create( + ctx.leader_dc().guard_port(), + common::api_types::actors::create::CreateQuery { + namespace: namespace.clone(), + }, + common::api_types::actors::create::CreateRequest { + datacenter: None, + name: "test-actor".to_string(), + key: Some(key.clone()), + input: None, + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await + .expect("should succeed with key at max size"); + + let actor_id = res.actor.actor_id.to_string(); + assert!(!actor_id.is_empty(), "actor ID should not be empty"); + + // Verify actor exists with correct key + let actor = + common::assert_actor_exists(ctx.leader_dc().guard_port(), &actor_id, &namespace) + .await; + assert_eq!(actor.key, Some(key)); + }, + ); } #[test] fn create_actor_key_exceeds_max_size() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - - // Create key exceeding 1024 bytes - let key = "a".repeat(1025); - - let res = common::api::public::actors_create( - ctx.leader_dc().guard_port(), - common::api_types::actors::create::CreateQuery { - namespace: namespace.clone(), - }, - common::api_types::actors::create::CreateRequest { - datacenter: None, - name: "test-actor".to_string(), - key: Some(key), - input: None, - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, - }, - ) - .await; - - assert!( - res.is_err(), - "should fail to create actor with key exceeding max size" - ); - }); + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + + // Create key exceeding 1024 bytes + let key = "a".repeat(1025); + + let res = common::api::public::actors_create( + ctx.leader_dc().guard_port(), + common::api_types::actors::create::CreateQuery { + namespace: namespace.clone(), + }, + common::api_types::actors::create::CreateRequest { + datacenter: None, + name: "test-actor".to_string(), + key: Some(key), + input: None, + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await; + + assert!( + res.is_err(), + "should fail to create actor with key exceeding max size" + ); + }, + ); } diff --git a/engine/packages/engine/tests/envoy/api_actors_delete.rs b/engine/packages/engine/tests/envoy/api_actors_delete.rs index bd148d4394..bd67ad0d15 100644 --- a/engine/packages/engine/tests/envoy/api_actors_delete.rs +++ b/engine/packages/engine/tests/envoy/api_actors_delete.rs @@ -3,303 +3,326 @@ use super::super::common; // MARK: Basic #[test] fn delete_existing_actor_with_namespace() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - - // Create an actor - let res = common::api::public::actors_create( - ctx.leader_dc().guard_port(), - common::api_types::actors::create::CreateQuery { - namespace: namespace.clone(), - }, - common::api_types::actors::create::CreateRequest { - datacenter: None, - name: "test-actor".to_string(), - key: None, - input: None, - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, - }, - ) - .await - .expect("failed to create actor"); - let actor_id = res.actor.actor_id.to_string(); - - // Verify actor exists - common::assert_actor_exists(ctx.leader_dc().guard_port(), &actor_id, &namespace).await; - - // Delete the actor with namespace - common::api::public::actors_delete( - ctx.leader_dc().guard_port(), - common::api_types::actors::delete::DeletePath { - actor_id: actor_id.parse().expect("failed to parse actor_id"), - }, - common::api_types::actors::delete::DeleteQuery { - namespace: namespace.clone(), - }, - ) - .await - .expect("failed to delete actor"); - - // Verify actor is destroyed - common::assert_actor_is_destroyed(ctx.leader_dc().guard_port(), &actor_id, &namespace) - .await; - }); + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + + // Create an actor + let res = common::api::public::actors_create( + ctx.leader_dc().guard_port(), + common::api_types::actors::create::CreateQuery { + namespace: namespace.clone(), + }, + common::api_types::actors::create::CreateRequest { + datacenter: None, + name: "test-actor".to_string(), + key: None, + input: None, + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await + .expect("failed to create actor"); + let actor_id = res.actor.actor_id.to_string(); + + // Verify actor exists + common::assert_actor_exists(ctx.leader_dc().guard_port(), &actor_id, &namespace).await; + + // Delete the actor with namespace + common::api::public::actors_delete( + ctx.leader_dc().guard_port(), + common::api_types::actors::delete::DeletePath { + actor_id: actor_id.parse().expect("failed to parse actor_id"), + }, + common::api_types::actors::delete::DeleteQuery { + namespace: namespace.clone(), + }, + ) + .await + .expect("failed to delete actor"); + + // Verify actor is destroyed + common::assert_actor_is_destroyed(ctx.leader_dc().guard_port(), &actor_id, &namespace) + .await; + }, + ); } #[test] fn delete_existing_actor_without_namespace() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - - // Create an actor - let res = common::api::public::actors_create( - ctx.leader_dc().guard_port(), - common::api_types::actors::create::CreateQuery { - namespace: namespace.clone(), - }, - common::api_types::actors::create::CreateRequest { - datacenter: None, - name: "test-actor".to_string(), - key: None, - input: None, - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, - }, - ) - .await - .expect("failed to create actor"); - let actor_id = res.actor.actor_id.to_string(); - - // Verify actor exists - common::assert_actor_exists(ctx.leader_dc().guard_port(), &actor_id, &namespace).await; - - // Delete the actor without namespace parameter - common::api::public::actors_delete( - ctx.leader_dc().guard_port(), - common::api_types::actors::delete::DeletePath { - actor_id: actor_id.parse().expect("failed to parse actor_id"), - }, - common::api_types::actors::delete::DeleteQuery { - namespace: namespace.to_string(), - }, - ) - .await - .expect("failed to delete actor"); - - // Verify actor is destroyed - common::assert_actor_is_destroyed(ctx.leader_dc().guard_port(), &actor_id, &namespace) - .await; - }); + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + + // Create an actor + let res = common::api::public::actors_create( + ctx.leader_dc().guard_port(), + common::api_types::actors::create::CreateQuery { + namespace: namespace.clone(), + }, + common::api_types::actors::create::CreateRequest { + datacenter: None, + name: "test-actor".to_string(), + key: None, + input: None, + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await + .expect("failed to create actor"); + let actor_id = res.actor.actor_id.to_string(); + + // Verify actor exists + common::assert_actor_exists(ctx.leader_dc().guard_port(), &actor_id, &namespace).await; + + // Delete the actor without namespace parameter + common::api::public::actors_delete( + ctx.leader_dc().guard_port(), + common::api_types::actors::delete::DeletePath { + actor_id: actor_id.parse().expect("failed to parse actor_id"), + }, + common::api_types::actors::delete::DeleteQuery { + namespace: namespace.to_string(), + }, + ) + .await + .expect("failed to delete actor"); + + // Verify actor is destroyed + common::assert_actor_is_destroyed(ctx.leader_dc().guard_port(), &actor_id, &namespace) + .await; + }, + ); } #[test] fn delete_actor_current_datacenter() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - - // Create an actor in current datacenter - let res = common::api::public::actors_create( - ctx.leader_dc().guard_port(), - common::api_types::actors::create::CreateQuery { - namespace: namespace.clone(), - }, - common::api_types::actors::create::CreateRequest { - datacenter: None, - name: "test-actor".to_string(), - key: None, - input: None, - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, - }, - ) - .await - .expect("failed to create actor"); - let actor_id = res.actor.actor_id; - - // Delete the actor - common::api::public::actors_delete( - ctx.leader_dc().guard_port(), - common::api_types::actors::delete::DeletePath { actor_id: actor_id }, - common::api_types::actors::delete::DeleteQuery { - namespace: namespace.clone(), - }, - ) - .await - .expect("failed to delete actor"); - - // Verify actor is destroyed - common::assert_actor_is_destroyed( - ctx.leader_dc().guard_port(), - &actor_id.to_string(), - &namespace, - ) - .await; - }); + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + + // Create an actor in current datacenter + let res = common::api::public::actors_create( + ctx.leader_dc().guard_port(), + common::api_types::actors::create::CreateQuery { + namespace: namespace.clone(), + }, + common::api_types::actors::create::CreateRequest { + datacenter: None, + name: "test-actor".to_string(), + key: None, + input: None, + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await + .expect("failed to create actor"); + let actor_id = res.actor.actor_id; + + // Delete the actor + common::api::public::actors_delete( + ctx.leader_dc().guard_port(), + common::api_types::actors::delete::DeletePath { actor_id: actor_id }, + common::api_types::actors::delete::DeleteQuery { + namespace: namespace.clone(), + }, + ) + .await + .expect("failed to delete actor"); + + // Verify actor is destroyed + common::assert_actor_is_destroyed( + ctx.leader_dc().guard_port(), + &actor_id.to_string(), + &namespace, + ) + .await; + }, + ); } #[test] fn delete_actor_remote_datacenter() { - common::run(common::TestOpts::new(2).with_timeout(45), |ctx| async move { - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - - // Create an actor in DC2 - let res = common::api::public::actors_create( - ctx.leader_dc().guard_port(), - common::api_types::actors::create::CreateQuery { - namespace: namespace.clone(), - }, - common::api_types::actors::create::CreateRequest { - datacenter: Some("dc-2".to_string()), - name: "test-actor".to_string(), - key: None, - input: None, - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, - }, - ) - .await - .expect("failed to create actor"); - let actor_id = res.actor.actor_id.to_string(); - - // Delete the actor from DC1 (will route to DC2) - common::api::public::actors_delete( - ctx.leader_dc().guard_port(), - common::api_types::actors::delete::DeletePath { - actor_id: actor_id.parse().expect("failed to parse actor_id"), - }, - common::api_types::actors::delete::DeleteQuery { - namespace: namespace.clone(), - }, - ) - .await - .expect("failed to delete actor"); - - // Verify actor is destroyed in DC2 - common::assert_actor_is_destroyed(ctx.get_dc(2).guard_port(), &actor_id, &namespace).await; - }); + common::run( + common::TestOpts::new(2).with_timeout(45), + |ctx| async move { + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + + // Create an actor in DC2 + let res = common::api::public::actors_create( + ctx.leader_dc().guard_port(), + common::api_types::actors::create::CreateQuery { + namespace: namespace.clone(), + }, + common::api_types::actors::create::CreateRequest { + datacenter: Some("dc-2".to_string()), + name: "test-actor".to_string(), + key: None, + input: None, + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await + .expect("failed to create actor"); + let actor_id = res.actor.actor_id.to_string(); + + // Delete the actor from DC1 (will route to DC2) + common::api::public::actors_delete( + ctx.leader_dc().guard_port(), + common::api_types::actors::delete::DeletePath { + actor_id: actor_id.parse().expect("failed to parse actor_id"), + }, + common::api_types::actors::delete::DeleteQuery { + namespace: namespace.clone(), + }, + ) + .await + .expect("failed to delete actor"); + + // Verify actor is destroyed in DC2 + common::assert_actor_is_destroyed(ctx.get_dc(2).guard_port(), &actor_id, &namespace) + .await; + }, + ); } // MARK: Error cases #[test] fn delete_non_existent_actor() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - - // Generate a fake actor ID with valid format but non-existent - let fake_actor_id = rivet_util::Id::new_v1(ctx.leader_dc().config.dc_label()); - - let res = common::api::public::actors_delete( - ctx.leader_dc().guard_port(), - common::api_types::actors::delete::DeletePath { - actor_id: fake_actor_id, - }, - common::api_types::actors::delete::DeleteQuery { - namespace: namespace.to_string(), - }, - ) - .await; - - assert!(res.is_err(), "should fail to delete non-existent actor"); - }); + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + + // Generate a fake actor ID with valid format but non-existent + let fake_actor_id = rivet_util::Id::new_v1(ctx.leader_dc().config.dc_label()); + + let res = common::api::public::actors_delete( + ctx.leader_dc().guard_port(), + common::api_types::actors::delete::DeletePath { + actor_id: fake_actor_id, + }, + common::api_types::actors::delete::DeleteQuery { + namespace: namespace.to_string(), + }, + ) + .await; + + assert!(res.is_err(), "should fail to delete non-existent actor"); + }, + ); } #[test] fn delete_actor_wrong_namespace() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace1, _, _runner1) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - let (namespace2, _, _runner2) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - - // Create actor in namespace1 - let res = common::api::public::actors_create( - ctx.leader_dc().guard_port(), - common::api_types::actors::create::CreateQuery { - namespace: namespace1.clone(), - }, - common::api_types::actors::create::CreateRequest { - datacenter: None, - name: "test-actor".to_string(), - key: None, - input: None, - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, - }, - ) - .await - .expect("failed to create actor"); - let actor_id = res.actor.actor_id.to_string(); - - // Try to delete with namespace2 - let res = common::api::public::actors_delete( - ctx.leader_dc().guard_port(), - common::api_types::actors::delete::DeletePath { - actor_id: actor_id.parse().expect("failed to parse actor_id"), - }, - common::api_types::actors::delete::DeleteQuery { - namespace: namespace2.clone(), - }, - ) - .await; - - assert!( - res.is_err(), - "should fail to delete actor with wrong namespace" - ); - - // Verify actor still exists in namespace1 - common::assert_actor_is_alive(ctx.leader_dc().guard_port(), &actor_id, &namespace1).await; - }); + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace1, _, _runner1) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + let (namespace2, _, _runner2) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + + // Create actor in namespace1 + let res = common::api::public::actors_create( + ctx.leader_dc().guard_port(), + common::api_types::actors::create::CreateQuery { + namespace: namespace1.clone(), + }, + common::api_types::actors::create::CreateRequest { + datacenter: None, + name: "test-actor".to_string(), + key: None, + input: None, + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await + .expect("failed to create actor"); + let actor_id = res.actor.actor_id.to_string(); + + // Try to delete with namespace2 + let res = common::api::public::actors_delete( + ctx.leader_dc().guard_port(), + common::api_types::actors::delete::DeletePath { + actor_id: actor_id.parse().expect("failed to parse actor_id"), + }, + common::api_types::actors::delete::DeleteQuery { + namespace: namespace2.clone(), + }, + ) + .await; + + assert!( + res.is_err(), + "should fail to delete actor with wrong namespace" + ); + + // Verify actor still exists in namespace1 + common::assert_actor_is_alive(ctx.leader_dc().guard_port(), &actor_id, &namespace1) + .await; + }, + ); } #[test] // Broken legacy Pegboard Runner test: full engine sweep timed out in // `delete_with_non_existent_namespace`. fn delete_with_non_existent_namespace() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - - // Create an actor - let res = common::api::public::actors_create( - ctx.leader_dc().guard_port(), - common::api_types::actors::create::CreateQuery { - namespace: namespace.clone(), - }, - common::api_types::actors::create::CreateRequest { - datacenter: None, - name: "test-actor".to_string(), - key: None, - input: None, - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, - }, - ) - .await - .expect("failed to create actor"); - let actor_id = res.actor.actor_id.to_string(); - - // Try to delete with non-existent namespace - let res = common::api::public::actors_delete( - ctx.leader_dc().guard_port(), - common::api_types::actors::delete::DeletePath { - actor_id: actor_id.parse().expect("failed to parse actor_id"), - }, - common::api_types::actors::delete::DeleteQuery { - namespace: "non-existent-namespace".to_string(), - }, - ) - .await; - - assert!(res.is_err(), "should fail with non-existent namespace"); - }); + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + + // Create an actor + let res = common::api::public::actors_create( + ctx.leader_dc().guard_port(), + common::api_types::actors::create::CreateQuery { + namespace: namespace.clone(), + }, + common::api_types::actors::create::CreateRequest { + datacenter: None, + name: "test-actor".to_string(), + key: None, + input: None, + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await + .expect("failed to create actor"); + let actor_id = res.actor.actor_id.to_string(); + + // Try to delete with non-existent namespace + let res = common::api::public::actors_delete( + ctx.leader_dc().guard_port(), + common::api_types::actors::delete::DeletePath { + actor_id: actor_id.parse().expect("failed to parse actor_id"), + }, + common::api_types::actors::delete::DeleteQuery { + namespace: "non-existent-namespace".to_string(), + }, + ) + .await; + + assert!(res.is_err(), "should fail with non-existent namespace"); + }, + ); } // Note: Invalid actor ID format test removed because it would be caught at parsing level @@ -309,51 +332,55 @@ fn delete_with_non_existent_namespace() { #[test] fn delete_remote_actor_verify_propagation() { - common::run(common::TestOpts::new(2).with_timeout(45), |ctx| async move { - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - - // Create an actor in DC2 - let res = common::api::public::actors_create( - ctx.leader_dc().guard_port(), - common::api_types::actors::create::CreateQuery { - namespace: namespace.clone(), - }, - common::api_types::actors::create::CreateRequest { - datacenter: Some("dc-2".to_string()), - name: "test-actor".to_string(), - key: None, - input: None, - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, - }, - ) - .await - .expect("failed to create actor"); - let actor_id = res.actor.actor_id.to_string(); - - // Verify actor exists in both datacenters - common::assert_actor_exists(ctx.leader_dc().guard_port(), &actor_id, &namespace).await; - common::assert_actor_exists(ctx.get_dc(2).guard_port(), &actor_id, &namespace).await; - - // Delete the actor from DC1 - common::api::public::actors_delete( - ctx.leader_dc().guard_port(), - common::api_types::actors::delete::DeletePath { - actor_id: actor_id.parse().expect("failed to parse actor_id"), - }, - common::api_types::actors::delete::DeleteQuery { - namespace: namespace.clone(), - }, - ) - .await - .expect("failed to delete actor"); - - // Verify actor is destroyed in both datacenters - common::assert_actor_is_destroyed(ctx.leader_dc().guard_port(), &actor_id, &namespace) - .await; - common::assert_actor_is_destroyed(ctx.get_dc(2).guard_port(), &actor_id, &namespace).await; - }); + common::run( + common::TestOpts::new(2).with_timeout(45), + |ctx| async move { + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + + // Create an actor in DC2 + let res = common::api::public::actors_create( + ctx.leader_dc().guard_port(), + common::api_types::actors::create::CreateQuery { + namespace: namespace.clone(), + }, + common::api_types::actors::create::CreateRequest { + datacenter: Some("dc-2".to_string()), + name: "test-actor".to_string(), + key: None, + input: None, + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await + .expect("failed to create actor"); + let actor_id = res.actor.actor_id.to_string(); + + // Verify actor exists in both datacenters + common::assert_actor_exists(ctx.leader_dc().guard_port(), &actor_id, &namespace).await; + common::assert_actor_exists(ctx.get_dc(2).guard_port(), &actor_id, &namespace).await; + + // Delete the actor from DC1 + common::api::public::actors_delete( + ctx.leader_dc().guard_port(), + common::api_types::actors::delete::DeletePath { + actor_id: actor_id.parse().expect("failed to parse actor_id"), + }, + common::api_types::actors::delete::DeleteQuery { + namespace: namespace.clone(), + }, + ) + .await + .expect("failed to delete actor"); + + // Verify actor is destroyed in both datacenters + common::assert_actor_is_destroyed(ctx.leader_dc().guard_port(), &actor_id, &namespace) + .await; + common::assert_actor_is_destroyed(ctx.get_dc(2).guard_port(), &actor_id, &namespace) + .await; + }, + ); } // MARK: Edge cases @@ -362,96 +389,34 @@ fn delete_remote_actor_verify_propagation() { // `actor.not_found` instead of the idempotent success this test expects. #[test] fn delete_already_destroyed_actor() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - - // Create an actor - let res = common::api::public::actors_create( - ctx.leader_dc().guard_port(), - common::api_types::actors::create::CreateQuery { - namespace: namespace.clone(), - }, - common::api_types::actors::create::CreateRequest { - datacenter: None, - name: "test-actor".to_string(), - key: None, - input: None, - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, - }, - ) - .await - .expect("failed to create actor"); - let actor_id = res.actor.actor_id.to_string(); - - // Delete the actor once - common::api::public::actors_delete( - ctx.leader_dc().guard_port(), - common::api_types::actors::delete::DeletePath { - actor_id: actor_id.parse().expect("failed to parse actor_id"), - }, - common::api_types::actors::delete::DeleteQuery { - namespace: namespace.clone(), - }, - ) - .await - .expect("failed to delete actor"); - - // Delete the actor again - should handle gracefully (WorkflowNotFound) - // The implementation logs a warning but doesn't error when workflow is not found - let res = common::api::public::actors_delete( - ctx.leader_dc().guard_port(), - common::api_types::actors::delete::DeletePath { - actor_id: actor_id.parse().expect("failed to parse actor_id"), - }, - common::api_types::actors::delete::DeleteQuery { - namespace: namespace.clone(), - }, - ) - .await; - - // Should succeed even though actor was already destroyed - assert!( - res.is_ok(), - "deleting already destroyed actor should succeed gracefully" - ); - }); -} + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + + // Create an actor + let res = common::api::public::actors_create( + ctx.leader_dc().guard_port(), + common::api_types::actors::create::CreateQuery { + namespace: namespace.clone(), + }, + common::api_types::actors::create::CreateRequest { + datacenter: None, + name: "test-actor".to_string(), + key: None, + input: None, + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await + .expect("failed to create actor"); + let actor_id = res.actor.actor_id.to_string(); -#[test] -fn delete_actor_twice_rapidly() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - - // Create an actor - let res = common::api::public::actors_create( - ctx.leader_dc().guard_port(), - common::api_types::actors::create::CreateQuery { - namespace: namespace.clone(), - }, - common::api_types::actors::create::CreateRequest { - datacenter: None, - name: "test-actor".to_string(), - key: None, - input: None, - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, - }, - ) - .await - .expect("failed to create actor"); - let actor_id = res.actor.actor_id.to_string(); - - // Send two delete requests in rapid succession - let actor_id_clone = actor_id.clone(); - let namespace_clone = namespace.clone(); - let port = ctx.leader_dc().guard_port(); - - let delete1 = tokio::spawn(async move { + // Delete the actor once common::api::public::actors_delete( - port, + ctx.leader_dc().guard_port(), common::api_types::actors::delete::DeletePath { actor_id: actor_id.parse().expect("failed to parse actor_id"), }, @@ -460,32 +425,100 @@ fn delete_actor_twice_rapidly() { }, ) .await - }); + .expect("failed to delete actor"); - let delete2 = tokio::spawn(async move { - common::api::public::actors_delete( - port, + // Delete the actor again - should handle gracefully (WorkflowNotFound) + // The implementation logs a warning but doesn't error when workflow is not found + let res = common::api::public::actors_delete( + ctx.leader_dc().guard_port(), common::api_types::actors::delete::DeletePath { - actor_id: actor_id_clone.parse().expect("failed to parse actor_id"), + actor_id: actor_id.parse().expect("failed to parse actor_id"), }, common::api_types::actors::delete::DeleteQuery { - namespace: namespace_clone.clone(), + namespace: namespace.clone(), }, ) - .await - }); - - // Both should complete without panicking - let (res1, res2) = tokio::join!(delete1, delete2); + .await; - // At least one should succeed - let res1 = res1.expect("task should not panic"); - let res2 = res2.expect("task should not panic"); + // Should succeed even though actor was already destroyed + assert!( + res.is_ok(), + "deleting already destroyed actor should succeed gracefully" + ); + }, + ); +} - // Both requests should succeed or fail gracefully (no panics) - assert!( - res1.is_ok() || res2.is_ok(), - "at least one delete should succeed in race condition" - ); - }); +#[test] +fn delete_actor_twice_rapidly() { + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + + // Create an actor + let res = common::api::public::actors_create( + ctx.leader_dc().guard_port(), + common::api_types::actors::create::CreateQuery { + namespace: namespace.clone(), + }, + common::api_types::actors::create::CreateRequest { + datacenter: None, + name: "test-actor".to_string(), + key: None, + input: None, + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await + .expect("failed to create actor"); + let actor_id = res.actor.actor_id.to_string(); + + // Send two delete requests in rapid succession + let actor_id_clone = actor_id.clone(); + let namespace_clone = namespace.clone(); + let port = ctx.leader_dc().guard_port(); + + let delete1 = tokio::spawn(async move { + common::api::public::actors_delete( + port, + common::api_types::actors::delete::DeletePath { + actor_id: actor_id.parse().expect("failed to parse actor_id"), + }, + common::api_types::actors::delete::DeleteQuery { + namespace: namespace.clone(), + }, + ) + .await + }); + + let delete2 = tokio::spawn(async move { + common::api::public::actors_delete( + port, + common::api_types::actors::delete::DeletePath { + actor_id: actor_id_clone.parse().expect("failed to parse actor_id"), + }, + common::api_types::actors::delete::DeleteQuery { + namespace: namespace_clone.clone(), + }, + ) + .await + }); + + // Both should complete without panicking + let (res1, res2) = tokio::join!(delete1, delete2); + + // At least one should succeed + let res1 = res1.expect("task should not panic"); + let res2 = res2.expect("task should not panic"); + + // Both requests should succeed or fail gracefully (no panics) + assert!( + res1.is_ok() || res2.is_ok(), + "at least one delete should succeed in race condition" + ); + }, + ); } diff --git a/engine/packages/engine/tests/envoy/api_actors_get_or_create.rs b/engine/packages/engine/tests/envoy/api_actors_get_or_create.rs index a8b031e9ea..df4991084f 100644 --- a/engine/packages/engine/tests/envoy/api_actors_get_or_create.rs +++ b/engine/packages/engine/tests/envoy/api_actors_get_or_create.rs @@ -4,168 +4,76 @@ use super::super::common; #[test] fn get_or_create_creates_new_actor() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - - let actor_name = "test-actor"; - let actor_key = "unique-key-1"; - - // First call should create the actor - let response = common::api::public::actors_get_or_create( - ctx.leader_dc().guard_port(), - common::api::public::GetOrCreateQuery { - namespace: namespace.clone(), - }, - common::api::public::GetOrCreateRequest { - datacenter: None, - name: actor_name.to_string(), - key: actor_key.to_string(), - input: None, - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, - }, - ) - .await - .expect("failed to get or create actor"); - - assert!(response.created, "Actor should be newly created"); - assert_eq!(response.actor.name, actor_name); - assert_eq!(response.actor.key.as_ref().unwrap(), actor_key); - }); + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + + let actor_name = "test-actor"; + let actor_key = "unique-key-1"; + + // First call should create the actor + let response = common::api::public::actors_get_or_create( + ctx.leader_dc().guard_port(), + common::api::public::GetOrCreateQuery { + namespace: namespace.clone(), + }, + common::api::public::GetOrCreateRequest { + datacenter: None, + name: actor_name.to_string(), + key: actor_key.to_string(), + input: None, + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await + .expect("failed to get or create actor"); + + assert!(response.created, "Actor should be newly created"); + assert_eq!(response.actor.name, actor_name); + assert_eq!(response.actor.key.as_ref().unwrap(), actor_key); + }, + ); } #[test] // Broken legacy Pegboard Runner test: full engine sweep timed out in // `get_or_create_returns_existing_actor`. fn get_or_create_returns_existing_actor() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - - let actor_name = "test-actor"; - let actor_key = "unique-key-2"; - - // First call - create - let response1 = common::api::public::actors_get_or_create( - ctx.leader_dc().guard_port(), - common::api::public::GetOrCreateQuery { - namespace: namespace.clone(), - }, - common::api::public::GetOrCreateRequest { - datacenter: None, - name: actor_name.to_string(), - key: actor_key.to_string(), - input: None, - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, - }, - ) - .await - .expect("failed to get or create actor"); - - assert!(response1.created, "First call should create actor"); - let first_actor_id = response1.actor.actor_id; - - // Second call with same key - should return existing - let response2 = common::api::public::actors_get_or_create( - ctx.leader_dc().guard_port(), - common::api::public::GetOrCreateQuery { - namespace: namespace.clone(), - }, - common::api::public::GetOrCreateRequest { - datacenter: None, - name: actor_name.to_string(), - key: actor_key.to_string(), - input: Some("different-input".to_string()), // Different input should be ignored - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, - }, - ) - .await - .expect("failed to get or create actor"); - - assert!( - !response2.created, - "Second call should return existing actor" - ); - assert_eq!( - response2.actor.actor_id, first_actor_id, - "Should return the same actor ID" - ); - }); -} + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; -#[test] -// Broken legacy Pegboard Runner test: full engine sweep timed out in -// `get_or_create_same_name_different_keys`. -fn get_or_create_same_name_different_keys() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - - let actor_name = "shared-name"; - - // Create first actor with key1 - let response1 = common::api::public::actors_get_or_create( - ctx.leader_dc().guard_port(), - common::api::public::GetOrCreateQuery { - namespace: namespace.clone(), - }, - common::api::public::GetOrCreateRequest { - datacenter: None, - name: actor_name.to_string(), - key: "key1".to_string(), - input: None, - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, - }, - ) - .await - .expect("failed to get or create actor 1"); - - // Create second actor with same name but different key - let response2 = common::api::public::actors_get_or_create( - ctx.leader_dc().guard_port(), - common::api::public::GetOrCreateQuery { - namespace: namespace.clone(), - }, - common::api::public::GetOrCreateRequest { - datacenter: None, - name: actor_name.to_string(), - key: "key2".to_string(), - input: None, - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, - }, - ) - .await - .expect("failed to get or create actor 2"); - - assert!(response1.created, "First actor should be created"); - assert!(response2.created, "Second actor should be created"); - assert_ne!( - response1.actor.actor_id, response2.actor.actor_id, - "Different keys should create different actors" - ); - assert_eq!(response1.actor.name, actor_name); - assert_eq!(response2.actor.name, actor_name); - }); -} + let actor_name = "test-actor"; + let actor_key = "unique-key-2"; -#[test] -fn get_or_create_idempotent() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + // First call - create + let response1 = common::api::public::actors_get_or_create( + ctx.leader_dc().guard_port(), + common::api::public::GetOrCreateQuery { + namespace: namespace.clone(), + }, + common::api::public::GetOrCreateRequest { + datacenter: None, + name: actor_name.to_string(), + key: actor_key.to_string(), + input: None, + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await + .expect("failed to get or create actor"); - let actor_name = "idempotent-actor"; - let actor_key = "idempotent-key"; + assert!(response1.created, "First call should create actor"); + let first_actor_id = response1.actor.actor_id; - // Make multiple calls with the same key - let mut actor_id = None; - for i in 0..5 { - let response = common::api::public::actors_get_or_create( + // Second call with same key - should return existing + let response2 = common::api::public::actors_get_or_create( ctx.leader_dc().guard_port(), common::api::public::GetOrCreateQuery { namespace: namespace.clone(), @@ -174,7 +82,7 @@ fn get_or_create_idempotent() { datacenter: None, name: actor_name.to_string(), key: actor_key.to_string(), - input: None, + input: Some("different-input".to_string()), // Different input should be ignored runner_name_selector: common::TEST_RUNNER_NAME.to_string(), crash_policy: rivet_types::actors::CrashPolicy::Sleep, }, @@ -182,115 +90,147 @@ fn get_or_create_idempotent() { .await .expect("failed to get or create actor"); - if i == 0 { - assert!(response.created, "First call should create"); - actor_id = Some(response.actor.actor_id); - } else { - assert!(!response.created, "Subsequent calls should return existing"); - assert_eq!( - response.actor.actor_id, - actor_id.unwrap(), - "All calls should return the same actor" - ); - } - } - }); + assert!( + !response2.created, + "Second call should return existing actor" + ); + assert_eq!( + response2.actor.actor_id, first_actor_id, + "Should return the same actor ID" + ); + }, + ); } -// MARK: Race condition tests - #[test] -fn get_or_create_race_condition_handling() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - - let actor_name = "race-actor"; - let actor_key = "race-key"; - let port = ctx.leader_dc().guard_port(); - let namespace_clone1 = namespace.clone(); - let namespace_clone2 = namespace.clone(); - - // Launch two concurrent get_or_create requests with the same key - let handle1 = tokio::spawn(async move { - common::api::public::actors_get_or_create( - port, +// Broken legacy Pegboard Runner test: full engine sweep timed out in +// `get_or_create_same_name_different_keys`. +fn get_or_create_same_name_different_keys() { + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + + let actor_name = "shared-name"; + + // Create first actor with key1 + let response1 = common::api::public::actors_get_or_create( + ctx.leader_dc().guard_port(), common::api::public::GetOrCreateQuery { - namespace: namespace_clone1, + namespace: namespace.clone(), }, common::api::public::GetOrCreateRequest { datacenter: None, name: actor_name.to_string(), - key: actor_key.to_string(), + key: "key1".to_string(), input: None, runner_name_selector: common::TEST_RUNNER_NAME.to_string(), crash_policy: rivet_types::actors::CrashPolicy::Sleep, }, ) .await - }); + .expect("failed to get or create actor 1"); - let handle2 = tokio::spawn(async move { - common::api::public::actors_get_or_create( - port, + // Create second actor with same name but different key + let response2 = common::api::public::actors_get_or_create( + ctx.leader_dc().guard_port(), common::api::public::GetOrCreateQuery { - namespace: namespace_clone2, + namespace: namespace.clone(), }, common::api::public::GetOrCreateRequest { datacenter: None, name: actor_name.to_string(), - key: actor_key.to_string(), + key: "key2".to_string(), input: None, runner_name_selector: common::TEST_RUNNER_NAME.to_string(), crash_policy: rivet_types::actors::CrashPolicy::Sleep, }, ) .await - }); - - let (result1, result2) = tokio::join!(handle1, handle2); - let response1 = result1.expect("task 1 panicked").expect("request 1 failed"); - let response2 = result2.expect("task 2 panicked").expect("request 2 failed"); - - // Both should succeed - assert_eq!( - response1.actor.actor_id, response2.actor.actor_id, - "Both requests should return the same actor" - ); - - // Exactly one should have created=true - let created_count = [response1.created, response2.created] - .iter() - .filter(|&&c| c) - .count(); - assert_eq!( - created_count, 1, - "Exactly one request should report creation" - ); - }); + .expect("failed to get or create actor 2"); + + assert!(response1.created, "First actor should be created"); + assert!(response2.created, "Second actor should be created"); + assert_ne!( + response1.actor.actor_id, response2.actor.actor_id, + "Different keys should create different actors" + ); + assert_eq!(response1.actor.name, actor_name); + assert_eq!(response2.actor.name, actor_name); + }, + ); } #[test] -// Broken legacy Pegboard Runner test: full engine sweep timed out in -// `get_or_create_returns_winner_on_race`. -fn get_or_create_returns_winner_on_race() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - - let actor_name = "race-winner-actor"; - let actor_key = "race-winner-key"; - let port = ctx.leader_dc().guard_port(); - - // Launch multiple concurrent requests - let mut handles = vec![]; - for _ in 0..10 { - let namespace_clone = namespace.clone(); - let handle = tokio::spawn(async move { +fn get_or_create_idempotent() { + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + + let actor_name = "idempotent-actor"; + let actor_key = "idempotent-key"; + + // Make multiple calls with the same key + let mut actor_id = None; + for i in 0..5 { + let response = common::api::public::actors_get_or_create( + ctx.leader_dc().guard_port(), + common::api::public::GetOrCreateQuery { + namespace: namespace.clone(), + }, + common::api::public::GetOrCreateRequest { + datacenter: None, + name: actor_name.to_string(), + key: actor_key.to_string(), + input: None, + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await + .expect("failed to get or create actor"); + + if i == 0 { + assert!(response.created, "First call should create"); + actor_id = Some(response.actor.actor_id); + } else { + assert!(!response.created, "Subsequent calls should return existing"); + assert_eq!( + response.actor.actor_id, + actor_id.unwrap(), + "All calls should return the same actor" + ); + } + } + }, + ); +} + +// MARK: Race condition tests + +#[test] +fn get_or_create_race_condition_handling() { + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + + let actor_name = "race-actor"; + let actor_key = "race-key"; + let port = ctx.leader_dc().guard_port(); + let namespace_clone1 = namespace.clone(); + let namespace_clone2 = namespace.clone(); + + // Launch two concurrent get_or_create requests with the same key + let handle1 = tokio::spawn(async move { common::api::public::actors_get_or_create( port, common::api::public::GetOrCreateQuery { - namespace: namespace_clone, + namespace: namespace_clone1, }, common::api::public::GetOrCreateRequest { datacenter: None, @@ -303,26 +243,71 @@ fn get_or_create_returns_winner_on_race() { ) .await }); - handles.push(handle); - } - - // Wait for all to complete - let mut results = vec![]; - for handle in handles { - let task_result = handle.await.expect("task panicked"); - // Handle destroyed_during_creation error which can occur in race conditions - match task_result { - Ok(response) => results.push(response), - Err(e) => { - // destroyed_during_creation is an expected race condition error - if !e.to_string().contains("destroyed_during_creation") { - panic!("unexpected error: {}", e); - } - // Skip this result and retry with get_or_create again - let retry_result = common::api::public::actors_get_or_create( + + let handle2 = tokio::spawn(async move { + common::api::public::actors_get_or_create( + port, + common::api::public::GetOrCreateQuery { + namespace: namespace_clone2, + }, + common::api::public::GetOrCreateRequest { + datacenter: None, + name: actor_name.to_string(), + key: actor_key.to_string(), + input: None, + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await + }); + + let (result1, result2) = tokio::join!(handle1, handle2); + let response1 = result1.expect("task 1 panicked").expect("request 1 failed"); + let response2 = result2.expect("task 2 panicked").expect("request 2 failed"); + + // Both should succeed + assert_eq!( + response1.actor.actor_id, response2.actor.actor_id, + "Both requests should return the same actor" + ); + + // Exactly one should have created=true + let created_count = [response1.created, response2.created] + .iter() + .filter(|&&c| c) + .count(); + assert_eq!( + created_count, 1, + "Exactly one request should report creation" + ); + }, + ); +} + +#[test] +// Broken legacy Pegboard Runner test: full engine sweep timed out in +// `get_or_create_returns_winner_on_race`. +fn get_or_create_returns_winner_on_race() { + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + + let actor_name = "race-winner-actor"; + let actor_key = "race-winner-key"; + let port = ctx.leader_dc().guard_port(); + + // Launch multiple concurrent requests + let mut handles = vec![]; + for _ in 0..10 { + let namespace_clone = namespace.clone(); + let handle = tokio::spawn(async move { + common::api::public::actors_get_or_create( port, common::api::public::GetOrCreateQuery { - namespace: namespace.clone(), + namespace: namespace_clone, }, common::api::public::GetOrCreateRequest { datacenter: None, @@ -334,375 +319,433 @@ fn get_or_create_returns_winner_on_race() { }, ) .await - .expect("retry request failed"); - results.push(retry_result); + }); + handles.push(handle); + } + + // Wait for all to complete + let mut results = vec![]; + for handle in handles { + let task_result = handle.await.expect("task panicked"); + // Handle destroyed_during_creation error which can occur in race conditions + match task_result { + Ok(response) => results.push(response), + Err(e) => { + // destroyed_during_creation is an expected race condition error + if !e.to_string().contains("destroyed_during_creation") { + panic!("unexpected error: {}", e); + } + // Skip this result and retry with get_or_create again + let retry_result = common::api::public::actors_get_or_create( + port, + common::api::public::GetOrCreateQuery { + namespace: namespace.clone(), + }, + common::api::public::GetOrCreateRequest { + datacenter: None, + name: actor_name.to_string(), + key: actor_key.to_string(), + input: None, + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await + .expect("retry request failed"); + results.push(retry_result); + } } } - } - // All should return the same actor ID - let first_actor_id = results[0].actor.actor_id; - for result in &results { - assert_eq!( - result.actor.actor_id, first_actor_id, - "All requests should return the same actor" + // All should return the same actor ID + let first_actor_id = results[0].actor.actor_id; + for result in &results { + assert_eq!( + result.actor.actor_id, first_actor_id, + "All requests should return the same actor" + ); + } + + // At least one request should report creation + let created_count = results.iter().filter(|r| r.created).count(); + assert!( + created_count >= 1, + "At least one request should report creation" ); - } - - // At least one request should report creation - let created_count = results.iter().filter(|r| r.created).count(); - assert!( - created_count >= 1, - "At least one request should report creation" - ); - }); + }, + ); } #[test] #[ignore = "cross-DC reserve_key race: concurrent same-key requests from different DCs produce two distinct actors instead of converging"] fn get_or_create_race_condition_across_datacenters() { - common::run(common::TestOpts::new(2).with_timeout(45), |ctx| async move { - const DC2_RUNNER_NAME: &'static str = "dc-2-runner"; - - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - - let _envoy2 = common::test_envoy::TestEnvoyBuilder::new(&namespace) - .with_version(1) - .with_pool_name(DC2_RUNNER_NAME) - .with_actor_behavior("test-actor", |_config| { - Box::new(common::test_envoy::EchoActor::new()) - }) - .build(ctx.get_dc(2)) - .await - .expect("failed to build test envoy"); - - common::upsert_normal_runner_config(ctx.get_dc(2), &namespace, DC2_RUNNER_NAME).await; - _envoy2.start().await.expect("failed to start envoy"); - _envoy2.wait_ready().await; - - let actor_name = "cross-dc-race-actor"; - let actor_key = "cross-dc-race-key"; - let port1 = ctx.leader_dc().guard_port(); - let port2 = ctx.get_dc(2).guard_port(); - let namespace_clone1 = namespace.clone(); - let namespace_clone2 = namespace.clone(); - - // Launch concurrent requests from two different datacenters - let handle1 = tokio::spawn(async move { - common::api::public::actors_get_or_create( - port1, + common::run( + common::TestOpts::new(2).with_timeout(45), + |ctx| async move { + const DC2_RUNNER_NAME: &'static str = "dc-2-runner"; + + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + + let _envoy2 = common::test_envoy::TestEnvoyBuilder::new(&namespace) + .with_version(1) + .with_pool_name(DC2_RUNNER_NAME) + .with_actor_behavior("test-actor", |_config| { + Box::new(common::test_envoy::EchoActor::new()) + }) + .build(ctx.get_dc(2)) + .await + .expect("failed to build test envoy"); + + common::upsert_normal_runner_config(ctx.get_dc(2), &namespace, DC2_RUNNER_NAME).await; + _envoy2.start().await.expect("failed to start envoy"); + _envoy2.wait_ready().await; + + let actor_name = "cross-dc-race-actor"; + let actor_key = "cross-dc-race-key"; + let port1 = ctx.leader_dc().guard_port(); + let port2 = ctx.get_dc(2).guard_port(); + let namespace_clone1 = namespace.clone(); + let namespace_clone2 = namespace.clone(); + + // Launch concurrent requests from two different datacenters + let handle1 = tokio::spawn(async move { + common::api::public::actors_get_or_create( + port1, + common::api::public::GetOrCreateQuery { + namespace: namespace_clone1, + }, + common::api::public::GetOrCreateRequest { + datacenter: None, + name: actor_name.to_string(), + key: actor_key.to_string(), + input: None, + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await + }); + + let handle2 = tokio::spawn(async move { + common::api::public::actors_get_or_create( + port2, + common::api::public::GetOrCreateQuery { + namespace: namespace_clone2, + }, + common::api::public::GetOrCreateRequest { + datacenter: None, + name: actor_name.to_string(), + key: actor_key.to_string(), + input: None, + runner_name_selector: DC2_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await + }); + + let (result1, result2) = tokio::join!(handle1, handle2); + let response1 = result1 + .expect("DC1 task panicked") + .expect("DC1 request failed"); + let response2 = result2 + .expect("DC2 task panicked") + .expect("DC2 request failed"); + + // Both should succeed and return the same actor + assert_eq!( + response1.actor.actor_id, response2.actor.actor_id, + "Both datacenters should return the same actor" + ); + + // At least one should report creation + assert!( + (response1.created || response2.created) + && !(response1.created && response2.created), + "At least one datacenter should report creation, but not both" + ); + }, + ); +} + +// MARK: Datacenter tests + +#[test] +fn get_or_create_in_current_datacenter() { + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + + let response = common::api::public::actors_get_or_create( + ctx.leader_dc().guard_port(), common::api::public::GetOrCreateQuery { - namespace: namespace_clone1, + namespace: namespace.clone(), }, common::api::public::GetOrCreateRequest { - datacenter: None, - name: actor_name.to_string(), - key: actor_key.to_string(), + datacenter: None, // Should default to current DC + name: "current-dc-actor".to_string(), + key: "current-dc-key".to_string(), input: None, runner_name_selector: common::TEST_RUNNER_NAME.to_string(), crash_policy: rivet_types::actors::CrashPolicy::Sleep, }, ) .await - }); + .expect("failed to get or create actor"); + + assert!(response.created, "Actor should be created"); + + // Verify actor is in current DC (DC1) + let actor_id_str = response.actor.actor_id.to_string(); + common::assert_actor_in_dc(&actor_id_str, 1).await; + }, + ); +} + +// Broken legacy Pegboard Runner multi-DC coverage: remote get-or-create returns +// `core.internal_error` with `target_replicas must include the local replica`. +#[test] +fn get_or_create_in_remote_datacenter() { + common::run( + common::TestOpts::new(2).with_timeout(45), + |ctx| async move { + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + let _runner_dc2 = common::setup_envoy_on_dc( + ctx.get_dc(2), + &namespace, + vec!["remote-dc-actor".to_string()], + ) + .await; - let handle2 = tokio::spawn(async move { - common::api::public::actors_get_or_create( - port2, + // Request from DC1 but specify DC2 + let response = common::api::public::actors_get_or_create( + ctx.leader_dc().guard_port(), common::api::public::GetOrCreateQuery { - namespace: namespace_clone2, + namespace: namespace.clone(), }, common::api::public::GetOrCreateRequest { - datacenter: None, - name: actor_name.to_string(), - key: actor_key.to_string(), + datacenter: Some("dc-2".to_string()), + name: "remote-dc-actor".to_string(), + key: "remote-dc-key".to_string(), input: None, - runner_name_selector: DC2_RUNNER_NAME.to_string(), + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), crash_policy: rivet_types::actors::CrashPolicy::Sleep, }, ) .await - }); - - let (result1, result2) = tokio::join!(handle1, handle2); - let response1 = result1 - .expect("DC1 task panicked") - .expect("DC1 request failed"); - let response2 = result2 - .expect("DC2 task panicked") - .expect("DC2 request failed"); - - // Both should succeed and return the same actor - assert_eq!( - response1.actor.actor_id, response2.actor.actor_id, - "Both datacenters should return the same actor" - ); - - // At least one should report creation - assert!( - (response1.created || response2.created) && !(response1.created && response2.created), - "At least one datacenter should report creation, but not both" - ); - }); -} + .expect("failed to get or create actor"); -// MARK: Datacenter tests + assert!(response.created, "Actor should be created"); -#[test] -fn get_or_create_in_current_datacenter() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - - let response = common::api::public::actors_get_or_create( - ctx.leader_dc().guard_port(), - common::api::public::GetOrCreateQuery { - namespace: namespace.clone(), - }, - common::api::public::GetOrCreateRequest { - datacenter: None, // Should default to current DC - name: "current-dc-actor".to_string(), - key: "current-dc-key".to_string(), - input: None, - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, - }, - ) - .await - .expect("failed to get or create actor"); - - assert!(response.created, "Actor should be created"); - - // Verify actor is in current DC (DC1) - let actor_id_str = response.actor.actor_id.to_string(); - common::assert_actor_in_dc(&actor_id_str, 1).await; - }); -} + // Wait for actor to propagate across datacenters + let actor_id_str = response.actor.actor_id.to_string(); -// Broken legacy Pegboard Runner multi-DC coverage: remote get-or-create returns -// `core.internal_error` with `target_replicas must include the local replica`. -#[test] -fn get_or_create_in_remote_datacenter() { - common::run(common::TestOpts::new(2).with_timeout(45), |ctx| async move { - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - let _runner_dc2 = common::setup_envoy_on_dc( - ctx.get_dc(2), - &namespace, - vec!["remote-dc-actor".to_string()], - ) - .await; - - // Request from DC1 but specify DC2 - let response = common::api::public::actors_get_or_create( - ctx.leader_dc().guard_port(), - common::api::public::GetOrCreateQuery { - namespace: namespace.clone(), - }, - common::api::public::GetOrCreateRequest { - datacenter: Some("dc-2".to_string()), - name: "remote-dc-actor".to_string(), - key: "remote-dc-key".to_string(), - input: None, - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, - }, - ) - .await - .expect("failed to get or create actor"); - - assert!(response.created, "Actor should be created"); - - // Wait for actor to propagate across datacenters - let actor_id_str = response.actor.actor_id.to_string(); - - // Verify actor is in DC2 - common::assert_actor_in_dc(&actor_id_str, 2).await; - }); + // Verify actor is in DC2 + common::assert_actor_in_dc(&actor_id_str, 2).await; + }, + ); } // MARK: Error cases #[test] fn get_or_create_with_non_existent_namespace() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let res = common::api::public::actors_get_or_create( - ctx.leader_dc().guard_port(), - common::api::public::GetOrCreateQuery { - namespace: "non-existent-namespace".to_string(), - }, - common::api::public::GetOrCreateRequest { - datacenter: None, - name: "test-actor".to_string(), - key: "test-key".to_string(), - input: None, - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, - }, - ) - .await; - - assert!(res.is_err(), "Should fail with non-existent namespace"); - }); + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let res = common::api::public::actors_get_or_create( + ctx.leader_dc().guard_port(), + common::api::public::GetOrCreateQuery { + namespace: "non-existent-namespace".to_string(), + }, + common::api::public::GetOrCreateRequest { + datacenter: None, + name: "test-actor".to_string(), + key: "test-key".to_string(), + input: None, + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await; + + assert!(res.is_err(), "Should fail with non-existent namespace"); + }, + ); } #[test] fn get_or_create_with_invalid_datacenter() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - - let res = common::api::public::actors_get_or_create( - ctx.leader_dc().guard_port(), - common::api::public::GetOrCreateQuery { - namespace: namespace.clone(), - }, - common::api::public::GetOrCreateRequest { - datacenter: Some("non-existent-dc".to_string()), - name: "test-actor".to_string(), - key: "test-key".to_string(), - input: None, - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, - }, - ) - .await; - - assert!(res.is_err(), "Should fail with invalid datacenter"); - }); + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + + let res = common::api::public::actors_get_or_create( + ctx.leader_dc().guard_port(), + common::api::public::GetOrCreateQuery { + namespace: namespace.clone(), + }, + common::api::public::GetOrCreateRequest { + datacenter: Some("non-existent-dc".to_string()), + name: "test-actor".to_string(), + key: "test-key".to_string(), + input: None, + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await; + + assert!(res.is_err(), "Should fail with invalid datacenter"); + }, + ); } #[test] fn get_or_create_empty_key() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - - let res = common::api::public::actors_get_or_create( - ctx.leader_dc().guard_port(), - common::api::public::GetOrCreateQuery { - namespace: namespace.clone(), - }, - common::api::public::GetOrCreateRequest { - datacenter: None, - name: "test-actor".to_string(), - key: "".to_string(), - input: None, - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, - }, - ) - .await; - - assert!( - res.is_err(), - "should fail to get or create actor with empty key" - ); - }); + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + + let res = common::api::public::actors_get_or_create( + ctx.leader_dc().guard_port(), + common::api::public::GetOrCreateQuery { + namespace: namespace.clone(), + }, + common::api::public::GetOrCreateRequest { + datacenter: None, + name: "test-actor".to_string(), + key: "".to_string(), + input: None, + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await; + + assert!( + res.is_err(), + "should fail to get or create actor with empty key" + ); + }, + ); } #[test] fn get_or_create_key_exceeds_max_size() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - - let key = "a".repeat(1025); - - let res = common::api::public::actors_get_or_create( - ctx.leader_dc().guard_port(), - common::api::public::GetOrCreateQuery { - namespace: namespace.clone(), - }, - common::api::public::GetOrCreateRequest { - datacenter: None, - name: "test-actor".to_string(), - key, - input: None, - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, - }, - ) - .await; - - assert!( - res.is_err(), - "should fail to get or create actor with key exceeding max size" - ); - }); + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + + let key = "a".repeat(1025); + + let res = common::api::public::actors_get_or_create( + ctx.leader_dc().guard_port(), + common::api::public::GetOrCreateQuery { + namespace: namespace.clone(), + }, + common::api::public::GetOrCreateRequest { + datacenter: None, + name: "test-actor".to_string(), + key, + input: None, + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await; + + assert!( + res.is_err(), + "should fail to get or create actor with key exceeding max size" + ); + }, + ); } // MARK: Edge cases #[test] fn get_or_create_with_destroyed_actor() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - - let actor_name = "destroyed-actor"; - let actor_key = "destroyed-key"; - - // Create actor - let response1 = common::api::public::actors_get_or_create( - ctx.leader_dc().guard_port(), - common::api::public::GetOrCreateQuery { - namespace: namespace.clone(), - }, - common::api::public::GetOrCreateRequest { - datacenter: None, - name: actor_name.to_string(), - key: actor_key.to_string(), - input: None, - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, - }, - ) - .await - .expect("failed to get or create actor"); - - assert!(response1.created, "First call should create actor"); - let first_actor_id = response1.actor.actor_id; - - // Destroy the actor - common::api::public::actors_delete( - ctx.leader_dc().guard_port(), - common::api_types::actors::delete::DeletePath { - actor_id: first_actor_id, - }, - common::api_types::actors::delete::DeleteQuery { - namespace: namespace.clone(), - }, - ) - .await - .expect("failed to delete actor"); - - // Call get_or_create again with same key - should create a new actor - let response2 = common::api::public::actors_get_or_create( - ctx.leader_dc().guard_port(), - common::api::public::GetOrCreateQuery { - namespace: namespace.clone(), - }, - common::api::public::GetOrCreateRequest { - datacenter: None, - name: actor_name.to_string(), - key: actor_key.to_string(), - input: None, - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, - }, - ) - .await - .expect("failed to get or create actor after destroy"); - - assert!( - response2.created, - "Should create new actor after old one was destroyed" - ); - assert_ne!( - response2.actor.actor_id, first_actor_id, - "Should be a different actor ID" - ); - }); + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + + let actor_name = "destroyed-actor"; + let actor_key = "destroyed-key"; + + // Create actor + let response1 = common::api::public::actors_get_or_create( + ctx.leader_dc().guard_port(), + common::api::public::GetOrCreateQuery { + namespace: namespace.clone(), + }, + common::api::public::GetOrCreateRequest { + datacenter: None, + name: actor_name.to_string(), + key: actor_key.to_string(), + input: None, + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await + .expect("failed to get or create actor"); + + assert!(response1.created, "First call should create actor"); + let first_actor_id = response1.actor.actor_id; + + // Destroy the actor + common::api::public::actors_delete( + ctx.leader_dc().guard_port(), + common::api_types::actors::delete::DeletePath { + actor_id: first_actor_id, + }, + common::api_types::actors::delete::DeleteQuery { + namespace: namespace.clone(), + }, + ) + .await + .expect("failed to delete actor"); + + // Call get_or_create again with same key - should create a new actor + let response2 = common::api::public::actors_get_or_create( + ctx.leader_dc().guard_port(), + common::api::public::GetOrCreateQuery { + namespace: namespace.clone(), + }, + common::api::public::GetOrCreateRequest { + datacenter: None, + name: actor_name.to_string(), + key: actor_key.to_string(), + input: None, + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await + .expect("failed to get or create actor after destroy"); + + assert!( + response2.created, + "Should create new actor after old one was destroyed" + ); + assert_ne!( + response2.actor.actor_id, first_actor_id, + "Should be a different actor ID" + ); + }, + ); } diff --git a/engine/packages/engine/tests/envoy/api_actors_list.rs b/engine/packages/engine/tests/envoy/api_actors_list.rs index f9764198e5..25acab8d27 100644 --- a/engine/packages/engine/tests/envoy/api_actors_list.rs +++ b/engine/packages/engine/tests/envoy/api_actors_list.rs @@ -6,746 +6,268 @@ use std::collections::HashSet; #[test] fn list_actors_by_namespace_and_name() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - - let name = "list-test-actor"; - - // Create multiple actors with same name - let mut actor_ids = Vec::new(); - for i in 0..3 { - let res = common::api::public::actors_create( + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + + let name = "list-test-actor"; + + // Create multiple actors with same name + let mut actor_ids = Vec::new(); + for i in 0..3 { + let res = common::api::public::actors_create( + ctx.leader_dc().guard_port(), + common::api_types::actors::create::CreateQuery { + namespace: namespace.clone(), + }, + common::api_types::actors::create::CreateRequest { + datacenter: None, + name: name.to_string(), + key: Some(format!("key-{}", i)), + input: None, + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await + .expect("failed to create actor"); + actor_ids.push(res.actor.actor_id.to_string()); + } + + // List actors by name + let response = common::api::public::actors_list( ctx.leader_dc().guard_port(), - common::api_types::actors::create::CreateQuery { + common::api_types::actors::list::ListQuery { namespace: namespace.clone(), - }, - common::api_types::actors::create::CreateRequest { - datacenter: None, - name: name.to_string(), - key: Some(format!("key-{}", i)), - input: None, - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, + name: Some(name.to_string()), + key: None, + actor_ids: None, + actor_id: vec![], + include_destroyed: None, + limit: None, + cursor: None, }, ) .await - .expect("failed to create actor"); - actor_ids.push(res.actor.actor_id.to_string()); - } - - // List actors by name - let response = common::api::public::actors_list( - ctx.leader_dc().guard_port(), - common::api_types::actors::list::ListQuery { - namespace: namespace.clone(), - name: Some(name.to_string()), - key: None, - actor_ids: None, - actor_id: vec![], - include_destroyed: None, - limit: None, - cursor: None, - }, - ) - .await - .expect("failed to list actors"); - - assert_eq!(response.actors.len(), 3, "Should return all 3 actors"); - - // Verify all created actors are in the response - let returned_ids: HashSet = response - .actors - .iter() - .map(|a| a.actor_id.to_string()) - .collect(); - for actor_id in &actor_ids { - assert!( - returned_ids.contains(actor_id), - "Actor {} should be in results", - actor_id - ); - } - }); + .expect("failed to list actors"); + + assert_eq!(response.actors.len(), 3, "Should return all 3 actors"); + + // Verify all created actors are in the response + let returned_ids: HashSet = response + .actors + .iter() + .map(|a| a.actor_id.to_string()) + .collect(); + for actor_id in &actor_ids { + assert!( + returned_ids.contains(actor_id), + "Actor {} should be in results", + actor_id + ); + } + }, + ); } #[test] fn list_with_pagination() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - - let name = "paginated-actor"; - - // Create 5 actors with the same name but different keys - let mut actor_ids = Vec::new(); - for i in 0..5 { - let res = common::api::public::actors_create( + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + + let name = "paginated-actor"; + + // Create 5 actors with the same name but different keys + let mut actor_ids = Vec::new(); + for i in 0..5 { + let res = common::api::public::actors_create( + ctx.leader_dc().guard_port(), + common::api_types::actors::create::CreateQuery { + namespace: namespace.clone(), + }, + common::api_types::actors::create::CreateRequest { + datacenter: None, + name: name.to_string(), + key: Some(format!("key-{}", i)), + input: None, + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await + .expect("failed to create actor"); + actor_ids.push(res.actor.actor_id.to_string()); + } + + // First page - limit 2 + let response1 = common::api::public::actors_list( ctx.leader_dc().guard_port(), - common::api_types::actors::create::CreateQuery { + common::api_types::actors::list::ListQuery { namespace: namespace.clone(), - }, - common::api_types::actors::create::CreateRequest { - datacenter: None, - name: name.to_string(), - key: Some(format!("key-{}", i)), - input: None, - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, + name: Some(name.to_string()), + key: None, + actor_ids: None, + actor_id: vec![], + include_destroyed: None, + limit: Some(2), + cursor: None, }, ) .await - .expect("failed to create actor"); - actor_ids.push(res.actor.actor_id.to_string()); - } - - // First page - limit 2 - let response1 = common::api::public::actors_list( - ctx.leader_dc().guard_port(), - common::api_types::actors::list::ListQuery { - namespace: namespace.clone(), - name: Some(name.to_string()), - key: None, - actor_ids: None, - actor_id: vec![], - include_destroyed: None, - limit: Some(2), - cursor: None, - }, - ) - .await - .expect("failed to list actors"); - - assert_eq!( - response1.actors.len(), - 2, - "Should return 2 actors with limit=2" - ); - - // Get all actors to verify ordering - let all_response = common::api::public::actors_list( - ctx.leader_dc().guard_port(), - common::api_types::actors::list::ListQuery { - namespace: namespace.clone(), - name: Some(name.to_string()), - key: None, - actor_ids: None, - actor_id: vec![], - include_destroyed: None, - limit: None, - cursor: None, - }, - ) - .await - .expect("failed to list all actors"); - - // Verify we have all 5 actors when querying without limit - assert_eq!( - all_response.actors.len(), - 5, - "Should return all 5 actors when no limit specified" - ); - - // Use actors from position 2-4 as actors2 for remaining test logic - let actors2 = if all_response.actors.len() > 2 { - &all_response.actors[2..std::cmp::min(4, all_response.actors.len())] - } else { - &[] - }; - - // Verify no duplicates between pages - let ids1: HashSet = response1 - .actors - .iter() - .map(|a| a.actor_id.to_string()) - .collect(); - let ids2: HashSet = actors2.iter().map(|a| a.actor_id.to_string()).collect(); - assert!( - ids1.is_disjoint(&ids2), - "Pages should not have duplicate actors" - ); - - // Verify consistent ordering using the full actor list - let all_timestamps: Vec = all_response.actors.iter().map(|a| a.create_ts).collect(); - - // Verify all timestamps are valid and reasonable (not zero, not in future) - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_millis() as i64; - - for &ts in &all_timestamps { - assert!(ts > 0, "create_ts should be positive: {}", ts); - assert!(ts <= now, "create_ts should not be in future: {}", ts); - } - - // Verify that all actors are returned in descending timestamp order (newest first) - for i in 1..all_timestamps.len() { - assert!( - all_timestamps[i - 1] >= all_timestamps[i], - "Actors should be ordered by create_ts descending: {} >= {} (index {} vs {})", - all_timestamps[i - 1], - all_timestamps[i], - i - 1, - i - ); - } - - // Verify that the limited query returns the newest actors - let paginated_timestamps: Vec = response1.actors.iter().map(|a| a.create_ts).collect(); - - assert_eq!( - paginated_timestamps, - all_timestamps[0..2].to_vec(), - "Paginated result should return the 2 newest actors" - ); - - // Test that limit=2 actually limits results to 2 - assert_eq!( - response1.actors.len(), - 2, - "Limit=2 should return exactly 2 actors" - ); - assert_eq!( - all_response.actors.len(), - 5, - "Query without limit should return all 5 actors" - ); - }); -} - -#[test] -fn list_returns_empty_array_when_no_actors() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - - // List actors that don't exist - let response = common::api::public::actors_list( - ctx.leader_dc().guard_port(), - common::api_types::actors::list::ListQuery { - namespace: namespace.clone(), - name: Some("non-existent-actor".to_string()), - key: None, - actor_ids: None, - actor_id: vec![], - include_destroyed: None, - limit: None, - cursor: None, - }, - ) - .await - .expect("failed to list actors"); - - assert_eq!(response.actors.len(), 0, "Should return empty array"); - }); -} - -// MARK: List by Name + Key + .expect("failed to list actors"); -#[test] -fn list_actors_by_namespace_name_and_key() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - - let name = "keyed-actor"; - let key1 = "key1".to_string(); - let key2 = "key2".to_string(); - - // Create actors with different keys - let res1 = common::api::public::actors_create( - ctx.leader_dc().guard_port(), - common::api_types::actors::create::CreateQuery { - namespace: namespace.clone(), - }, - common::api_types::actors::create::CreateRequest { - datacenter: None, - name: name.to_string(), - key: Some(key1.clone()), - input: None, - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, - }, - ) - .await - .expect("failed to create actor1"); - let actor_id1 = res1.actor.actor_id.to_string(); - - let _res2 = common::api::public::actors_create( - ctx.leader_dc().guard_port(), - common::api_types::actors::create::CreateQuery { - namespace: namespace.clone(), - }, - common::api_types::actors::create::CreateRequest { - datacenter: None, - name: name.to_string(), - key: Some(key2.clone()), - input: None, - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, - }, - ) - .await - .expect("failed to create actor2"); - - // List with key1 - should find actor1 - let response = common::api::public::actors_list( - ctx.leader_dc().guard_port(), - common::api_types::actors::list::ListQuery { - namespace: namespace.clone(), - name: Some(name.to_string()), - key: Some("key1".to_string()), - actor_ids: None, - actor_id: vec![], - include_destroyed: None, - limit: None, - cursor: None, - }, - ) - .await - .expect("failed to list actors"); - - assert_eq!(response.actors.len(), 1, "Should return 1 actor"); - assert_eq!(response.actors[0].actor_id.to_string(), actor_id1); - }); -} + assert_eq!( + response1.actors.len(), + 2, + "Should return 2 actors with limit=2" + ); -#[test] -fn list_with_include_destroyed_false() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - - let name = "destroyed-test"; - - // Create and destroy an actor - let res1 = common::api::public::actors_create( - ctx.leader_dc().guard_port(), - common::api_types::actors::create::CreateQuery { - namespace: namespace.clone(), - }, - common::api_types::actors::create::CreateRequest { - datacenter: None, - name: name.to_string(), - key: Some("destroyed-key".to_string()), - input: None, - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, - }, - ) - .await - .expect("failed to create actor"); - let destroyed_actor_id = res1.actor.actor_id; - - common::api::public::actors_delete( - ctx.leader_dc().guard_port(), - common::api_types::actors::delete::DeletePath { - actor_id: destroyed_actor_id, - }, - common::api_types::actors::delete::DeleteQuery { - namespace: namespace.clone(), - }, - ) - .await - .expect("failed to delete actor"); - - // Create an active actor - let res2 = common::api::public::actors_create( - ctx.leader_dc().guard_port(), - common::api_types::actors::create::CreateQuery { - namespace: namespace.clone(), - }, - common::api_types::actors::create::CreateRequest { - datacenter: None, - name: name.to_string(), - key: Some("active-key".to_string()), - input: None, - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, - }, - ) - .await - .expect("failed to create actor"); - let active_actor_id = res2.actor.actor_id.to_string(); - - // List without include_destroyed (default false) - let response = common::api::public::actors_list( - ctx.leader_dc().guard_port(), - common::api_types::actors::list::ListQuery { - namespace: namespace.clone(), - name: Some(name.to_string()), - key: None, - actor_ids: None, - actor_id: vec![], - include_destroyed: Some(false), - limit: None, - cursor: None, - }, - ) - .await - .expect("failed to list actors"); - - assert_eq!(response.actors.len(), 1, "Should only return active actor"); - assert_eq!(response.actors[0].actor_id.to_string(), active_actor_id); - }); -} + // Get all actors to verify ordering + let all_response = common::api::public::actors_list( + ctx.leader_dc().guard_port(), + common::api_types::actors::list::ListQuery { + namespace: namespace.clone(), + name: Some(name.to_string()), + key: None, + actor_ids: None, + actor_id: vec![], + include_destroyed: None, + limit: None, + cursor: None, + }, + ) + .await + .expect("failed to list all actors"); -#[test] -fn list_with_include_destroyed_true() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - - let name = "destroyed-included"; - - // Create and destroy an actor - let res1 = common::api::public::actors_create( - ctx.leader_dc().guard_port(), - common::api_types::actors::create::CreateQuery { - namespace: namespace.clone(), - }, - common::api_types::actors::create::CreateRequest { - datacenter: None, - name: name.to_string(), - key: Some("destroyed-key".to_string()), - input: None, - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, - }, - ) - .await - .expect("failed to create actor"); - let destroyed_actor_id = res1.actor.actor_id.to_string(); - - common::api::public::actors_delete( - ctx.leader_dc().guard_port(), - common::api_types::actors::delete::DeletePath { - actor_id: res1.actor.actor_id, - }, - common::api_types::actors::delete::DeleteQuery { - namespace: namespace.clone(), - }, - ) - .await - .expect("failed to delete actor"); - - // Create an active actor - let res2 = common::api::public::actors_create( - ctx.leader_dc().guard_port(), - common::api_types::actors::create::CreateQuery { - namespace: namespace.clone(), - }, - common::api_types::actors::create::CreateRequest { - datacenter: None, - name: name.to_string(), - key: Some("active-key".to_string()), - input: None, - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, - }, - ) - .await - .expect("failed to create actor"); - let active_actor_id = res2.actor.actor_id.to_string(); - - // List with include_destroyed=true - let response = common::api::public::actors_list( - ctx.leader_dc().guard_port(), - common::api_types::actors::list::ListQuery { - namespace: namespace.clone(), - name: Some(name.to_string()), - key: None, - actor_ids: None, - actor_id: vec![], - include_destroyed: Some(true), - limit: None, - cursor: None, - }, - ) - .await - .expect("failed to list actors"); - - assert_eq!( - response.actors.len(), - 2, - "Should return both active and destroyed actors" - ); - - // Verify both actors are in results - let returned_ids: HashSet = response - .actors - .iter() - .map(|a| a.actor_id.to_string()) - .collect(); - assert!(returned_ids.contains(&active_actor_id)); - assert!(returned_ids.contains(&destroyed_actor_id)); - }); -} + // Verify we have all 5 actors when querying without limit + assert_eq!( + all_response.actors.len(), + 5, + "Should return all 5 actors when no limit specified" + ); -// MARK: List by Actor IDs + // Use actors from position 2-4 as actors2 for remaining test logic + let actors2 = if all_response.actors.len() > 2 { + &all_response.actors[2..std::cmp::min(4, all_response.actors.len())] + } else { + &[] + }; -// Broken legacy Pegboard Runner coverage: full `runner::` sweep times out with -// `test timed out: Elapsed(())`. -#[test] -fn list_specific_actors_by_ids() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - - // Create multiple actors - let actor_ids = - common::bulk_create_actors(ctx.leader_dc().guard_port(), &namespace, "id-list-test", 5) - .await; - - // Select specific actors to list - let selected_ids = vec![ - actor_ids[0].clone(), - actor_ids[2].clone(), - actor_ids[4].clone(), - ]; - - // List by actor IDs (comma-separated string) - let response = common::api::public::actors_list( - ctx.leader_dc().guard_port(), - common::api_types::actors::list::ListQuery { - namespace: namespace.clone(), - name: None, - key: None, - actor_id: selected_ids.clone(), - actor_ids: None, - include_destroyed: None, - limit: None, - cursor: None, - }, - ) - .await - .expect("failed to list actors"); - - assert_eq!( - response.actors.len(), - 3, - "Should return exactly the requested actors" - ); - - // Verify correct actors returned - let returned_ids: HashSet = response - .actors - .iter() - .map(|a| a.actor_id.to_string()) - .collect(); - for id in &selected_ids { + // Verify no duplicates between pages + let ids1: HashSet = response1 + .actors + .iter() + .map(|a| a.actor_id.to_string()) + .collect(); + let ids2: HashSet = actors2.iter().map(|a| a.actor_id.to_string()).collect(); assert!( - returned_ids.contains(&id.to_string()), - "Actor {} should be in results", - id + ids1.is_disjoint(&ids2), + "Pages should not have duplicate actors" ); - } - }); -} - -#[test] -fn list_actors_from_multiple_datacenters() { - common::run(common::TestOpts::new(2).with_timeout(45), |ctx| async move { - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - let _runner_dc2 = common::setup_envoy_on_dc( - ctx.get_dc(2), - &namespace, - vec!["multi-dc-actor".to_string()], - ) - .await; - - // Create actors in different DCs - let res1 = common::api::public::actors_create( - ctx.leader_dc().guard_port(), - common::api_types::actors::create::CreateQuery { - namespace: namespace.clone(), - }, - common::api_types::actors::create::CreateRequest { - datacenter: None, - name: "multi-dc-actor".to_string(), - key: Some("dc1-key".to_string()), - input: None, - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, - }, - ) - .await - .expect("failed to create actor in DC1"); - let actor_id_dc1 = res1.actor.actor_id; - - let res2 = common::api::public::actors_create( - ctx.leader_dc().guard_port(), - common::api_types::actors::create::CreateQuery { - namespace: namespace.clone(), - }, - common::api_types::actors::create::CreateRequest { - datacenter: Some("dc-2".to_string()), - name: "multi-dc-actor".to_string(), - key: Some("dc2-key".to_string()), - input: None, - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, - }, - ) - .await - .expect("failed to create actor in DC2"); - let actor_id_dc2 = res2.actor.actor_id; - - // List by actor IDs - should fetch from both DCs - let response = common::api::public::actors_list( - ctx.leader_dc().guard_port(), - common::api_types::actors::list::ListQuery { - namespace: namespace.clone(), - name: None, - key: None, - actor_id: vec![actor_id_dc1, actor_id_dc2], - actor_ids: None, - include_destroyed: None, - limit: None, - cursor: None, - }, - ) - .await - .expect("failed to list actors"); - - assert_eq!( - response.actors.len(), - 2, - "Should return actors from both DCs" - ); - }); -} -// MARK: Error cases + // Verify consistent ordering using the full actor list + let all_timestamps: Vec = + all_response.actors.iter().map(|a| a.create_ts).collect(); + + // Verify all timestamps are valid and reasonable (not zero, not in future) + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as i64; + + for &ts in &all_timestamps { + assert!(ts > 0, "create_ts should be positive: {}", ts); + assert!(ts <= now, "create_ts should not be in future: {}", ts); + } + + // Verify that all actors are returned in descending timestamp order (newest first) + for i in 1..all_timestamps.len() { + assert!( + all_timestamps[i - 1] >= all_timestamps[i], + "Actors should be ordered by create_ts descending: {} >= {} (index {} vs {})", + all_timestamps[i - 1], + all_timestamps[i], + i - 1, + i + ); + } + + // Verify that the limited query returns the newest actors + let paginated_timestamps: Vec = + response1.actors.iter().map(|a| a.create_ts).collect(); -#[test] -fn list_with_non_existent_namespace() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - // Try to list with non-existent namespace - let res = common::api::public::actors_list( - ctx.leader_dc().guard_port(), - common::api_types::actors::list::ListQuery { - namespace: "non-existent-namespace".to_string(), - name: Some("test-actor".to_string()), - key: None, - actor_ids: None, - actor_id: vec![], - include_destroyed: None, - limit: None, - cursor: None, - }, - ) - .await; - - // Should fail with namespace not found - assert!(res.is_err(), "Should fail with non-existent namespace"); - }); -} + assert_eq!( + paginated_timestamps, + all_timestamps[0..2].to_vec(), + "Paginated result should return the 2 newest actors" + ); -#[test] -fn list_with_key_but_no_name() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - - // Try to list with key but no name (validation error) - let res = common::api::public::actors_list( - ctx.leader_dc().guard_port(), - common::api_types::actors::list::ListQuery { - namespace: namespace.clone(), - name: None, - key: Some("key1".to_string()), - actor_ids: None, - actor_id: vec![], - include_destroyed: None, - limit: None, - cursor: None, - }, - ) - .await; - - // Should fail with validation error - assert!(res.is_err(), "Should return error for key without name"); - }); + // Test that limit=2 actually limits results to 2 + assert_eq!( + response1.actors.len(), + 2, + "Limit=2 should return exactly 2 actors" + ); + assert_eq!( + all_response.actors.len(), + 5, + "Query without limit should return all 5 actors" + ); + }, + ); } #[test] -fn list_with_more_than_32_actor_ids() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - - // Try to list with more than 32 actor IDs - let actor_ids: Vec = (0..33) - .map(|_| rivet_util::Id::new_v1(ctx.leader_dc().config.dc_label())) - .collect(); - - let res = common::api::public::actors_list( - ctx.leader_dc().guard_port(), - common::api_types::actors::list::ListQuery { - namespace: namespace.clone(), - name: None, - key: None, - actor_id: actor_ids, - actor_ids: None, - include_destroyed: None, - limit: None, - cursor: None, - }, - ) - .await; - - // Should fail with validation error - assert!(res.is_err(), "Should return error for too many actor IDs"); - }); -} +fn list_returns_empty_array_when_no_actors() { + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + + // List actors that don't exist + let response = common::api::public::actors_list( + ctx.leader_dc().guard_port(), + common::api_types::actors::list::ListQuery { + namespace: namespace.clone(), + name: Some("non-existent-actor".to_string()), + key: None, + actor_ids: None, + actor_id: vec![], + include_destroyed: None, + limit: None, + cursor: None, + }, + ) + .await + .expect("failed to list actors"); -#[test] -fn list_without_name_when_not_using_actor_ids() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - - // Try to list without name or actor_ids - let res = common::api::public::actors_list( - ctx.leader_dc().guard_port(), - common::api_types::actors::list::ListQuery { - namespace: namespace.clone(), - name: None, - key: None, - actor_ids: None, - actor_id: vec![], - include_destroyed: None, - limit: None, - cursor: None, - }, - ) - .await; - - // Should fail with validation error - assert!( - res.is_err(), - "Should return error when neither name nor actor_ids provided" - ); - }); + assert_eq!(response.actors.len(), 0, "Should return empty array"); + }, + ); } -// MARK: Pagination and Sorting +// MARK: List by Name + Key #[test] -fn verify_sorting_by_create_ts_descending() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - - let name = "sorted-actor"; - - // Create actors with slight delays to ensure different timestamps - let mut actor_ids = Vec::new(); - for i in 0..3 { - let res = common::api::public::actors_create( +fn list_actors_by_namespace_name_and_key() { + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + + let name = "keyed-actor"; + let key1 = "key1".to_string(); + let key2 = "key2".to_string(); + + // Create actors with different keys + let res1 = common::api::public::actors_create( ctx.leader_dc().guard_port(), common::api_types::actors::create::CreateQuery { namespace: namespace.clone(), @@ -753,388 +275,17 @@ fn verify_sorting_by_create_ts_descending() { common::api_types::actors::create::CreateRequest { datacenter: None, name: name.to_string(), - key: Some(format!("key-{}", i)), + key: Some(key1.clone()), input: None, runner_name_selector: common::TEST_RUNNER_NAME.to_string(), crash_policy: rivet_types::actors::CrashPolicy::Sleep, }, ) .await - .expect("failed to create actor"); - actor_ids.push(res.actor.actor_id.to_string()); - } - - // List actors - let response = common::api::public::actors_list( - ctx.leader_dc().guard_port(), - common::api_types::actors::list::ListQuery { - namespace: namespace.clone(), - name: Some(name.to_string()), - key: None, - actor_ids: None, - actor_id: vec![], - include_destroyed: None, - limit: None, - cursor: None, - }, - ) - .await - .expect("failed to list actors"); - - // Verify order - newest first (descending by create_ts) - for i in 0..response.actors.len() { - assert_eq!( - response.actors[i].actor_id.to_string(), - actor_ids[actor_ids.len() - 1 - i], - "Actors should be sorted by create_ts descending" - ); - } - }); -} - -// MARK: Cross-datacenter - -// Broken legacy Pegboard Runner multi-DC coverage: full `runner::` sweep times -// out with `test timed out: Elapsed(())`. -#[test] -fn list_aggregates_results_from_all_datacenters() { - common::run(common::TestOpts::new(2).with_timeout(45), |ctx| async move { - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - let _runner_dc2 = common::setup_envoy_on_dc( - ctx.get_dc(2), - &namespace, - vec!["fanout-test-actor".to_string()], - ) - .await; - - let name = "fanout-test-actor"; - - // Create actors in both DCs - let res1 = common::api::public::actors_create( - ctx.leader_dc().guard_port(), - common::api_types::actors::create::CreateQuery { - namespace: namespace.clone(), - }, - common::api_types::actors::create::CreateRequest { - datacenter: None, - name: name.to_string(), - key: Some("dc1-key".to_string()), - input: None, - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, - }, - ) - .await - .expect("failed to create actor in DC1"); - let actor_id_dc1 = res1.actor.actor_id.to_string(); - - let res2 = common::api::public::actors_create( - ctx.get_dc(2).guard_port(), - common::api_types::actors::create::CreateQuery { - namespace: namespace.clone(), - }, - common::api_types::actors::create::CreateRequest { - datacenter: Some("dc-2".to_string()), - name: name.to_string(), - key: Some("dc2-key".to_string()), - input: None, - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, - }, - ) - .await - .expect("failed to create actor in DC2"); - let actor_id_dc2 = res2.actor.actor_id.to_string(); - - // List by name - should fanout to all DCs - let response = common::api::public::actors_list( - ctx.leader_dc().guard_port(), - common::api_types::actors::list::ListQuery { - namespace: namespace.clone(), - name: Some(name.to_string()), - key: None, - actor_ids: None, - actor_id: vec![], - include_destroyed: None, - limit: None, - cursor: None, - }, - ) - .await - .expect("failed to list actors"); - - assert_eq!( - response.actors.len(), - 2, - "Should return actors from both DCs" - ); - - // Verify both actors are present - let returned_ids: HashSet = response - .actors - .iter() - .map(|a| a.actor_id.to_string()) - .collect(); - assert!(returned_ids.contains(&actor_id_dc1)); - assert!(returned_ids.contains(&actor_id_dc2)); - }); -} - -// MARK: Edge cases - -#[test] -fn list_with_exactly_32_actor_ids() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - - // Create exactly 32 actor IDs (boundary condition) - let actor_ids: Vec = (0..32) - .map(|_| rivet_util::Id::new_v1(ctx.leader_dc().config.dc_label())) - .collect(); - - // Should succeed with exactly 32 IDs - let response = common::api::public::actors_list( - ctx.leader_dc().guard_port(), - common::api_types::actors::list::ListQuery { - namespace: namespace.clone(), - name: None, - key: None, - actor_id: actor_ids, - actor_ids: None, - include_destroyed: None, - limit: None, - cursor: None, - }, - ) - .await - .expect("should succeed with exactly 32 actor IDs"); - - // Since these are fake IDs, we expect 0 results, but no error - assert_eq!( - response.actors.len(), - 0, - "Fake IDs should return empty results" - ); - }); -} - -#[test] -fn list_by_key_with_include_destroyed_true() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - - let name = "key-destroyed-test"; - let key = "test-key"; - - // Create and destroy an actor with a key - let res1 = common::api::public::actors_create( - ctx.leader_dc().guard_port(), - common::api_types::actors::create::CreateQuery { - namespace: namespace.clone(), - }, - common::api_types::actors::create::CreateRequest { - datacenter: None, - name: name.to_string(), - key: Some(key.to_string()), - input: None, - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, - }, - ) - .await - .expect("failed to create actor"); - let destroyed_actor_id = res1.actor.actor_id.to_string(); - - common::api::public::actors_delete( - ctx.leader_dc().guard_port(), - common::api_types::actors::delete::DeletePath { - actor_id: res1.actor.actor_id, - }, - common::api_types::actors::delete::DeleteQuery { - namespace: namespace.clone(), - }, - ) - .await - .expect("failed to delete actor"); - - // Create a new actor with the same key - let res2 = common::api::public::actors_create( - ctx.leader_dc().guard_port(), - common::api_types::actors::create::CreateQuery { - namespace: namespace.clone(), - }, - common::api_types::actors::create::CreateRequest { - datacenter: None, - name: name.to_string(), - key: Some(key.to_string()), - input: None, - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, - }, - ) - .await - .expect("failed to create actor"); - let active_actor_id = res2.actor.actor_id.to_string(); - - // List by key with include_destroyed=true - // This should use the fanout path, not the optimized key path - let response = common::api::public::actors_list( - ctx.leader_dc().guard_port(), - common::api_types::actors::list::ListQuery { - namespace: namespace.clone(), - name: Some(name.to_string()), - key: Some(key.to_string()), - actor_ids: None, - actor_id: vec![], - include_destroyed: Some(true), - limit: None, - cursor: None, - }, - ) - .await - .expect("failed to list actors"); - - // Should return both actors (destroyed and active) - assert_eq!( - response.actors.len(), - 2, - "Should return both destroyed and active actors with same key" - ); - - let returned_ids: HashSet = response - .actors - .iter() - .map(|a| a.actor_id.to_string()) - .collect(); - assert!(returned_ids.contains(&destroyed_actor_id)); - assert!(returned_ids.contains(&active_actor_id)); - }); -} - -#[test] -fn list_default_limit_100() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - - let name = "limit-test"; - - // Create 105 actors to test the default limit of 100 - let actor_ids = - common::bulk_create_actors(ctx.leader_dc().guard_port(), &namespace, name, 105).await; - - assert_eq!(actor_ids.len(), 105, "Should have created 105 actors"); - - // List without specifying limit - should use default limit of 100 - let response = common::api::public::actors_list( - ctx.leader_dc().guard_port(), - common::api_types::actors::list::ListQuery { - namespace: namespace.clone(), - name: Some(name.to_string()), - key: None, - actor_ids: None, - actor_id: vec![], - include_destroyed: None, - limit: None, // No limit specified - should default to 100 - cursor: None, - }, - ) - .await - .expect("failed to list actors"); - - // Should return exactly 100 actors due to default limit - assert_eq!( - response.actors.len(), - 100, - "Should return exactly 100 actors when default limit is applied" - ); - - // Verify cursor exists since there are more results - assert!( - response.pagination.cursor.is_some(), - "Cursor should exist when there are more results beyond the limit" - ); - }); -} - -// Broken legacy Pegboard Runner coverage: full `runner::` sweep times out with -// `test timed out: Elapsed(())`. -#[test] -fn list_with_invalid_actor_id_format_in_comma_list() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - - // Create a valid actor - let res = common::api::public::actors_create( - ctx.leader_dc().guard_port(), - common::api_types::actors::create::CreateQuery { - namespace: namespace.clone(), - }, - common::api_types::actors::create::CreateRequest { - datacenter: None, - name: "test-actor".to_string(), - key: Some("test-key".to_string()), - input: None, - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, - }, - ) - .await - .expect("failed to create actor"); - let valid_actor_id = res.actor.actor_id.to_string(); - - // Mix valid and invalid IDs in the comma-separated list - let mixed_ids = vec![ - valid_actor_id.clone(), - "invalid-uuid".to_string(), - "not-a-uuid".to_string(), - valid_actor_id.clone(), - ]; - - let response = common::api::public::actors_list( - ctx.leader_dc().guard_port(), - common::api_types::actors::list::ListQuery { - namespace: namespace.clone(), - name: None, - key: None, - actor_id: vec![], - actor_ids: Some(mixed_ids.join(",")), - include_destroyed: None, - limit: None, - cursor: None, - }, - ) - .await - .expect("should filter out invalid IDs gracefully"); - - // Should return only the valid actor (twice) (parsed IDs are filtered) - assert_eq!( - response.actors.len(), - 2, - "Should filter out invalid IDs and return only valid ones" - ); - assert_eq!(response.actors[0].actor_id.to_string(), valid_actor_id); - }); -} - -// MARK: Cursor pagination - -#[test] -fn list_with_cursor_pagination() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + .expect("failed to create actor1"); + let actor_id1 = res1.actor.actor_id.to_string(); - let name = "cursor-test-actor"; - - // Create 5 actors with same name - let mut actor_ids = Vec::new(); - for i in 0..5 { - let res = common::api::public::actors_create( + let _res2 = common::api::public::actors_create( ctx.leader_dc().guard_port(), common::api_types::actors::create::CreateQuery { namespace: namespace.clone(), @@ -1142,229 +293,50 @@ fn list_with_cursor_pagination() { common::api_types::actors::create::CreateRequest { datacenter: None, name: name.to_string(), - key: Some(format!("cursor-key-{}", i)), + key: Some(key2.clone()), input: None, runner_name_selector: common::TEST_RUNNER_NAME.to_string(), crash_policy: rivet_types::actors::CrashPolicy::Sleep, }, ) .await - .expect("failed to create actor"); - actor_ids.push(res.actor.actor_id.to_string()); - } - - // Fetch first page with limit=2 - let page1 = common::api::public::actors_list( - ctx.leader_dc().guard_port(), - common::api_types::actors::list::ListQuery { - namespace: namespace.clone(), - name: Some(name.to_string()), - key: None, - actor_ids: None, - actor_id: vec![], - include_destroyed: None, - limit: Some(2), - cursor: None, - }, - ) - .await - .expect("failed to list page 1"); - - assert_eq!(page1.actors.len(), 2, "Page 1 should have 2 actors"); - assert!( - page1.pagination.cursor.is_some(), - "Page 1 should return a cursor" - ); - - // Fetch second page using cursor - let page2 = common::api::public::actors_list( - ctx.leader_dc().guard_port(), - common::api_types::actors::list::ListQuery { - namespace: namespace.clone(), - name: Some(name.to_string()), - key: None, - actor_ids: None, - actor_id: vec![], - include_destroyed: None, - limit: Some(2), - cursor: page1.pagination.cursor.clone(), - }, - ) - .await - .expect("failed to list page 2"); - - assert_eq!(page2.actors.len(), 2, "Page 2 should have 2 actors"); - assert!( - page2.pagination.cursor.is_some(), - "Page 2 should return a cursor" - ); - - // Fetch third page using cursor - let page3 = common::api::public::actors_list( - ctx.leader_dc().guard_port(), - common::api_types::actors::list::ListQuery { - namespace: namespace.clone(), - name: Some(name.to_string()), - key: None, - actor_ids: None, - actor_id: vec![], - include_destroyed: None, - limit: Some(2), - cursor: page2.pagination.cursor.clone(), - }, - ) - .await - .expect("failed to list page 3"); - - assert_eq!(page3.actors.len(), 1, "Page 3 should have 1 actor"); - - // Verify no duplicates across pages - let ids1: HashSet = page1 - .actors - .iter() - .map(|a| a.actor_id.to_string()) - .collect(); - let ids2: HashSet = page2 - .actors - .iter() - .map(|a| a.actor_id.to_string()) - .collect(); - let ids3: HashSet = page3 - .actors - .iter() - .map(|a| a.actor_id.to_string()) - .collect(); - - assert!( - ids1.is_disjoint(&ids2), - "Page 1 and 2 should have no duplicates" - ); - assert!( - ids1.is_disjoint(&ids3), - "Page 1 and 3 should have no duplicates" - ); - assert!( - ids2.is_disjoint(&ids3), - "Page 2 and 3 should have no duplicates" - ); - - // Verify all actors are returned across all pages - let mut all_returned_ids = ids1; - all_returned_ids.extend(ids2); - all_returned_ids.extend(ids3); - - assert_eq!( - all_returned_ids.len(), - 5, - "All 5 actors should be returned across pages" - ); - for actor_id in &actor_ids { - assert!( - all_returned_ids.contains(&actor_id.to_string()), - "Actor {} should be in results", - actor_id - ); - } - }); -} - -#[test] -fn list_cursor_filters_by_timestamp() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - - let name = "timestamp-filter-test"; + .expect("failed to create actor2"); - // Create 3 actors - for i in 0..3 { - common::api::public::actors_create( + // List with key1 - should find actor1 + let response = common::api::public::actors_list( ctx.leader_dc().guard_port(), - common::api_types::actors::create::CreateQuery { + common::api_types::actors::list::ListQuery { namespace: namespace.clone(), - }, - common::api_types::actors::create::CreateRequest { - datacenter: None, - name: name.to_string(), - key: Some(format!("ts-key-{}", i)), - input: None, - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, + name: Some(name.to_string()), + key: Some("key1".to_string()), + actor_ids: None, + actor_id: vec![], + include_destroyed: None, + limit: None, + cursor: None, }, ) .await - .expect("failed to create actor"); - } - - // Get all actors to find a middle timestamp - let all_actors = common::api::public::actors_list( - ctx.leader_dc().guard_port(), - common::api_types::actors::list::ListQuery { - namespace: namespace.clone(), - name: Some(name.to_string()), - key: None, - actor_ids: None, - actor_id: vec![], - include_destroyed: None, - limit: None, - cursor: None, - }, - ) - .await - .expect("failed to list all actors"); - - assert_eq!(all_actors.actors.len(), 3, "Should have 3 actors"); - - // Use the first actor's timestamp as cursor (should filter out that actor and newer) - let cursor = all_actors.actors[0].create_ts.to_string(); - - // List with cursor - let filtered = common::api::public::actors_list( - ctx.leader_dc().guard_port(), - common::api_types::actors::list::ListQuery { - namespace: namespace.clone(), - name: Some(name.to_string()), - key: None, - actor_ids: None, - actor_id: vec![], - include_destroyed: None, - limit: None, - cursor: Some(cursor.clone()), - }, - ) - .await - .expect("failed to list with cursor"); - - // Should return only actors older than the cursor timestamp - assert!( - filtered.actors.len() < 3, - "Cursor should filter out some actors" - ); - - // Verify all returned actors have timestamps less than cursor - let cursor_ts: i64 = cursor.parse().expect("cursor should be valid i64"); - for actor in &filtered.actors { - assert!( - actor.create_ts < cursor_ts, - "Actor timestamp {} should be less than cursor {}", - actor.create_ts, - cursor_ts - ); - } - }); + .expect("failed to list actors"); + + assert_eq!(response.actors.len(), 1, "Should return 1 actor"); + assert_eq!(response.actors[0].actor_id.to_string(), actor_id1); + }, + ); } #[test] -fn list_cursor_with_exact_timestamp_boundary() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; +fn list_with_include_destroyed_false() { + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - let name = "boundary-test"; + let name = "destroyed-test"; - // Create 3 actors - for i in 0..3 { - common::api::public::actors_create( + // Create and destroy an actor + let res1 = common::api::public::actors_create( ctx.leader_dc().guard_port(), common::api_types::actors::create::CreateQuery { namespace: namespace.clone(), @@ -1372,7 +344,7 @@ fn list_cursor_with_exact_timestamp_boundary() { common::api_types::actors::create::CreateRequest { datacenter: None, name: name.to_string(), - key: Some(format!("boundary-key-{}", i)), + key: Some("destroyed-key".to_string()), input: None, runner_name_selector: common::TEST_RUNNER_NAME.to_string(), crash_policy: rivet_types::actors::CrashPolicy::Sleep, @@ -1380,69 +352,22 @@ fn list_cursor_with_exact_timestamp_boundary() { ) .await .expect("failed to create actor"); - } - - // Get first page with limit=1 - let page1 = common::api::public::actors_list( - ctx.leader_dc().guard_port(), - common::api_types::actors::list::ListQuery { - namespace: namespace.clone(), - name: Some(name.to_string()), - key: None, - actor_ids: None, - actor_id: vec![], - include_destroyed: None, - limit: Some(1), - cursor: None, - }, - ) - .await - .expect("failed to list page 1"); - - assert_eq!(page1.actors.len(), 1, "Page 1 should have 1 actor"); - let first_actor_id = page1.actors[0].actor_id.to_string(); - - // Get second page using cursor - let page2 = common::api::public::actors_list( - ctx.leader_dc().guard_port(), - common::api_types::actors::list::ListQuery { - namespace: namespace.clone(), - name: Some(name.to_string()), - key: None, - actor_ids: None, - actor_id: vec![], - include_destroyed: None, - limit: None, - cursor: page1.pagination.cursor.clone(), - }, - ) - .await - .expect("failed to list page 2"); - - // Verify first actor is NOT in page 2 (exact boundary excluded) - let page2_ids: HashSet = page2 - .actors - .iter() - .map(|a| a.actor_id.to_string()) - .collect(); - assert!( - !page2_ids.contains(&first_actor_id), - "Actor with exact cursor timestamp should be excluded" - ); - }); -} - -#[test] -fn list_cursor_empty_results_when_no_more_actors() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + let destroyed_actor_id = res1.actor.actor_id; - let name = "empty-cursor-test"; + common::api::public::actors_delete( + ctx.leader_dc().guard_port(), + common::api_types::actors::delete::DeletePath { + actor_id: destroyed_actor_id, + }, + common::api_types::actors::delete::DeleteQuery { + namespace: namespace.clone(), + }, + ) + .await + .expect("failed to delete actor"); - // Create 2 actors - for i in 0..2 { - common::api::public::actors_create( + // Create an active actor + let res2 = common::api::public::actors_create( ctx.leader_dc().guard_port(), common::api_types::actors::create::CreateQuery { namespace: namespace.clone(), @@ -1450,7 +375,7 @@ fn list_cursor_empty_results_when_no_more_actors() { common::api_types::actors::create::CreateRequest { datacenter: None, name: name.to_string(), - key: Some(format!("empty-key-{}", i)), + key: Some("active-key".to_string()), input: None, runner_name_selector: common::TEST_RUNNER_NAME.to_string(), crash_policy: rivet_types::actors::CrashPolicy::Sleep, @@ -1458,30 +383,10 @@ fn list_cursor_empty_results_when_no_more_actors() { ) .await .expect("failed to create actor"); - } - - // List all actors - let all_actors = common::api::public::actors_list( - ctx.leader_dc().guard_port(), - common::api_types::actors::list::ListQuery { - namespace: namespace.clone(), - name: Some(name.to_string()), - key: None, - actor_ids: None, - actor_id: vec![], - include_destroyed: None, - limit: Some(10), - cursor: None, - }, - ) - .await - .expect("failed to list all actors"); - - assert_eq!(all_actors.actors.len(), 2, "Should have 2 actors"); - - // Use cursor to fetch next page (should be empty) - if let Some(cursor) = all_actors.pagination.cursor { - let next_page = common::api::public::actors_list( + let active_actor_id = res2.actor.actor_id.to_string(); + + // List without include_destroyed (default false) + let response = common::api::public::actors_list( ctx.leader_dc().guard_port(), common::api_types::actors::list::ListQuery { namespace: namespace.clone(), @@ -1489,78 +394,32 @@ fn list_cursor_empty_results_when_no_more_actors() { key: None, actor_ids: None, actor_id: vec![], - include_destroyed: None, - limit: Some(10), - cursor: Some(cursor), + include_destroyed: Some(false), + limit: None, + cursor: None, }, ) .await - .expect("failed to list next page"); + .expect("failed to list actors"); - assert_eq!( - next_page.actors.len(), - 0, - "Should return empty results when no more actors" - ); - assert!( - next_page.pagination.cursor.is_none(), - "Should not return cursor when no more results" - ); - } - }); + assert_eq!(response.actors.len(), 1, "Should only return active actor"); + assert_eq!(response.actors[0].actor_id.to_string(), active_actor_id); + }, + ); } #[test] -fn list_invalid_cursor_format() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - - let name = "invalid-cursor-test"; - - // Try to list with invalid cursor (non-numeric string) - let res = common::api::public::actors_list( - ctx.leader_dc().guard_port(), - common::api_types::actors::list::ListQuery { - namespace: namespace.clone(), - name: Some(name.to_string()), - key: None, - actor_ids: None, - actor_id: vec![], - include_destroyed: None, - limit: None, - cursor: Some("not-a-number".to_string()), - }, - ) - .await; - - // Should fail with parse error - assert!( - res.is_err(), - "Should return error for invalid cursor format" - ); - }); -} +fn list_with_include_destroyed_true() { + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; -// Broken legacy Pegboard Runner multi-DC coverage: full `runner::` sweep times -// out with `test timed out: Elapsed(())`. -#[test] -fn list_cursor_across_datacenters() { - common::run(common::TestOpts::new(2).with_timeout(45), |ctx| async move { - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - let _runner_dc2 = common::setup_envoy_on_dc( - ctx.get_dc(2), - &namespace, - vec!["multi-dc-cursor-test".to_string()], - ) - .await; - - let name = "multi-dc-cursor-test"; - - // Create actors in both DC1 and DC2 - for i in 0..3 { - common::api::public::actors_create( + let name = "destroyed-included"; + + // Create and destroy an actor + let res1 = common::api::public::actors_create( ctx.leader_dc().guard_port(), common::api_types::actors::create::CreateQuery { namespace: namespace.clone(), @@ -1568,60 +427,49 @@ fn list_cursor_across_datacenters() { common::api_types::actors::create::CreateRequest { datacenter: None, name: name.to_string(), - key: Some(format!("dc1-cursor-key-{}", i)), + key: Some("destroyed-key".to_string()), input: None, runner_name_selector: common::TEST_RUNNER_NAME.to_string(), crash_policy: rivet_types::actors::CrashPolicy::Sleep, }, ) .await - .expect("failed to create actor in DC1"); - } + .expect("failed to create actor"); + let destroyed_actor_id = res1.actor.actor_id.to_string(); - for i in 0..3 { - common::api::public::actors_create( - ctx.get_dc(2).guard_port(), + common::api::public::actors_delete( + ctx.leader_dc().guard_port(), + common::api_types::actors::delete::DeletePath { + actor_id: res1.actor.actor_id, + }, + common::api_types::actors::delete::DeleteQuery { + namespace: namespace.clone(), + }, + ) + .await + .expect("failed to delete actor"); + + // Create an active actor + let res2 = common::api::public::actors_create( + ctx.leader_dc().guard_port(), common::api_types::actors::create::CreateQuery { namespace: namespace.clone(), }, common::api_types::actors::create::CreateRequest { - datacenter: Some("dc-2".to_string()), + datacenter: None, name: name.to_string(), - key: Some(format!("dc2-cursor-key-{}", i)), + key: Some("active-key".to_string()), input: None, runner_name_selector: common::TEST_RUNNER_NAME.to_string(), crash_policy: rivet_types::actors::CrashPolicy::Sleep, }, ) .await - .expect("failed to create actor in DC2"); - } - - // Fetch first page with limit=3 - let page1 = common::api::public::actors_list( - ctx.leader_dc().guard_port(), - common::api_types::actors::list::ListQuery { - namespace: namespace.clone(), - name: Some(name.to_string()), - key: None, - actor_ids: None, - actor_id: vec![], - include_destroyed: None, - limit: Some(3), - cursor: None, - }, - ) - .await - .expect("failed to list page 1"); - - assert!( - page1.actors.len() <= 3, - "Page 1 should have at most 3 actors" - ); - - // Fetch second page using cursor - if let Some(cursor) = page1.pagination.cursor { - let page2 = common::api::public::actors_list( + .expect("failed to create actor"); + let active_actor_id = res2.actor.actor_id.to_string(); + + // List with include_destroyed=true + let response = common::api::public::actors_list( ctx.leader_dc().guard_port(), common::api_types::actors::list::ListQuery { namespace: namespace.clone(), @@ -1629,171 +477,1405 @@ fn list_cursor_across_datacenters() { key: None, actor_ids: None, actor_id: vec![], - include_destroyed: None, - limit: Some(3), - cursor: Some(cursor), + include_destroyed: Some(true), + limit: None, + cursor: None, }, ) .await - .expect("failed to list page 2"); + .expect("failed to list actors"); - // Verify no duplicates between pages - let ids1: HashSet = page1 - .actors - .iter() - .map(|a| a.actor_id.to_string()) - .collect(); - let ids2: HashSet = page2 + assert_eq!( + response.actors.len(), + 2, + "Should return both active and destroyed actors" + ); + + // Verify both actors are in results + let returned_ids: HashSet = response .actors .iter() .map(|a| a.actor_id.to_string()) .collect(); - - assert!( - ids1.is_disjoint(&ids2), - "Pages should have no duplicate actors across DCs" - ); - } - }); + assert!(returned_ids.contains(&active_actor_id)); + assert!(returned_ids.contains(&destroyed_actor_id)); + }, + ); } +// MARK: List by Actor IDs + // Broken legacy Pegboard Runner coverage: full `runner::` sweep times out with // `test timed out: Elapsed(())`. #[test] -fn list_actor_ids_with_cursor_pagination() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; - - let name = "actor-ids-cursor-test"; - - // Create 5 actors - let actor_ids = - common::bulk_create_actors(ctx.leader_dc().guard_port(), &namespace, name, 5).await; - - // List by actor_ids with limit=2 - let page1 = common::api::public::actors_list( - ctx.leader_dc().guard_port(), - common::api_types::actors::list::ListQuery { - namespace: namespace.clone(), - name: None, - key: None, - actor_id: actor_ids.clone(), - actor_ids: None, - include_destroyed: None, - limit: Some(2), - cursor: None, - }, - ) - .await - .expect("failed to list page 1"); - - assert_eq!( - page1.actors.len(), - 2, - "Page 1 should return exactly 2 actors" - ); - assert!( - page1.pagination.cursor.is_some(), - "Page 1 should return a cursor" - ); - - // Fetch second page using cursor - let page2 = common::api::public::actors_list( - ctx.leader_dc().guard_port(), - common::api_types::actors::list::ListQuery { - namespace: namespace.clone(), - name: None, - key: None, - actor_id: actor_ids.clone(), - actor_ids: None, - include_destroyed: None, - limit: Some(2), - cursor: page1.pagination.cursor.clone(), - }, - ) - .await - .expect("failed to list page 2"); - - assert_eq!( - page2.actors.len(), - 2, - "Page 2 should return exactly 2 actors" - ); - assert!( - page2.pagination.cursor.is_some(), - "Page 2 should return a cursor" - ); - - // Fetch third page using cursor - let page3 = common::api::public::actors_list( - ctx.leader_dc().guard_port(), - common::api_types::actors::list::ListQuery { - namespace: namespace.clone(), - name: None, - key: None, - actor_id: actor_ids.clone(), - actor_ids: None, - include_destroyed: None, - limit: Some(2), - cursor: page2.pagination.cursor.clone(), - }, - ) - .await - .expect("failed to list page 3"); - - assert_eq!( - page3.actors.len(), - 1, - "Page 3 should return 1 remaining actor" - ); - - // Verify no duplicates across pages - let ids1: HashSet = page1 - .actors - .iter() - .map(|a| a.actor_id.to_string()) - .collect(); - let ids2: HashSet = page2 - .actors - .iter() - .map(|a| a.actor_id.to_string()) - .collect(); - let ids3: HashSet = page3 - .actors - .iter() - .map(|a| a.actor_id.to_string()) - .collect(); - - assert!( - ids1.is_disjoint(&ids2), - "Page 1 and 2 should have no duplicates" - ); - assert!( - ids1.is_disjoint(&ids3), - "Page 1 and 3 should have no duplicates" - ); - assert!( - ids2.is_disjoint(&ids3), - "Page 2 and 3 should have no duplicates" - ); - - // Verify all actors are returned across all pages - let mut all_returned_ids = ids1; - all_returned_ids.extend(ids2); - all_returned_ids.extend(ids3); - - assert_eq!( - all_returned_ids.len(), - 5, - "All 5 actors should be returned across pages" - ); - for actor_id in &actor_ids { +fn list_specific_actors_by_ids() { + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + + // Create multiple actors + let actor_ids = common::bulk_create_actors( + ctx.leader_dc().guard_port(), + &namespace, + "id-list-test", + 5, + ) + .await; + + // Select specific actors to list + let selected_ids = vec![ + actor_ids[0].clone(), + actor_ids[2].clone(), + actor_ids[4].clone(), + ]; + + // List by actor IDs (comma-separated string) + let response = common::api::public::actors_list( + ctx.leader_dc().guard_port(), + common::api_types::actors::list::ListQuery { + namespace: namespace.clone(), + name: None, + key: None, + actor_id: selected_ids.clone(), + actor_ids: None, + include_destroyed: None, + limit: None, + cursor: None, + }, + ) + .await + .expect("failed to list actors"); + + assert_eq!( + response.actors.len(), + 3, + "Should return exactly the requested actors" + ); + + // Verify correct actors returned + let returned_ids: HashSet = response + .actors + .iter() + .map(|a| a.actor_id.to_string()) + .collect(); + for id in &selected_ids { + assert!( + returned_ids.contains(&id.to_string()), + "Actor {} should be in results", + id + ); + } + }, + ); +} + +#[test] +fn list_actors_from_multiple_datacenters() { + common::run( + common::TestOpts::new(2).with_timeout(45), + |ctx| async move { + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + let _runner_dc2 = common::setup_envoy_on_dc( + ctx.get_dc(2), + &namespace, + vec!["multi-dc-actor".to_string()], + ) + .await; + + // Create actors in different DCs + let res1 = common::api::public::actors_create( + ctx.leader_dc().guard_port(), + common::api_types::actors::create::CreateQuery { + namespace: namespace.clone(), + }, + common::api_types::actors::create::CreateRequest { + datacenter: None, + name: "multi-dc-actor".to_string(), + key: Some("dc1-key".to_string()), + input: None, + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await + .expect("failed to create actor in DC1"); + let actor_id_dc1 = res1.actor.actor_id; + + let res2 = common::api::public::actors_create( + ctx.leader_dc().guard_port(), + common::api_types::actors::create::CreateQuery { + namespace: namespace.clone(), + }, + common::api_types::actors::create::CreateRequest { + datacenter: Some("dc-2".to_string()), + name: "multi-dc-actor".to_string(), + key: Some("dc2-key".to_string()), + input: None, + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await + .expect("failed to create actor in DC2"); + let actor_id_dc2 = res2.actor.actor_id; + + // List by actor IDs - should fetch from both DCs + let response = common::api::public::actors_list( + ctx.leader_dc().guard_port(), + common::api_types::actors::list::ListQuery { + namespace: namespace.clone(), + name: None, + key: None, + actor_id: vec![actor_id_dc1, actor_id_dc2], + actor_ids: None, + include_destroyed: None, + limit: None, + cursor: None, + }, + ) + .await + .expect("failed to list actors"); + + assert_eq!( + response.actors.len(), + 2, + "Should return actors from both DCs" + ); + }, + ); +} + +// MARK: Error cases + +#[test] +fn list_with_non_existent_namespace() { + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + // Try to list with non-existent namespace + let res = common::api::public::actors_list( + ctx.leader_dc().guard_port(), + common::api_types::actors::list::ListQuery { + namespace: "non-existent-namespace".to_string(), + name: Some("test-actor".to_string()), + key: None, + actor_ids: None, + actor_id: vec![], + include_destroyed: None, + limit: None, + cursor: None, + }, + ) + .await; + + // Should fail with namespace not found + assert!(res.is_err(), "Should fail with non-existent namespace"); + }, + ); +} + +#[test] +fn list_with_key_but_no_name() { + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + + // Try to list with key but no name (validation error) + let res = common::api::public::actors_list( + ctx.leader_dc().guard_port(), + common::api_types::actors::list::ListQuery { + namespace: namespace.clone(), + name: None, + key: Some("key1".to_string()), + actor_ids: None, + actor_id: vec![], + include_destroyed: None, + limit: None, + cursor: None, + }, + ) + .await; + + // Should fail with validation error + assert!(res.is_err(), "Should return error for key without name"); + }, + ); +} + +#[test] +fn list_with_more_than_32_actor_ids() { + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + + // Try to list with more than 32 actor IDs + let actor_ids: Vec = (0..33) + .map(|_| rivet_util::Id::new_v1(ctx.leader_dc().config.dc_label())) + .collect(); + + let res = common::api::public::actors_list( + ctx.leader_dc().guard_port(), + common::api_types::actors::list::ListQuery { + namespace: namespace.clone(), + name: None, + key: None, + actor_id: actor_ids, + actor_ids: None, + include_destroyed: None, + limit: None, + cursor: None, + }, + ) + .await; + + // Should fail with validation error + assert!(res.is_err(), "Should return error for too many actor IDs"); + }, + ); +} + +#[test] +fn list_without_name_when_not_using_actor_ids() { + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + + // Try to list without name or actor_ids + let res = common::api::public::actors_list( + ctx.leader_dc().guard_port(), + common::api_types::actors::list::ListQuery { + namespace: namespace.clone(), + name: None, + key: None, + actor_ids: None, + actor_id: vec![], + include_destroyed: None, + limit: None, + cursor: None, + }, + ) + .await; + + // Should fail with validation error + assert!( + res.is_err(), + "Should return error when neither name nor actor_ids provided" + ); + }, + ); +} + +// MARK: Pagination and Sorting + +#[test] +fn verify_sorting_by_create_ts_descending() { + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + + let name = "sorted-actor"; + + // Create actors with slight delays to ensure different timestamps + let mut actor_ids = Vec::new(); + for i in 0..3 { + let res = common::api::public::actors_create( + ctx.leader_dc().guard_port(), + common::api_types::actors::create::CreateQuery { + namespace: namespace.clone(), + }, + common::api_types::actors::create::CreateRequest { + datacenter: None, + name: name.to_string(), + key: Some(format!("key-{}", i)), + input: None, + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await + .expect("failed to create actor"); + actor_ids.push(res.actor.actor_id.to_string()); + } + + // List actors + let response = common::api::public::actors_list( + ctx.leader_dc().guard_port(), + common::api_types::actors::list::ListQuery { + namespace: namespace.clone(), + name: Some(name.to_string()), + key: None, + actor_ids: None, + actor_id: vec![], + include_destroyed: None, + limit: None, + cursor: None, + }, + ) + .await + .expect("failed to list actors"); + + // Verify order - newest first (descending by create_ts) + for i in 0..response.actors.len() { + assert_eq!( + response.actors[i].actor_id.to_string(), + actor_ids[actor_ids.len() - 1 - i], + "Actors should be sorted by create_ts descending" + ); + } + }, + ); +} + +// MARK: Cross-datacenter + +// Broken legacy Pegboard Runner multi-DC coverage: full `runner::` sweep times +// out with `test timed out: Elapsed(())`. +#[test] +fn list_aggregates_results_from_all_datacenters() { + common::run( + common::TestOpts::new(2).with_timeout(45), + |ctx| async move { + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + let _runner_dc2 = common::setup_envoy_on_dc( + ctx.get_dc(2), + &namespace, + vec!["fanout-test-actor".to_string()], + ) + .await; + + let name = "fanout-test-actor"; + + // Create actors in both DCs + let res1 = common::api::public::actors_create( + ctx.leader_dc().guard_port(), + common::api_types::actors::create::CreateQuery { + namespace: namespace.clone(), + }, + common::api_types::actors::create::CreateRequest { + datacenter: None, + name: name.to_string(), + key: Some("dc1-key".to_string()), + input: None, + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await + .expect("failed to create actor in DC1"); + let actor_id_dc1 = res1.actor.actor_id.to_string(); + + let res2 = common::api::public::actors_create( + ctx.get_dc(2).guard_port(), + common::api_types::actors::create::CreateQuery { + namespace: namespace.clone(), + }, + common::api_types::actors::create::CreateRequest { + datacenter: Some("dc-2".to_string()), + name: name.to_string(), + key: Some("dc2-key".to_string()), + input: None, + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await + .expect("failed to create actor in DC2"); + let actor_id_dc2 = res2.actor.actor_id.to_string(); + + // List by name - should fanout to all DCs + let response = common::api::public::actors_list( + ctx.leader_dc().guard_port(), + common::api_types::actors::list::ListQuery { + namespace: namespace.clone(), + name: Some(name.to_string()), + key: None, + actor_ids: None, + actor_id: vec![], + include_destroyed: None, + limit: None, + cursor: None, + }, + ) + .await + .expect("failed to list actors"); + + assert_eq!( + response.actors.len(), + 2, + "Should return actors from both DCs" + ); + + // Verify both actors are present + let returned_ids: HashSet = response + .actors + .iter() + .map(|a| a.actor_id.to_string()) + .collect(); + assert!(returned_ids.contains(&actor_id_dc1)); + assert!(returned_ids.contains(&actor_id_dc2)); + }, + ); +} + +// MARK: Edge cases + +#[test] +fn list_with_exactly_32_actor_ids() { + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + + // Create exactly 32 actor IDs (boundary condition) + let actor_ids: Vec = (0..32) + .map(|_| rivet_util::Id::new_v1(ctx.leader_dc().config.dc_label())) + .collect(); + + // Should succeed with exactly 32 IDs + let response = common::api::public::actors_list( + ctx.leader_dc().guard_port(), + common::api_types::actors::list::ListQuery { + namespace: namespace.clone(), + name: None, + key: None, + actor_id: actor_ids, + actor_ids: None, + include_destroyed: None, + limit: None, + cursor: None, + }, + ) + .await + .expect("should succeed with exactly 32 actor IDs"); + + // Since these are fake IDs, we expect 0 results, but no error + assert_eq!( + response.actors.len(), + 0, + "Fake IDs should return empty results" + ); + }, + ); +} + +#[test] +fn list_by_key_with_include_destroyed_true() { + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + + let name = "key-destroyed-test"; + let key = "test-key"; + + // Create and destroy an actor with a key + let res1 = common::api::public::actors_create( + ctx.leader_dc().guard_port(), + common::api_types::actors::create::CreateQuery { + namespace: namespace.clone(), + }, + common::api_types::actors::create::CreateRequest { + datacenter: None, + name: name.to_string(), + key: Some(key.to_string()), + input: None, + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await + .expect("failed to create actor"); + let destroyed_actor_id = res1.actor.actor_id.to_string(); + + common::api::public::actors_delete( + ctx.leader_dc().guard_port(), + common::api_types::actors::delete::DeletePath { + actor_id: res1.actor.actor_id, + }, + common::api_types::actors::delete::DeleteQuery { + namespace: namespace.clone(), + }, + ) + .await + .expect("failed to delete actor"); + + // Create a new actor with the same key + let res2 = common::api::public::actors_create( + ctx.leader_dc().guard_port(), + common::api_types::actors::create::CreateQuery { + namespace: namespace.clone(), + }, + common::api_types::actors::create::CreateRequest { + datacenter: None, + name: name.to_string(), + key: Some(key.to_string()), + input: None, + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await + .expect("failed to create actor"); + let active_actor_id = res2.actor.actor_id.to_string(); + + // List by key with include_destroyed=true + // This should use the fanout path, not the optimized key path + let response = common::api::public::actors_list( + ctx.leader_dc().guard_port(), + common::api_types::actors::list::ListQuery { + namespace: namespace.clone(), + name: Some(name.to_string()), + key: Some(key.to_string()), + actor_ids: None, + actor_id: vec![], + include_destroyed: Some(true), + limit: None, + cursor: None, + }, + ) + .await + .expect("failed to list actors"); + + // Should return both actors (destroyed and active) + assert_eq!( + response.actors.len(), + 2, + "Should return both destroyed and active actors with same key" + ); + + let returned_ids: HashSet = response + .actors + .iter() + .map(|a| a.actor_id.to_string()) + .collect(); + assert!(returned_ids.contains(&destroyed_actor_id)); + assert!(returned_ids.contains(&active_actor_id)); + }, + ); +} + +#[test] +fn list_default_limit_100() { + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + + let name = "limit-test"; + + // Create 105 actors to test the default limit of 100 + let actor_ids = + common::bulk_create_actors(ctx.leader_dc().guard_port(), &namespace, name, 105) + .await; + + assert_eq!(actor_ids.len(), 105, "Should have created 105 actors"); + + // List without specifying limit - should use default limit of 100 + let response = common::api::public::actors_list( + ctx.leader_dc().guard_port(), + common::api_types::actors::list::ListQuery { + namespace: namespace.clone(), + name: Some(name.to_string()), + key: None, + actor_ids: None, + actor_id: vec![], + include_destroyed: None, + limit: None, // No limit specified - should default to 100 + cursor: None, + }, + ) + .await + .expect("failed to list actors"); + + // Should return exactly 100 actors due to default limit + assert_eq!( + response.actors.len(), + 100, + "Should return exactly 100 actors when default limit is applied" + ); + + // Verify cursor exists since there are more results + assert!( + response.pagination.cursor.is_some(), + "Cursor should exist when there are more results beyond the limit" + ); + }, + ); +} + +// Broken legacy Pegboard Runner coverage: full `runner::` sweep times out with +// `test timed out: Elapsed(())`. +#[test] +fn list_with_invalid_actor_id_format_in_comma_list() { + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + + // Create a valid actor + let res = common::api::public::actors_create( + ctx.leader_dc().guard_port(), + common::api_types::actors::create::CreateQuery { + namespace: namespace.clone(), + }, + common::api_types::actors::create::CreateRequest { + datacenter: None, + name: "test-actor".to_string(), + key: Some("test-key".to_string()), + input: None, + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await + .expect("failed to create actor"); + let valid_actor_id = res.actor.actor_id.to_string(); + + // Mix valid and invalid IDs in the comma-separated list + let mixed_ids = vec![ + valid_actor_id.clone(), + "invalid-uuid".to_string(), + "not-a-uuid".to_string(), + valid_actor_id.clone(), + ]; + + let response = common::api::public::actors_list( + ctx.leader_dc().guard_port(), + common::api_types::actors::list::ListQuery { + namespace: namespace.clone(), + name: None, + key: None, + actor_id: vec![], + actor_ids: Some(mixed_ids.join(",")), + include_destroyed: None, + limit: None, + cursor: None, + }, + ) + .await + .expect("should filter out invalid IDs gracefully"); + + // Should return only the valid actor (twice) (parsed IDs are filtered) + assert_eq!( + response.actors.len(), + 2, + "Should filter out invalid IDs and return only valid ones" + ); + assert_eq!(response.actors[0].actor_id.to_string(), valid_actor_id); + }, + ); +} + +// MARK: Cursor pagination + +#[test] +fn list_with_cursor_pagination() { + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + + let name = "cursor-test-actor"; + + // Create 5 actors with same name + let mut actor_ids = Vec::new(); + for i in 0..5 { + let res = common::api::public::actors_create( + ctx.leader_dc().guard_port(), + common::api_types::actors::create::CreateQuery { + namespace: namespace.clone(), + }, + common::api_types::actors::create::CreateRequest { + datacenter: None, + name: name.to_string(), + key: Some(format!("cursor-key-{}", i)), + input: None, + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await + .expect("failed to create actor"); + actor_ids.push(res.actor.actor_id.to_string()); + } + + // Fetch first page with limit=2 + let page1 = common::api::public::actors_list( + ctx.leader_dc().guard_port(), + common::api_types::actors::list::ListQuery { + namespace: namespace.clone(), + name: Some(name.to_string()), + key: None, + actor_ids: None, + actor_id: vec![], + include_destroyed: None, + limit: Some(2), + cursor: None, + }, + ) + .await + .expect("failed to list page 1"); + + assert_eq!(page1.actors.len(), 2, "Page 1 should have 2 actors"); + assert!( + page1.pagination.cursor.is_some(), + "Page 1 should return a cursor" + ); + + // Fetch second page using cursor + let page2 = common::api::public::actors_list( + ctx.leader_dc().guard_port(), + common::api_types::actors::list::ListQuery { + namespace: namespace.clone(), + name: Some(name.to_string()), + key: None, + actor_ids: None, + actor_id: vec![], + include_destroyed: None, + limit: Some(2), + cursor: page1.pagination.cursor.clone(), + }, + ) + .await + .expect("failed to list page 2"); + + assert_eq!(page2.actors.len(), 2, "Page 2 should have 2 actors"); + assert!( + page2.pagination.cursor.is_some(), + "Page 2 should return a cursor" + ); + + // Fetch third page using cursor + let page3 = common::api::public::actors_list( + ctx.leader_dc().guard_port(), + common::api_types::actors::list::ListQuery { + namespace: namespace.clone(), + name: Some(name.to_string()), + key: None, + actor_ids: None, + actor_id: vec![], + include_destroyed: None, + limit: Some(2), + cursor: page2.pagination.cursor.clone(), + }, + ) + .await + .expect("failed to list page 3"); + + assert_eq!(page3.actors.len(), 1, "Page 3 should have 1 actor"); + + // Verify no duplicates across pages + let ids1: HashSet = page1 + .actors + .iter() + .map(|a| a.actor_id.to_string()) + .collect(); + let ids2: HashSet = page2 + .actors + .iter() + .map(|a| a.actor_id.to_string()) + .collect(); + let ids3: HashSet = page3 + .actors + .iter() + .map(|a| a.actor_id.to_string()) + .collect(); + + assert!( + ids1.is_disjoint(&ids2), + "Page 1 and 2 should have no duplicates" + ); + assert!( + ids1.is_disjoint(&ids3), + "Page 1 and 3 should have no duplicates" + ); + assert!( + ids2.is_disjoint(&ids3), + "Page 2 and 3 should have no duplicates" + ); + + // Verify all actors are returned across all pages + let mut all_returned_ids = ids1; + all_returned_ids.extend(ids2); + all_returned_ids.extend(ids3); + + assert_eq!( + all_returned_ids.len(), + 5, + "All 5 actors should be returned across pages" + ); + for actor_id in &actor_ids { + assert!( + all_returned_ids.contains(&actor_id.to_string()), + "Actor {} should be in results", + actor_id + ); + } + }, + ); +} + +#[test] +fn list_cursor_filters_by_timestamp() { + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + + let name = "timestamp-filter-test"; + + // Create 3 actors + for i in 0..3 { + common::api::public::actors_create( + ctx.leader_dc().guard_port(), + common::api_types::actors::create::CreateQuery { + namespace: namespace.clone(), + }, + common::api_types::actors::create::CreateRequest { + datacenter: None, + name: name.to_string(), + key: Some(format!("ts-key-{}", i)), + input: None, + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await + .expect("failed to create actor"); + } + + // Get all actors to find a middle timestamp + let all_actors = common::api::public::actors_list( + ctx.leader_dc().guard_port(), + common::api_types::actors::list::ListQuery { + namespace: namespace.clone(), + name: Some(name.to_string()), + key: None, + actor_ids: None, + actor_id: vec![], + include_destroyed: None, + limit: None, + cursor: None, + }, + ) + .await + .expect("failed to list all actors"); + + assert_eq!(all_actors.actors.len(), 3, "Should have 3 actors"); + + // Use the first actor's timestamp as cursor (should filter out that actor and newer) + let cursor = all_actors.actors[0].create_ts.to_string(); + + // List with cursor + let filtered = common::api::public::actors_list( + ctx.leader_dc().guard_port(), + common::api_types::actors::list::ListQuery { + namespace: namespace.clone(), + name: Some(name.to_string()), + key: None, + actor_ids: None, + actor_id: vec![], + include_destroyed: None, + limit: None, + cursor: Some(cursor.clone()), + }, + ) + .await + .expect("failed to list with cursor"); + + // Should return only actors older than the cursor timestamp + assert!( + filtered.actors.len() < 3, + "Cursor should filter out some actors" + ); + + // Verify all returned actors have timestamps less than cursor + let cursor_ts: i64 = cursor.parse().expect("cursor should be valid i64"); + for actor in &filtered.actors { + assert!( + actor.create_ts < cursor_ts, + "Actor timestamp {} should be less than cursor {}", + actor.create_ts, + cursor_ts + ); + } + }, + ); +} + +#[test] +fn list_cursor_with_exact_timestamp_boundary() { + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + + let name = "boundary-test"; + + // Create 3 actors + for i in 0..3 { + common::api::public::actors_create( + ctx.leader_dc().guard_port(), + common::api_types::actors::create::CreateQuery { + namespace: namespace.clone(), + }, + common::api_types::actors::create::CreateRequest { + datacenter: None, + name: name.to_string(), + key: Some(format!("boundary-key-{}", i)), + input: None, + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await + .expect("failed to create actor"); + } + + // Get first page with limit=1 + let page1 = common::api::public::actors_list( + ctx.leader_dc().guard_port(), + common::api_types::actors::list::ListQuery { + namespace: namespace.clone(), + name: Some(name.to_string()), + key: None, + actor_ids: None, + actor_id: vec![], + include_destroyed: None, + limit: Some(1), + cursor: None, + }, + ) + .await + .expect("failed to list page 1"); + + assert_eq!(page1.actors.len(), 1, "Page 1 should have 1 actor"); + let first_actor_id = page1.actors[0].actor_id.to_string(); + + // Get second page using cursor + let page2 = common::api::public::actors_list( + ctx.leader_dc().guard_port(), + common::api_types::actors::list::ListQuery { + namespace: namespace.clone(), + name: Some(name.to_string()), + key: None, + actor_ids: None, + actor_id: vec![], + include_destroyed: None, + limit: None, + cursor: page1.pagination.cursor.clone(), + }, + ) + .await + .expect("failed to list page 2"); + + // Verify first actor is NOT in page 2 (exact boundary excluded) + let page2_ids: HashSet = page2 + .actors + .iter() + .map(|a| a.actor_id.to_string()) + .collect(); + assert!( + !page2_ids.contains(&first_actor_id), + "Actor with exact cursor timestamp should be excluded" + ); + }, + ); +} + +#[test] +fn list_cursor_empty_results_when_no_more_actors() { + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + + let name = "empty-cursor-test"; + + // Create 2 actors + for i in 0..2 { + common::api::public::actors_create( + ctx.leader_dc().guard_port(), + common::api_types::actors::create::CreateQuery { + namespace: namespace.clone(), + }, + common::api_types::actors::create::CreateRequest { + datacenter: None, + name: name.to_string(), + key: Some(format!("empty-key-{}", i)), + input: None, + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await + .expect("failed to create actor"); + } + + // List all actors + let all_actors = common::api::public::actors_list( + ctx.leader_dc().guard_port(), + common::api_types::actors::list::ListQuery { + namespace: namespace.clone(), + name: Some(name.to_string()), + key: None, + actor_ids: None, + actor_id: vec![], + include_destroyed: None, + limit: Some(10), + cursor: None, + }, + ) + .await + .expect("failed to list all actors"); + + assert_eq!(all_actors.actors.len(), 2, "Should have 2 actors"); + + // Use cursor to fetch next page (should be empty) + if let Some(cursor) = all_actors.pagination.cursor { + let next_page = common::api::public::actors_list( + ctx.leader_dc().guard_port(), + common::api_types::actors::list::ListQuery { + namespace: namespace.clone(), + name: Some(name.to_string()), + key: None, + actor_ids: None, + actor_id: vec![], + include_destroyed: None, + limit: Some(10), + cursor: Some(cursor), + }, + ) + .await + .expect("failed to list next page"); + + assert_eq!( + next_page.actors.len(), + 0, + "Should return empty results when no more actors" + ); + assert!( + next_page.pagination.cursor.is_none(), + "Should not return cursor when no more results" + ); + } + }, + ); +} + +#[test] +fn list_invalid_cursor_format() { + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + + let name = "invalid-cursor-test"; + + // Try to list with invalid cursor (non-numeric string) + let res = common::api::public::actors_list( + ctx.leader_dc().guard_port(), + common::api_types::actors::list::ListQuery { + namespace: namespace.clone(), + name: Some(name.to_string()), + key: None, + actor_ids: None, + actor_id: vec![], + include_destroyed: None, + limit: None, + cursor: Some("not-a-number".to_string()), + }, + ) + .await; + + // Should fail with parse error + assert!( + res.is_err(), + "Should return error for invalid cursor format" + ); + }, + ); +} + +// Broken legacy Pegboard Runner multi-DC coverage: full `runner::` sweep times +// out with `test timed out: Elapsed(())`. +#[test] +fn list_cursor_across_datacenters() { + common::run( + common::TestOpts::new(2).with_timeout(45), + |ctx| async move { + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + let _runner_dc2 = common::setup_envoy_on_dc( + ctx.get_dc(2), + &namespace, + vec!["multi-dc-cursor-test".to_string()], + ) + .await; + + let name = "multi-dc-cursor-test"; + + // Create actors in both DC1 and DC2 + for i in 0..3 { + common::api::public::actors_create( + ctx.leader_dc().guard_port(), + common::api_types::actors::create::CreateQuery { + namespace: namespace.clone(), + }, + common::api_types::actors::create::CreateRequest { + datacenter: None, + name: name.to_string(), + key: Some(format!("dc1-cursor-key-{}", i)), + input: None, + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await + .expect("failed to create actor in DC1"); + } + + for i in 0..3 { + common::api::public::actors_create( + ctx.get_dc(2).guard_port(), + common::api_types::actors::create::CreateQuery { + namespace: namespace.clone(), + }, + common::api_types::actors::create::CreateRequest { + datacenter: Some("dc-2".to_string()), + name: name.to_string(), + key: Some(format!("dc2-cursor-key-{}", i)), + input: None, + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await + .expect("failed to create actor in DC2"); + } + + // Fetch first page with limit=3 + let page1 = common::api::public::actors_list( + ctx.leader_dc().guard_port(), + common::api_types::actors::list::ListQuery { + namespace: namespace.clone(), + name: Some(name.to_string()), + key: None, + actor_ids: None, + actor_id: vec![], + include_destroyed: None, + limit: Some(3), + cursor: None, + }, + ) + .await + .expect("failed to list page 1"); + + assert!( + page1.actors.len() <= 3, + "Page 1 should have at most 3 actors" + ); + + // Fetch second page using cursor + if let Some(cursor) = page1.pagination.cursor { + let page2 = common::api::public::actors_list( + ctx.leader_dc().guard_port(), + common::api_types::actors::list::ListQuery { + namespace: namespace.clone(), + name: Some(name.to_string()), + key: None, + actor_ids: None, + actor_id: vec![], + include_destroyed: None, + limit: Some(3), + cursor: Some(cursor), + }, + ) + .await + .expect("failed to list page 2"); + + // Verify no duplicates between pages + let ids1: HashSet = page1 + .actors + .iter() + .map(|a| a.actor_id.to_string()) + .collect(); + let ids2: HashSet = page2 + .actors + .iter() + .map(|a| a.actor_id.to_string()) + .collect(); + + assert!( + ids1.is_disjoint(&ids2), + "Pages should have no duplicate actors across DCs" + ); + } + }, + ); +} + +// Broken legacy Pegboard Runner coverage: full `runner::` sweep times out with +// `test timed out: Elapsed(())`. +#[test] +fn list_actor_ids_with_cursor_pagination() { + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy(ctx.leader_dc()).await; + + let name = "actor-ids-cursor-test"; + + // Create 5 actors + let actor_ids = + common::bulk_create_actors(ctx.leader_dc().guard_port(), &namespace, name, 5).await; + + // List by actor_ids with limit=2 + let page1 = common::api::public::actors_list( + ctx.leader_dc().guard_port(), + common::api_types::actors::list::ListQuery { + namespace: namespace.clone(), + name: None, + key: None, + actor_id: actor_ids.clone(), + actor_ids: None, + include_destroyed: None, + limit: Some(2), + cursor: None, + }, + ) + .await + .expect("failed to list page 1"); + + assert_eq!( + page1.actors.len(), + 2, + "Page 1 should return exactly 2 actors" + ); + assert!( + page1.pagination.cursor.is_some(), + "Page 1 should return a cursor" + ); + + // Fetch second page using cursor + let page2 = common::api::public::actors_list( + ctx.leader_dc().guard_port(), + common::api_types::actors::list::ListQuery { + namespace: namespace.clone(), + name: None, + key: None, + actor_id: actor_ids.clone(), + actor_ids: None, + include_destroyed: None, + limit: Some(2), + cursor: page1.pagination.cursor.clone(), + }, + ) + .await + .expect("failed to list page 2"); + + assert_eq!( + page2.actors.len(), + 2, + "Page 2 should return exactly 2 actors" + ); + assert!( + page2.pagination.cursor.is_some(), + "Page 2 should return a cursor" + ); + + // Fetch third page using cursor + let page3 = common::api::public::actors_list( + ctx.leader_dc().guard_port(), + common::api_types::actors::list::ListQuery { + namespace: namespace.clone(), + name: None, + key: None, + actor_id: actor_ids.clone(), + actor_ids: None, + include_destroyed: None, + limit: Some(2), + cursor: page2.pagination.cursor.clone(), + }, + ) + .await + .expect("failed to list page 3"); + + assert_eq!( + page3.actors.len(), + 1, + "Page 3 should return 1 remaining actor" + ); + + // Verify no duplicates across pages + let ids1: HashSet = page1 + .actors + .iter() + .map(|a| a.actor_id.to_string()) + .collect(); + let ids2: HashSet = page2 + .actors + .iter() + .map(|a| a.actor_id.to_string()) + .collect(); + let ids3: HashSet = page3 + .actors + .iter() + .map(|a| a.actor_id.to_string()) + .collect(); + + assert!( + ids1.is_disjoint(&ids2), + "Page 1 and 2 should have no duplicates" + ); + assert!( + ids1.is_disjoint(&ids3), + "Page 1 and 3 should have no duplicates" + ); assert!( - all_returned_ids.contains(&actor_id.to_string()), - "Actor {} should be in results", - actor_id + ids2.is_disjoint(&ids3), + "Page 2 and 3 should have no duplicates" + ); + + // Verify all actors are returned across all pages + let mut all_returned_ids = ids1; + all_returned_ids.extend(ids2); + all_returned_ids.extend(ids3); + + assert_eq!( + all_returned_ids.len(), + 5, + "All 5 actors should be returned across pages" ); - } - }); + for actor_id in &actor_ids { + assert!( + all_returned_ids.contains(&actor_id.to_string()), + "Actor {} should be in results", + actor_id + ); + } + }, + ); } diff --git a/engine/packages/engine/tests/envoy/api_actors_list_names.rs b/engine/packages/engine/tests/envoy/api_actors_list_names.rs index 9c03711852..e080ad77bf 100644 --- a/engine/packages/engine/tests/envoy/api_actors_list_names.rs +++ b/engine/packages/engine/tests/envoy/api_actors_list_names.rs @@ -6,201 +6,213 @@ use std::collections::HashSet; #[test] fn list_all_actor_names_in_namespace() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let names = vec!["actor-alpha", "actor-beta", "actor-gamma"]; - let (namespace, _, _runner) = common::setup_test_namespace_with_envoy_for_names( - ctx.leader_dc(), - names.iter().map(|s| s.to_string()).collect(), - ) - .await; - for name in &names { - common::api::public::actors_create( + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let names = vec!["actor-alpha", "actor-beta", "actor-gamma"]; + let (namespace, _, _runner) = common::setup_test_namespace_with_envoy_for_names( + ctx.leader_dc(), + names.iter().map(|s| s.to_string()).collect(), + ) + .await; + for name in &names { + common::api::public::actors_create( + ctx.leader_dc().guard_port(), + common::api_types::actors::create::CreateQuery { + namespace: namespace.clone(), + }, + common::api_types::actors::create::CreateRequest { + datacenter: None, + name: name.to_string(), + key: Some(common::generate_unique_key()), + input: None, + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await + .expect("failed to create actor"); + } + + // Create multiple actors with same name (should deduplicate) + for i in 0..3 { + common::api::public::actors_create( + ctx.leader_dc().guard_port(), + common::api_types::actors::create::CreateQuery { + namespace: namespace.clone(), + }, + common::api_types::actors::create::CreateRequest { + datacenter: None, + name: "actor-alpha".to_string(), + key: Some(format!("key-{}", i)), + input: None, + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await + .expect("failed to create actor"); + } + + // List actor names + let response = common::api::public::actors_list_names( ctx.leader_dc().guard_port(), - common::api_types::actors::create::CreateQuery { + common::api_types::actors::list_names::ListNamesQuery { namespace: namespace.clone(), - }, - common::api_types::actors::create::CreateRequest { - datacenter: None, - name: name.to_string(), - key: Some(common::generate_unique_key()), - input: None, - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, + limit: None, + cursor: None, }, ) .await - .expect("failed to create actor"); - } + .expect("failed to list actor names"); - // Create multiple actors with same name (should deduplicate) - for i in 0..3 { - common::api::public::actors_create( + // Should return unique names only (HashMap automatically deduplicates) + assert_eq!(response.names.len(), 3, "Should return 3 unique names"); + + // Verify all names are present in the HashMap keys + let returned_names: HashSet = response.names.keys().cloned().collect(); + for name in &names { + assert!( + returned_names.contains(*name), + "Name {} should be in results", + name + ); + } + }, + ); +} + +#[test] +fn list_names_with_pagination() { + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _, _runner) = common::setup_test_namespace_with_envoy_for_names( + ctx.leader_dc(), + (0..9).map(|i| format!("actor-{:02}", i)).collect(), + ) + .await; + + // Create actors with many different names + for i in 0..9 { + common::api::public::actors_create( + ctx.leader_dc().guard_port(), + common::api_types::actors::create::CreateQuery { + namespace: namespace.clone(), + }, + common::api_types::actors::create::CreateRequest { + datacenter: None, + name: format!("actor-{:02}", i), + key: Some(common::generate_unique_key()), + input: None, + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await + .expect("failed to create actor"); + } + + // First page - limit 5 + let response1 = common::api::public::actors_list_names( ctx.leader_dc().guard_port(), - common::api_types::actors::create::CreateQuery { + common::api_types::actors::list_names::ListNamesQuery { namespace: namespace.clone(), + limit: Some(5), + cursor: None, }, - common::api_types::actors::create::CreateRequest { - datacenter: None, - name: "actor-alpha".to_string(), - key: Some(format!("key-{}", i)), - input: None, - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, + ) + .await + .expect("failed to list actor names"); + + assert_eq!( + response1.names.len(), + 5, + "Should return 5 names with limit=5" + ); + + let cursor = response1 + .pagination + .cursor + .as_ref() + .expect("Should have cursor for pagination"); + + // Second page - use cursor + let response2 = common::api::public::actors_list_names( + ctx.leader_dc().guard_port(), + common::api_types::actors::list_names::ListNamesQuery { + namespace: namespace.clone(), + limit: Some(5), + cursor: Some(cursor.clone()), }, ) .await - .expect("failed to create actor"); - } - - // List actor names - let response = common::api::public::actors_list_names( - ctx.leader_dc().guard_port(), - common::api_types::actors::list_names::ListNamesQuery { - namespace: namespace.clone(), - limit: None, - cursor: None, - }, - ) - .await - .expect("failed to list actor names"); - - // Should return unique names only (HashMap automatically deduplicates) - assert_eq!(response.names.len(), 3, "Should return 3 unique names"); - - // Verify all names are present in the HashMap keys - let returned_names: HashSet = response.names.keys().cloned().collect(); - for name in &names { + .expect("failed to list actor names page 2"); + + assert_eq!(response2.names.len(), 4, "Should return remaining 4 names"); + + // Verify no duplicates between pages + let set1: HashSet = response1.names.keys().cloned().collect(); + let set2: HashSet = response2.names.keys().cloned().collect(); assert!( - returned_names.contains(*name), - "Name {} should be in results", - name + set1.is_disjoint(&set2), + "Pages should not have duplicate names" ); - } - }); + }, + ); } #[test] -fn list_names_with_pagination() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _, _runner) = common::setup_test_namespace_with_envoy_for_names( - ctx.leader_dc(), - (0..9).map(|i| format!("actor-{:02}", i)).collect(), - ) - .await; - - // Create actors with many different names - for i in 0..9 { - common::api::public::actors_create( +fn list_names_returns_empty_for_empty_namespace() { + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy_for_names(ctx.leader_dc(), vec![]).await; + + // List names in empty namespace + let response = common::api::public::actors_list_names( ctx.leader_dc().guard_port(), - common::api_types::actors::create::CreateQuery { + common::api_types::actors::list_names::ListNamesQuery { namespace: namespace.clone(), - }, - common::api_types::actors::create::CreateRequest { - datacenter: None, - name: format!("actor-{:02}", i), - key: Some(common::generate_unique_key()), - input: None, - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, + limit: None, + cursor: None, }, ) .await - .expect("failed to create actor"); - } - - // First page - limit 5 - let response1 = common::api::public::actors_list_names( - ctx.leader_dc().guard_port(), - common::api_types::actors::list_names::ListNamesQuery { - namespace: namespace.clone(), - limit: Some(5), - cursor: None, - }, - ) - .await - .expect("failed to list actor names"); - - assert_eq!( - response1.names.len(), - 5, - "Should return 5 names with limit=5" - ); - - let cursor = response1 - .pagination - .cursor - .as_ref() - .expect("Should have cursor for pagination"); - - // Second page - use cursor - let response2 = common::api::public::actors_list_names( - ctx.leader_dc().guard_port(), - common::api_types::actors::list_names::ListNamesQuery { - namespace: namespace.clone(), - limit: Some(5), - cursor: Some(cursor.clone()), - }, - ) - .await - .expect("failed to list actor names page 2"); - - assert_eq!(response2.names.len(), 4, "Should return remaining 4 names"); - - // Verify no duplicates between pages - let set1: HashSet = response1.names.keys().cloned().collect(); - let set2: HashSet = response2.names.keys().cloned().collect(); - assert!( - set1.is_disjoint(&set2), - "Pages should not have duplicate names" - ); - }); -} + .expect("failed to list actor names"); -#[test] -fn list_names_returns_empty_for_empty_namespace() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy_for_names(ctx.leader_dc(), vec![]).await; - - // List names in empty namespace - let response = common::api::public::actors_list_names( - ctx.leader_dc().guard_port(), - common::api_types::actors::list_names::ListNamesQuery { - namespace: namespace.clone(), - limit: None, - cursor: None, - }, - ) - .await - .expect("failed to list actor names"); - - assert_eq!( - response.names.len(), - 0, - "Should return empty HashMap for empty namespace" - ); - }); + assert_eq!( + response.names.len(), + 0, + "Should return empty HashMap for empty namespace" + ); + }, + ); } // MARK: Error cases #[test] fn list_names_with_non_existent_namespace() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - // Try to list names with non-existent namespace - let res = common::api::public::actors_list_names( - ctx.leader_dc().guard_port(), - common::api_types::actors::list_names::ListNamesQuery { - namespace: "non-existent-namespace".to_string(), - limit: None, - cursor: None, - }, - ) - .await; - - // Should fail with namespace not found - assert!(res.is_err(), "Should fail with non-existent namespace"); - }); + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + // Try to list names with non-existent namespace + let res = common::api::public::actors_list_names( + ctx.leader_dc().guard_port(), + common::api_types::actors::list_names::ListNamesQuery { + namespace: "non-existent-namespace".to_string(), + limit: None, + cursor: None, + }, + ) + .await; + + // Should fail with namespace not found + assert!(res.is_err(), "Should fail with non-existent namespace"); + }, + ); } // MARK: Cross-datacenter tests @@ -209,171 +221,19 @@ fn list_names_with_non_existent_namespace() { // `actor.destroyed_during_creation` while creating the DC2 actor. #[test] fn list_names_fanout_to_all_datacenters() { - common::run(common::TestOpts::new(2).with_timeout(45), |ctx| async move { - let (namespace, _, _runner) = common::setup_test_namespace_with_envoy_for_names( - ctx.leader_dc(), - vec!["dc1-actor".to_string()], - ) - .await; - let _runner_dc2 = common::setup_envoy_on_dc( - ctx.get_dc(2), - &namespace, - vec!["dc2-actor".to_string()], - ) - .await; - - // Create actors with different names in different DCs - common::api::public::actors_create( - ctx.leader_dc().guard_port(), - common::api_types::actors::create::CreateQuery { - namespace: namespace.clone(), - }, - common::api_types::actors::create::CreateRequest { - datacenter: None, - name: "dc1-actor".to_string(), - key: Some(common::generate_unique_key()), - input: None, - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, - }, - ) - .await - .expect("failed to create actor in DC1"); - - common::api::public::actors_create( - ctx.get_dc(2).guard_port(), - common::api_types::actors::create::CreateQuery { - namespace: namespace.clone(), - }, - common::api_types::actors::create::CreateRequest { - datacenter: Some("dc-2".to_string()), - name: "dc2-actor".to_string(), - key: Some(common::generate_unique_key()), - input: None, - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, - }, - ) - .await - .expect("failed to create actor in DC2"); - - // List names from DC 1 - should fanout to all DCs - let response = common::api::public::actors_list_names( - ctx.leader_dc().guard_port(), - common::api_types::actors::list_names::ListNamesQuery { - namespace: namespace.clone(), - limit: None, - cursor: None, - }, - ) - .await - .expect("failed to list actor names"); - - // Should return names from both DCs - let returned_names: HashSet = response.names.keys().cloned().collect(); - assert!( - returned_names.contains("dc1-actor"), - "Should contain DC1 actor name" - ); - assert!( - returned_names.contains("dc2-actor"), - "Should contain DC2 actor name" - ); - }); -} - -#[test] -// Broken legacy Pegboard Runner test: full engine sweep timed out in -// `list_names_deduplication_across_datacenters`. -fn list_names_deduplication_across_datacenters() { - common::run(common::TestOpts::new(2).with_timeout(45), |ctx| async move { - let (namespace, _, _runner) = common::setup_test_namespace_with_envoy_for_names( - ctx.leader_dc(), - vec!["shared-name-actor".to_string()], - ) - .await; - let _runner_dc2 = common::setup_envoy_on_dc( - ctx.get_dc(2), - &namespace, - vec!["shared-name-actor".to_string()], - ) - .await; - - // Create actors with same name in different DCs - let shared_name = "shared-name-actor"; - - common::api::public::actors_create( - ctx.leader_dc().guard_port(), - common::api_types::actors::create::CreateQuery { - namespace: namespace.clone(), - }, - common::api_types::actors::create::CreateRequest { - datacenter: None, - name: shared_name.to_string(), - key: Some("dc1-key".to_string()), - input: None, - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, - }, - ) - .await - .expect("failed to create actor in DC1"); - - common::api::public::actors_create( - ctx.get_dc(2).guard_port(), - common::api_types::actors::create::CreateQuery { - namespace: namespace.clone(), - }, - common::api_types::actors::create::CreateRequest { - datacenter: Some("dc-2".to_string()), - name: shared_name.to_string(), - key: Some("dc2-key".to_string()), - input: None, - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, - }, - ) - .await - .expect("failed to create actor in DC2"); - - // List names - should deduplicate - let response = common::api::public::actors_list_names( - ctx.leader_dc().guard_port(), - common::api_types::actors::list_names::ListNamesQuery { - namespace: namespace.clone(), - limit: None, - cursor: None, - }, - ) - .await - .expect("failed to list actor names"); - - // Should return only one instance of the name (HashMap deduplicates) - assert!( - response.names.contains_key(shared_name), - "Should contain the shared name" - ); - - // Count occurrences - should be exactly 1 in the HashMap - let name_count = response - .names - .keys() - .filter(|n| n.as_str() == shared_name) - .count(); - assert_eq!(name_count, 1, "Should deduplicate names across datacenters"); - }); -} + common::run( + common::TestOpts::new(2).with_timeout(45), + |ctx| async move { + let (namespace, _, _runner) = common::setup_test_namespace_with_envoy_for_names( + ctx.leader_dc(), + vec!["dc1-actor".to_string()], + ) + .await; + let _runner_dc2 = + common::setup_envoy_on_dc(ctx.get_dc(2), &namespace, vec!["dc2-actor".to_string()]) + .await; -#[test] -fn list_names_alphabetical_sorting() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let unsorted_names = vec!["zebra-actor", "alpha-actor", "beta-actor", "gamma-actor"]; - let (namespace, _, _runner) = common::setup_test_namespace_with_envoy_for_names( - ctx.leader_dc(), - unsorted_names.iter().map(|s| s.to_string()).collect(), - ) - .await; - for name in &unsorted_names { + // Create actors with different names in different DCs common::api::public::actors_create( ctx.leader_dc().guard_port(), common::api_types::actors::create::CreateQuery { @@ -381,7 +241,7 @@ fn list_names_alphabetical_sorting() { }, common::api_types::actors::create::CreateRequest { datacenter: None, - name: name.to_string(), + name: "dc1-actor".to_string(), key: Some(common::generate_unique_key()), input: None, runner_name_selector: common::TEST_RUNNER_NAME.to_string(), @@ -389,55 +249,16 @@ fn list_names_alphabetical_sorting() { }, ) .await - .expect("failed to create actor"); - } - - // List names - let response = common::api::public::actors_list_names( - ctx.leader_dc().guard_port(), - common::api_types::actors::list_names::ListNamesQuery { - namespace: namespace.clone(), - limit: None, - cursor: None, - }, - ) - .await - .expect("failed to list actor names"); - - // Convert HashMap keys to sorted vector - let mut returned_names: Vec = response.names.keys().cloned().collect(); - returned_names.sort(); - - // Verify alphabetical order - assert_eq!(returned_names.len(), 4, "Should return all 4 unique names"); - assert_eq!(returned_names[0], "alpha-actor"); - assert_eq!(returned_names[1], "beta-actor"); - assert_eq!(returned_names[2], "gamma-actor"); - assert_eq!(returned_names[3], "zebra-actor"); - }); -} + .expect("failed to create actor in DC1"); -// MARK: Edge cases - -#[test] -fn list_names_default_limit_100() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _, _runner) = common::setup_test_namespace_with_envoy_for_names( - ctx.leader_dc(), - (0..105).map(|i| format!("actor-{:03}", i)).collect(), - ) - .await; - - // Create 105 actors with different names to test the default limit of 100 - for i in 0..105 { common::api::public::actors_create( - ctx.leader_dc().guard_port(), + ctx.get_dc(2).guard_port(), common::api_types::actors::create::CreateQuery { namespace: namespace.clone(), }, common::api_types::actors::create::CreateRequest { - datacenter: None, - name: format!("actor-{:03}", i), + datacenter: Some("dc-2".to_string()), + name: "dc2-actor".to_string(), key: Some(common::generate_unique_key()), input: None, runner_name_selector: common::TEST_RUNNER_NAME.to_string(), @@ -445,136 +266,56 @@ fn list_names_default_limit_100() { }, ) .await - .expect("failed to create actor"); - } - - // List without specifying limit - should use default limit of 100 - let response = common::api::public::actors_list_names( - ctx.leader_dc().guard_port(), - common::api_types::actors::list_names::ListNamesQuery { - namespace: namespace.clone(), - limit: None, // No limit specified - should default to 100 - cursor: None, - }, - ) - .await - .expect("failed to list actor names"); - - // Should return exactly 100 names due to default limit - assert_eq!( - response.names.len(), - 100, - "Should return exactly 100 names when default limit is applied" - ); - - // Verify cursor exists since there are more results - assert!( - response.pagination.cursor.is_some(), - "Cursor should exist when there are more results beyond the limit" - ); - }); -} + .expect("failed to create actor in DC2"); -#[test] -fn list_names_with_metadata() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let actor_name = "test-actor-with-metadata"; - let (namespace, _, _runner) = common::setup_test_namespace_with_envoy_for_names( - ctx.leader_dc(), - vec![actor_name.to_string()], - ) - .await; - - // Create an actor - common::api::public::actors_create( - ctx.leader_dc().guard_port(), - common::api_types::actors::create::CreateQuery { - namespace: namespace.clone(), - }, - common::api_types::actors::create::CreateRequest { - datacenter: None, - name: actor_name.to_string(), - key: Some(common::generate_unique_key()), - input: None, - runner_name_selector: common::TEST_RUNNER_NAME.to_string(), - crash_policy: rivet_types::actors::CrashPolicy::Sleep, - }, - ) - .await - .expect("failed to create actor"); - - // List names - let response = common::api::public::actors_list_names( - ctx.leader_dc().guard_port(), - common::api_types::actors::list_names::ListNamesQuery { - namespace: namespace.clone(), - limit: None, - cursor: None, - }, - ) - .await - .expect("failed to list actor names"); - - // Verify the name exists and has metadata - assert!( - response.names.contains_key(actor_name), - "Should contain the actor name" - ); - - let _actor_name_info = response - .names - .get(actor_name) - .expect("Should have actor name info"); - - // Verify ActorName exists - the fact that we got it from the HashMap means - // it has the expected structure with metadata field - // No need to assert further on the metadata since it's always present as a Map - }); + // List names from DC 1 - should fanout to all DCs + let response = common::api::public::actors_list_names( + ctx.leader_dc().guard_port(), + common::api_types::actors::list_names::ListNamesQuery { + namespace: namespace.clone(), + limit: None, + cursor: None, + }, + ) + .await + .expect("failed to list actor names"); + + // Should return names from both DCs + let returned_names: HashSet = response.names.keys().cloned().collect(); + assert!( + returned_names.contains("dc1-actor"), + "Should contain DC1 actor name" + ); + assert!( + returned_names.contains("dc2-actor"), + "Should contain DC2 actor name" + ); + }, + ); } #[test] -fn list_names_empty_response_no_cursor() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _, _runner) = - common::setup_test_namespace_with_envoy_for_names(ctx.leader_dc(), vec![]).await; - - // List names in empty namespace - let response = common::api::public::actors_list_names( - ctx.leader_dc().guard_port(), - common::api_types::actors::list_names::ListNamesQuery { - namespace: namespace.clone(), - limit: None, - cursor: None, - }, - ) - .await - .expect("failed to list actor names"); - - // Empty response should have no cursor - assert_eq!(response.names.len(), 0, "Should return empty HashMap"); - assert!( - response.pagination.cursor.is_none(), - "Empty response should not have a cursor" - ); - }); -} +// Broken legacy Pegboard Runner test: full engine sweep timed out in +// `list_names_deduplication_across_datacenters`. +fn list_names_deduplication_across_datacenters() { + common::run( + common::TestOpts::new(2).with_timeout(45), + |ctx| async move { + let (namespace, _, _runner) = common::setup_test_namespace_with_envoy_for_names( + ctx.leader_dc(), + vec!["shared-name-actor".to_string()], + ) + .await; + let _runner_dc2 = common::setup_envoy_on_dc( + ctx.get_dc(2), + &namespace, + vec!["shared-name-actor".to_string()], + ) + .await; -// MARK: Comprehensive pagination tests + // Create actors with same name in different DCs + let shared_name = "shared-name-actor"; -/// This test exhaustively checks that pagination works correctly by iterating -/// through all pages and verifying no duplicates appear across pages. -/// This is a regression test for the cursor being inclusive instead of exclusive. -#[test] -fn list_names_pagination_no_duplicates_comprehensive() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let (namespace, _, _runner) = common::setup_test_namespace_with_envoy_for_names( - ctx.leader_dc(), - (0..15).map(|i| format!("paginate-actor-{:02}", i)).collect(), - ) - .await; - - // Create actors with sequential names - for i in 0..15 { common::api::public::actors_create( ctx.leader_dc().guard_port(), common::api_types::actors::create::CreateQuery { @@ -582,88 +323,192 @@ fn list_names_pagination_no_duplicates_comprehensive() { }, common::api_types::actors::create::CreateRequest { datacenter: None, - name: format!("paginate-actor-{:02}", i), - key: Some(common::generate_unique_key()), + name: shared_name.to_string(), + key: Some("dc1-key".to_string()), input: None, runner_name_selector: common::TEST_RUNNER_NAME.to_string(), crash_policy: rivet_types::actors::CrashPolicy::Sleep, }, ) .await - .expect("failed to create actor"); - } + .expect("failed to create actor in DC1"); - // Paginate through all results with small page size - let mut all_names: HashSet = HashSet::new(); - let mut cursor: Option = None; - let mut page_count = 0; + common::api::public::actors_create( + ctx.get_dc(2).guard_port(), + common::api_types::actors::create::CreateQuery { + namespace: namespace.clone(), + }, + common::api_types::actors::create::CreateRequest { + datacenter: Some("dc-2".to_string()), + name: shared_name.to_string(), + key: Some("dc2-key".to_string()), + input: None, + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await + .expect("failed to create actor in DC2"); - loop { + // List names - should deduplicate let response = common::api::public::actors_list_names( ctx.leader_dc().guard_port(), common::api_types::actors::list_names::ListNamesQuery { namespace: namespace.clone(), - limit: Some(4), - cursor: cursor.clone(), + limit: None, + cursor: None, }, ) .await .expect("failed to list actor names"); - page_count += 1; + // Should return only one instance of the name (HashMap deduplicates) + assert!( + response.names.contains_key(shared_name), + "Should contain the shared name" + ); - // Check for duplicates - this is the key assertion for the bug fix - for name in response.names.keys() { - assert!( - !all_names.contains(name), - "DUPLICATE FOUND: '{}' appeared on page {} but was already seen. \ - This indicates the cursor is inclusive instead of exclusive. \ - All names so far: {:?}", - name, - page_count, - all_names - ); - all_names.insert(name.clone()); - } + // Count occurrences - should be exactly 1 in the HashMap + let name_count = response + .names + .keys() + .filter(|n| n.as_str() == shared_name) + .count(); + assert_eq!(name_count, 1, "Should deduplicate names across datacenters"); + }, + ); +} - // Move to next page or break - if response.pagination.cursor.is_none() || response.names.is_empty() { - break; +#[test] +fn list_names_alphabetical_sorting() { + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let unsorted_names = vec!["zebra-actor", "alpha-actor", "beta-actor", "gamma-actor"]; + let (namespace, _, _runner) = common::setup_test_namespace_with_envoy_for_names( + ctx.leader_dc(), + unsorted_names.iter().map(|s| s.to_string()).collect(), + ) + .await; + for name in &unsorted_names { + common::api::public::actors_create( + ctx.leader_dc().guard_port(), + common::api_types::actors::create::CreateQuery { + namespace: namespace.clone(), + }, + common::api_types::actors::create::CreateRequest { + datacenter: None, + name: name.to_string(), + key: Some(common::generate_unique_key()), + input: None, + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await + .expect("failed to create actor"); } - cursor = response.pagination.cursor; - // Safety limit to prevent infinite loops - if page_count > 20 { - panic!("Too many pages, possible infinite loop"); + // List names + let response = common::api::public::actors_list_names( + ctx.leader_dc().guard_port(), + common::api_types::actors::list_names::ListNamesQuery { + namespace: namespace.clone(), + limit: None, + cursor: None, + }, + ) + .await + .expect("failed to list actor names"); + + // Convert HashMap keys to sorted vector + let mut returned_names: Vec = response.names.keys().cloned().collect(); + returned_names.sort(); + + // Verify alphabetical order + assert_eq!(returned_names.len(), 4, "Should return all 4 unique names"); + assert_eq!(returned_names[0], "alpha-actor"); + assert_eq!(returned_names[1], "beta-actor"); + assert_eq!(returned_names[2], "gamma-actor"); + assert_eq!(returned_names[3], "zebra-actor"); + }, + ); +} + +// MARK: Edge cases + +#[test] +fn list_names_default_limit_100() { + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _, _runner) = common::setup_test_namespace_with_envoy_for_names( + ctx.leader_dc(), + (0..105).map(|i| format!("actor-{:03}", i)).collect(), + ) + .await; + + // Create 105 actors with different names to test the default limit of 100 + for i in 0..105 { + common::api::public::actors_create( + ctx.leader_dc().guard_port(), + common::api_types::actors::create::CreateQuery { + namespace: namespace.clone(), + }, + common::api_types::actors::create::CreateRequest { + datacenter: None, + name: format!("actor-{:03}", i), + key: Some(common::generate_unique_key()), + input: None, + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await + .expect("failed to create actor"); } - } - - // Should have found all actor names - assert_eq!( - all_names.len(), - 15, - "Should find all 15 actor names across pages, found: {}. Names: {:?}", - all_names.len(), - all_names - ); - }); + + // List without specifying limit - should use default limit of 100 + let response = common::api::public::actors_list_names( + ctx.leader_dc().guard_port(), + common::api_types::actors::list_names::ListNamesQuery { + namespace: namespace.clone(), + limit: None, // No limit specified - should default to 100 + cursor: None, + }, + ) + .await + .expect("failed to list actor names"); + + // Should return exactly 100 names due to default limit + assert_eq!( + response.names.len(), + 100, + "Should return exactly 100 names when default limit is applied" + ); + + // Verify cursor exists since there are more results + assert!( + response.pagination.cursor.is_some(), + "Cursor should exist when there are more results beyond the limit" + ); + }, + ); } -/// Tests that the cursor correctly advances past boundary conditions. -/// Creates actors with names that test edge cases in lexicographic ordering. #[test] -fn list_names_pagination_boundary_cases() { - common::run(common::TestOpts::new(1).with_timeout(30), |ctx| async move { - let names = vec![ - "test-a", "test-aa", "test-aaa", "test-ab", "test-b", "test-ba", - ]; - let (namespace, _, _runner) = common::setup_test_namespace_with_envoy_for_names( - ctx.leader_dc(), - names.iter().map(|s| s.to_string()).collect(), - ) - .await; - - for name in &names { +fn list_names_with_metadata() { + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let actor_name = "test-actor-with-metadata"; + let (namespace, _, _runner) = common::setup_test_namespace_with_envoy_for_names( + ctx.leader_dc(), + vec![actor_name.to_string()], + ) + .await; + + // Create an actor common::api::public::actors_create( ctx.leader_dc().guard_port(), common::api_types::actors::create::CreateQuery { @@ -671,7 +516,7 @@ fn list_names_pagination_boundary_cases() { }, common::api_types::actors::create::CreateRequest { datacenter: None, - name: name.to_string(), + name: actor_name.to_string(), key: Some(common::generate_unique_key()), input: None, runner_name_selector: common::TEST_RUNNER_NAME.to_string(), @@ -680,48 +525,238 @@ fn list_names_pagination_boundary_cases() { ) .await .expect("failed to create actor"); - } - // Page through with limit=2 - let mut collected_names: Vec = Vec::new(); - let mut cursor: Option = None; + // List names + let response = common::api::public::actors_list_names( + ctx.leader_dc().guard_port(), + common::api_types::actors::list_names::ListNamesQuery { + namespace: namespace.clone(), + limit: None, + cursor: None, + }, + ) + .await + .expect("failed to list actor names"); + + // Verify the name exists and has metadata + assert!( + response.names.contains_key(actor_name), + "Should contain the actor name" + ); - loop { + let _actor_name_info = response + .names + .get(actor_name) + .expect("Should have actor name info"); + + // Verify ActorName exists - the fact that we got it from the HashMap means + // it has the expected structure with metadata field + // No need to assert further on the metadata since it's always present as a Map + }, + ); +} + +#[test] +fn list_names_empty_response_no_cursor() { + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _, _runner) = + common::setup_test_namespace_with_envoy_for_names(ctx.leader_dc(), vec![]).await; + + // List names in empty namespace let response = common::api::public::actors_list_names( ctx.leader_dc().guard_port(), common::api_types::actors::list_names::ListNamesQuery { namespace: namespace.clone(), - limit: Some(2), - cursor: cursor.clone(), + limit: None, + cursor: None, }, ) .await .expect("failed to list actor names"); - // Collect names from this page - let page_names: Vec<_> = response.names.keys().cloned().collect(); - collected_names.extend(page_names); + // Empty response should have no cursor + assert_eq!(response.names.len(), 0, "Should return empty HashMap"); + assert!( + response.pagination.cursor.is_none(), + "Empty response should not have a cursor" + ); + }, + ); +} + +// MARK: Comprehensive pagination tests + +/// This test exhaustively checks that pagination works correctly by iterating +/// through all pages and verifying no duplicates appear across pages. +/// This is a regression test for the cursor being inclusive instead of exclusive. +#[test] +fn list_names_pagination_no_duplicates_comprehensive() { + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let (namespace, _, _runner) = common::setup_test_namespace_with_envoy_for_names( + ctx.leader_dc(), + (0..15) + .map(|i| format!("paginate-actor-{:02}", i)) + .collect(), + ) + .await; + + // Create actors with sequential names + for i in 0..15 { + common::api::public::actors_create( + ctx.leader_dc().guard_port(), + common::api_types::actors::create::CreateQuery { + namespace: namespace.clone(), + }, + common::api_types::actors::create::CreateRequest { + datacenter: None, + name: format!("paginate-actor-{:02}", i), + key: Some(common::generate_unique_key()), + input: None, + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await + .expect("failed to create actor"); + } + + // Paginate through all results with small page size + let mut all_names: HashSet = HashSet::new(); + let mut cursor: Option = None; + let mut page_count = 0; + + loop { + let response = common::api::public::actors_list_names( + ctx.leader_dc().guard_port(), + common::api_types::actors::list_names::ListNamesQuery { + namespace: namespace.clone(), + limit: Some(4), + cursor: cursor.clone(), + }, + ) + .await + .expect("failed to list actor names"); + + page_count += 1; + + // Check for duplicates - this is the key assertion for the bug fix + for name in response.names.keys() { + assert!( + !all_names.contains(name), + "DUPLICATE FOUND: '{}' appeared on page {} but was already seen. \ + This indicates the cursor is inclusive instead of exclusive. \ + All names so far: {:?}", + name, + page_count, + all_names + ); + all_names.insert(name.clone()); + } + + // Move to next page or break + if response.pagination.cursor.is_none() || response.names.is_empty() { + break; + } + cursor = response.pagination.cursor; + + // Safety limit to prevent infinite loops + if page_count > 20 { + panic!("Too many pages, possible infinite loop"); + } + } + + // Should have found all actor names + assert_eq!( + all_names.len(), + 15, + "Should find all 15 actor names across pages, found: {}. Names: {:?}", + all_names.len(), + all_names + ); + }, + ); +} + +/// Tests that the cursor correctly advances past boundary conditions. +/// Creates actors with names that test edge cases in lexicographic ordering. +#[test] +fn list_names_pagination_boundary_cases() { + common::run( + common::TestOpts::new(1).with_timeout(30), + |ctx| async move { + let names = vec![ + "test-a", "test-aa", "test-aaa", "test-ab", "test-b", "test-ba", + ]; + let (namespace, _, _runner) = common::setup_test_namespace_with_envoy_for_names( + ctx.leader_dc(), + names.iter().map(|s| s.to_string()).collect(), + ) + .await; + + for name in &names { + common::api::public::actors_create( + ctx.leader_dc().guard_port(), + common::api_types::actors::create::CreateQuery { + namespace: namespace.clone(), + }, + common::api_types::actors::create::CreateRequest { + datacenter: None, + name: name.to_string(), + key: Some(common::generate_unique_key()), + input: None, + runner_name_selector: common::TEST_RUNNER_NAME.to_string(), + crash_policy: rivet_types::actors::CrashPolicy::Sleep, + }, + ) + .await + .expect("failed to create actor"); + } - if response.pagination.cursor.is_none() || response.names.is_empty() { - break; + // Page through with limit=2 + let mut collected_names: Vec = Vec::new(); + let mut cursor: Option = None; + + loop { + let response = common::api::public::actors_list_names( + ctx.leader_dc().guard_port(), + common::api_types::actors::list_names::ListNamesQuery { + namespace: namespace.clone(), + limit: Some(2), + cursor: cursor.clone(), + }, + ) + .await + .expect("failed to list actor names"); + + // Collect names from this page + let page_names: Vec<_> = response.names.keys().cloned().collect(); + collected_names.extend(page_names); + + if response.pagination.cursor.is_none() || response.names.is_empty() { + break; + } + cursor = response.pagination.cursor; } - cursor = response.pagination.cursor; - } - - // Filter to just our test names - let test_names: HashSet<_> = collected_names - .iter() - .filter(|n| names.contains(&n.as_str())) - .cloned() - .collect(); - - // All names should be present exactly once - assert_eq!( - test_names.len(), - names.len(), - "All test names should be present. Expected {:?}, got {:?}", - names, - test_names - ); - }); + + // Filter to just our test names + let test_names: HashSet<_> = collected_names + .iter() + .filter(|n| names.contains(&n.as_str())) + .cloned() + .collect(); + + // All names should be present exactly once + assert_eq!( + test_names.len(), + names.len(), + "All test names should be present. Expected {:?}, got {:?}", + names, + test_names + ); + }, + ); } diff --git a/engine/packages/engine/tests/envoy/auth.rs b/engine/packages/engine/tests/envoy/auth.rs index 6a903695e0..f8da57ef44 100644 --- a/engine/packages/engine/tests/envoy/auth.rs +++ b/engine/packages/engine/tests/envoy/auth.rs @@ -38,9 +38,10 @@ fn envoy_connect_rejects_bad_token() { envoy_connect_url(ctx.leader_dc().guard_port(), &namespace, "bad-token-envoy") .into_client_request() .expect("failed to create envoy connect request"); - request - .headers_mut() - .insert("Sec-WebSocket-Protocol", "rivet, rivet_token.bad-token".parse().unwrap()); + request.headers_mut().insert( + "Sec-WebSocket-Protocol", + "rivet, rivet_token.bad-token".parse().unwrap(), + ); assert_envoy_rejection(request, "token_not_found").await; }, diff --git a/engine/packages/guard/src/routing/pegboard_gateway/resolve_actor_query.rs b/engine/packages/guard/src/routing/pegboard_gateway/resolve_actor_query.rs index b14437f49d..bd2633f444 100644 --- a/engine/packages/guard/src/routing/pegboard_gateway/resolve_actor_query.rs +++ b/engine/packages/guard/src/routing/pegboard_gateway/resolve_actor_query.rs @@ -192,11 +192,12 @@ async fn resolve_query_target_dc_label( region: Option<&str>, ) -> Result { let requested_dc_label = if let Some(region) = region { - Some(ctx - .config() - .dc_for_name(region) - .ok_or_else(|| rivet_api_util::errors::Datacenter::NotFound.build())? - .datacenter_label) + Some( + ctx.config() + .dc_for_name(region) + .ok_or_else(|| rivet_api_util::errors::Datacenter::NotFound.build())? + .datacenter_label, + ) } else { None }; diff --git a/engine/packages/pegboard-envoy/src/sqlite_runtime.rs b/engine/packages/pegboard-envoy/src/sqlite_runtime.rs index 4127c0b56c..c030a87135 100644 --- a/engine/packages/pegboard-envoy/src/sqlite_runtime.rs +++ b/engine/packages/pegboard-envoy/src/sqlite_runtime.rs @@ -1,5 +1,5 @@ -use rivet_envoy_protocol as protocol; use depot::types::FetchedPage; +use rivet_envoy_protocol as protocol; pub fn protocol_sqlite_conveyer_fetched_page(page: FetchedPage) -> protocol::SqliteFetchedPage { protocol::SqliteFetchedPage { diff --git a/engine/packages/pegboard-envoy/src/ws_to_tunnel_task.rs b/engine/packages/pegboard-envoy/src/ws_to_tunnel_task.rs index f265af26f4..8c18f3ef30 100644 --- a/engine/packages/pegboard-envoy/src/ws_to_tunnel_task.rs +++ b/engine/packages/pegboard-envoy/src/ws_to_tunnel_task.rs @@ -9,7 +9,7 @@ use depot::{ }; use depot_client::{ database::{NativeDatabaseHandle, open_database_from_conveyer}, - types::{BindParam, ColumnValue, ExecuteResult, ExecuteRoute, QueryResult}, + types::{BindParam, ColumnValue, ExecuteResult, QueryResult}, }; use futures_util::{FutureExt, TryStreamExt}; use gas::prelude::Id; @@ -417,10 +417,6 @@ async fn handle_message( let response = handle_remote_sqlite_execute_response(ctx, &conn, req.data).await; send_sqlite_execute_response(&conn, req.request_id, response).await?; } - protocol::ToRivet::ToRivetSqliteExecuteWriteRequest(req) => { - let response = handle_remote_sqlite_execute_write_response(ctx, &conn, req.data).await; - send_sqlite_execute_write_response(&conn, req.request_id, response).await?; - } protocol::ToRivet::ToRivetTunnelMessage(tunnel_msg) => { handle_tunnel_message(ctx, &conn.authorized_tunnel_routes, tunnel_msg) .await @@ -533,25 +529,6 @@ async fn handle_remote_sqlite_execute_response( } } -async fn handle_remote_sqlite_execute_write_response( - ctx: &StandaloneCtx, - conn: &Conn, - request: protocol::SqliteExecuteWriteRequest, -) -> protocol::SqliteExecuteWriteResponse { - let actor_id = request.actor_id.clone(); - match handle_remote_sqlite_execute_write(ctx, conn, request).await { - Ok(result) => protocol::SqliteExecuteWriteResponse::SqliteExecuteWriteOk( - protocol::SqliteExecuteWriteOk { - result: protocol_execute_result(result), - }, - ), - Err(err) => { - tracing::error!(actor_id = %actor_id, ?err, "remote sqlite execute_write request failed"); - protocol::SqliteExecuteWriteResponse::SqliteErrorResponse(sqlite_error_response(&err)) - } - } -} - async fn ack_commands( ctx: &StandaloneCtx, namespace_id: Id, @@ -820,34 +797,6 @@ async fn handle_remote_sqlite_execute( database.execute(request.sql, params).await } -async fn handle_remote_sqlite_execute_write( - ctx: &StandaloneCtx, - conn: &Conn, - request: protocol::SqliteExecuteWriteRequest, -) -> Result { - validate_remote_sqlite_actor( - ctx, - conn, - &request.namespace_id, - &request.actor_id, - request.generation, - ) - .await?; - validate_remote_sqlite_params(request.params.as_ref())?; - let params = request - .params - .map(|params| params.into_iter().map(bind_param_from_protocol).collect()); - let actor_db = actor_db(ctx, conn, request.actor_id.clone()).await?; - let database = remote_sqlite_executor_from_parts( - &conn.remote_sqlite_executors, - actor_db, - &request.actor_id, - request.generation, - ) - .await?; - database.execute_write(request.sql, params).await -} - async fn validate_remote_sqlite_actor( ctx: &StandaloneCtx, conn: &Conn, @@ -1063,7 +1012,6 @@ fn protocol_execute_result(result: ExecuteResult) -> protocol::SqliteExecuteResu .collect(), changes: result.changes, last_insert_row_id: result.last_insert_row_id, - route: protocol_execute_route(result.route), } } @@ -1087,14 +1035,6 @@ fn protocol_column_value(value: ColumnValue) -> protocol::SqliteColumnValue { } } -fn protocol_execute_route(route: ExecuteRoute) -> protocol::SqliteExecuteRoute { - match route { - ExecuteRoute::Read => protocol::SqliteExecuteRoute::Read, - ExecuteRoute::Write => protocol::SqliteExecuteRoute::Write, - ExecuteRoute::WriteFallback => protocol::SqliteExecuteRoute::WriteFallback, - } -} - async fn actor_db(ctx: &StandaloneCtx, conn: &Conn, actor_id: String) -> Result> { let db = conn .actor_dbs @@ -1263,21 +1203,6 @@ async fn send_sqlite_execute_response( .await } -async fn send_sqlite_execute_write_response( - conn: &Conn, - request_id: u32, - data: protocol::SqliteExecuteWriteResponse, -) -> Result<()> { - send_to_envoy( - conn, - protocol::ToEnvoy::ToEnvoySqliteExecuteWriteResponse( - protocol::ToEnvoySqliteExecuteWriteResponse { request_id, data }, - ), - "sqlite execute_write response", - ) - .await -} - async fn send_to_envoy(conn: &Conn, msg: protocol::ToEnvoy, description: &str) -> Result<()> { let serialized = versioned::ToEnvoy::wrap_latest(msg) .serialize(conn.protocol_version) diff --git a/engine/packages/pegboard-envoy/tests/actor_lifecycle.rs b/engine/packages/pegboard-envoy/tests/actor_lifecycle.rs index ab296930c1..6000ea17cd 100644 --- a/engine/packages/pegboard-envoy/tests/actor_lifecycle.rs +++ b/engine/packages/pegboard-envoy/tests/actor_lifecycle.rs @@ -1,25 +1,25 @@ use std::sync::Arc; use anyhow::Result; -use gas::prelude::Id; -use rivet_envoy_protocol as protocol; -use rivet_pools::NodeId; -use scc::HashMap; use depot::{ + conveyer::Db, keys::{ delta_chunk_key, meta_compact_key, meta_compactor_lease_key, meta_head_key, meta_quota_key, pidx_delta_key, shard_key, }, - conveyer::Db, }; +use gas::prelude::Id; +use rivet_envoy_protocol as protocol; +use rivet_pools::NodeId; +use scc::HashMap; use tempfile::Builder; use universaldb::utils::IsolationLevel::Snapshot; mod conn { use std::sync::Arc; - use scc::HashMap; use depot::conveyer::Db; + use scc::HashMap; pub struct Conn { pub actor_dbs: HashMap>, @@ -89,11 +89,7 @@ fn sqlite_keys(actor_id: &str) -> Vec> { ] } -fn new_actor_db( - db: Arc, - namespace_label: u16, - actor_id: &str, -) -> Arc { +fn new_actor_db(db: Arc, namespace_label: u16, actor_id: &str) -> Arc { Arc::new(Db::new( db, Id::new_v1(namespace_label), @@ -110,11 +106,12 @@ async fn stop_actor_evicts_cached_actor_db() -> Result<()> { actor_dbs: HashMap::new(), }; - assert!(conn - .actor_dbs - .insert_async(TEST_ACTOR.to_string(), actor_db) - .await - .is_ok()); + assert!( + conn.actor_dbs + .insert_async(TEST_ACTOR.to_string(), actor_db) + .await + .is_ok() + ); actor_lifecycle::stop_actor(&conn, &checkpoint(TEST_ACTOR)).await?; @@ -129,11 +126,12 @@ async fn stop_actor_does_not_touch_udb() -> Result<()> { let conn = conn::Conn { actor_dbs: HashMap::new(), }; - assert!(conn - .actor_dbs - .insert_async(TEST_ACTOR.to_string(), actor_db) - .await - .is_ok()); + assert!( + conn.actor_dbs + .insert_async(TEST_ACTOR.to_string(), actor_db) + .await + .is_ok() + ); let keys = sqlite_keys(TEST_ACTOR); seed(&db, &keys).await?; @@ -166,17 +164,17 @@ async fn shutdown_conn_actors_evicts_all_cached_actor_dbs() -> Result<()> { actor_dbs: HashMap::new(), }; - for (idx, actor_id) in ["shutdown-actor-a", "shutdown-actor-b"].into_iter().enumerate() { - let actor_db = new_actor_db( - Arc::clone(&db), - TEST_NAMESPACE_LABEL + idx as u16, - actor_id, + for (idx, actor_id) in ["shutdown-actor-a", "shutdown-actor-b"] + .into_iter() + .enumerate() + { + let actor_db = new_actor_db(Arc::clone(&db), TEST_NAMESPACE_LABEL + idx as u16, actor_id); + assert!( + conn.actor_dbs + .insert_async(actor_id.to_string(), actor_db) + .await + .is_ok() ); - assert!(conn - .actor_dbs - .insert_async(actor_id.to_string(), actor_db) - .await - .is_ok()); } actor_lifecycle::shutdown_conn_actors(&conn).await; diff --git a/engine/packages/pegboard-envoy/tests/support/ws_to_tunnel_task.rs b/engine/packages/pegboard-envoy/tests/support/ws_to_tunnel_task.rs index 7a49987d21..a23391f74a 100644 --- a/engine/packages/pegboard-envoy/tests/support/ws_to_tunnel_task.rs +++ b/engine/packages/pegboard-envoy/tests/support/ws_to_tunnel_task.rs @@ -103,10 +103,8 @@ use super::{ ActiveActor, ActiveActorState, clear_remote_sqlite_executors, remove_remote_sqlite_executor_generation, remove_remote_sqlite_executors_for_actor, }, - cached_active_sqlite_actor, - cached_serverless_sqlite_generation, - remote_sqlite_executor_cell, remote_sqlite_executor_from_parts, - spawn_tracked_remote_sqlite_task, + cached_active_sqlite_actor, cached_serverless_sqlite_generation, remote_sqlite_executor_cell, + remote_sqlite_executor_from_parts, spawn_tracked_remote_sqlite_task, validate_remote_sqlite_params, validate_sqlite_get_page_range_request, }; use crate::conn::{ @@ -390,13 +388,13 @@ async fn remote_sqlite_executor_reopens_fresh_cell_with_persisted_contents() -> .await?; assert_eq!(executors.len(), 1); handle - .execute_write( + .execute( "CREATE TABLE items(id INTEGER PRIMARY KEY, label TEXT);".to_string(), None, ) .await?; handle - .execute_write( + .execute( "INSERT INTO items(label) VALUES (?);".to_string(), Some(vec![BindParam::Text("alpha".to_string())]), ) diff --git a/engine/packages/pegboard/src/actor_sqlite.rs b/engine/packages/pegboard/src/actor_sqlite.rs index b298b93b2e..9b7f849201 100644 --- a/engine/packages/pegboard/src/actor_sqlite.rs +++ b/engine/packages/pegboard/src/actor_sqlite.rs @@ -1,14 +1,14 @@ use std::{sync::Arc, time::Instant}; use anyhow::{Context, Result, ensure}; -use gas::prelude::{Id, util::timestamp}; -use rivet_envoy_protocol as protocol; -use rivet_pools::NodeId; use depot::{ + conveyer::{Db, branch as depot_branch}, keys as depot_keys, - conveyer::{branch as depot_branch, Db}, - types::{DBHead, DirtyPage, BucketId, SQLITE_PAGE_SIZE, decode_db_head}, + types::{BucketId, DBHead, DirtyPage, SQLITE_PAGE_SIZE, decode_db_head}, }; +use gas::prelude::{Id, util::timestamp}; +use rivet_envoy_protocol as protocol; +use rivet_pools::NodeId; use crate::{actor_kv::Recipient, metrics}; @@ -29,12 +29,10 @@ const SQLITE_MAGIC: &[u8; 16] = b"SQLite format 3\0"; pub fn clear_v2_storage_for_destroy(tx: &universaldb::Transaction, actor_id: Id) { let actor_id = actor_id.to_string(); - tx.informal() - .clear(&depot_keys::meta_head_key(&actor_id)); + tx.informal().clear(&depot_keys::meta_head_key(&actor_id)); tx.informal() .clear(&depot_keys::meta_compact_key(&actor_id)); - tx.informal() - .clear(&depot_keys::meta_quota_key(&actor_id)); + tx.informal().clear(&depot_keys::meta_quota_key(&actor_id)); // Clear the lease with the rest of Depot. // Otherwise dead lease keys accumulate in UDB indefinitely. tx.informal() @@ -81,10 +79,7 @@ pub async fn migrate_v1_to_v2( Ok(MigrateV1ToV2Output { migrated }) } -async fn maybe_migrate_v1_to_v2( - db: &universaldb::Database, - recipient: &Recipient, -) -> Result { +async fn maybe_migrate_v1_to_v2(db: &universaldb::Database, recipient: &Recipient) -> Result { if !crate::actor_kv::sqlite_v1_data_exists(db, recipient.actor_id).await? { return Ok(false); } diff --git a/engine/packages/pegboard/tests/actor_sqlite_destroy.rs b/engine/packages/pegboard/tests/actor_sqlite_destroy.rs index 6e28fd982d..ceece1f8ae 100644 --- a/engine/packages/pegboard/tests/actor_sqlite_destroy.rs +++ b/engine/packages/pegboard/tests/actor_sqlite_destroy.rs @@ -1,11 +1,11 @@ use std::sync::Arc; use anyhow::{Result, anyhow}; -use gas::prelude::Id; use depot::keys::{ delta_chunk_key, meta_compact_key, meta_compactor_lease_key, meta_head_key, meta_quota_key, pidx_delta_key, shard_key, }; +use gas::prelude::Id; use tempfile::Builder; use universaldb::utils::IsolationLevel::Snapshot; @@ -85,13 +85,12 @@ async fn actor_destroy_in_one_tx() -> Result<()> { let keys = sqlite_keys(actor_id); seed(&db, &keys).await?; - db - .run(move |tx| async move { - pegboard::actor_sqlite::clear_v2_storage_for_destroy(&tx, actor_id); - Err::<(), anyhow::Error>(anyhow!("rollback sqlite destroy")) - }) - .await - .expect_err("failed transaction should roll back sqlite clears"); + db.run(move |tx| async move { + pegboard::actor_sqlite::clear_v2_storage_for_destroy(&tx, actor_id); + Err::<(), anyhow::Error>(anyhow!("rollback sqlite destroy")) + }) + .await + .expect_err("failed transaction should roll back sqlite clears"); for key in keys { assert!(value_exists(&db, key).await?); diff --git a/engine/packages/pegboard/tests/actor_sqlite_migration.rs b/engine/packages/pegboard/tests/actor_sqlite_migration.rs index 67e2bedd32..9ce5257b1e 100644 --- a/engine/packages/pegboard/tests/actor_sqlite_migration.rs +++ b/engine/packages/pegboard/tests/actor_sqlite_migration.rs @@ -2,15 +2,15 @@ use std::path::Path; use std::sync::Arc; use anyhow::Result; +use depot::{ + conveyer::{Db, branch as depot_branch}, + keys::{branch_meta_head_key, meta_head_key}, + types::{BucketId, DirtyPage, SQLITE_PAGE_SIZE, decode_db_head}, +}; use gas::prelude::{Id, util::timestamp}; use pegboard::actor_kv::Recipient; use rivet_pools::NodeId; use rusqlite::{Connection, params}; -use depot::{ - keys::{branch_meta_head_key, meta_head_key}, - conveyer::{branch as depot_branch, Db}, - types::{DirtyPage, BucketId, SQLITE_PAGE_SIZE, decode_db_head}, -}; use tempfile::tempdir; use universaldb::driver::RocksDbDatabaseDriver; diff --git a/engine/packages/pools/src/lib.rs b/engine/packages/pools/src/lib.rs index be2696b109..675648f426 100644 --- a/engine/packages/pools/src/lib.rs +++ b/engine/packages/pools/src/lib.rs @@ -7,8 +7,8 @@ pub mod prelude; pub mod reqwest; pub use crate::{ - db::clickhouse::ClickHousePool, db::udb::UdbPool, db::ups::UpsPool, error::Error, pools::Pools, - node_id::NodeId, + db::clickhouse::ClickHousePool, db::udb::UdbPool, db::ups::UpsPool, error::Error, + node_id::NodeId, pools::Pools, }; // Re-export for macros diff --git a/engine/packages/universaldb/src/driver/postgres/transaction_task.rs b/engine/packages/universaldb/src/driver/postgres/transaction_task.rs index 3c811f2693..132ffaa9d5 100644 --- a/engine/packages/universaldb/src/driver/postgres/transaction_task.rs +++ b/engine/packages/universaldb/src/driver/postgres/transaction_task.rs @@ -422,10 +422,7 @@ impl TransactionTask { .map_err(anyhow::Error::msg) .context("failed substituting versionstamped key")?; let query = "INSERT INTO kv (key, value) VALUES ($1, $2) ON CONFLICT (key) DO UPDATE SET value = $2"; - let stmt = tx - .prepare_cached(query) - .await - .map_err(map_postgres_error)?; + let stmt = tx.prepare_cached(query).await.map_err(map_postgres_error)?; tx.execute(&stmt, &[&key, ¶m]) .await @@ -438,10 +435,7 @@ impl TransactionTask { .map_err(anyhow::Error::msg) .context("failed substituting versionstamped value")?; let query = "INSERT INTO kv (key, value) VALUES ($1, $2) ON CONFLICT (key) DO UPDATE SET value = $2"; - let stmt = tx - .prepare_cached(query) - .await - .map_err(map_postgres_error)?; + let stmt = tx.prepare_cached(query).await.map_err(map_postgres_error)?; tx.execute(&stmt, &[&key, &value]) .await diff --git a/engine/sdks/rust/depot-protocol/build.rs b/engine/sdks/rust/depot-protocol/build.rs index 25fba3d4e7..5bf2c2b399 100644 --- a/engine/sdks/rust/depot-protocol/build.rs +++ b/engine/sdks/rust/depot-protocol/build.rs @@ -8,10 +8,7 @@ fn main() -> Result<(), Box> { .and_then(|p| p.parent()) .ok_or("Failed to find workspace root")?; - let schema_dir = workspace_root - .join("sdks") - .join("schemas") - .join("depot"); + let schema_dir = workspace_root.join("sdks").join("schemas").join("depot"); let cfg = vbare_compiler::Config::default(); vbare_compiler::process_schemas_with_config(&schema_dir, &cfg)?; diff --git a/engine/sdks/rust/envoy-client/src/actor.rs b/engine/sdks/rust/envoy-client/src/actor.rs index efe6885983..c214d94c96 100644 --- a/engine/sdks/rust/envoy-client/src/actor.rs +++ b/engine/sdks/rust/envoy-client/src/actor.rs @@ -2,8 +2,8 @@ use std::collections::BTreeMap; use std::collections::HashMap; use std::sync::Arc; -use rivet_envoy_protocol as protocol; use crate::async_counter::AsyncCounter; +use rivet_envoy_protocol as protocol; use rivet_util_serde::HashableMap; use tokio::sync::mpsc; use tokio::sync::oneshot; @@ -190,10 +190,10 @@ async fn actor_inner( .on_actor_start( handle.clone(), actor_id.clone(), - generation, - config, - preloaded_kv, - ) + generation, + config, + preloaded_kv, + ) .await; if let Err(error) = start_result { @@ -1389,12 +1389,11 @@ mod tests { use std::future::pending; use std::sync::Mutex; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; - use std::time::Duration; + use std::time::{Duration, Instant}; use tokio::sync::Notify; use tokio::sync::oneshot; use tokio::task::yield_now; - use tokio::time::Instant; use super::*; use crate::config::{BoxFuture, EnvoyCallbacks, WebSocketHandler, WebSocketSender}; @@ -1459,10 +1458,10 @@ mod tests { &self, _handle: EnvoyHandle, _actor_id: String, - _generation: u32, - _config: protocol::ActorConfig, - _preloaded_kv: Option, - ) -> BoxFuture> { + _generation: u32, + _config: protocol::ActorConfig, + _preloaded_kv: Option, + ) -> BoxFuture> { Box::pin(async { Ok(()) }) } @@ -1559,10 +1558,10 @@ mod tests { &self, _handle: EnvoyHandle, _actor_id: String, - _generation: u32, - _config: protocol::ActorConfig, - _preloaded_kv: Option, - ) -> BoxFuture> { + _generation: u32, + _config: protocol::ActorConfig, + _preloaded_kv: Option, + ) -> BoxFuture> { Box::pin(async { Ok(()) }) } diff --git a/engine/sdks/rust/envoy-client/src/connection/mod.rs b/engine/sdks/rust/envoy-client/src/connection/mod.rs index b685a440d8..73f9138f65 100644 --- a/engine/sdks/rust/envoy-client/src/connection/mod.rs +++ b/engine/sdks/rust/envoy-client/src/connection/mod.rs @@ -50,7 +50,8 @@ async fn send_initial_metadata(shared: &SharedContext) { prepopulate_map.insert( name.clone(), protocol::ActorName { - metadata: serde_json::to_string(&actor.metadata).unwrap_or_else(|_| "{}".to_string()), + metadata: serde_json::to_string(&actor.metadata) + .unwrap_or_else(|_| "{}".to_string()), }, ); } diff --git a/engine/sdks/rust/envoy-client/src/connection/wasm.rs b/engine/sdks/rust/envoy-client/src/connection/wasm.rs index 2eb230b962..c9a3d87037 100644 --- a/engine/sdks/rust/envoy-client/src/connection/wasm.rs +++ b/engine/sdks/rust/envoy-client/src/connection/wasm.rs @@ -172,17 +172,18 @@ mod imp { super::super::send_initial_metadata(&shared).await; while let Some(msg) = ws_rx.recv().await { - match msg { - WsTxMessage::Send(data) => { - let data = Uint8Array::from(data.as_slice()); - if let Err(error) = ws.send_with_array_buffer(&data.buffer()) { - tracing::error!(error = %js_error(error), "failed to send ws message"); - let _ = event_tx.send(ConnectionEvent::WriteFailed); - break; - } + match msg { + WsTxMessage::Send(data) => { + let data = Uint8Array::from(data.as_slice()); + if let Err(error) = ws.send_with_array_buffer(&data.buffer()) { + tracing::error!(error = %js_error(error), "failed to send ws message"); + let _ = event_tx.send(ConnectionEvent::WriteFailed); + break; + } } WsTxMessage::Close => { - let _ = ws.close_with_code_and_reason(NORMAL_CLOSE_CODE, "envoy.shutdown"); + let _ = + ws.close_with_code_and_reason(NORMAL_CLOSE_CODE, "envoy.shutdown"); break; } } @@ -295,7 +296,11 @@ mod imp { fn js_error(error: JsValue) -> String { error .as_string() - .or_else(|| js_sys::JSON::stringify(&error).ok().and_then(|s| s.as_string())) + .or_else(|| { + js_sys::JSON::stringify(&error) + .ok() + .and_then(|s| s.as_string()) + }) .unwrap_or_else(|| "unknown JavaScript error".to_string()) } } @@ -308,7 +313,9 @@ mod imp { use crate::envoy::ToEnvoyMessage; pub fn start_connection(shared: Arc) { - let _ = shared.envoy_tx.send(ToEnvoyMessage::ConnClose { evict: false }); + let _ = shared + .envoy_tx + .send(ToEnvoyMessage::ConnClose { evict: false }); tracing::error!("wasm envoy transport requires the wasm32 target"); } } diff --git a/engine/sdks/rust/envoy-client/src/context.rs b/engine/sdks/rust/envoy-client/src/context.rs index 56347564d7..141ac66c64 100644 --- a/engine/sdks/rust/envoy-client/src/context.rs +++ b/engine/sdks/rust/envoy-client/src/context.rs @@ -3,8 +3,8 @@ use std::sync::Arc; use std::sync::Mutex as StdMutex; use std::sync::atomic::AtomicBool; -use rivet_envoy_protocol as protocol; use crate::async_counter::AsyncCounter; +use rivet_envoy_protocol as protocol; use tokio::sync::Mutex; use tokio::sync::mpsc; use tokio::sync::watch; diff --git a/engine/sdks/rust/envoy-client/src/envoy.rs b/engine/sdks/rust/envoy-client/src/envoy.rs index b79e28e4af..af4fbc6fab 100644 --- a/engine/sdks/rust/envoy-client/src/envoy.rs +++ b/engine/sdks/rust/envoy-client/src/envoy.rs @@ -7,8 +7,8 @@ use std::sync::atomic::Ordering; #[cfg(not(target_arch = "wasm32"))] use parking_lot::Mutex; -use rivet_envoy_protocol as protocol; use crate::async_counter::AsyncCounter; +use rivet_envoy_protocol as protocol; use tokio::sync::mpsc; use tokio::sync::oneshot; @@ -29,9 +29,8 @@ use crate::sqlite::{ cleanup_old_sqlite_requests, fail_remote_sqlite_requests_with_shutdown, fail_sent_remote_sqlite_requests_with_indeterminate_result, fail_sqlite_requests_with_shutdown, handle_remote_sqlite_exec_response, handle_remote_sqlite_execute_response, - handle_remote_sqlite_execute_write_response, handle_remote_sqlite_request, - handle_sqlite_commit_response, handle_sqlite_get_pages_response, handle_sqlite_request, - process_unsent_remote_sqlite_requests, process_unsent_sqlite_requests, + handle_remote_sqlite_request, handle_sqlite_commit_response, handle_sqlite_get_pages_response, + handle_sqlite_request, process_unsent_remote_sqlite_requests, process_unsent_sqlite_requests, }; use crate::tunnel::{ handle_tunnel_message, resend_buffered_tunnel_messages, send_hibernatable_ws_message_ack, @@ -342,8 +341,7 @@ async fn envoy_loop( start_tx: tokio::sync::watch::Sender<()>, ) { let mut ack_tick = boxed_sleep(std::time::Duration::from_millis(ACK_COMMANDS_INTERVAL_MS)); - let mut kv_cleanup_tick = - boxed_sleep(std::time::Duration::from_millis(KV_CLEANUP_INTERVAL_MS)); + let mut kv_cleanup_tick = boxed_sleep(std::time::Duration::from_millis(KV_CLEANUP_INTERVAL_MS)); let mut lost_timeout: Option = None; @@ -548,9 +546,6 @@ async fn handle_conn_message( protocol::ToEnvoy::ToEnvoySqliteExecuteResponse(response) => { handle_remote_sqlite_execute_response(ctx, response).await; } - protocol::ToEnvoy::ToEnvoySqliteExecuteWriteResponse(response) => { - handle_remote_sqlite_execute_write_response(ctx, response).await; - } protocol::ToEnvoy::ToEnvoyTunnelMessage(tunnel_msg) => { handle_tunnel_message(ctx, tunnel_msg).await; } @@ -562,10 +557,7 @@ async fn handle_conn_message( lost_timeout } -fn handle_conn_close( - ctx: &EnvoyContext, - lost_timeout: Option, -) -> Option { +fn handle_conn_close(ctx: &EnvoyContext, lost_timeout: Option) -> Option { if lost_timeout.is_some() { return lost_timeout; } @@ -580,7 +572,9 @@ fn handle_conn_close( tracing::debug!(ms = lost_threshold, "starting envoy lost timeout"); - Some(boxed_sleep(std::time::Duration::from_millis(lost_threshold))) + Some(boxed_sleep(std::time::Duration::from_millis( + lost_threshold, + ))) } async fn handle_shutdown(ctx: &mut EnvoyContext) { diff --git a/engine/sdks/rust/envoy-client/src/events.rs b/engine/sdks/rust/envoy-client/src/events.rs index 806a7f812b..cfdcd8118e 100644 --- a/engine/sdks/rust/envoy-client/src/events.rs +++ b/engine/sdks/rust/envoy-client/src/events.rs @@ -78,8 +78,8 @@ mod tests { use std::collections::HashMap; use std::sync::Arc; - use rivet_envoy_protocol as protocol; use crate::async_counter::AsyncCounter; + use rivet_envoy_protocol as protocol; use tokio::sync::mpsc; use super::handle_send_events; @@ -99,10 +99,10 @@ mod tests { &self, _handle: EnvoyHandle, _actor_id: String, - _generation: u32, - _config: protocol::ActorConfig, - _preloaded_kv: Option, - ) -> BoxFuture> { + _generation: u32, + _config: protocol::ActorConfig, + _preloaded_kv: Option, + ) -> BoxFuture> { Box::pin(async { Ok(()) }) } diff --git a/engine/sdks/rust/envoy-client/src/handle.rs b/engine/sdks/rust/envoy-client/src/handle.rs index 5e3af875ec..6d3633bd75 100644 --- a/engine/sdks/rust/envoy-client/src/handle.rs +++ b/engine/sdks/rust/envoy-client/src/handle.rs @@ -1,8 +1,8 @@ use std::sync::Arc; use std::sync::atomic::Ordering; -use rivet_envoy_protocol as protocol; use crate::async_counter::AsyncCounter; +use rivet_envoy_protocol as protocol; use tokio::sync::oneshot; use crate::context::SharedContext; @@ -445,19 +445,6 @@ impl EnvoyHandle { } } - pub async fn remote_sqlite_execute_write( - &self, - request: protocol::SqliteExecuteWriteRequest, - ) -> anyhow::Result { - match self - .send_remote_sqlite_request(RemoteSqliteRequest::ExecuteWrite(request)) - .await? - { - RemoteSqliteResponse::ExecuteWrite(response) => Ok(response), - _ => anyhow::bail!("unexpected remote sqlite execute_write response type"), - } - } - pub fn restore_hibernating_requests( &self, actor_id: String, diff --git a/engine/sdks/rust/envoy-client/src/sqlite.rs b/engine/sdks/rust/envoy-client/src/sqlite.rs index a18e7c3c53..8dfa06628a 100644 --- a/engine/sdks/rust/envoy-client/src/sqlite.rs +++ b/engine/sdks/rust/envoy-client/src/sqlite.rs @@ -21,14 +21,12 @@ pub enum SqliteResponse { pub enum RemoteSqliteRequest { Exec(protocol::SqliteExecRequest), Execute(protocol::SqliteExecuteRequest), - ExecuteWrite(protocol::SqliteExecuteWriteRequest), } #[derive(Debug)] pub enum RemoteSqliteResponse { Exec(protocol::SqliteExecResponse), Execute(protocol::SqliteExecuteResponse), - ExecuteWrite(protocol::SqliteExecuteWriteResponse), } impl RemoteSqliteRequest { @@ -36,7 +34,6 @@ impl RemoteSqliteRequest { match self { RemoteSqliteRequest::Exec(_) => "exec", RemoteSqliteRequest::Execute(_) => "execute", - RemoteSqliteRequest::ExecuteWrite(_) => "execute_write", } } } @@ -157,18 +154,6 @@ pub async fn handle_remote_sqlite_execute_response( ); } -pub async fn handle_remote_sqlite_execute_write_response( - ctx: &mut EnvoyContext, - response: protocol::ToEnvoySqliteExecuteWriteResponse, -) { - handle_remote_sqlite_response( - ctx, - response.request_id, - RemoteSqliteResponse::ExecuteWrite(response.data), - "remote_sqlite_execute_write", - ); -} - fn handle_sqlite_response( ctx: &mut EnvoyContext, request_id: u32, @@ -266,11 +251,6 @@ pub fn remote_sqlite_request_to_message( data, }) } - RemoteSqliteRequest::ExecuteWrite(data) => { - protocol::ToRivet::ToRivetSqliteExecuteWriteRequest( - protocol::ToRivetSqliteExecuteWriteRequest { request_id, data }, - ) - } } } @@ -358,13 +338,17 @@ pub fn cleanup_old_remote_sqlite_requests(ctx: &mut EnvoyContext) { pub fn fail_sqlite_requests_with_shutdown(ctx: &mut EnvoyContext) { for (_id, request) in ctx.sqlite_requests.drain() { - let _ = request.response_tx.send(Err(anyhow::anyhow!(EnvoyShutdownError))); + let _ = request + .response_tx + .send(Err(anyhow::anyhow!(EnvoyShutdownError))); } } pub fn fail_remote_sqlite_requests_with_shutdown(ctx: &mut EnvoyContext) { for (_id, request) in ctx.remote_sqlite_requests.drain() { - let _ = request.response_tx.send(Err(anyhow::anyhow!(EnvoyShutdownError))); + let _ = request + .response_tx + .send(Err(anyhow::anyhow!(EnvoyShutdownError))); } } @@ -384,11 +368,9 @@ pub fn fail_sent_remote_sqlite_requests_with_indeterminate_result(ctx: &mut Envo operation, "remote sqlite response lost after websocket disconnect" ); - let _ = request - .response_tx - .send(Err(anyhow::anyhow!(RemoteSqliteIndeterminateResultError { - operation, - }))); + let _ = request.response_tx.send(Err(anyhow::anyhow!( + RemoteSqliteIndeterminateResultError { operation } + ))); } } } @@ -419,7 +401,6 @@ mod tests { _generation: u32, _config: protocol::ActorConfig, _preloaded_kv: Option, - _sqlite_startup_data: Option, ) -> BoxFuture> { Box::pin(async { Ok(()) }) } @@ -530,20 +511,6 @@ mod tests { } } - fn execute_write_request() -> protocol::SqliteExecuteWriteRequest { - protocol::SqliteExecuteWriteRequest { - namespace_id: "ns".to_string(), - actor_id: "actor".to_string(), - generation: 1, - sql: "insert into test values (?)".to_string(), - params: Some(vec![protocol::SqliteBindParam::SqliteValueText( - protocol::SqliteValueText { - value: "value".to_string(), - }, - )]), - } - } - #[tokio::test] async fn remote_sqlite_exec_response_matches_pending_request() { let mut ctx = new_envoy_context(); @@ -587,7 +554,6 @@ mod tests { let requests = vec![ RemoteSqliteRequest::Exec(exec_request()), RemoteSqliteRequest::Execute(execute_request()), - RemoteSqliteRequest::ExecuteWrite(execute_write_request()), ]; for request in requests { @@ -612,8 +578,12 @@ mod tests { let mut ctx = new_envoy_context(); let (tx, rx) = oneshot::channel(); - handle_remote_sqlite_request(&mut ctx, RemoteSqliteRequest::Execute(execute_request()), tx) - .await; + handle_remote_sqlite_request( + &mut ctx, + RemoteSqliteRequest::Execute(execute_request()), + tx, + ) + .await; fail_remote_sqlite_requests_with_shutdown(&mut ctx); let err = rx @@ -633,7 +603,7 @@ mod tests { handle_remote_sqlite_request( &mut ctx, - RemoteSqliteRequest::ExecuteWrite(execute_write_request()), + RemoteSqliteRequest::Execute(execute_request()), tx, ) .await; @@ -654,7 +624,7 @@ mod tests { let indeterminate = err .downcast_ref::() .expect("error should describe indeterminate remote sqlite result"); - assert_eq!(indeterminate.operation, "execute_write"); + assert_eq!(indeterminate.operation, "execute"); assert!(ctx.remote_sqlite_requests.is_empty()); } @@ -663,8 +633,12 @@ mod tests { let mut ctx = new_envoy_context(); let (tx, mut rx) = oneshot::channel(); - handle_remote_sqlite_request(&mut ctx, RemoteSqliteRequest::Execute(execute_request()), tx) - .await; + handle_remote_sqlite_request( + &mut ctx, + RemoteSqliteRequest::Execute(execute_request()), + tx, + ) + .await; assert!( !ctx.remote_sqlite_requests .get(&0) diff --git a/engine/sdks/rust/envoy-client/src/stringify.rs b/engine/sdks/rust/envoy-client/src/stringify.rs index 9c21fd3a8a..6fd1ffb8ad 100644 --- a/engine/sdks/rust/envoy-client/src/stringify.rs +++ b/engine/sdks/rust/envoy-client/src/stringify.rs @@ -287,12 +287,6 @@ pub fn stringify_to_rivet(message: &protocol::ToRivet) -> String { val.request_id, val.data.actor_id, val.data.generation ) } - protocol::ToRivet::ToRivetSqliteExecuteWriteRequest(val) => { - format!( - "ToRivetSqliteExecuteWriteRequest{{requestId: {}, actorId: \"{}\", generation: {}}}", - val.request_id, val.data.actor_id, val.data.generation - ) - } protocol::ToRivet::ToRivetTunnelMessage(val) => { format!( "ToRivetTunnelMessage{{messageId: {}, messageKind: {}}}", @@ -354,12 +348,6 @@ pub fn stringify_to_envoy(message: &protocol::ToEnvoy) -> String { val.request_id ) } - protocol::ToEnvoy::ToEnvoySqliteExecuteWriteResponse(val) => { - format!( - "ToEnvoySqliteExecuteWriteResponse{{requestId: {}}}", - val.request_id - ) - } protocol::ToEnvoy::ToEnvoyTunnelMessage(val) => { format!( "ToEnvoyTunnelMessage{{messageId: {}, messageKind: {}}}", diff --git a/engine/sdks/rust/envoy-client/tests/command_dedup.rs b/engine/sdks/rust/envoy-client/tests/command_dedup.rs index ee5cea90e7..8f3650331d 100644 --- a/engine/sdks/rust/envoy-client/tests/command_dedup.rs +++ b/engine/sdks/rust/envoy-client/tests/command_dedup.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use std::sync::Arc; use rivet_envoy_client::actor::ToActor; +use rivet_envoy_client::async_counter::AsyncCounter; use rivet_envoy_client::commands::handle_commands; use rivet_envoy_client::config::{ BoxFuture, EnvoyCallbacks, EnvoyConfig, HttpRequest, HttpResponse, WebSocketHandler, @@ -16,7 +17,6 @@ use rivet_envoy_client::sqlite::{ }; use rivet_envoy_client::utils::{BufferMap, RemoteSqliteIndeterminateResultError}; use rivet_envoy_protocol as protocol; -use rivet_envoy_client::async_counter::AsyncCounter; use tokio::sync::mpsc; struct IdleCallbacks; @@ -130,8 +130,8 @@ fn stop_command(actor_id: &str, generation: u32, index: i64) -> protocol::Comman } } -fn execute_write_request() -> protocol::SqliteExecuteWriteRequest { - protocol::SqliteExecuteWriteRequest { +fn execute_request() -> protocol::SqliteExecuteRequest { + protocol::SqliteExecuteRequest { namespace_id: "test".to_string(), actor_id: "actor-replay".to_string(), generation: 1, @@ -240,7 +240,7 @@ async fn replayed_command_is_dropped_after_remote_sql_lost_response() { let (sql_tx, sql_rx) = tokio::sync::oneshot::channel(); handle_remote_sqlite_request( &mut ctx, - RemoteSqliteRequest::ExecuteWrite(execute_write_request()), + RemoteSqliteRequest::Execute(execute_request()), sql_tx, ) .await; diff --git a/engine/sdks/rust/envoy-protocol/src/versioned.rs b/engine/sdks/rust/envoy-protocol/src/versioned.rs index b8060acf55..3c4952339b 100644 --- a/engine/sdks/rust/envoy-protocol/src/versioned.rs +++ b/engine/sdks/rust/envoy-protocol/src/versioned.rs @@ -99,7 +99,6 @@ fn incompatible( .into() } - pub enum ToEnvoy { V4(v4::ToEnvoy), } @@ -138,8 +137,10 @@ impl OwnedVersionedData for ToEnvoy { convert_to_envoy_v4_to_v3(data, 1)?, )?)?) .map_err(Into::into), - 2 => serde_bare::to_vec(&convert_to_envoy_v3_to_v2(convert_to_envoy_v4_to_v3(data, 2)?)?) - .map_err(Into::into), + 2 => serde_bare::to_vec(&convert_to_envoy_v3_to_v2(convert_to_envoy_v4_to_v3( + data, 2, + )?)?) + .map_err(Into::into), 3 => serde_bare::to_vec(&convert_to_envoy_v4_to_v3(data, 3)?).map_err(Into::into), 4 => serde_bare::to_vec(&data).map_err(Into::into), _ => bail!("invalid version: {version}"), @@ -187,8 +188,7 @@ impl OwnedVersionedData for ToRivet { let Self::V4(data) = self; match version { 1 | 2 => serde_bare::to_vec(&convert_to_rivet_v3_to_v2(convert_to_rivet_v4_to_v3( - data, - version, + data, version, )?)?) .map_err(Into::into), 3 => serde_bare::to_vec(&convert_to_rivet_v4_to_v3(data, 3)?).map_err(Into::into), @@ -242,12 +242,10 @@ impl OwnedVersionedData for ToEnvoyConn { let data_v3 = || convert_same_bytes_ref::<_, v3::ToEnvoyConn>(&data); match version { 1 => { - serde_bare::to_vec(&convert_to_envoy_conn_v3_to_v1(data_v3()?)?) - .map_err(Into::into) + serde_bare::to_vec(&convert_to_envoy_conn_v3_to_v1(data_v3()?)?).map_err(Into::into) } 2 => { - serde_bare::to_vec(&convert_to_envoy_conn_v3_to_v2(data_v3()?)?) - .map_err(Into::into) + serde_bare::to_vec(&convert_to_envoy_conn_v3_to_v2(data_v3()?)?).map_err(Into::into) } 3 => serde_bare::to_vec(&data_v3()?).map_err(Into::into), 4 => serde_bare::to_vec(&data).map_err(Into::into), @@ -283,8 +281,12 @@ impl OwnedVersionedData for ToGateway { fn deserialize_version(payload: &[u8], version: u16) -> Result { Ok(Self::V4(match version { - 1 => convert_same_bytes(convert_to_gateway_v1_to_v3(serde_bare::from_slice(payload)?))?, - 2 => convert_same_bytes(convert_to_gateway_v2_to_v3(serde_bare::from_slice(payload)?))?, + 1 => convert_same_bytes(convert_to_gateway_v1_to_v3(serde_bare::from_slice( + payload, + )?))?, + 2 => convert_same_bytes(convert_to_gateway_v2_to_v3(serde_bare::from_slice( + payload, + )?))?, 3 => convert_same_bytes(serde_bare::from_slice::(payload)?)?, 4 => serde_bare::from_slice(payload)?, _ => bail!("invalid version: {version}"), @@ -399,14 +401,10 @@ impl OwnedVersionedData for ActorCommandKeyData { let Self::V4(data) = self; let data_v3 = || convert_same_bytes_ref::<_, v3::ActorCommandKeyData>(&data); match version { - 1 => { - serde_bare::to_vec(&convert_actor_command_key_data_v3_to_v1(data_v3()?)) - .map_err(Into::into) - } - 2 => { - serde_bare::to_vec(&convert_actor_command_key_data_v3_to_v2(data_v3()?)) - .map_err(Into::into) - } + 1 => serde_bare::to_vec(&convert_actor_command_key_data_v3_to_v1(data_v3()?)) + .map_err(Into::into), + 2 => serde_bare::to_vec(&convert_actor_command_key_data_v3_to_v2(data_v3()?)) + .map_err(Into::into), 3 => serde_bare::to_vec(&data_v3()?).map_err(Into::into), 4 => serde_bare::to_vec(&data).map_err(Into::into), _ => bail!("invalid version: {version}"), @@ -444,14 +442,6 @@ fn convert_to_envoy_v4_to_v3(message: v4::ToEnvoy, target_version: u16) -> Resul target_version, )); } - v4::ToEnvoy::ToEnvoySqliteExecuteWriteResponse(_) => { - return Err(incompatible( - ProtocolCompatibilityFeature::RemoteSqliteExecution, - ProtocolCompatibilityDirection::ToEnvoy, - 4, - target_version, - )); - } v4::ToEnvoy::ToEnvoyInit(_) | v4::ToEnvoy::ToEnvoyCommands(_) | v4::ToEnvoy::ToEnvoyAckEvents(_) @@ -487,14 +477,6 @@ fn convert_to_rivet_v4_to_v3(message: v4::ToRivet, target_version: u16) -> Resul target_version, )); } - v4::ToRivet::ToRivetSqliteExecuteWriteRequest(_) => { - return Err(incompatible( - ProtocolCompatibilityFeature::RemoteSqliteExecution, - ProtocolCompatibilityDirection::ToRivet, - 4, - target_version, - )); - } v4::ToRivet::ToRivetMetadata(_) | v4::ToRivet::ToRivetEvents(_) | v4::ToRivet::ToRivetAckCommands(_) @@ -529,9 +511,7 @@ fn convert_to_envoy_v2_to_v3(message: v2::ToEnvoy) -> Result { v2::ToEnvoy::ToEnvoyTunnelMessage(message) => { v3::ToEnvoy::ToEnvoyTunnelMessage(convert_to_envoy_tunnel_message_v2_to_v3(message)) } - v2::ToEnvoy::ToEnvoyPing(ping) => { - v3::ToEnvoy::ToEnvoyPing(v3::ToEnvoyPing { ts: ping.ts }) - } + v2::ToEnvoy::ToEnvoyPing(ping) => v3::ToEnvoy::ToEnvoyPing(v3::ToEnvoyPing { ts: ping.ts }), v2::ToEnvoy::ToEnvoySqliteGetPagesResponse(_) | v2::ToEnvoy::ToEnvoySqliteCommitResponse(_) | v2::ToEnvoy::ToEnvoySqliteCommitStageBeginResponse(_) @@ -562,9 +542,7 @@ fn convert_to_envoy_v3_to_v2(message: v3::ToEnvoy) -> Result { v3::ToEnvoy::ToEnvoyTunnelMessage(message) => { v2::ToEnvoy::ToEnvoyTunnelMessage(convert_to_envoy_tunnel_message_v3_to_v2(message)) } - v3::ToEnvoy::ToEnvoyPing(ping) => { - v2::ToEnvoy::ToEnvoyPing(v2::ToEnvoyPing { ts: ping.ts }) - } + v3::ToEnvoy::ToEnvoyPing(ping) => v2::ToEnvoy::ToEnvoyPing(v2::ToEnvoyPing { ts: ping.ts }), v3::ToEnvoy::ToEnvoySqliteGetPagesResponse(_) | v3::ToEnvoy::ToEnvoySqliteCommitResponse(_) => { bail!("stateless sqlite responses require envoy-protocol v3") @@ -578,7 +556,10 @@ fn convert_to_rivet_v2_to_v3(message: v2::ToRivet) -> Result { v3::ToRivet::ToRivetMetadata(convert_to_rivet_metadata_v2_to_v3(metadata)) } v2::ToRivet::ToRivetEvents(events) => v3::ToRivet::ToRivetEvents( - events.into_iter().map(convert_event_wrapper_v2_to_v3).collect(), + events + .into_iter() + .map(convert_event_wrapper_v2_to_v3) + .collect(), ), v2::ToRivet::ToRivetAckCommands(ack) => { v3::ToRivet::ToRivetAckCommands(convert_to_rivet_ack_commands_v2_to_v3(ack)) @@ -607,7 +588,10 @@ fn convert_to_rivet_v3_to_v2(message: v3::ToRivet) -> Result { v2::ToRivet::ToRivetMetadata(convert_to_rivet_metadata_v3_to_v2(metadata)) } v3::ToRivet::ToRivetEvents(events) => v2::ToRivet::ToRivetEvents( - events.into_iter().map(convert_event_wrapper_v3_to_v2).collect(), + events + .into_iter() + .map(convert_event_wrapper_v3_to_v2) + .collect(), ), v3::ToRivet::ToRivetAckCommands(ack) => { v2::ToRivet::ToRivetAckCommands(convert_to_rivet_ack_commands_v3_to_v2(ack)) @@ -847,9 +831,7 @@ fn convert_to_envoy_v1_to_v2(message: v1::ToEnvoy) -> Result { v1::ToEnvoy::ToEnvoyTunnelMessage(message) => { v2::ToEnvoy::ToEnvoyTunnelMessage(convert_to_envoy_tunnel_message_v1_to_v2(message)) } - v1::ToEnvoy::ToEnvoyPing(ping) => { - v2::ToEnvoy::ToEnvoyPing(v2::ToEnvoyPing { ts: ping.ts }) - } + v1::ToEnvoy::ToEnvoyPing(ping) => v2::ToEnvoy::ToEnvoyPing(v2::ToEnvoyPing { ts: ping.ts }), }) } @@ -873,9 +855,7 @@ fn convert_to_envoy_v2_to_v1(message: v2::ToEnvoy) -> Result { v2::ToEnvoy::ToEnvoyTunnelMessage(message) => { v1::ToEnvoy::ToEnvoyTunnelMessage(convert_to_envoy_tunnel_message_v2_to_v1(message)) } - v2::ToEnvoy::ToEnvoyPing(ping) => { - v1::ToEnvoy::ToEnvoyPing(v1::ToEnvoyPing { ts: ping.ts }) - } + v2::ToEnvoy::ToEnvoyPing(ping) => v1::ToEnvoy::ToEnvoyPing(v1::ToEnvoyPing { ts: ping.ts }), v2::ToEnvoy::ToEnvoySqliteGetPagesResponse(_) | v2::ToEnvoy::ToEnvoySqliteCommitResponse(_) | v2::ToEnvoy::ToEnvoySqliteCommitStageBeginResponse(_) @@ -1091,7 +1071,9 @@ fn convert_command_start_actor_v3_to_v2(start: v3::CommandStartActor) -> v2::Com } } -fn convert_actor_command_key_data_v1_to_v3(data: v1::ActorCommandKeyData) -> v3::ActorCommandKeyData { +fn convert_actor_command_key_data_v1_to_v3( + data: v1::ActorCommandKeyData, +) -> v3::ActorCommandKeyData { match data { v1::ActorCommandKeyData::CommandStartActor(start) => { v3::ActorCommandKeyData::CommandStartActor(convert_command_start_actor_v1_to_v3(start)) @@ -1104,7 +1086,9 @@ fn convert_actor_command_key_data_v1_to_v3(data: v1::ActorCommandKeyData) -> v3: } } -fn convert_actor_command_key_data_v2_to_v3(data: v2::ActorCommandKeyData) -> v3::ActorCommandKeyData { +fn convert_actor_command_key_data_v2_to_v3( + data: v2::ActorCommandKeyData, +) -> v3::ActorCommandKeyData { match data { v2::ActorCommandKeyData::CommandStartActor(start) => { v3::ActorCommandKeyData::CommandStartActor(convert_command_start_actor_v2_to_v3(start)) @@ -1117,7 +1101,9 @@ fn convert_actor_command_key_data_v2_to_v3(data: v2::ActorCommandKeyData) -> v3: } } -fn convert_actor_command_key_data_v3_to_v1(data: v3::ActorCommandKeyData) -> v1::ActorCommandKeyData { +fn convert_actor_command_key_data_v3_to_v1( + data: v3::ActorCommandKeyData, +) -> v1::ActorCommandKeyData { match data { v3::ActorCommandKeyData::CommandStartActor(start) => { v1::ActorCommandKeyData::CommandStartActor(convert_command_start_actor_v3_to_v1(start)) @@ -1130,7 +1116,9 @@ fn convert_actor_command_key_data_v3_to_v1(data: v3::ActorCommandKeyData) -> v1: } } -fn convert_actor_command_key_data_v3_to_v2(data: v3::ActorCommandKeyData) -> v2::ActorCommandKeyData { +fn convert_actor_command_key_data_v3_to_v2( + data: v3::ActorCommandKeyData, +) -> v2::ActorCommandKeyData { match data { v3::ActorCommandKeyData::CommandStartActor(start) => { v2::ActorCommandKeyData::CommandStartActor(convert_command_start_actor_v3_to_v2(start)) @@ -1176,141 +1164,279 @@ fn convert_protocol_metadata_v3_to_v2(value: v3::ProtocolMetadata) -> v2::Protoc } fn convert_actor_config_v1_to_v2(value: v1::ActorConfig) -> v2::ActorConfig { - v2::ActorConfig { name: value.name, key: value.key, create_ts: value.create_ts, input: value.input } + v2::ActorConfig { + name: value.name, + key: value.key, + create_ts: value.create_ts, + input: value.input, + } } fn convert_actor_config_v2_to_v1(value: v2::ActorConfig) -> v1::ActorConfig { - v1::ActorConfig { name: value.name, key: value.key, create_ts: value.create_ts, input: value.input } + v1::ActorConfig { + name: value.name, + key: value.key, + create_ts: value.create_ts, + input: value.input, + } } fn convert_actor_config_v1_to_v3(value: v1::ActorConfig) -> v3::ActorConfig { - v3::ActorConfig { name: value.name, key: value.key, create_ts: value.create_ts, input: value.input } + v3::ActorConfig { + name: value.name, + key: value.key, + create_ts: value.create_ts, + input: value.input, + } } fn convert_actor_config_v2_to_v3(value: v2::ActorConfig) -> v3::ActorConfig { - v3::ActorConfig { name: value.name, key: value.key, create_ts: value.create_ts, input: value.input } + v3::ActorConfig { + name: value.name, + key: value.key, + create_ts: value.create_ts, + input: value.input, + } } fn convert_actor_config_v3_to_v1(value: v3::ActorConfig) -> v1::ActorConfig { - v1::ActorConfig { name: value.name, key: value.key, create_ts: value.create_ts, input: value.input } + v1::ActorConfig { + name: value.name, + key: value.key, + create_ts: value.create_ts, + input: value.input, + } } fn convert_actor_config_v3_to_v2(value: v3::ActorConfig) -> v2::ActorConfig { - v2::ActorConfig { name: value.name, key: value.key, create_ts: value.create_ts, input: value.input } + v2::ActorConfig { + name: value.name, + key: value.key, + create_ts: value.create_ts, + input: value.input, + } } fn convert_actor_checkpoint_v1_to_v2(value: v1::ActorCheckpoint) -> v2::ActorCheckpoint { - v2::ActorCheckpoint { actor_id: value.actor_id, generation: value.generation, index: value.index } + v2::ActorCheckpoint { + actor_id: value.actor_id, + generation: value.generation, + index: value.index, + } } fn convert_actor_checkpoint_v2_to_v1(value: v2::ActorCheckpoint) -> v1::ActorCheckpoint { - v1::ActorCheckpoint { actor_id: value.actor_id, generation: value.generation, index: value.index } + v1::ActorCheckpoint { + actor_id: value.actor_id, + generation: value.generation, + index: value.index, + } } fn convert_actor_checkpoint_v1_to_v3(value: v1::ActorCheckpoint) -> v3::ActorCheckpoint { - v3::ActorCheckpoint { actor_id: value.actor_id, generation: value.generation, index: value.index } + v3::ActorCheckpoint { + actor_id: value.actor_id, + generation: value.generation, + index: value.index, + } } fn convert_actor_checkpoint_v2_to_v3(value: v2::ActorCheckpoint) -> v3::ActorCheckpoint { - v3::ActorCheckpoint { actor_id: value.actor_id, generation: value.generation, index: value.index } + v3::ActorCheckpoint { + actor_id: value.actor_id, + generation: value.generation, + index: value.index, + } } fn convert_actor_checkpoint_v3_to_v1(value: v3::ActorCheckpoint) -> v1::ActorCheckpoint { - v1::ActorCheckpoint { actor_id: value.actor_id, generation: value.generation, index: value.index } + v1::ActorCheckpoint { + actor_id: value.actor_id, + generation: value.generation, + index: value.index, + } } fn convert_actor_checkpoint_v3_to_v2(value: v3::ActorCheckpoint) -> v2::ActorCheckpoint { - v2::ActorCheckpoint { actor_id: value.actor_id, generation: value.generation, index: value.index } + v2::ActorCheckpoint { + actor_id: value.actor_id, + generation: value.generation, + index: value.index, + } } fn convert_hibernating_request_v1_to_v2(value: v1::HibernatingRequest) -> v2::HibernatingRequest { - v2::HibernatingRequest { gateway_id: value.gateway_id, request_id: value.request_id } + v2::HibernatingRequest { + gateway_id: value.gateway_id, + request_id: value.request_id, + } } fn convert_hibernating_request_v2_to_v1(value: v2::HibernatingRequest) -> v1::HibernatingRequest { - v1::HibernatingRequest { gateway_id: value.gateway_id, request_id: value.request_id } + v1::HibernatingRequest { + gateway_id: value.gateway_id, + request_id: value.request_id, + } } fn convert_hibernating_request_v1_to_v3(value: v1::HibernatingRequest) -> v3::HibernatingRequest { - v3::HibernatingRequest { gateway_id: value.gateway_id, request_id: value.request_id } + v3::HibernatingRequest { + gateway_id: value.gateway_id, + request_id: value.request_id, + } } fn convert_hibernating_request_v2_to_v3(value: v2::HibernatingRequest) -> v3::HibernatingRequest { - v3::HibernatingRequest { gateway_id: value.gateway_id, request_id: value.request_id } + v3::HibernatingRequest { + gateway_id: value.gateway_id, + request_id: value.request_id, + } } fn convert_hibernating_request_v3_to_v1(value: v3::HibernatingRequest) -> v1::HibernatingRequest { - v1::HibernatingRequest { gateway_id: value.gateway_id, request_id: value.request_id } + v1::HibernatingRequest { + gateway_id: value.gateway_id, + request_id: value.request_id, + } } fn convert_hibernating_request_v3_to_v2(value: v3::HibernatingRequest) -> v2::HibernatingRequest { - v2::HibernatingRequest { gateway_id: value.gateway_id, request_id: value.request_id } + v2::HibernatingRequest { + gateway_id: value.gateway_id, + request_id: value.request_id, + } } fn convert_preloaded_kv_v1_to_v2(preloaded: v1::PreloadedKv) -> v2::PreloadedKv { v2::PreloadedKv { - entries: preloaded.entries.into_iter().map(convert_preloaded_kv_entry_v1_to_v2).collect(), + entries: preloaded + .entries + .into_iter() + .map(convert_preloaded_kv_entry_v1_to_v2) + .collect(), requested_get_keys: preloaded.requested_get_keys, requested_prefixes: preloaded.requested_prefixes, } } fn convert_preloaded_kv_v2_to_v1(preloaded: v2::PreloadedKv) -> v1::PreloadedKv { v1::PreloadedKv { - entries: preloaded.entries.into_iter().map(convert_preloaded_kv_entry_v2_to_v1).collect(), + entries: preloaded + .entries + .into_iter() + .map(convert_preloaded_kv_entry_v2_to_v1) + .collect(), requested_get_keys: preloaded.requested_get_keys, requested_prefixes: preloaded.requested_prefixes, } } fn convert_preloaded_kv_v1_to_v3(preloaded: v1::PreloadedKv) -> v3::PreloadedKv { v3::PreloadedKv { - entries: preloaded.entries.into_iter().map(convert_preloaded_kv_entry_v1_to_v3).collect(), + entries: preloaded + .entries + .into_iter() + .map(convert_preloaded_kv_entry_v1_to_v3) + .collect(), requested_get_keys: preloaded.requested_get_keys, requested_prefixes: preloaded.requested_prefixes, } } fn convert_preloaded_kv_v2_to_v3(preloaded: v2::PreloadedKv) -> v3::PreloadedKv { v3::PreloadedKv { - entries: preloaded.entries.into_iter().map(convert_preloaded_kv_entry_v2_to_v3).collect(), + entries: preloaded + .entries + .into_iter() + .map(convert_preloaded_kv_entry_v2_to_v3) + .collect(), requested_get_keys: preloaded.requested_get_keys, requested_prefixes: preloaded.requested_prefixes, } } fn convert_preloaded_kv_v3_to_v1(preloaded: v3::PreloadedKv) -> v1::PreloadedKv { v1::PreloadedKv { - entries: preloaded.entries.into_iter().map(convert_preloaded_kv_entry_v3_to_v1).collect(), + entries: preloaded + .entries + .into_iter() + .map(convert_preloaded_kv_entry_v3_to_v1) + .collect(), requested_get_keys: preloaded.requested_get_keys, requested_prefixes: preloaded.requested_prefixes, } } fn convert_preloaded_kv_v3_to_v2(preloaded: v3::PreloadedKv) -> v2::PreloadedKv { v2::PreloadedKv { - entries: preloaded.entries.into_iter().map(convert_preloaded_kv_entry_v3_to_v2).collect(), + entries: preloaded + .entries + .into_iter() + .map(convert_preloaded_kv_entry_v3_to_v2) + .collect(), requested_get_keys: preloaded.requested_get_keys, requested_prefixes: preloaded.requested_prefixes, } } fn convert_preloaded_kv_entry_v1_to_v2(entry: v1::PreloadedKvEntry) -> v2::PreloadedKvEntry { - v2::PreloadedKvEntry { key: entry.key, value: entry.value, metadata: convert_kv_metadata_v1_to_v2(entry.metadata) } + v2::PreloadedKvEntry { + key: entry.key, + value: entry.value, + metadata: convert_kv_metadata_v1_to_v2(entry.metadata), + } } fn convert_preloaded_kv_entry_v2_to_v1(entry: v2::PreloadedKvEntry) -> v1::PreloadedKvEntry { - v1::PreloadedKvEntry { key: entry.key, value: entry.value, metadata: convert_kv_metadata_v2_to_v1(entry.metadata) } + v1::PreloadedKvEntry { + key: entry.key, + value: entry.value, + metadata: convert_kv_metadata_v2_to_v1(entry.metadata), + } } fn convert_preloaded_kv_entry_v1_to_v3(entry: v1::PreloadedKvEntry) -> v3::PreloadedKvEntry { - v3::PreloadedKvEntry { key: entry.key, value: entry.value, metadata: convert_kv_metadata_v1_to_v3(entry.metadata) } + v3::PreloadedKvEntry { + key: entry.key, + value: entry.value, + metadata: convert_kv_metadata_v1_to_v3(entry.metadata), + } } fn convert_preloaded_kv_entry_v2_to_v3(entry: v2::PreloadedKvEntry) -> v3::PreloadedKvEntry { - v3::PreloadedKvEntry { key: entry.key, value: entry.value, metadata: convert_kv_metadata_v2_to_v3(entry.metadata) } + v3::PreloadedKvEntry { + key: entry.key, + value: entry.value, + metadata: convert_kv_metadata_v2_to_v3(entry.metadata), + } } fn convert_preloaded_kv_entry_v3_to_v1(entry: v3::PreloadedKvEntry) -> v1::PreloadedKvEntry { - v1::PreloadedKvEntry { key: entry.key, value: entry.value, metadata: convert_kv_metadata_v3_to_v1(entry.metadata) } + v1::PreloadedKvEntry { + key: entry.key, + value: entry.value, + metadata: convert_kv_metadata_v3_to_v1(entry.metadata), + } } fn convert_preloaded_kv_entry_v3_to_v2(entry: v3::PreloadedKvEntry) -> v2::PreloadedKvEntry { - v2::PreloadedKvEntry { key: entry.key, value: entry.value, metadata: convert_kv_metadata_v3_to_v2(entry.metadata) } + v2::PreloadedKvEntry { + key: entry.key, + value: entry.value, + metadata: convert_kv_metadata_v3_to_v2(entry.metadata), + } } fn convert_kv_metadata_v1_to_v2(value: v1::KvMetadata) -> v2::KvMetadata { - v2::KvMetadata { version: value.version, update_ts: value.update_ts } + v2::KvMetadata { + version: value.version, + update_ts: value.update_ts, + } } fn convert_kv_metadata_v2_to_v1(value: v2::KvMetadata) -> v1::KvMetadata { - v1::KvMetadata { version: value.version, update_ts: value.update_ts } + v1::KvMetadata { + version: value.version, + update_ts: value.update_ts, + } } fn convert_kv_metadata_v1_to_v3(value: v1::KvMetadata) -> v3::KvMetadata { - v3::KvMetadata { version: value.version, update_ts: value.update_ts } + v3::KvMetadata { + version: value.version, + update_ts: value.update_ts, + } } fn convert_kv_metadata_v2_to_v3(value: v2::KvMetadata) -> v3::KvMetadata { - v3::KvMetadata { version: value.version, update_ts: value.update_ts } + v3::KvMetadata { + version: value.version, + update_ts: value.update_ts, + } } fn convert_kv_metadata_v3_to_v1(value: v3::KvMetadata) -> v1::KvMetadata { - v1::KvMetadata { version: value.version, update_ts: value.update_ts } + v1::KvMetadata { + version: value.version, + update_ts: value.update_ts, + } } fn convert_kv_metadata_v3_to_v2(value: v3::KvMetadata) -> v2::KvMetadata { - v2::KvMetadata { version: value.version, update_ts: value.update_ts } + v2::KvMetadata { + version: value.version, + update_ts: value.update_ts, + } } include!("versioned_conversions.in"); @@ -1383,8 +1509,8 @@ mod tests { #[test] fn actor_command_key_data_round_trips_to_v1() -> Result<()> { - let encoded = ActorCommandKeyData::wrap_latest( - v4::ActorCommandKeyData::CommandStartActor(v4::CommandStartActor { + let encoded = ActorCommandKeyData::wrap_latest(v4::ActorCommandKeyData::CommandStartActor( + v4::CommandStartActor { config: v4::ActorConfig { name: "demo".into(), key: None, @@ -1393,8 +1519,8 @@ mod tests { }, hibernating_requests: Vec::new(), preloaded_kv: None, - }), - ) + }, + )) .serialize_version(1)?; let decoded = ActorCommandKeyData::deserialize_version(&encoded, 1)?.unwrap_latest()?; diff --git a/engine/sdks/rust/envoy-protocol/tests/remote_sql_compat.rs b/engine/sdks/rust/envoy-protocol/tests/remote_sql_compat.rs index ac867d68f2..17d4e7b55a 100644 --- a/engine/sdks/rust/envoy-protocol/tests/remote_sql_compat.rs +++ b/engine/sdks/rust/envoy-protocol/tests/remote_sql_compat.rs @@ -2,8 +2,8 @@ use anyhow::Result; use rivet_envoy_protocol::{ generated::v4, versioned::{ - ProtocolCompatibilityDirection, ProtocolCompatibilityError, - ProtocolCompatibilityFeature, ToEnvoy, ToRivet, + ProtocolCompatibilityDirection, ProtocolCompatibilityError, ProtocolCompatibilityFeature, + ToEnvoy, ToRivet, }, }; use vbare::OwnedVersionedData; @@ -35,23 +35,6 @@ fn remote_sql_request_execute() -> v4::ToRivet { }) } -fn remote_sql_request_execute_write() -> v4::ToRivet { - v4::ToRivet::ToRivetSqliteExecuteWriteRequest(v4::ToRivetSqliteExecuteWriteRequest { - request_id: 3, - data: v4::SqliteExecuteWriteRequest { - namespace_id: "namespace".into(), - actor_id: "actor".into(), - generation: 7, - sql: "insert into t values (?)".into(), - params: Some(vec![v4::SqliteBindParam::SqliteValueText( - v4::SqliteValueText { - value: "value".into(), - }, - )]), - }, - }) -} - fn remote_sql_response_exec() -> v4::ToEnvoy { v4::ToEnvoy::ToEnvoySqliteExecResponse(v4::ToEnvoySqliteExecResponse { request_id: 1, @@ -70,15 +53,6 @@ fn remote_sql_response_execute() -> v4::ToEnvoy { }) } -fn remote_sql_response_execute_write() -> v4::ToEnvoy { - v4::ToEnvoy::ToEnvoySqliteExecuteWriteResponse(v4::ToEnvoySqliteExecuteWriteResponse { - request_id: 3, - data: v4::SqliteExecuteWriteResponse::SqliteErrorResponse(v4::SqliteErrorResponse { - message: "remote sql execution is unavailable".into(), - }), - }) -} - fn assert_compatibility_error( err: anyhow::Error, direction: ProtocolCompatibilityDirection, @@ -88,7 +62,10 @@ fn assert_compatibility_error( .downcast_ref::() .expect("expected structured protocol compatibility error"); - assert_eq!(err.feature, ProtocolCompatibilityFeature::RemoteSqliteExecution); + assert_eq!( + err.feature, + ProtocolCompatibilityFeature::RemoteSqliteExecution + ); assert_eq!(err.direction, direction); assert_eq!(err.required_version, 4); assert_eq!(err.target_version, target_version); @@ -156,16 +133,12 @@ fn v4_remote_sql_payloads_do_not_decode_as_v3() -> Result<()> { #[test] fn all_remote_sql_request_variants_require_v4() { for version in 1..4 { - for request in [ - remote_sql_request_exec(), - remote_sql_request_execute(), - remote_sql_request_execute_write(), - ] { + for request in [remote_sql_request_exec(), remote_sql_request_execute()] { let err = ToRivet::wrap_latest(request) .serialize(version) .expect_err("remote SQL request variant must not serialize below v4"); - assert_compatibility_error(err, ProtocolCompatibilityDirection::ToRivet, 3); + assert_compatibility_error(err, ProtocolCompatibilityDirection::ToRivet, version); } } } @@ -173,16 +146,12 @@ fn all_remote_sql_request_variants_require_v4() { #[test] fn all_remote_sql_response_variants_require_v4() { for version in 1..4 { - for response in [ - remote_sql_response_exec(), - remote_sql_response_execute(), - remote_sql_response_execute_write(), - ] { + for response in [remote_sql_response_exec(), remote_sql_response_execute()] { let err = ToEnvoy::wrap_latest(response) .serialize(version) .expect_err("remote SQL response variant must not serialize below v4"); - assert_compatibility_error(err, ProtocolCompatibilityDirection::ToEnvoy, 3); + assert_compatibility_error(err, ProtocolCompatibilityDirection::ToEnvoy, version); } } } diff --git a/engine/sdks/rust/envoy-protocol/tests/stateless_sqlite_v3.rs b/engine/sdks/rust/envoy-protocol/tests/stateless_sqlite_v3.rs index 65e109c4be..4fcfabd259 100644 --- a/engine/sdks/rust/envoy-protocol/tests/stateless_sqlite_v3.rs +++ b/engine/sdks/rust/envoy-protocol/tests/stateless_sqlite_v3.rs @@ -18,18 +18,17 @@ fn roundtrip_to_envoy(message: protocol::ToEnvoy) -> anyhow::Result anyhow::Result<()> { for pgnos in [Vec::new(), vec![7], (1..=1000).collect::>()] { - let decoded = - roundtrip_to_rivet(protocol::ToRivet::ToRivetSqliteGetPagesRequest( - protocol::ToRivetSqliteGetPagesRequest { - request_id: 42, - data: protocol::SqliteGetPagesRequest { - actor_id: "actor-a".into(), - pgnos: pgnos.clone(), - expected_generation: None, - expected_head_txid: None, - }, + let decoded = roundtrip_to_rivet(protocol::ToRivet::ToRivetSqliteGetPagesRequest( + protocol::ToRivetSqliteGetPagesRequest { + request_id: 42, + data: protocol::SqliteGetPagesRequest { + actor_id: "actor-a".into(), + pgnos: pgnos.clone(), + expected_generation: None, + expected_head_txid: None, }, - ))?; + }, + ))?; let protocol::ToRivet::ToRivetSqliteGetPagesRequest(decoded) = decoded else { panic!("expected get_pages request"); @@ -49,7 +48,11 @@ fn commit_request_roundtrip() -> anyhow::Result<()> { for (dirty_pages, db_size_pages, now_ms) in [ (Vec::new(), 1, 0), (vec![dirty_page(1, 1)], 5, 1234), - ((1..=1000).map(|pgno| dirty_page(pgno, 9)).collect(), 1000, i64::MAX - 7), + ( + (1..=1000).map(|pgno| dirty_page(pgno, 9)).collect(), + 1000, + i64::MAX - 7, + ), ] { let decoded = roundtrip_to_rivet(protocol::ToRivet::ToRivetSqliteCommitRequest( protocol::ToRivetSqliteCommitRequest { @@ -119,18 +122,17 @@ fn commit_response_ok_and_err_roundtrip() -> anyhow::Result<()> { #[test] fn expected_generation_optional_present_and_absent() -> anyhow::Result<()> { for (expected_generation, expected_head_txid) in [(None, None), (Some(7), Some(11))] { - let decoded = - roundtrip_to_rivet(protocol::ToRivet::ToRivetSqliteGetPagesRequest( - protocol::ToRivetSqliteGetPagesRequest { - request_id: 3, - data: protocol::SqliteGetPagesRequest { - actor_id: "actor-c".into(), - pgnos: vec![1], - expected_generation, - expected_head_txid, - }, + let decoded = roundtrip_to_rivet(protocol::ToRivet::ToRivetSqliteGetPagesRequest( + protocol::ToRivetSqliteGetPagesRequest { + request_id: 3, + data: protocol::SqliteGetPagesRequest { + actor_id: "actor-c".into(), + pgnos: vec![1], + expected_generation, + expected_head_txid, }, - ))?; + }, + ))?; let protocol::ToRivet::ToRivetSqliteGetPagesRequest(decoded) = decoded else { panic!("expected get_pages request"); }; @@ -184,7 +186,10 @@ fn removed_op_types_not_in_module_namespace() { "CommitFinalize", "ForceCloseRequest", ] { - assert!(!schema.contains(removed), "{removed} still exists in v3 schema"); + assert!( + !schema.contains(removed), + "{removed} still exists in v3 schema" + ); } } diff --git a/engine/sdks/schemas/envoy-protocol/v4.bare b/engine/sdks/schemas/envoy-protocol/v4.bare index 9396e3e5aa..c61d1b743c 100644 --- a/engine/sdks/schemas/envoy-protocol/v4.bare +++ b/engine/sdks/schemas/envoy-protocol/v4.bare @@ -195,18 +195,11 @@ type SqliteQueryResult struct { rows: list> } -type SqliteExecuteRoute enum { - READ - WRITE - WRITE_FALLBACK -} - type SqliteExecuteResult struct { columns: list rows: list> changes: i64 lastInsertRowId: optional - route: SqliteExecuteRoute } type SqliteExecRequest struct { @@ -224,14 +217,6 @@ type SqliteExecuteRequest struct { params: optional> } -type SqliteExecuteWriteRequest struct { - namespaceId: Id - actorId: Id - generation: SqliteGeneration - sql: str - params: optional> -} - type SqliteExecOk struct { result: SqliteQueryResult } @@ -240,10 +225,6 @@ type SqliteExecuteOk struct { result: SqliteExecuteResult } -type SqliteExecuteWriteOk struct { - result: SqliteExecuteResult -} - type SqliteExecResponse union { SqliteExecOk | SqliteErrorResponse @@ -254,11 +235,6 @@ type SqliteExecuteResponse union { SqliteErrorResponse } -type SqliteExecuteWriteResponse union { - SqliteExecuteWriteOk | - SqliteErrorResponse -} - # MARK: Actor # Core @@ -551,11 +527,6 @@ type ToRivetSqliteExecuteRequest struct { data: SqliteExecuteRequest } -type ToRivetSqliteExecuteWriteRequest struct { - requestId: u32 - data: SqliteExecuteWriteRequest -} - type ToRivet union { ToRivetMetadata | ToRivetEvents | @@ -567,8 +538,7 @@ type ToRivet union { ToRivetSqliteGetPagesRequest | ToRivetSqliteCommitRequest | ToRivetSqliteExecRequest | - ToRivetSqliteExecuteRequest | - ToRivetSqliteExecuteWriteRequest + ToRivetSqliteExecuteRequest } # MARK: To Envoy @@ -613,11 +583,6 @@ type ToEnvoySqliteExecuteResponse struct { data: SqliteExecuteResponse } -type ToEnvoySqliteExecuteWriteResponse struct { - requestId: u32 - data: SqliteExecuteWriteResponse -} - type ToEnvoy union { ToEnvoyInit | ToEnvoyCommands | @@ -628,8 +593,7 @@ type ToEnvoy union { ToEnvoySqliteGetPagesResponse | ToEnvoySqliteCommitResponse | ToEnvoySqliteExecResponse | - ToEnvoySqliteExecuteResponse | - ToEnvoySqliteExecuteWriteResponse + ToEnvoySqliteExecuteResponse } # MARK: To Envoy Conn diff --git a/engine/sdks/typescript/envoy-protocol/src/index.ts b/engine/sdks/typescript/envoy-protocol/src/index.ts index 7171249f82..65c8863383 100644 --- a/engine/sdks/typescript/envoy-protocol/src/index.ts +++ b/engine/sdks/typescript/envoy-protocol/src/index.ts @@ -1071,46 +1071,6 @@ export function writeSqliteQueryResult(bc: bare.ByteCursor, x: SqliteQueryResult write11(bc, x.rows) } -export enum SqliteExecuteRoute { - Read = "Read", - Write = "Write", - WriteFallback = "WriteFallback", -} - -export function readSqliteExecuteRoute(bc: bare.ByteCursor): SqliteExecuteRoute { - const offset = bc.offset - const tag = bare.readU8(bc) - switch (tag) { - case 0: - return SqliteExecuteRoute.Read - case 1: - return SqliteExecuteRoute.Write - case 2: - return SqliteExecuteRoute.WriteFallback - default: { - bc.offset = offset - throw new bare.BareError(offset, "invalid tag") - } - } -} - -export function writeSqliteExecuteRoute(bc: bare.ByteCursor, x: SqliteExecuteRoute): void { - switch (x) { - case SqliteExecuteRoute.Read: { - bare.writeU8(bc, 0) - break - } - case SqliteExecuteRoute.Write: { - bare.writeU8(bc, 1) - break - } - case SqliteExecuteRoute.WriteFallback: { - bare.writeU8(bc, 2) - break - } - } -} - function read12(bc: bare.ByteCursor): i64 | null { return bare.readBool(bc) ? bare.readI64(bc) : null } @@ -1127,7 +1087,6 @@ export type SqliteExecuteResult = { readonly rows: readonly (readonly SqliteColumnValue[])[] readonly changes: i64 readonly lastInsertRowId: i64 | null - readonly route: SqliteExecuteRoute } export function readSqliteExecuteResult(bc: bare.ByteCursor): SqliteExecuteResult { @@ -1136,7 +1095,6 @@ export function readSqliteExecuteResult(bc: bare.ByteCursor): SqliteExecuteResul rows: read11(bc), changes: bare.readI64(bc), lastInsertRowId: read12(bc), - route: readSqliteExecuteRoute(bc), } } @@ -1145,7 +1103,6 @@ export function writeSqliteExecuteResult(bc: bare.ByteCursor, x: SqliteExecuteRe write11(bc, x.rows) bare.writeI64(bc, x.changes) write12(bc, x.lastInsertRowId) - writeSqliteExecuteRoute(bc, x.route) } export type SqliteExecRequest = { @@ -1227,32 +1184,6 @@ export function writeSqliteExecuteRequest(bc: bare.ByteCursor, x: SqliteExecuteR write14(bc, x.params) } -export type SqliteExecuteWriteRequest = { - readonly namespaceId: Id - readonly actorId: Id - readonly generation: SqliteGeneration - readonly sql: string - readonly params: readonly SqliteBindParam[] | null -} - -export function readSqliteExecuteWriteRequest(bc: bare.ByteCursor): SqliteExecuteWriteRequest { - return { - namespaceId: readId(bc), - actorId: readId(bc), - generation: readSqliteGeneration(bc), - sql: bare.readString(bc), - params: read14(bc), - } -} - -export function writeSqliteExecuteWriteRequest(bc: bare.ByteCursor, x: SqliteExecuteWriteRequest): void { - writeId(bc, x.namespaceId) - writeId(bc, x.actorId) - writeSqliteGeneration(bc, x.generation) - bare.writeString(bc, x.sql) - write14(bc, x.params) -} - export type SqliteExecOk = { readonly result: SqliteQueryResult } @@ -1281,20 +1212,6 @@ export function writeSqliteExecuteOk(bc: bare.ByteCursor, x: SqliteExecuteOk): v writeSqliteExecuteResult(bc, x.result) } -export type SqliteExecuteWriteOk = { - readonly result: SqliteExecuteResult -} - -export function readSqliteExecuteWriteOk(bc: bare.ByteCursor): SqliteExecuteWriteOk { - return { - result: readSqliteExecuteResult(bc), - } -} - -export function writeSqliteExecuteWriteOk(bc: bare.ByteCursor, x: SqliteExecuteWriteOk): void { - writeSqliteExecuteResult(bc, x.result) -} - export type SqliteExecResponse = | { readonly tag: "SqliteExecOk"; readonly val: SqliteExecOk } | { readonly tag: "SqliteErrorResponse"; readonly val: SqliteErrorResponse } @@ -1363,40 +1280,6 @@ export function writeSqliteExecuteResponse(bc: bare.ByteCursor, x: SqliteExecute } } -export type SqliteExecuteWriteResponse = - | { readonly tag: "SqliteExecuteWriteOk"; readonly val: SqliteExecuteWriteOk } - | { readonly tag: "SqliteErrorResponse"; readonly val: SqliteErrorResponse } - -export function readSqliteExecuteWriteResponse(bc: bare.ByteCursor): SqliteExecuteWriteResponse { - const offset = bc.offset - const tag = bare.readU8(bc) - switch (tag) { - case 0: - return { tag: "SqliteExecuteWriteOk", val: readSqliteExecuteWriteOk(bc) } - case 1: - return { tag: "SqliteErrorResponse", val: readSqliteErrorResponse(bc) } - default: { - bc.offset = offset - throw new bare.BareError(offset, "invalid tag") - } - } -} - -export function writeSqliteExecuteWriteResponse(bc: bare.ByteCursor, x: SqliteExecuteWriteResponse): void { - switch (x.tag) { - case "SqliteExecuteWriteOk": { - bare.writeU8(bc, 0) - writeSqliteExecuteWriteOk(bc, x.val) - break - } - case "SqliteErrorResponse": { - bare.writeU8(bc, 1) - writeSqliteErrorResponse(bc, x.val) - break - } - } -} - /** * Core */ @@ -2716,23 +2599,6 @@ export function writeToRivetSqliteExecuteRequest(bc: bare.ByteCursor, x: ToRivet writeSqliteExecuteRequest(bc, x.data) } -export type ToRivetSqliteExecuteWriteRequest = { - readonly requestId: u32 - readonly data: SqliteExecuteWriteRequest -} - -export function readToRivetSqliteExecuteWriteRequest(bc: bare.ByteCursor): ToRivetSqliteExecuteWriteRequest { - return { - requestId: bare.readU32(bc), - data: readSqliteExecuteWriteRequest(bc), - } -} - -export function writeToRivetSqliteExecuteWriteRequest(bc: bare.ByteCursor, x: ToRivetSqliteExecuteWriteRequest): void { - bare.writeU32(bc, x.requestId) - writeSqliteExecuteWriteRequest(bc, x.data) -} - export type ToRivet = | { readonly tag: "ToRivetMetadata"; readonly val: ToRivetMetadata } | { readonly tag: "ToRivetEvents"; readonly val: ToRivetEvents } @@ -2745,7 +2611,6 @@ export type ToRivet = | { readonly tag: "ToRivetSqliteCommitRequest"; readonly val: ToRivetSqliteCommitRequest } | { readonly tag: "ToRivetSqliteExecRequest"; readonly val: ToRivetSqliteExecRequest } | { readonly tag: "ToRivetSqliteExecuteRequest"; readonly val: ToRivetSqliteExecuteRequest } - | { readonly tag: "ToRivetSqliteExecuteWriteRequest"; readonly val: ToRivetSqliteExecuteWriteRequest } export function readToRivet(bc: bare.ByteCursor): ToRivet { const offset = bc.offset @@ -2773,8 +2638,6 @@ export function readToRivet(bc: bare.ByteCursor): ToRivet { return { tag: "ToRivetSqliteExecRequest", val: readToRivetSqliteExecRequest(bc) } case 10: return { tag: "ToRivetSqliteExecuteRequest", val: readToRivetSqliteExecuteRequest(bc) } - case 11: - return { tag: "ToRivetSqliteExecuteWriteRequest", val: readToRivetSqliteExecuteWriteRequest(bc) } default: { bc.offset = offset throw new bare.BareError(offset, "invalid tag") @@ -2838,11 +2701,6 @@ export function writeToRivet(bc: bare.ByteCursor, x: ToRivet): void { writeToRivetSqliteExecuteRequest(bc, x.val) break } - case "ToRivetSqliteExecuteWriteRequest": { - bare.writeU8(bc, 11) - writeToRivetSqliteExecuteWriteRequest(bc, x.val) - break - } } } @@ -3022,23 +2880,6 @@ export function writeToEnvoySqliteExecuteResponse(bc: bare.ByteCursor, x: ToEnvo writeSqliteExecuteResponse(bc, x.data) } -export type ToEnvoySqliteExecuteWriteResponse = { - readonly requestId: u32 - readonly data: SqliteExecuteWriteResponse -} - -export function readToEnvoySqliteExecuteWriteResponse(bc: bare.ByteCursor): ToEnvoySqliteExecuteWriteResponse { - return { - requestId: bare.readU32(bc), - data: readSqliteExecuteWriteResponse(bc), - } -} - -export function writeToEnvoySqliteExecuteWriteResponse(bc: bare.ByteCursor, x: ToEnvoySqliteExecuteWriteResponse): void { - bare.writeU32(bc, x.requestId) - writeSqliteExecuteWriteResponse(bc, x.data) -} - export type ToEnvoy = | { readonly tag: "ToEnvoyInit"; readonly val: ToEnvoyInit } | { readonly tag: "ToEnvoyCommands"; readonly val: ToEnvoyCommands } @@ -3050,7 +2891,6 @@ export type ToEnvoy = | { readonly tag: "ToEnvoySqliteCommitResponse"; readonly val: ToEnvoySqliteCommitResponse } | { readonly tag: "ToEnvoySqliteExecResponse"; readonly val: ToEnvoySqliteExecResponse } | { readonly tag: "ToEnvoySqliteExecuteResponse"; readonly val: ToEnvoySqliteExecuteResponse } - | { readonly tag: "ToEnvoySqliteExecuteWriteResponse"; readonly val: ToEnvoySqliteExecuteWriteResponse } export function readToEnvoy(bc: bare.ByteCursor): ToEnvoy { const offset = bc.offset @@ -3076,8 +2916,6 @@ export function readToEnvoy(bc: bare.ByteCursor): ToEnvoy { return { tag: "ToEnvoySqliteExecResponse", val: readToEnvoySqliteExecResponse(bc) } case 9: return { tag: "ToEnvoySqliteExecuteResponse", val: readToEnvoySqliteExecuteResponse(bc) } - case 10: - return { tag: "ToEnvoySqliteExecuteWriteResponse", val: readToEnvoySqliteExecuteWriteResponse(bc) } default: { bc.offset = offset throw new bare.BareError(offset, "invalid tag") @@ -3137,11 +2975,6 @@ export function writeToEnvoy(bc: bare.ByteCursor, x: ToEnvoy): void { writeToEnvoySqliteExecuteResponse(bc, x.val) break } - case "ToEnvoySqliteExecuteWriteResponse": { - bare.writeU8(bc, 10) - writeToEnvoySqliteExecuteWriteResponse(bc, x.val) - break - } } } diff --git a/rivetkit-rust/packages/rivetkit-core/src/actor/factory.rs b/rivetkit-rust/packages/rivetkit-core/src/actor/factory.rs index 6c98cdf617..34ad277055 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/actor/factory.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/actor/factory.rs @@ -2,9 +2,9 @@ use std::fmt; use anyhow::Result; -use crate::runtime::RuntimeBoxFuture; use crate::ActorConfig; use crate::actor::lifecycle_hooks::ActorStart; +use crate::runtime::RuntimeBoxFuture; #[cfg(feature = "wasm-runtime")] pub type ActorEntryFn = dyn Fn(ActorStart) -> RuntimeBoxFuture>; diff --git a/rivetkit-rust/packages/rivetkit-core/src/actor/metrics.rs b/rivetkit-rust/packages/rivetkit-core/src/actor/metrics.rs index 885adf8395..ce9ba3fce3 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/actor/metrics.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/actor/metrics.rs @@ -4,9 +4,11 @@ use std::sync::Arc; use std::time::Duration; use anyhow::{Context, Result}; +#[cfg(feature = "sqlite-local")] +use prometheus::Histogram; use prometheus::{ - CounterVec, Encoder, Gauge, Histogram, HistogramOpts, HistogramVec, IntCounter, IntGauge, - IntGaugeVec, Opts, Registry, TextEncoder, + CounterVec, Encoder, Gauge, HistogramOpts, HistogramVec, IntCounter, IntGauge, IntGaugeVec, + Opts, Registry, TextEncoder, }; use crate::actor::task_types::{ShutdownKind, StateMutationReason, UserTaskKind}; @@ -65,27 +67,21 @@ struct ActorMetricsInner { #[cfg(feature = "sqlite-local")] sqlite_vfs_commit_duration_seconds_total: CounterVec, #[cfg(feature = "sqlite-local")] - sqlite_read_pool_active_readers: IntGauge, - #[cfg(feature = "sqlite-local")] - sqlite_read_pool_idle_readers: IntGauge, - #[cfg(feature = "sqlite-local")] - sqlite_read_pool_read_wait_duration_seconds: Histogram, - #[cfg(feature = "sqlite-local")] - sqlite_read_pool_write_wait_duration_seconds: Histogram, + sqlite_worker_queue_depth: IntGauge, #[cfg(feature = "sqlite-local")] - sqlite_read_pool_routed_read_queries_total: IntCounter, + sqlite_worker_queue_overload_total: IntCounter, #[cfg(feature = "sqlite-local")] - sqlite_read_pool_write_fallback_queries_total: IntCounter, + sqlite_worker_command_duration_seconds: HistogramVec, #[cfg(feature = "sqlite-local")] - sqlite_read_pool_manual_transaction_duration_seconds: Histogram, + sqlite_worker_command_error_total: CounterVec, #[cfg(feature = "sqlite-local")] - sqlite_read_pool_reader_opens_total: IntCounter, + sqlite_worker_close_duration_seconds: Histogram, #[cfg(feature = "sqlite-local")] - sqlite_read_pool_reader_closes_total: IntCounter, + sqlite_worker_close_timeout_total: IntCounter, #[cfg(feature = "sqlite-local")] - sqlite_read_pool_rejected_reader_mutations_total: IntCounter, + sqlite_worker_crash_total: IntCounter, #[cfg(feature = "sqlite-local")] - sqlite_read_pool_mode_transitions_total: CounterVec, + sqlite_worker_unclean_close_total: IntCounter, } impl ActorMetrics { @@ -324,84 +320,63 @@ impl ActorMetrics { ) .context("create sqlite_vfs_commit_duration_seconds_total counter")?; #[cfg(feature = "sqlite-local")] - let sqlite_read_pool_active_readers = IntGauge::with_opts(Opts::new( - "sqlite_read_pool_active_readers", - "current active SQLite read-pool readers", + let sqlite_worker_queue_depth = IntGauge::with_opts(Opts::new( + "sqlite_worker_queue_depth", + "current native SQLite worker SQL command queue depth", )) - .context("create sqlite_read_pool_active_readers gauge")?; + .context("create sqlite_worker_queue_depth gauge")?; #[cfg(feature = "sqlite-local")] - let sqlite_read_pool_idle_readers = IntGauge::with_opts(Opts::new( - "sqlite_read_pool_idle_readers", - "current idle SQLite read-pool readers", + let sqlite_worker_queue_overload_total = IntCounter::with_opts(Opts::new( + "sqlite_worker_queue_overload_total", + "total native SQLite worker SQL command queue overloads", )) - .context("create sqlite_read_pool_idle_readers gauge")?; + .context("create sqlite_worker_queue_overload_total counter")?; #[cfg(feature = "sqlite-local")] - let sqlite_read_pool_read_wait_duration_seconds = Histogram::with_opts( + let sqlite_worker_command_duration_seconds = HistogramVec::new( HistogramOpts::new( - "sqlite_read_pool_read_wait_duration_seconds", - "SQLite read-pool read admission wait duration in seconds", + "sqlite_worker_command_duration_seconds", + "native SQLite worker SQL command duration in seconds", ) - .buckets(sqlite_pool_wait_buckets()), + .buckets(sqlite_worker_duration_buckets()), + &["operation"], ) - .context("create sqlite_read_pool_read_wait_duration_seconds histogram")?; + .context("create sqlite_worker_command_duration_seconds histogram")?; #[cfg(feature = "sqlite-local")] - let sqlite_read_pool_write_wait_duration_seconds = Histogram::with_opts( - HistogramOpts::new( - "sqlite_read_pool_write_wait_duration_seconds", - "SQLite read-pool write-mode admission wait duration in seconds", - ) - .buckets(sqlite_pool_wait_buckets()), + let sqlite_worker_command_error_total = CounterVec::new( + Opts::new( + "sqlite_worker_command_error_total", + "total native SQLite worker SQL command errors", + ), + &["operation", "code"], ) - .context("create sqlite_read_pool_write_wait_duration_seconds histogram")?; + .context("create sqlite_worker_command_error_total counter")?; #[cfg(feature = "sqlite-local")] - let sqlite_read_pool_routed_read_queries_total = IntCounter::with_opts(Opts::new( - "sqlite_read_pool_routed_read_queries_total", - "total SQLite statements routed to read-pool readers", - )) - .context("create sqlite_read_pool_routed_read_queries_total counter")?; - #[cfg(feature = "sqlite-local")] - let sqlite_read_pool_write_fallback_queries_total = IntCounter::with_opts(Opts::new( - "sqlite_read_pool_write_fallback_queries_total", - "total SQLite statements routed to write mode as read-pool fallbacks", - )) - .context("create sqlite_read_pool_write_fallback_queries_total counter")?; - #[cfg(feature = "sqlite-local")] - let sqlite_read_pool_manual_transaction_duration_seconds = Histogram::with_opts( + let sqlite_worker_close_duration_seconds = Histogram::with_opts( HistogramOpts::new( - "sqlite_read_pool_manual_transaction_duration_seconds", - "SQLite read-pool manual transaction write-mode duration in seconds", + "sqlite_worker_close_duration_seconds", + "native SQLite worker close duration in seconds", ) - .buckets(sqlite_pool_wait_buckets()), + .buckets(sqlite_worker_duration_buckets()), ) - .context("create sqlite_read_pool_manual_transaction_duration_seconds histogram")?; + .context("create sqlite_worker_close_duration_seconds histogram")?; #[cfg(feature = "sqlite-local")] - let sqlite_read_pool_reader_opens_total = IntCounter::with_opts(Opts::new( - "sqlite_read_pool_reader_opens_total", - "total SQLite read-pool reader connection opens", + let sqlite_worker_close_timeout_total = IntCounter::with_opts(Opts::new( + "sqlite_worker_close_timeout_total", + "total native SQLite worker close timeouts", )) - .context("create sqlite_read_pool_reader_opens_total counter")?; + .context("create sqlite_worker_close_timeout_total counter")?; #[cfg(feature = "sqlite-local")] - let sqlite_read_pool_reader_closes_total = IntCounter::with_opts(Opts::new( - "sqlite_read_pool_reader_closes_total", - "total SQLite read-pool reader connection closes", + let sqlite_worker_crash_total = IntCounter::with_opts(Opts::new( + "sqlite_worker_crash_total", + "total native SQLite worker crashes", )) - .context("create sqlite_read_pool_reader_closes_total counter")?; + .context("create sqlite_worker_crash_total counter")?; #[cfg(feature = "sqlite-local")] - let sqlite_read_pool_rejected_reader_mutations_total = IntCounter::with_opts(Opts::new( - "sqlite_read_pool_rejected_reader_mutations_total", - "total SQLite reader mutation attempts rejected by read-pool safeguards", + let sqlite_worker_unclean_close_total = IntCounter::with_opts(Opts::new( + "sqlite_worker_unclean_close_total", + "total native SQLite worker channel drops without clean close", )) - .context("create sqlite_read_pool_rejected_reader_mutations_total counter")?; - #[cfg(feature = "sqlite-local")] - let sqlite_read_pool_mode_transitions_total = CounterVec::new( - Opts::new( - "sqlite_read_pool_mode_transitions_total", - "total SQLite read-pool mode transitions", - ), - &["from", "to"], - ) - .context("create sqlite_read_pool_mode_transitions_total counter")?; - + .context("create sqlite_worker_unclean_close_total counter")?; register_metric(®istry, create_state_ms.clone()); register_metric(®istry, create_vars_ms.clone()); register_metric(®istry, queue_depth.clone()); @@ -426,7 +401,10 @@ impl ActorMetrics { register_metric(®istry, sqlite_vfs_resolve_pages_total.clone()); register_metric(®istry, sqlite_vfs_resolve_pages_requested_total.clone()); register_metric(®istry, sqlite_vfs_resolve_pages_cache_hits_total.clone()); - register_metric(®istry, sqlite_vfs_resolve_pages_cache_misses_total.clone()); + register_metric( + ®istry, + sqlite_vfs_resolve_pages_cache_misses_total.clone(), + ); register_metric(®istry, sqlite_vfs_get_pages_total.clone()); register_metric(®istry, sqlite_vfs_pages_fetched_total.clone()); register_metric(®istry, sqlite_vfs_prefetch_pages_total.clone()); @@ -439,29 +417,14 @@ impl ActorMetrics { sqlite_vfs_commit_phase_duration_seconds_total.clone(), ); register_metric(®istry, sqlite_vfs_commit_duration_seconds_total.clone()); - register_metric(®istry, sqlite_read_pool_active_readers.clone()); - register_metric(®istry, sqlite_read_pool_idle_readers.clone()); - register_metric( - ®istry, - sqlite_read_pool_read_wait_duration_seconds.clone(), - ); - register_metric( - ®istry, - sqlite_read_pool_write_wait_duration_seconds.clone(), - ); - register_metric(®istry, sqlite_read_pool_routed_read_queries_total.clone()); - register_metric(®istry, sqlite_read_pool_write_fallback_queries_total.clone()); - register_metric( - ®istry, - sqlite_read_pool_manual_transaction_duration_seconds.clone(), - ); - register_metric(®istry, sqlite_read_pool_reader_opens_total.clone()); - register_metric(®istry, sqlite_read_pool_reader_closes_total.clone()); - register_metric( - ®istry, - sqlite_read_pool_rejected_reader_mutations_total.clone(), - ); - register_metric(®istry, sqlite_read_pool_mode_transitions_total.clone()); + register_metric(®istry, sqlite_worker_queue_depth.clone()); + register_metric(®istry, sqlite_worker_queue_overload_total.clone()); + register_metric(®istry, sqlite_worker_command_duration_seconds.clone()); + register_metric(®istry, sqlite_worker_command_error_total.clone()); + register_metric(®istry, sqlite_worker_close_duration_seconds.clone()); + register_metric(®istry, sqlite_worker_close_timeout_total.clone()); + register_metric(®istry, sqlite_worker_crash_total.clone()); + register_metric(®istry, sqlite_worker_unclean_close_total.clone()); } for kind in UserTaskKind::ALL { @@ -483,16 +446,11 @@ impl ActorMetrics { sqlite_vfs_commit_phase_duration_seconds_total.with_label_values(&[phase]); } sqlite_vfs_commit_duration_seconds_total.with_label_values(&["total"]); - for (from, to) in [ - ("closed", "read"), - ("closed", "write"), - ("read", "write"), - ("write", "read"), - ("read", "closing"), - ("write", "closing"), - ("closing", "closed"), - ] { - sqlite_read_pool_mode_transitions_total.with_label_values(&[from, to]); + for operation in ["exec", "execute"] { + sqlite_worker_command_duration_seconds.with_label_values(&[operation]); + for code in ["sqlite", "closing", "dead", "overloaded", "close_timeout"] { + sqlite_worker_command_error_total.with_label_values(&[operation, code]); + } } } @@ -544,27 +502,21 @@ impl ActorMetrics { #[cfg(feature = "sqlite-local")] sqlite_vfs_commit_duration_seconds_total, #[cfg(feature = "sqlite-local")] - sqlite_read_pool_active_readers, + sqlite_worker_queue_depth, #[cfg(feature = "sqlite-local")] - sqlite_read_pool_idle_readers, + sqlite_worker_queue_overload_total, #[cfg(feature = "sqlite-local")] - sqlite_read_pool_read_wait_duration_seconds, + sqlite_worker_command_duration_seconds, #[cfg(feature = "sqlite-local")] - sqlite_read_pool_write_wait_duration_seconds, + sqlite_worker_command_error_total, #[cfg(feature = "sqlite-local")] - sqlite_read_pool_routed_read_queries_total, + sqlite_worker_close_duration_seconds, #[cfg(feature = "sqlite-local")] - sqlite_read_pool_write_fallback_queries_total, + sqlite_worker_close_timeout_total, #[cfg(feature = "sqlite-local")] - sqlite_read_pool_manual_transaction_duration_seconds, + sqlite_worker_crash_total, #[cfg(feature = "sqlite-local")] - sqlite_read_pool_reader_opens_total, - #[cfg(feature = "sqlite-local")] - sqlite_read_pool_reader_closes_total, - #[cfg(feature = "sqlite-local")] - sqlite_read_pool_rejected_reader_mutations_total, - #[cfg(feature = "sqlite-local")] - sqlite_read_pool_mode_transitions_total, + sqlite_worker_unclean_close_total, }) } @@ -757,7 +709,7 @@ impl ActorMetrics { inner .direct_subsystem_shutdown_warning_total .with_label_values(&[subsystem, operation]) - .inc(); + .inc(); } } @@ -777,7 +729,9 @@ impl depot_client::vfs::SqliteVfsMetrics for ActorMetrics { let Some(inner) = self.inner.as_ref().as_ref() else { return; }; - inner.sqlite_vfs_resolve_pages_cache_hits_total.inc_by(pages); + inner + .sqlite_vfs_resolve_pages_cache_hits_total + .inc_by(pages); } fn record_resolve_cache_misses(&self, pages: u64) { @@ -795,9 +749,7 @@ impl depot_client::vfs::SqliteVfsMetrics for ActorMetrics { }; inner.sqlite_vfs_get_pages_total.inc(); inner.sqlite_vfs_pages_fetched_total.inc_by(pages); - inner - .sqlite_vfs_prefetch_pages_total - .inc_by(prefetch_pages); + inner.sqlite_vfs_prefetch_pages_total.inc_by(prefetch_pages); inner .sqlite_vfs_bytes_fetched_total .inc_by(pages.saturating_mul(page_size)); @@ -850,94 +802,68 @@ impl depot_client::vfs::SqliteVfsMetrics for ActorMetrics { .inc_by(ns_to_seconds(total_ns)); } - fn set_read_pool_active_readers(&self, readers: u64) { + fn set_worker_queue_depth(&self, depth: u64) { let Some(inner) = self.inner.as_ref().as_ref() else { return; }; - inner - .sqlite_read_pool_active_readers - .set(readers.try_into().unwrap_or(i64::MAX)); + inner.sqlite_worker_queue_depth.set(depth as i64); } - fn set_read_pool_idle_readers(&self, readers: u64) { + fn record_worker_queue_overload(&self) { let Some(inner) = self.inner.as_ref().as_ref() else { return; }; - inner - .sqlite_read_pool_idle_readers - .set(readers.try_into().unwrap_or(i64::MAX)); + inner.sqlite_worker_queue_overload_total.inc(); } - fn observe_read_pool_read_wait(&self, duration: Duration) { + fn observe_worker_command_duration(&self, operation: &'static str, duration_ns: u64) { let Some(inner) = self.inner.as_ref().as_ref() else { return; }; inner - .sqlite_read_pool_read_wait_duration_seconds - .observe(duration.as_secs_f64()); + .sqlite_worker_command_duration_seconds + .with_label_values(&[operation]) + .observe(ns_to_seconds(duration_ns)); } - fn observe_read_pool_write_wait(&self, duration: Duration) { + fn record_worker_command_error(&self, operation: &'static str, code: &'static str) { let Some(inner) = self.inner.as_ref().as_ref() else { return; }; inner - .sqlite_read_pool_write_wait_duration_seconds - .observe(duration.as_secs_f64()); - } - - fn record_read_pool_routed_read_query(&self) { - let Some(inner) = self.inner.as_ref().as_ref() else { - return; - }; - inner.sqlite_read_pool_routed_read_queries_total.inc(); - } - - fn record_read_pool_write_fallback_query(&self) { - let Some(inner) = self.inner.as_ref().as_ref() else { - return; - }; - inner.sqlite_read_pool_write_fallback_queries_total.inc(); + .sqlite_worker_command_error_total + .with_label_values(&[operation, code]) + .inc(); } - fn observe_read_pool_manual_transaction(&self, duration: Duration) { + fn observe_worker_close_duration(&self, duration_ns: u64) { let Some(inner) = self.inner.as_ref().as_ref() else { return; }; inner - .sqlite_read_pool_manual_transaction_duration_seconds - .observe(duration.as_secs_f64()); - } - - fn record_read_pool_reader_open(&self) { - let Some(inner) = self.inner.as_ref().as_ref() else { - return; - }; - inner.sqlite_read_pool_reader_opens_total.inc(); + .sqlite_worker_close_duration_seconds + .observe(ns_to_seconds(duration_ns)); } - fn record_read_pool_reader_close(&self, count: u64) { + fn record_worker_close_timeout(&self) { let Some(inner) = self.inner.as_ref().as_ref() else { return; }; - inner.sqlite_read_pool_reader_closes_total.inc_by(count); + inner.sqlite_worker_close_timeout_total.inc(); } - fn record_read_pool_rejected_reader_mutation(&self) { + fn record_worker_crash(&self) { let Some(inner) = self.inner.as_ref().as_ref() else { return; }; - inner.sqlite_read_pool_rejected_reader_mutations_total.inc(); + inner.sqlite_worker_crash_total.inc(); } - fn record_read_pool_mode_transition(&self, from: &str, to: &str) { + fn record_worker_unclean_close(&self) { let Some(inner) = self.inner.as_ref().as_ref() else { return; }; - inner - .sqlite_read_pool_mode_transitions_total - .with_label_values(&[from, to]) - .inc(); + inner.sqlite_worker_unclean_close_total.inc(); } } @@ -963,10 +889,9 @@ fn ns_to_seconds(duration_ns: u64) -> f64 { } #[cfg(feature = "sqlite-local")] -fn sqlite_pool_wait_buckets() -> Vec { +fn sqlite_worker_duration_buckets() -> Vec { vec![ - 0.000_1, 0.000_5, 0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, - 5.0, + 0.000_1, 0.000_5, 0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, ] } diff --git a/rivetkit-rust/packages/rivetkit-core/src/actor/mod.rs b/rivetkit-rust/packages/rivetkit-core/src/actor/mod.rs index feceed14c5..6a3dd1df54 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/actor/mod.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/actor/mod.rs @@ -33,8 +33,7 @@ pub use queue::{ QueueTryNextBatchOpts, QueueTryNextOpts, QueueWaitOpts, }; pub use sqlite::{ - BindParam, ColumnValue, ExecResult, ExecuteResult, ExecuteRoute, QueryResult, SqliteDb, - SqliteBackend, + BindParam, ColumnValue, ExecResult, ExecuteResult, QueryResult, SqliteBackend, SqliteDb, }; pub use state::RequestSaveOpts; pub use task::{ diff --git a/rivetkit-rust/packages/rivetkit-core/src/actor/schedule.rs b/rivetkit-rust/packages/rivetkit-core/src/actor/schedule.rs index f3d1d4f840..b5640b67b6 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/actor/schedule.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/actor/schedule.rs @@ -368,22 +368,21 @@ impl ActorContext { // Intentionally detached but abortable: the handle is stored in // `local_alarm_task` and cancelled when alarms are resynced or stopped. let task = async move { - sleep(Duration::from_millis(delay_ms)).await; - if schedule.0.schedule_local_alarm_epoch.load(Ordering::SeqCst) != local_alarm_epoch - { - return; - } - tracing::debug!( - timestamp_ms = next_alarm, - local_alarm_epoch, - "local actor alarm fired" - ); - let Some(callback) = schedule.0.schedule_local_alarm_callback.lock().clone() else { - return; - }; - callback().await; + sleep(Duration::from_millis(delay_ms)).await; + if schedule.0.schedule_local_alarm_epoch.load(Ordering::SeqCst) != local_alarm_epoch { + return; } - .in_current_span(); + tracing::debug!( + timestamp_ms = next_alarm, + local_alarm_epoch, + "local actor alarm fired" + ); + let Some(callback) = schedule.0.schedule_local_alarm_callback.lock().clone() else { + return; + }; + callback().await; + } + .in_current_span(); #[cfg(not(feature = "wasm-runtime"))] let handle = tokio_handle.spawn(task); diff --git a/rivetkit-rust/packages/rivetkit-core/src/actor/sqlite.rs b/rivetkit-rust/packages/rivetkit-core/src/actor/sqlite.rs index 3bd384fcfb..40fff2898b 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/actor/sqlite.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/actor/sqlite.rs @@ -1,38 +1,38 @@ use std::collections::HashSet; use std::io::Cursor; #[cfg(feature = "sqlite-local")] -use std::sync::Arc; -#[cfg(feature = "sqlite-local")] -use std::time::Duration; +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; use anyhow::{Context, Result}; +pub use depot_client_types::{BindParam, ColumnValue, ExecResult, ExecuteResult, QueryResult}; #[cfg(feature = "sqlite-local")] use parking_lot::Mutex; use rivet_envoy_client::protocol; -use rivet_envoy_client::{ - handle::EnvoyHandle, utils::RemoteSqliteIndeterminateResultError, -}; -pub use depot_client_types::{ - BindParam, ColumnValue, ExecResult, ExecuteResult, ExecuteRoute, QueryResult, -}; +use rivet_envoy_client::{handle::EnvoyHandle, utils::RemoteSqliteIndeterminateResultError}; use serde::Serialize; use serde_json::{Map as JsonMap, Value as JsonValue}; #[cfg(feature = "sqlite-local")] use tokio::sync::Mutex as AsyncMutex; #[cfg(feature = "sqlite-local")] use tokio::task::JoinHandle; -#[cfg(feature = "sqlite-local")] -use tokio::time::{interval, timeout}; -#[cfg(feature = "sqlite-local")] -use tracing::Instrument; +#[cfg(feature = "sqlite-local")] +use crate::error::ActorLifecycle; use crate::error::SqliteRuntimeError; +#[cfg(feature = "sqlite-local")] +use crate::runtime::RuntimeSpawner; #[cfg(feature = "sqlite-local")] use depot_client::{ database::{NativeDatabaseHandle, open_database_from_envoy}, - optimization_flags::sqlite_optimization_flags, - vfs::{SqliteVfsMetrics, SqliteVfsMetricsSnapshot, VfsPreloadHintSnapshot}, + vfs::{SqliteVfsMetrics, SqliteVfsMetricsSnapshot}, + worker::{ + SQLITE_WORKER_QUEUE_CAPACITY, SqliteWorkerCloseTimeoutError, SqliteWorkerClosingError, + SqliteWorkerDeadError, SqliteWorkerOverloadedError, + }, }; #[cfg(not(feature = "sqlite-local"))] @@ -46,11 +46,6 @@ pub struct SqliteVfsMetricsSnapshot { pub commit_count: u64, } -#[cfg(feature = "sqlite-local")] -const PRELOAD_HINT_FLUSH_INTERVAL: Duration = Duration::from_secs(30); -#[cfg(feature = "sqlite-local")] -const PRELOAD_HINT_FLUSH_TIMEOUT: Duration = Duration::from_secs(5); - #[derive(Clone)] pub struct SqliteRuntimeConfig { pub handle: EnvoyHandle, @@ -88,19 +83,15 @@ pub struct SqliteDb { #[cfg(feature = "sqlite-local")] open_lock: Arc>, #[cfg(feature = "sqlite-local")] - // Forced-sync: the background task is spawned and aborted from sync cleanup - // paths around the native database handle. - preload_hint_flush_task: Arc>>>, + worker_failure_task: Arc>>>, + #[cfg(feature = "sqlite-local")] + worker_fatal_reported: Arc, #[cfg(feature = "sqlite-local")] vfs_metrics: Option>, } impl SqliteDb { - pub fn new( - handle: EnvoyHandle, - actor_id: impl Into, - enabled: bool, - ) -> Self { + pub fn new(handle: EnvoyHandle, actor_id: impl Into, enabled: bool) -> Self { Self::new_with_remote_sqlite(handle, actor_id, None, enabled, false) } @@ -122,7 +113,9 @@ impl SqliteDb { #[cfg(feature = "sqlite-local")] open_lock: Default::default(), #[cfg(feature = "sqlite-local")] - preload_hint_flush_task: Default::default(), + worker_failure_task: Default::default(), + #[cfg(feature = "sqlite-local")] + worker_fatal_reported: Default::default(), #[cfg(feature = "sqlite-local")] vfs_metrics: None, } @@ -171,8 +164,8 @@ impl SqliteDb { .context("open sqlite database requires a tokio runtime")?; let native_db = open_database_from_envoy( - config.handle, - config.actor_id, + config.handle.clone(), + config.actor_id.clone(), config .generation .ok_or_else(|| sqlite_not_configured("generation"))?, @@ -180,8 +173,9 @@ impl SqliteDb { vfs_metrics, ) .await?; + self.worker_fatal_reported.store(false, Ordering::Release); + self.start_worker_failure_monitor(native_db.clone(), config); *self.db.lock() = Some(native_db); - self.ensure_preload_hint_flush_task()?; Ok(()) } @@ -201,7 +195,7 @@ impl SqliteDb { #[cfg(feature = "sqlite-local")] async fn local_exec(&self, sql: String) -> Result { self.open().await?; - self.native_db_handle()?.exec(sql).await + self.map_local_worker_result(self.native_db_handle()?.exec(sql).await) } #[cfg(not(feature = "sqlite-local"))] @@ -210,9 +204,13 @@ impl SqliteDb { } #[cfg(feature = "sqlite-local")] - async fn local_query(&self, sql: String, params: Option>) -> Result { + async fn local_query( + &self, + sql: String, + params: Option>, + ) -> Result { self.open().await?; - self.native_db_handle()?.query(sql, params).await + self.map_local_worker_result(self.native_db_handle()?.query(sql, params).await) } #[cfg(not(feature = "sqlite-local"))] @@ -227,7 +225,7 @@ impl SqliteDb { #[cfg(feature = "sqlite-local")] async fn local_run(&self, sql: String, params: Option>) -> Result { self.open().await?; - self.native_db_handle()?.run(sql, params).await + self.map_local_worker_result(self.native_db_handle()?.run(sql, params).await) } #[cfg(not(feature = "sqlite-local"))] @@ -242,7 +240,7 @@ impl SqliteDb { params: Option>, ) -> Result { self.open().await?; - self.native_db_handle()?.execute(sql, params).await + self.map_local_worker_result(self.native_db_handle()?.execute(sql, params).await) } #[cfg(not(feature = "sqlite-local"))] @@ -254,25 +252,6 @@ impl SqliteDb { Err(SqliteRuntimeError::Unavailable.build()) } - #[cfg(feature = "sqlite-local")] - async fn local_execute_write( - &self, - sql: String, - params: Option>, - ) -> Result { - self.open().await?; - self.native_db_handle()?.execute_write(sql, params).await - } - - #[cfg(not(feature = "sqlite-local"))] - async fn local_execute_write( - &self, - _sql: String, - _params: Option>, - ) -> Result { - Err(SqliteRuntimeError::Unavailable.build()) - } - pub async fn exec(&self, sql: impl Into) -> Result { let sql = sql.into(); match self.backend { @@ -325,28 +304,16 @@ impl SqliteDb { } } - pub async fn execute_write( - &self, - sql: impl Into, - params: Option>, - ) -> Result { - let sql = sql.into(); - match self.backend { - SqliteBackend::LocalNative => self.local_execute_write(sql, params).await, - SqliteBackend::RemoteEnvoy => self.remote_execute_write(sql, params).await, - SqliteBackend::Unavailable => Err(SqliteRuntimeError::Unavailable.build()), - } - } - pub async fn close(&self) -> Result<()> { match self.backend { SqliteBackend::LocalNative => { #[cfg(feature = "sqlite-local")] { - self.stop_preload_hint_flush_task(); let native_db = self.db.lock().take(); if let Some(native_db) = native_db { - native_db.close().await?; + let result = self.map_local_worker_result(native_db.close().await); + self.abort_worker_failure_monitor(); + result?; } } Ok(()) @@ -356,86 +323,9 @@ impl SqliteDb { } pub(crate) async fn cleanup(&self) -> Result<()> { - #[cfg(feature = "sqlite-local")] - { - self.stop_preload_hint_flush_task(); - self.flush_preload_hints_before_close().await; - } self.close().await } - #[cfg(feature = "sqlite-local")] - fn ensure_preload_hint_flush_task(&self) -> Result<()> { - if !sqlite_optimization_flags().preload_hint_flush { - return Ok(()); - } - - let config = self.runtime_config()?; - let Some(generation) = config.generation else { - return Ok(()); - }; - if self.db.lock().is_none() { - return Ok(()); - } - - let mut task_guard = self.preload_hint_flush_task.lock(); - if task_guard.is_some() { - return Ok(()); - } - - let db = self.db.clone(); - let handle = config.handle; - let actor_id = config.actor_id; - *task_guard = Some(tokio::spawn( - async move { - let mut tick = interval(PRELOAD_HINT_FLUSH_INTERVAL); - tick.tick().await; - loop { - tick.tick().await; - flush_preload_hints_best_effort( - db.clone(), - handle.clone(), - actor_id.clone(), - generation, - "periodic", - ) - .await; - } - } - .in_current_span(), - )); - Ok(()) - } - - #[cfg(feature = "sqlite-local")] - fn stop_preload_hint_flush_task(&self) { - if let Some(task) = self.preload_hint_flush_task.lock().take() { - task.abort(); - } - } - - #[cfg(feature = "sqlite-local")] - async fn flush_preload_hints_before_close(&self) { - if !sqlite_optimization_flags().preload_hint_flush { - return; - } - - let Ok(config) = self.runtime_config() else { - return; - }; - let Some(generation) = config.generation else { - return; - }; - - enqueue_preload_hint_flush_best_effort( - self.db.clone(), - config.handle, - config.actor_id, - generation, - ) - .await; - } - pub fn take_last_kv_error(&self) -> Option { if self.backend != SqliteBackend::LocalNative { return None; @@ -463,6 +353,58 @@ impl SqliteDb { .ok_or_else(|| SqliteRuntimeError::Closed.build()) } + #[cfg(feature = "sqlite-local")] + fn map_local_worker_result(&self, result: Result) -> Result { + match result { + Ok(value) => Ok(value), + Err(error) => { + if is_fatal_worker_error(&error) { + self.report_worker_fatal(&error); + } + Err(map_local_worker_error(error)) + } + } + } + + #[cfg(feature = "sqlite-local")] + fn report_worker_fatal(&self, error: &anyhow::Error) { + let Ok(config) = self.runtime_config() else { + return; + }; + report_sqlite_worker_fatal( + &self.worker_fatal_reported, + config, + format!("sqlite worker failed: {error}"), + ); + } + + #[cfg(feature = "sqlite-local")] + fn start_worker_failure_monitor( + &self, + native_db: NativeDatabaseHandle, + config: SqliteRuntimeConfig, + ) { + 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(), + ); + } + }); + *self.worker_failure_task.lock() = Some(task); + } + + #[cfg(feature = "sqlite-local")] + fn abort_worker_failure_monitor(&self) { + if let Some(task) = self.worker_failure_task.lock().take() { + task.abort(); + } + } + pub fn metrics(&self) -> Option { #[cfg(feature = "sqlite-local")] { @@ -515,13 +457,13 @@ impl SqliteDb { .await .map_err(remote_request_error)?; - match response { - protocol::SqliteExecResponse::SqliteExecOk(ok) => { - Ok(query_result_from_protocol(ok.result)) - } - protocol::SqliteExecResponse::SqliteErrorResponse(error) => { - Err(remote_sqlite_error_response(error.message)) - } + match response { + protocol::SqliteExecResponse::SqliteExecOk(ok) => { + Ok(query_result_from_protocol(ok.result)) + } + protocol::SqliteExecResponse::SqliteErrorResponse(error) => { + Err(remote_sqlite_error_response(error.message)) + } } } @@ -543,41 +485,13 @@ impl SqliteDb { .await .map_err(remote_request_error)?; - match response { - protocol::SqliteExecuteResponse::SqliteExecuteOk(ok) => { - Ok(execute_result_from_protocol(ok.result)) - } - protocol::SqliteExecuteResponse::SqliteErrorResponse(error) => { - Err(remote_sqlite_error_response(error.message)) - } - } - } - - async fn remote_execute_write( - &self, - sql: String, - params: Option>, - ) -> Result { - let config = self.remote_config()?; - let response = config - .handle - .remote_sqlite_execute_write(protocol::SqliteExecuteWriteRequest { - namespace_id: config.namespace_id, - actor_id: config.actor_id, - generation: config.generation, - sql, - params: params.map(protocol_bind_params), - }) - .await - .map_err(remote_request_error)?; - - match response { - protocol::SqliteExecuteWriteResponse::SqliteExecuteWriteOk(ok) => { - Ok(execute_result_from_protocol(ok.result)) - } - protocol::SqliteExecuteWriteResponse::SqliteErrorResponse(error) => { - Err(remote_sqlite_error_response(error.message)) - } + match response { + protocol::SqliteExecuteResponse::SqliteExecuteOk(ok) => { + Ok(execute_result_from_protocol(ok.result)) + } + protocol::SqliteExecuteResponse::SqliteErrorResponse(error) => { + Err(remote_sqlite_error_response(error.message)) + } } } @@ -622,172 +536,20 @@ impl SqliteDb { } #[cfg(feature = "sqlite-local")] -async fn enqueue_preload_hint_flush_best_effort( - db: Arc>>, - handle: EnvoyHandle, - actor_id: String, - generation: u64, -) { - let snapshot = match snapshot_preload_hints(db).await { - Ok(Some(snapshot)) => snapshot, - Ok(None) => return, - Err(error) => { - tracing::warn!( - actor_id = %actor_id, - ?error, - reason = "shutdown", - "sqlite preload hint snapshot failed" - ); - return; - } - }; - if snapshot.pgnos.is_empty() && snapshot.ranges.is_empty() { - return; - } - - let hint_count = snapshot.pgnos.len() + snapshot.ranges.len(); - let request = protocol::SqlitePersistPreloadHintsRequest { - actor_id: actor_id.clone(), - generation, - hints: protocol_preload_hints(snapshot), - }; - match handle.sqlite_persist_preload_hints_fire_and_forget(request) { - Ok(()) => { - tracing::debug!( - actor_id = %actor_id, - generation, - reason = "shutdown", - hint_count, - "sqlite preload hint flush queued" - ); - } - Err(error) => { - tracing::warn!( - actor_id = %actor_id, - generation, - reason = "shutdown", - hint_count, - ?error, - "sqlite preload hint flush queue failed" - ); - } - } -} - -#[cfg(feature = "sqlite-local")] -async fn flush_preload_hints_best_effort( - db: Arc>>, - handle: EnvoyHandle, - actor_id: String, - generation: u64, - reason: &'static str, -) { - let snapshot = match snapshot_preload_hints(db).await { - Ok(Some(snapshot)) => snapshot, - Ok(None) => return, - Err(error) => { - tracing::warn!( - actor_id = %actor_id, - ?error, - reason, - "sqlite preload hint snapshot failed" - ); - return; - } - }; - if snapshot.pgnos.is_empty() && snapshot.ranges.is_empty() { +fn report_sqlite_worker_fatal(reported: &AtomicBool, config: SqliteRuntimeConfig, message: String) { + if reported.swap(true, Ordering::AcqRel) { return; } - - let hint_count = snapshot.pgnos.len() + snapshot.ranges.len(); - let request = protocol::SqlitePersistPreloadHintsRequest { - actor_id: actor_id.clone(), - generation, - hints: protocol_preload_hints(snapshot), - }; - let response = timeout( - PRELOAD_HINT_FLUSH_TIMEOUT, - handle.sqlite_persist_preload_hints(request), - ) - .await; - match response { - Ok(Ok(protocol::SqlitePersistPreloadHintsResponse::SqlitePersistPreloadHintsOk)) => { - tracing::debug!( - actor_id = %actor_id, - generation, - reason, - hint_count, - "sqlite preload hints flushed" - ); - } - Ok(Ok(protocol::SqlitePersistPreloadHintsResponse::SqliteFenceMismatch(mismatch))) => { - tracing::debug!( - actor_id = %actor_id, - generation, - reason, - hint_count, - fence_reason = %mismatch.reason, - "sqlite preload hint flush skipped after fence mismatch" - ); - } - Ok(Ok(protocol::SqlitePersistPreloadHintsResponse::SqliteErrorResponse(error))) => { - tracing::warn!( - actor_id = %actor_id, - generation, - reason, - hint_count, - error = %error.message, - "sqlite preload hint flush failed" - ); - } - Ok(Err(error)) => { - tracing::warn!( - actor_id = %actor_id, - generation, - reason, - hint_count, - ?error, - "sqlite preload hint flush failed" - ); - } - Err(_) => { - tracing::warn!( - actor_id = %actor_id, - generation, - reason, - hint_count, - timeout_ms = PRELOAD_HINT_FLUSH_TIMEOUT.as_millis() as u64, - "sqlite preload hint flush timed out" - ); - } - } -} - -#[cfg(feature = "sqlite-local")] -async fn snapshot_preload_hints( - db: Arc>>, -) -> Result> { - tokio::task::spawn_blocking(move || { - let guard = db.lock(); - Ok(guard.as_ref().map(NativeDatabaseHandle::snapshot_preload_hints)) - }) - .await - .context("join sqlite preload hint snapshot task")? -} - -#[cfg(feature = "sqlite-local")] -fn protocol_preload_hints(snapshot: VfsPreloadHintSnapshot) -> protocol::SqlitePreloadHints { - protocol::SqlitePreloadHints { - pgnos: snapshot.pgnos, - ranges: snapshot - .ranges - .into_iter() - .map(|range| protocol::SqlitePreloadHintRange { - start_pgno: range.start_pgno, - page_count: range.page_count, - }) - .collect(), - } + // A dead worker means SQLite's sole native connection is no longer a valid + // actor subsystem. Core reports that through envoy lifecycle instead of + // letting the actor continue to serve requests with a broken database. + config.handle.stop_actor( + config.actor_id, + config + .generation + .and_then(|generation| generation.try_into().ok()), + Some(message), + ); } struct RemoteSqliteConfig { @@ -813,6 +575,37 @@ fn select_sqlite_backend(enabled: bool, remote_sqlite: bool) -> SqliteBackend { } } +#[cfg(feature = "sqlite-local")] +fn is_fatal_worker_error(error: &anyhow::Error) -> bool { + error.downcast_ref::().is_some() + || error + .downcast_ref::() + .is_some() +} + +#[cfg(feature = "sqlite-local")] +fn map_local_worker_error(error: anyhow::Error) -> anyhow::Error { + if error + .downcast_ref::() + .is_some() + { + return ActorLifecycle::Overloaded { + channel: "sqlite_worker".to_string(), + capacity: SQLITE_WORKER_QUEUE_CAPACITY, + operation: "execute sqlite command".to_string(), + } + .build(); + } + + if error.downcast_ref::().is_some() + || error.downcast_ref::().is_some() + { + return SqliteRuntimeError::Closed.build(); + } + + error +} + fn protocol_bind_params(params: Vec) -> Vec { params.into_iter().map(protocol_bind_param).collect() } @@ -823,11 +616,11 @@ fn protocol_bind_param(param: BindParam) -> protocol::SqliteBindParam { BindParam::Integer(value) => { protocol::SqliteBindParam::SqliteValueInteger(protocol::SqliteValueInteger { value }) } - BindParam::Float(value) => protocol::SqliteBindParam::SqliteValueFloat( - protocol::SqliteValueFloat { + BindParam::Float(value) => { + protocol::SqliteBindParam::SqliteValueFloat(protocol::SqliteValueFloat { value: value.to_bits().to_be_bytes(), - }, - ), + }) + } BindParam::Text(value) => { protocol::SqliteBindParam::SqliteValueText(protocol::SqliteValueText { value }) } @@ -858,16 +651,13 @@ fn execute_result_from_protocol(result: protocol::SqliteExecuteResult) -> Execut .collect(), changes: result.changes, last_insert_row_id: result.last_insert_row_id, - route: execute_route_from_protocol(result.route), } } fn column_value_from_protocol(value: protocol::SqliteColumnValue) -> ColumnValue { match value { protocol::SqliteColumnValue::SqliteValueNull => ColumnValue::Null, - protocol::SqliteColumnValue::SqliteValueInteger(value) => { - ColumnValue::Integer(value.value) - } + protocol::SqliteColumnValue::SqliteValueInteger(value) => ColumnValue::Integer(value.value), protocol::SqliteColumnValue::SqliteValueFloat(value) => { ColumnValue::Float(f64::from_bits(u64::from_be_bytes(value.value))) } @@ -876,14 +666,6 @@ fn column_value_from_protocol(value: protocol::SqliteColumnValue) -> ColumnValue } } -fn execute_route_from_protocol(route: protocol::SqliteExecuteRoute) -> ExecuteRoute { - match route { - protocol::SqliteExecuteRoute::Read => ExecuteRoute::Read, - protocol::SqliteExecuteRoute::Write => ExecuteRoute::Write, - protocol::SqliteExecuteRoute::WriteFallback => ExecuteRoute::WriteFallback, - } -} - fn remote_request_error(error: anyhow::Error) -> anyhow::Error { if let Some(indeterminate) = error.downcast_ref::() { return SqliteRuntimeError::RemoteIndeterminateResult { diff --git a/rivetkit-rust/packages/rivetkit-core/src/actor/state.rs b/rivetkit-rust/packages/rivetkit-core/src/actor/state.rs index 5a0111784c..45e3e664f2 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/actor/state.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/actor/state.rs @@ -21,9 +21,9 @@ use crate::actor::messages::StateDelta; use crate::actor::persist::{ decode_latest_with_embedded_version, encode_latest_with_embedded_version, }; +use crate::actor::task::LifecycleEvent; #[cfg(not(target_arch = "wasm32"))] use crate::actor::task::{LIFECYCLE_EVENT_INBOX_CHANNEL, actor_channel_overloaded_error}; -use crate::actor::task::LifecycleEvent; use crate::actor::task_types::StateMutationReason; use crate::error::ActorRuntime; #[cfg(feature = "wasm-runtime")] @@ -149,7 +149,8 @@ impl ActorContext { #[cfg(target_arch = "wasm32")] fn request_save_best_effort(&self, opts: RequestSaveOpts) { let immediate = opts.immediate; - let _save_request_revision = self.0.save_request_revision.fetch_add(1, Ordering::SeqCst) + 1; + let _save_request_revision = + self.0.save_request_revision.fetch_add(1, Ordering::SeqCst) + 1; self.notify_request_save_hooks(opts); let already_requested = self.0.save_requested.swap(true, Ordering::SeqCst); let immediate_already_requested = if immediate { diff --git a/rivetkit-rust/packages/rivetkit-core/src/lib.rs b/rivetkit-rust/packages/rivetkit-core/src/lib.rs index 4f39c313b8..238fc88c48 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/lib.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/lib.rs @@ -131,8 +131,7 @@ pub use actor::queue::{ QueueTryNextBatchOpts, QueueTryNextOpts, QueueWaitOpts, }; pub use actor::sqlite::{ - BindParam, ColumnValue, ExecResult, ExecuteResult, ExecuteRoute, QueryResult, SqliteBackend, - SqliteDb, + BindParam, ColumnValue, ExecResult, ExecuteResult, QueryResult, SqliteBackend, SqliteDb, }; pub use actor::state::RequestSaveOpts; pub use actor::task::{ diff --git a/rivetkit-rust/packages/rivetkit-core/src/registry/envoy_callbacks.rs b/rivetkit-rust/packages/rivetkit-core/src/registry/envoy_callbacks.rs index 76fd10cf54..64f823f940 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/registry/envoy_callbacks.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/registry/envoy_callbacks.rs @@ -164,13 +164,13 @@ impl ServeSettings { serverless_base_path: None, serverless_package_version: env!("CARGO_PKG_VERSION").to_owned(), serverless_client_endpoint: None, - serverless_client_namespace: None, - serverless_client_token: None, - serverless_validate_endpoint: true, - serverless_max_start_payload_bytes: 1_048_576, - } + serverless_client_namespace: None, + serverless_client_token: None, + serverless_validate_endpoint: true, + serverless_max_start_payload_bytes: 1_048_576, } } +} impl Default for ServeConfig { fn default() -> Self { @@ -192,14 +192,14 @@ impl ServeConfig { serverless_base_path: settings.serverless_base_path, serverless_package_version: settings.serverless_package_version, serverless_client_endpoint: settings.serverless_client_endpoint, - serverless_client_namespace: settings.serverless_client_namespace, - serverless_client_token: settings.serverless_client_token, - serverless_validate_endpoint: settings.serverless_validate_endpoint, - serverless_max_start_payload_bytes: settings.serverless_max_start_payload_bytes, - serverless_cache_envoy: true, - } + serverless_client_namespace: settings.serverless_client_namespace, + serverless_client_token: settings.serverless_client_token, + serverless_validate_endpoint: settings.serverless_validate_endpoint, + serverless_max_start_payload_bytes: settings.serverless_max_start_payload_bytes, + serverless_cache_envoy: true, } } +} fn actor_key_from_protocol(key: Option) -> ActorKey { key.as_deref() diff --git a/rivetkit-rust/packages/rivetkit-core/src/registry/inspector_ws.rs b/rivetkit-rust/packages/rivetkit-core/src/registry/inspector_ws.rs index 8a1ff8cf52..5c7246efdc 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/registry/inspector_ws.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/registry/inspector_ws.rs @@ -201,7 +201,7 @@ impl RegistryDispatcher { let instance = listener_instance.clone(); let sender = listener_sender.clone(); let actor_id = instance.ctx.actor_id().to_owned(); - RuntimeSpawner::spawn( + RuntimeSpawner::spawn( async move { match dispatcher .inspector_push_message_for_signal(&instance, signal) @@ -318,17 +318,17 @@ impl RegistryDispatcher { message: inspector_protocol::ClientMessage, ) -> Result> { match message { - inspector_protocol::ClientMessage::PatchStateRequest(request) => { - let state = request.state; - instance - .ctx - .save_state(vec![StateDelta::ActorState(state.clone())]) - .await - .context("save inspector websocket state patch")?; - Ok(Some(InspectorServerMessage::StateUpdated( - inspector_protocol::StateUpdated { state }, - ))) - } + inspector_protocol::ClientMessage::PatchStateRequest(request) => { + let state = request.state; + instance + .ctx + .save_state(vec![StateDelta::ActorState(state.clone())]) + .await + .context("save inspector websocket state patch")?; + Ok(Some(InspectorServerMessage::StateUpdated( + inspector_protocol::StateUpdated { state }, + ))) + } inspector_protocol::ClientMessage::StateRequest(request) => { Ok(Some(InspectorServerMessage::StateResponse( self.inspector_state_response(instance, request.id), diff --git a/rivetkit-rust/packages/rivetkit-core/src/registry/mod.rs b/rivetkit-rust/packages/rivetkit-core/src/registry/mod.rs index 10281c24f0..497d6cf5a0 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/registry/mod.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/registry/mod.rs @@ -455,12 +455,7 @@ impl CoreRegistry { let prepopulate_actor_names = dispatcher .build_actor_metadata_map() .into_iter() - .map(|(name, metadata)| { - ( - name, - rivet_envoy_client::config::ActorName { metadata }, - ) - }) + .map(|(name, metadata)| (name, rivet_envoy_client::config::ActorName { metadata })) .collect(); let handle = start_envoy(rivet_envoy_client::config::EnvoyConfig { version: config.version, diff --git a/rivetkit-rust/packages/rivetkit-core/src/registry/websocket.rs b/rivetkit-rust/packages/rivetkit-core/src/registry/websocket.rs index ecf23df63a..acdffac059 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/registry/websocket.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/registry/websocket.rs @@ -249,40 +249,40 @@ impl RegistryDispatcher { let on_open: Option EnvoyBoxFuture<()> + Send>> = if is_restoring_hibernatable { - None - } else { - Some(Box::new(move |sender| { - let actor_id = init_actor_id.clone(); - let conn_id = init_conn_id.clone(); - Box::pin(async move { - if let Err(error) = send_actor_connect_message( - &sender, - encoding, - &ActorConnectToClient::Init(ActorConnectInit { - actor_id, - connection_id: conn_id, - }), - max_outgoing_message_size, - ) { - match error { - ActorConnectSendError::OutgoingTooLong => { - sender.close( - Some(1011), - Some("message.outgoing_too_long".to_owned()), - ); - } - ActorConnectSendError::Encode(error) => { - tracing::error!( - ?error, - "failed to send actor websocket init message" - ); - sender.close(Some(1011), Some("actor.init_error".to_owned())); + None + } else { + Some(Box::new(move |sender| { + let actor_id = init_actor_id.clone(); + let conn_id = init_conn_id.clone(); + Box::pin(async move { + if let Err(error) = send_actor_connect_message( + &sender, + encoding, + &ActorConnectToClient::Init(ActorConnectInit { + actor_id, + connection_id: conn_id, + }), + max_outgoing_message_size, + ) { + match error { + ActorConnectSendError::OutgoingTooLong => { + sender.close( + Some(1011), + Some("message.outgoing_too_long".to_owned()), + ); + } + ActorConnectSendError::Encode(error) => { + tracing::error!( + ?error, + "failed to send actor websocket init message" + ); + sender.close(Some(1011), Some("actor.init_error".to_owned())); + } } } - } - }) - })) - }; + }) + })) + }; Ok(WebSocketHandler { on_message: Box::new(move |message: WebSocketMessage| { @@ -355,8 +355,8 @@ impl RegistryDispatcher { let conn = conn.clone(); let message_index = message.message_index; let actor_id = ctx.actor_id().to_owned(); - RuntimeSpawner::spawn( - async move { + RuntimeSpawner::spawn( + async move { let response = match dispatch_action_through_task( &dispatch, on_message_dispatch_capacity, diff --git a/rivetkit-rust/packages/rivetkit-core/src/serverless.rs b/rivetkit-rust/packages/rivetkit-core/src/serverless.rs index 20f5f6ef1c..2032dbcd8e 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/serverless.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/serverless.rs @@ -181,11 +181,11 @@ impl CoreServerlessRuntime { package_version: config.serverless_package_version, client_endpoint: config.serverless_client_endpoint, client_namespace: config.serverless_client_namespace, - client_token: config.serverless_client_token, - validate_endpoint: config.serverless_validate_endpoint, - max_start_payload_bytes: config.serverless_max_start_payload_bytes, - cache_envoy: config.serverless_cache_envoy, - }), + client_token: config.serverless_client_token, + validate_endpoint: config.serverless_validate_endpoint, + max_start_payload_bytes: config.serverless_max_start_payload_bytes, + cache_envoy: config.serverless_cache_envoy, + }), dispatcher, envoy: Arc::new(TokioMutex::new(None)), #[cfg(feature = "native-runtime")] @@ -422,9 +422,7 @@ impl CoreServerlessRuntime { // installing it into the cache. if self.shutting_down.load(Ordering::Acquire) { drop(guard); - match timeout(SHUTDOWN_DRAIN_TIMEOUT, handle.shutdown_and_wait(false)) - .await - { + match timeout(SHUTDOWN_DRAIN_TIMEOUT, handle.shutdown_and_wait(false)).await { Ok(()) => {} Err(_) => { handle.shutdown(true); @@ -662,22 +660,18 @@ fn normalized_endpoint_candidates(value: &str) -> Vec { .split(',') .map(str::trim) .filter(|candidate| !candidate.is_empty()) - .map(|candidate| { - normalize_endpoint_url(candidate).unwrap_or_else(|| candidate.to_owned()) - }) + .map(|candidate| normalize_endpoint_url(candidate).unwrap_or_else(|| candidate.to_owned())) .collect() } pub fn endpoints_match(a: &str, b: &str) -> bool { let a_candidates = normalized_endpoint_candidates(a); let b_candidates = normalized_endpoint_candidates(b); - a_candidates - .iter() - .any(|a_candidate| { - b_candidates - .iter() - .any(|b_candidate| a_candidate == b_candidate) - }) + a_candidates.iter().any(|a_candidate| { + b_candidates + .iter() + .any(|b_candidate| a_candidate == b_candidate) + }) } fn normalize_regional_hostname(hostname: &str) -> String { diff --git a/rivetkit-rust/packages/rivetkit-core/tests/metrics.rs b/rivetkit-rust/packages/rivetkit-core/tests/metrics.rs index 0e0c1eee27..61eccc978f 100644 --- a/rivetkit-rust/packages/rivetkit-core/tests/metrics.rs +++ b/rivetkit-rust/packages/rivetkit-core/tests/metrics.rs @@ -34,40 +34,4 @@ mod moved_tests { .count() ); } - - #[cfg(feature = "sqlite-local")] - #[test] - fn sqlite_read_pool_metrics_render() { - use depot_client::vfs::SqliteVfsMetrics; - - let metrics = ActorMetrics::new("actor-1", "test"); - metrics.set_read_pool_active_readers(2); - metrics.set_read_pool_idle_readers(1); - metrics.observe_read_pool_read_wait(std::time::Duration::from_millis(3)); - metrics.observe_read_pool_write_wait(std::time::Duration::from_millis(5)); - metrics.record_read_pool_routed_read_query(); - metrics.record_read_pool_write_fallback_query(); - metrics.observe_read_pool_manual_transaction(std::time::Duration::from_millis(7)); - metrics.record_read_pool_reader_open(); - metrics.record_read_pool_reader_close(1); - metrics.record_read_pool_rejected_reader_mutation(); - metrics.record_read_pool_mode_transition("read", "write"); - - let output = metrics.render().expect("metrics should render"); - for name in [ - "sqlite_read_pool_active_readers", - "sqlite_read_pool_idle_readers", - "sqlite_read_pool_read_wait_duration_seconds", - "sqlite_read_pool_write_wait_duration_seconds", - "sqlite_read_pool_routed_read_queries_total", - "sqlite_read_pool_write_fallback_queries_total", - "sqlite_read_pool_manual_transaction_duration_seconds", - "sqlite_read_pool_reader_opens_total", - "sqlite_read_pool_reader_closes_total", - "sqlite_read_pool_rejected_reader_mutations_total", - "sqlite_read_pool_mode_transitions_total", - ] { - assert!(output.contains(name), "missing metric {name}"); - } - } } diff --git a/rivetkit-rust/packages/rivetkit-core/tests/serverless.rs b/rivetkit-rust/packages/rivetkit-core/tests/serverless.rs index b49cc7ec34..1317144439 100644 --- a/rivetkit-rust/packages/rivetkit-core/tests/serverless.rs +++ b/rivetkit-rust/packages/rivetkit-core/tests/serverless.rs @@ -167,8 +167,8 @@ mod moved_tests { async fn test_runtime() -> CoreServerlessRuntime { CoreServerlessRuntime::new(HashMap::new(), test_config()) - .await - .expect("runtime should build") + .await + .expect("runtime should build") } fn test_config() -> ServeConfig { diff --git a/rivetkit-rust/packages/rivetkit-core/tests/sleep.rs b/rivetkit-rust/packages/rivetkit-core/tests/sleep.rs index d4a2b3b634..dea65ec9df 100644 --- a/rivetkit-rust/packages/rivetkit-core/tests/sleep.rs +++ b/rivetkit-rust/packages/rivetkit-core/tests/sleep.rs @@ -5,9 +5,9 @@ mod moved_tests { use crate::actor::context::ActorContext; use parking_lot::Mutex as DropMutex; use rivet_envoy_client::async_counter::AsyncCounter; + use std::time::{Duration, Instant}; use tokio::sync::oneshot; use tokio::task::yield_now; - use std::time::{Duration, Instant}; use tokio::time::advance; use tracing::field::{Field, Visit}; @@ -84,8 +84,7 @@ mod moved_tests { let mut visitor = MessageVisitor::default(); event.record(&mut visitor); - if visitor.message.as_deref() - == Some("registered task cancelled by shutdown deadline") + if visitor.message.as_deref() == Some("registered task cancelled by shutdown deadline") && visitor.actor_id.as_deref() == Some("actor-register-task-deadline") && visitor.reason.as_deref() == Some("shutdown_deadline_elapsed") { diff --git a/rivetkit-rust/packages/rivetkit-core/tests/sqlite.rs b/rivetkit-rust/packages/rivetkit-core/tests/sqlite.rs index 0ef2d193a1..6ba7a74e79 100644 --- a/rivetkit-rust/packages/rivetkit-core/tests/sqlite.rs +++ b/rivetkit-rust/packages/rivetkit-core/tests/sqlite.rs @@ -2,18 +2,33 @@ use super::*; #[test] fn remote_backend_requires_declared_database_and_capability() { - assert_eq!(select_sqlite_backend(true, true), SqliteBackend::RemoteEnvoy); + assert_eq!( + select_sqlite_backend(true, true), + SqliteBackend::RemoteEnvoy + ); #[cfg(feature = "sqlite-local")] { - assert_eq!(select_sqlite_backend(true, false), SqliteBackend::LocalNative); - assert_eq!(select_sqlite_backend(false, true), SqliteBackend::LocalNative); + assert_eq!( + select_sqlite_backend(true, false), + SqliteBackend::LocalNative + ); + assert_eq!( + select_sqlite_backend(false, true), + SqliteBackend::LocalNative + ); } #[cfg(not(feature = "sqlite-local"))] { - assert_eq!(select_sqlite_backend(true, false), SqliteBackend::Unavailable); - assert_eq!(select_sqlite_backend(false, true), SqliteBackend::Unavailable); + assert_eq!( + select_sqlite_backend(true, false), + SqliteBackend::Unavailable + ); + assert_eq!( + select_sqlite_backend(false, true), + SqliteBackend::Unavailable + ); } } @@ -63,7 +78,6 @@ fn protocol_conversion_preserves_bind_and_result_values() { ]], changes: 3, last_insert_row_id: Some(11), - route: protocol::SqliteExecuteRoute::WriteFallback, }); assert_eq!(result.columns, vec!["id", "score"]); @@ -73,7 +87,6 @@ fn protocol_conversion_preserves_bind_and_result_values() { ); assert_eq!(result.changes, 3); assert_eq!(result.last_insert_row_id, Some(11)); - assert_eq!(result.route, ExecuteRoute::WriteFallback); } #[test] @@ -95,7 +108,7 @@ fn remote_protocol_compatibility_errors_become_remote_unavailable() { fn remote_lost_response_errors_become_indeterminate_result() { let err = anyhow::anyhow!( rivet_envoy_client::utils::RemoteSqliteIndeterminateResultError { - operation: "execute_write", + operation: "execute", } ); diff --git a/rivetkit-typescript/packages/rivetkit-napi/index.d.ts b/rivetkit-typescript/packages/rivetkit-napi/index.d.ts index 6508429ca2..f55f40b2ec 100644 --- a/rivetkit-typescript/packages/rivetkit-napi/index.d.ts +++ b/rivetkit-typescript/packages/rivetkit-napi/index.d.ts @@ -98,7 +98,6 @@ export interface NativeExecuteResult { rows: Array> changes: number lastInsertRowId?: number - route: string } export interface JsQueueNextOptions { names?: Array @@ -249,7 +248,6 @@ export declare class JsNativeDatabase { run(sql: string, params?: Array | undefined | null): Promise query(sql: string, params?: Array | undefined | null): Promise execute(sql: string, params?: Array | undefined | null): Promise - executeWrite(sql: string, params?: Array | undefined | null): Promise exec(sql: string): Promise close(): Promise } diff --git a/rivetkit-typescript/packages/rivetkit-napi/src/database.rs b/rivetkit-typescript/packages/rivetkit-napi/src/database.rs index ad17ecdf1a..94ef744b3d 100644 --- a/rivetkit-typescript/packages/rivetkit-napi/src/database.rs +++ b/rivetkit-typescript/packages/rivetkit-napi/src/database.rs @@ -1,8 +1,8 @@ use napi::bindgen_prelude::Buffer; use napi_derive::napi; use rivetkit_core::sqlite::{ - BindParam, ColumnValue, ExecuteResult as CoreExecuteResult, ExecuteRoute, - QueryResult as CoreQueryResult, SqliteDb as CoreSqliteDb, + BindParam, ColumnValue, ExecuteResult as CoreExecuteResult, QueryResult as CoreQueryResult, + SqliteDb as CoreSqliteDb, }; use crate::{NapiInvalidArgument, napi_anyhow_error}; @@ -60,7 +60,6 @@ pub struct NativeExecuteResult { pub rows: Vec>, pub changes: i64, pub last_insert_row_id: Option, - pub route: String, } #[napi] @@ -117,21 +116,6 @@ impl JsNativeDatabase { Ok(core_execute_result_to_js(result)) } - #[napi] - pub async fn execute_write( - &self, - sql: String, - params: Option>, - ) -> napi::Result { - let params = params.map(js_bind_params_to_core).transpose()?; - let result = self - .db - .execute_write(sql, params) - .await - .map_err(crate::napi_anyhow_error)?; - Ok(core_execute_result_to_js(result)) - } - #[napi] pub async fn exec(&self, sql: String) -> napi::Result { let result = self.db.exec(sql).await.map_err(crate::napi_anyhow_error)?; @@ -190,15 +174,6 @@ fn core_execute_result_to_js(result: CoreExecuteResult) -> NativeExecuteResult { .collect(), changes: result.changes, last_insert_row_id: result.last_insert_row_id, - route: execute_route_to_js(result.route), - } -} - -fn execute_route_to_js(route: ExecuteRoute) -> String { - match route { - ExecuteRoute::Read => "read".to_owned(), - ExecuteRoute::Write => "write".to_owned(), - ExecuteRoute::WriteFallback => "writeFallback".to_owned(), } } diff --git a/rivetkit-typescript/packages/rivetkit-napi/src/lib.rs b/rivetkit-typescript/packages/rivetkit-napi/src/lib.rs index 856854890c..849639cfda 100644 --- a/rivetkit-typescript/packages/rivetkit-napi/src/lib.rs +++ b/rivetkit-typescript/packages/rivetkit-napi/src/lib.rs @@ -60,7 +60,7 @@ fn anyhow_to_bridge_rivet_error_payload(error: anyhow::Error) -> serde_json::Val context.public_ != Some(true) || context.status_code.is_none() || context.status_code == Some(500) - }, + } None => true, }); let status_code = if should_promote { diff --git a/rivetkit-typescript/packages/rivetkit-napi/src/napi_actor_events.rs b/rivetkit-typescript/packages/rivetkit-napi/src/napi_actor_events.rs index c6f4c2e6c6..74fd941ccf 100644 --- a/rivetkit-typescript/packages/rivetkit-napi/src/napi_actor_events.rs +++ b/rivetkit-typescript/packages/rivetkit-napi/src/napi_actor_events.rs @@ -1,5 +1,5 @@ -use std::sync::{Arc, LazyLock}; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, LazyLock}; use std::time::Duration; use anyhow::Result; diff --git a/rivetkit-typescript/packages/rivetkit-napi/src/registry.rs b/rivetkit-typescript/packages/rivetkit-napi/src/registry.rs index 6ed3985d4e..c3fe026770 100644 --- a/rivetkit-typescript/packages/rivetkit-napi/src/registry.rs +++ b/rivetkit-typescript/packages/rivetkit-napi/src/registry.rs @@ -175,14 +175,14 @@ impl CoreRegistry { serverless_package_version: config.serverless_package_version, serverless_client_endpoint: config.serverless_client_endpoint, serverless_client_namespace: config.serverless_client_namespace, - serverless_client_token: config.serverless_client_token, - serverless_validate_endpoint: config.serverless_validate_endpoint, - serverless_max_start_payload_bytes: config.serverless_max_start_payload_bytes - as usize, - serverless_cache_envoy: true, - }, - self.shutdown_token.clone(), - ) + serverless_client_token: config.serverless_client_token, + serverless_validate_endpoint: config.serverless_validate_endpoint, + serverless_max_start_payload_bytes: config.serverless_max_start_payload_bytes + as usize, + serverless_cache_envoy: true, + }, + self.shutdown_token.clone(), + ) .await .map_err(napi_anyhow_error) } @@ -375,13 +375,13 @@ impl CoreRegistry { serverless_package_version: config.serverless_package_version, serverless_client_endpoint: config.serverless_client_endpoint, serverless_client_namespace: config.serverless_client_namespace, - serverless_client_token: config.serverless_client_token, - serverless_validate_endpoint: config.serverless_validate_endpoint, - serverless_max_start_payload_bytes: config.serverless_max_start_payload_bytes - as usize, - serverless_cache_envoy: true, - }) - .await; + serverless_client_token: config.serverless_client_token, + serverless_validate_endpoint: config.serverless_validate_endpoint, + serverless_max_start_payload_bytes: config.serverless_max_start_payload_bytes + as usize, + serverless_cache_envoy: true, + }) + .await; // Re-acquire the lock and re-check state. Shutdown may have run during // the build. If so, tear down the freshly-built runtime rather than diff --git a/rivetkit-typescript/packages/rivetkit-napi/tests/actor_factory.rs b/rivetkit-typescript/packages/rivetkit-napi/tests/actor_factory.rs index 65bee59ce7..34ae3c37f2 100644 --- a/rivetkit-typescript/packages/rivetkit-napi/tests/actor_factory.rs +++ b/rivetkit-typescript/packages/rivetkit-napi/tests/actor_factory.rs @@ -78,15 +78,16 @@ mod moved_tests { #[test] fn napi_bridge_payload_promotes_known_core_error_status() { - let payload = crate::anyhow_to_bridge_rivet_error_payload(anyhow::Error::new( - RivetError { - schema: &AUTH_FORBIDDEN_SCHEMA, - meta: None, - message: None, - }, - )); - - assert_eq!(payload.get("group").and_then(|value| value.as_str()), Some("auth")); + let payload = crate::anyhow_to_bridge_rivet_error_payload(anyhow::Error::new(RivetError { + schema: &AUTH_FORBIDDEN_SCHEMA, + meta: None, + message: None, + })); + + assert_eq!( + payload.get("group").and_then(|value| value.as_str()), + Some("auth") + ); assert_eq!( payload.get("code").and_then(|value| value.as_str()), Some("forbidden") diff --git a/rivetkit-typescript/packages/rivetkit-napi/tests/napi_actor_events.rs b/rivetkit-typescript/packages/rivetkit-napi/tests/napi_actor_events.rs index 79a1cda3cf..d2c8d04b1c 100644 --- a/rivetkit-typescript/packages/rivetkit-napi/tests/napi_actor_events.rs +++ b/rivetkit-typescript/packages/rivetkit-napi/tests/napi_actor_events.rs @@ -6,8 +6,8 @@ mod moved_tests { use std::time::Duration; use rivet_error::RivetError as RivetTransportError; - use rivetkit_actor_persist::versioned as persist_versioned; use rivet_error::{RivetError as RivetTransportError, RivetErrorSchema}; + use rivetkit_actor_persist::versioned as persist_versioned; use rivetkit_core::Kv; use rivetkit_core::actor::state::PERSIST_DATA_KEY; use tokio::sync::oneshot; @@ -396,7 +396,8 @@ mod moved_tests { let first = structured_timeout_schema("test", "slow_callback", "first message"); for i in 0..100 { - let schema = structured_timeout_schema("test", "slow_callback", &format!("message {i}")); + let schema = + structured_timeout_schema("test", "slow_callback", &format!("message {i}")); assert!(std::ptr::eq(schema, first)); } } diff --git a/rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs b/rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs index a6bda3e433..ddb0455d22 100644 --- a/rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs +++ b/rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs @@ -7,20 +7,17 @@ use std::time::Duration; use anyhow::{Result, anyhow}; use js_sys::{Array, Function, Object, Promise, Reflect, Uint8Array}; -use rivet_error::{ - MacroMarker, RivetError as RivetTransportError, RivetErrorSchema, -}; +use rivet_error::{MacroMarker, RivetError as RivetTransportError, RivetErrorSchema}; +use rivetkit_core::error::public_error_status_code; +use rivetkit_core::inspector::InspectorAuth; use rivetkit_core::{ - ActorConfig, ActorConfigInput, ActorEvent, ActorFactory as CoreActorFactory, - ActorStart, BindParam, ColumnValue, CoreRegistry as NativeCoreRegistry, EnqueueAndWaitOpts, - CoreServerlessRuntime, - ExecuteRoute, ListOpts, QueueMessage, QueueNextBatchOpts, QueueSendResult, QueueSendStatus, - QueueTryNextBatchOpts, QueueWaitOpts, Request, RequestSaveOpts, Response, RuntimeSpawner, - SerializeStateReason, ServeConfig, ServerlessRequest, StateDelta, WebSocket, + ActorConfig, ActorConfigInput, ActorEvent, ActorFactory as CoreActorFactory, ActorStart, + BindParam, ColumnValue, CoreRegistry as NativeCoreRegistry, CoreServerlessRuntime, + EnqueueAndWaitOpts, ListOpts, QueueMessage, QueueNextBatchOpts, QueueSendResult, + QueueSendStatus, QueueTryNextBatchOpts, QueueWaitOpts, Request, RequestSaveOpts, Response, + RuntimeSpawner, SerializeStateReason, ServeConfig, ServerlessRequest, StateDelta, WebSocket, WebSocketCallbackRegion, WsMessage, }; -use rivetkit_core::error::public_error_status_code; -use rivetkit_core::inspector::InspectorAuth; use scc::HashMap as SccHashMap; use tokio::sync::oneshot; use tokio_util::sync::CancellationToken as CoreCancellationToken; @@ -318,9 +315,7 @@ impl WasmCoreRegistry { let mut state = self.state.borrow_mut(); match &mut *state { RegistryState::Registering(registry) => { - let registry = registry - .take() - .ok_or_else(registry_not_registering_error)?; + let registry = registry.take().ok_or_else(registry_not_registering_error)?; *state = RegistryState::Serving; registry } @@ -336,10 +331,7 @@ impl WasmCoreRegistry { let local = tokio::task::LocalSet::new(); local - .run_until(registry.serve_with_config( - config.into(), - self.shutdown_token.clone(), - )) + .run_until(registry.serve_with_config(config.into(), self.shutdown_token.clone())) .await .map_err(anyhow_to_js_error) } @@ -377,12 +369,12 @@ impl WasmCoreRegistry { on_stream_event: Function, cancel_token: &WasmCancellationToken, config: JsValue, - ) -> Result { - let serverless = self.serverless_runtime(config).await?; - let req = serverless_request_from_js(req, cancel_token.inner.clone()) - .map_err(anyhow_to_js_error)?; - start_wasm_serverless_request(serverless.runtime, req, on_stream_event).await - } + ) -> Result { + let serverless = self.serverless_runtime(config).await?; + let req = serverless_request_from_js(req, cancel_token.inner.clone()) + .map_err(anyhow_to_js_error)?; + start_wasm_serverless_request(serverless.runtime, req, on_stream_event).await + } async fn serverless_runtime(&self, config: JsValue) -> Result { let config: WasmServeConfig = serde_wasm_bindgen::from_value(config)?; @@ -395,9 +387,8 @@ impl WasmCoreRegistry { let mut state = self.state.borrow_mut(); match &mut *state { RegistryState::Registering(registry) => { - let registry = registry - .take() - .ok_or_else(registry_not_registering_error)?; + let registry = + registry.take().ok_or_else(registry_not_registering_error)?; *state = RegistryState::BuildingServerless; Some(registry) } @@ -564,9 +555,12 @@ async fn run_actor_adapter(callbacks: WasmCallbacks, start: ActorStart) -> Resul let ctx = WasmActorContext::from_core(core_ctx.clone(), callbacks.clone()); let preamble = run_preamble(&callbacks, &ctx, input, snapshot).await; if let Some(reply) = startup_ready { - let _ = reply.send(preamble.as_ref().map(|_| ()).map_err(|error| { - anyhow!(RivetTransportError::extract(error)) - })); + let _ = reply.send( + preamble + .as_ref() + .map(|_| ()) + .map_err(|error| anyhow!(RivetTransportError::extract(error))), + ); } preamble?; start_run_handler(&callbacks, &ctx); @@ -858,11 +852,7 @@ async fn dispatch_event(callbacks: &WasmCallbacks, ctx: &WasmActorContext, event "conn", JsValue::from(WasmConnHandle::from_core(conn)), )?; - set_anyhow( - &payload, - "ws", - JsValue::from(WasmWebSocket::from_core(ws)), - )?; + set_anyhow(&payload, "ws", JsValue::from(WasmWebSocket::from_core(ws)))?; if let Some(request) = request { set_anyhow(&payload, "request", request_to_js(request)?)?; } @@ -940,13 +930,19 @@ async fn dispatch_event(callbacks: &WasmCallbacks, ctx: &WasmActorContext, event let result = async { let payload = object(); set_anyhow(&payload, "ctx", JsValue::from(ctx.clone()))?; - set_anyhow(&payload, "conn", JsValue::from(WasmConnHandle::from_core(conn)))?; + set_anyhow( + &payload, + "conn", + JsValue::from(WasmConnHandle::from_core(conn)), + )?; call_callback(callback, &payload.into()).await?; Ok::<_, anyhow::Error>(()) } .await; if let Err(error) = result { - console_error(&format!("wasm connection closed callback failed: {error:#}")); + console_error(&format!( + "wasm connection closed callback failed: {error:#}" + )); } } } @@ -1249,7 +1245,11 @@ impl WasmActorContext { "activeConnections", JsValue::from_f64(snapshot.active_connections as f64), )?; - set(&object, "queueSize", JsValue::from_f64(snapshot.queue_size as f64))?; + set( + &object, + "queueSize", + JsValue::from_f64(snapshot.queue_size as f64), + )?; set( &object, "connectedClients", @@ -1304,7 +1304,9 @@ impl WasmActorContext { let timestamp_ms = timestamp_ms .filter(|value| value.is_finite()) .map(|value| value.trunc() as i64); - self.inner.set_alarm(timestamp_ms).map_err(anyhow_to_js_error) + self.inner + .set_alarm(timestamp_ms) + .map_err(anyhow_to_js_error) } #[wasm_bindgen] @@ -1398,7 +1400,9 @@ impl WasmActorContext { if region_id == 0 { return; } - self.websocket_callback_regions.borrow_mut().remove(®ion_id); + self.websocket_callback_regions + .borrow_mut() + .remove(®ion_id); } #[wasm_bindgen] @@ -1506,17 +1510,18 @@ impl WasmWebSocket { pub fn set_event_callback(&self, callback: Function) { let callback = Arc::new(WasmFunction(callback)); let message_callback = callback.clone(); - self.inner - .configure_message_event_callback(Some(Arc::new(move |message, message_index| { + self.inner.configure_message_event_callback(Some(Arc::new( + move |message, message_index| { let event = websocket_message_event_to_js(message, message_index) .map_err(js_value_to_anyhow)?; message_callback.call1(&event)?; Ok(()) - }))); + }, + ))); let callback = callback.clone(); - self.inner - .configure_close_event_callback(Some(Arc::new(move |code, reason, was_clean| { + self.inner.configure_close_event_callback(Some(Arc::new( + move |code, reason, was_clean| { let callback = callback.clone(); let result = (|| { let event = websocket_close_event_to_js(code, reason, was_clean) @@ -1524,7 +1529,8 @@ impl WasmWebSocket { callback.call1(&event).map(|_| ()) })(); Box::pin(async move { result }) - }))); + }, + ))); } } @@ -1718,8 +1724,7 @@ impl WasmQueue { options: JsValue, ) -> Result<(), JsValue> { let names: Vec = serde_wasm_bindgen::from_value(names)?; - self - .inner + self.inner .wait_for_names_available(names, queue_wait_options(options)?) .await .map_err(anyhow_to_js_error)?; @@ -1821,10 +1826,7 @@ impl WasmQueueMessage { #[wasm_bindgen(js_name = isCompletable)] pub fn is_completable(&self) -> bool { - self.inner() - .clone() - .into_completable() - .is_ok() + self.inner().clone().into_completable().is_ok() } #[wasm_bindgen] @@ -1867,15 +1869,6 @@ impl WasmSqliteDb { .map_err(anyhow_to_js_error) } - #[wasm_bindgen(js_name = executeWrite)] - pub async fn execute_write(&self, sql: String, params: JsValue) -> Result { - self.inner - .execute_write(sql, bind_params_from_js(params)?) - .await - .map(execute_result_to_js) - .map_err(anyhow_to_js_error) - } - #[wasm_bindgen] pub async fn query(&self, sql: String, params: JsValue) -> Result { self.inner @@ -2263,8 +2256,7 @@ fn js_string_map_property(target: &JsValue, name: &str) -> Result Result { - js_string_property(target, name)? - .ok_or_else(|| anyhow!("property `{name}` must be a string")) + js_string_property(target, name)?.ok_or_else(|| anyhow!("property `{name}` must be a string")) } fn serverless_request_from_js( @@ -2284,7 +2276,10 @@ fn serverless_request_from_js( }) } -fn serverless_response_head_to_js(status: u16, headers: HashMap) -> Result { +fn serverless_response_head_to_js( + status: u16, + headers: HashMap, +) -> Result { let head = object(); set_anyhow(&head, "status", JsValue::from_f64(status as f64))?; let header_object = object(); @@ -2317,10 +2312,7 @@ fn serverless_stream_end_event( Ok(event.into()) } -async fn call_serverless_stream_callback( - callback: &Function, - event: JsValue, -) -> Result<()> { +async fn call_serverless_stream_callback(callback: &Function, event: JsValue) -> Result<()> { let value = callback .call2(&JsValue::UNDEFINED, &JsValue::NULL, &event) .map_err(js_value_to_anyhow)?; @@ -2371,12 +2363,16 @@ async fn start_wasm_serverless_request( if let Err(error) = call_serverless_stream_callback(&on_stream_event, event).await { - console_error(&format!("wasm serverless stream callback failed: {error:#}")); + console_error(&format!( + "wasm serverless stream callback failed: {error:#}" + )); break; } } Err(error) => { - console_error(&format!("wasm serverless stream event encode failed: {error:#}")); + console_error(&format!( + "wasm serverless stream event encode failed: {error:#}" + )); break; } } @@ -2407,9 +2403,11 @@ async fn start_wasm_serverless_request( let _ = done_tx.send(()); }); spawn_local(async move { - local.run_until(async { - let _ = done_rx.await; - }).await; + local + .run_until(async { + let _ = done_rx.await; + }) + .await; }); match head_rx.await { @@ -2521,7 +2519,9 @@ fn bind_params_from_js(value: JsValue) -> Result>, JsValue "float" => Ok(BindParam::Float(param.float_value.unwrap_or(0.0))), "text" => Ok(BindParam::Text(param.text_value.unwrap_or_default())), "blob" => Ok(BindParam::Blob(param.blob_value.unwrap_or_default())), - kind => Err(js_error(&format!("unsupported bind parameter kind: {kind}"))), + kind => Err(js_error(&format!( + "unsupported bind parameter kind: {kind}" + ))), }) .collect::, _>>() .map(Some) @@ -2529,14 +2529,24 @@ fn bind_params_from_js(value: JsValue) -> Result>, JsValue fn query_result_to_js(result: rivetkit_core::QueryResult) -> JsValue { let object = object(); - set(&object, "columns", strings_to_js_array(result.columns).into()).unwrap_throw(); + set( + &object, + "columns", + strings_to_js_array(result.columns).into(), + ) + .unwrap_throw(); set(&object, "rows", rows_to_js_array(result.rows).into()).unwrap_throw(); object.into() } fn execute_result_to_js(result: rivetkit_core::ExecuteResult) -> JsValue { let object = object(); - set(&object, "columns", strings_to_js_array(result.columns).into()).unwrap_throw(); + set( + &object, + "columns", + strings_to_js_array(result.columns).into(), + ) + .unwrap_throw(); set(&object, "rows", rows_to_js_array(result.rows).into()).unwrap_throw(); set(&object, "changes", JsValue::from_f64(result.changes as f64)).unwrap_throw(); if let Some(last_insert_row_id) = result.last_insert_row_id { @@ -2547,16 +2557,6 @@ fn execute_result_to_js(result: rivetkit_core::ExecuteResult) -> JsValue { ) .unwrap_throw(); } - set( - &object, - "route", - JsValue::from_str(match result.route { - ExecuteRoute::Read => "read", - ExecuteRoute::Write => "write", - ExecuteRoute::WriteFallback => "writeFallback", - }), - ) - .unwrap_throw(); object.into() } @@ -2730,7 +2730,7 @@ fn anyhow_to_bridge_rivet_error_payload(error: anyhow::Error) -> serde_json::Val context.public_ != Some(true) || context.status_code.is_none() || context.status_code == Some(500) - }, + } None => true, }); let status_code = if should_promote { @@ -2862,15 +2862,17 @@ mod tests { #[test] fn wasm_bridge_payload_promotes_known_core_error_status() { - let payload = anyhow_to_bridge_rivet_error_payload(anyhow::Error::new( - RivetTransportError { + let payload = + anyhow_to_bridge_rivet_error_payload(anyhow::Error::new(RivetTransportError { schema: &AUTH_FORBIDDEN_SCHEMA, meta: None, message: None, - }, - )); + })); - assert_eq!(payload.get("group").and_then(|value| value.as_str()), Some("auth")); + assert_eq!( + payload.get("group").and_then(|value| value.as_str()), + Some("auth") + ); assert_eq!( payload.get("code").and_then(|value| value.as_str()), Some("forbidden") @@ -2925,9 +2927,7 @@ mod tests { ); let first_id = context.begin_websocket_callback(); - context - .next_websocket_callback_region_id - .set(u32::MAX - 1); + context.next_websocket_callback_region_id.set(u32::MAX - 1); let max_id = context.begin_websocket_callback(); let wrapped_id = context.begin_websocket_callback(); diff --git a/rivetkit-typescript/packages/rivetkit/src/common/database/config.ts b/rivetkit-typescript/packages/rivetkit/src/common/database/config.ts index 7a20f452cb..5d44a91437 100644 --- a/rivetkit-typescript/packages/rivetkit/src/common/database/config.ts +++ b/rivetkit-typescript/packages/rivetkit/src/common/database/config.ts @@ -21,7 +21,6 @@ export interface SqliteQueryResult { export interface SqliteExecuteResult extends SqliteQueryResult { changes: number; lastInsertRowId?: number | null; - route: "read" | "write" | "writeFallback"; } export interface SqliteDatabase { 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 32fffe2a24..5e6fe0d5c9 100644 --- a/rivetkit-typescript/packages/rivetkit/src/common/database/mod.test.ts +++ b/rivetkit-typescript/packages/rivetkit/src/common/database/mod.test.ts @@ -31,7 +31,6 @@ class FakeSqliteDatabase implements SqliteDatabase { rows: [], changes: 0, lastInsertRowId: null, - route: this.writeModeDepth > 0 ? "write" : "read", }; } 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 b504f19990..e5141470a5 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 @@ -32,10 +32,6 @@ class FakeNativeDatabase implements JsNativeDatabaseLike { return await this.#startExecute(sql, params, false); } - async executeWrite(sql: string, params?: NativeParams) { - return await this.#startExecute(sql, params, true); - } - async query(sql: string, params?: NativeParams) { const { columns, rows } = await this.execute(sql, params); return { columns, rows }; @@ -64,7 +60,6 @@ class FakeNativeDatabase implements JsNativeDatabaseLike { rows: [], changes: 0, lastInsertRowId: null, - route: "read", ...result, }); } @@ -109,19 +104,18 @@ describe("wrapJsNativeDatabase", () => { }); }); - test("routes migration-mode calls through native write execution", async () => { + test("keeps write mode on the normal native execute lane", async () => { const native = new FakeNativeDatabase(); const db = wrapJsNativeDatabase(native); const query = db.writeMode(async () => { const promise = db.query("SELECT 1"); expect(native.executeCalls).toMatchObject([ - { sql: "SELECT 1", write: true }, + { sql: "SELECT 1", write: false }, ]); native.resolveNext({ columns: ["value"], rows: [[1]], - route: "write", }); return await promise; }); @@ -165,28 +159,21 @@ describe("wrapJsNativeDatabase", () => { }); }); - test("normalizes native execute routes and rejects unsupported routes", async () => { + test("returns native execute metadata", async () => { const native = new FakeNativeDatabase(); const db = wrapJsNativeDatabase(native); - const read = db.execute("SELECT 1"); - native.resolveNext({ route: "read" }); - await expect(read).resolves.toMatchObject({ route: "read" }); - const write = db.execute("INSERT INTO test VALUES (1)"); - native.resolveNext({ route: "write" }); - await expect(write).resolves.toMatchObject({ route: "write" }); + native.resolveNext({ changes: 1, lastInsertRowId: 7 }); + await expect(write).resolves.toMatchObject({ + changes: 1, + lastInsertRowId: 7, + }); const fallback = db.execute("SELECT last_insert_rowid()"); await expect(fallback).resolves.toMatchObject({ - route: "writeFallback", + rows: [[7]], }); - - const unsupported = db.execute("SELECT 2"); - native.resolveNext({ route: "custom" }); - await expect(unsupported).rejects.toThrow( - "unsupported sqlite execute route: custom", - ); }); test("close waits for admitted native calls and rejects new work", async () => { 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 8cd09d7e8b..c0b90b8f50 100644 --- a/rivetkit-typescript/packages/rivetkit/src/common/database/native-database.ts +++ b/rivetkit-typescript/packages/rivetkit/src/common/database/native-database.ts @@ -62,7 +62,6 @@ interface NativeExecuteResult { rows: unknown[][]; changes: number; lastInsertRowId?: number | null; - route: string; } export interface JsNativeDatabaseLike { @@ -71,10 +70,6 @@ export interface JsNativeDatabaseLike { sql: string, params?: NativeBindParam[] | null, ): Promise; - executeWrite( - sql: string, - params?: NativeBindParam[] | null, - ): Promise; query( sql: string, params?: NativeBindParam[] | null, @@ -206,13 +201,6 @@ function toNativeBindings( }); } -function normalizeExecuteRoute(route: string): SqliteExecuteResult["route"] { - if (route === "read" || route === "write" || route === "writeFallback") { - return route; - } - throw new Error(`unsupported sqlite execute route: ${route}`); -} - class NativeCloseGate { #active = 0; #closed = false; @@ -259,7 +247,6 @@ export function wrapJsNativeDatabase( ): SqliteDatabase { const gate = new NativeCloseGate(); let closePromise: Promise | undefined; - let writeModeDepth = 0; let lastInsertRowId: number | null = null; const executeNative = async ( @@ -273,24 +260,17 @@ export function wrapJsNativeDatabase( rows: [[lastInsertRowId ?? 0]], changes: 0, lastInsertRowId, - route: "writeFallback", }; } const release = gate.enter(); try { const nativeParams = toNativeBindings(sql, params); - const result = - writeModeDepth > 0 - ? await database.executeWrite(sql, nativeParams) - : await database.execute(sql, nativeParams); + const result = await database.execute(sql, nativeParams); if (result.lastInsertRowId !== undefined) { lastInsertRowId = result.lastInsertRowId; } - return { - ...result, - route: normalizeExecuteRoute(result.route), - }; + return result; } catch (error) { enrichNativeDatabaseError(database, error); } finally { @@ -333,12 +313,7 @@ export function wrapJsNativeDatabase( return { columns, rows }; }, async writeMode(callback: () => Promise): Promise { - writeModeDepth++; - try { - return await callback(); - } finally { - writeModeDepth--; - } + return await callback(); }, async close(): Promise { closePromise ??= gate.close(() => database.close()); diff --git a/rivetkit-typescript/packages/rivetkit/src/registry/napi-runtime.ts b/rivetkit-typescript/packages/rivetkit/src/registry/napi-runtime.ts index 40981abe78..e179ea32ae 100644 --- a/rivetkit-typescript/packages/rivetkit/src/registry/napi-runtime.ts +++ b/rivetkit-typescript/packages/rivetkit/src/registry/napi-runtime.ts @@ -534,18 +534,6 @@ export class NapiCoreRuntime implements CoreRuntime { return normalizeRuntimeSqlExecuteResult(result); } - async actorSqlExecuteWrite( - ctx: ActorContextHandle, - sql: string, - params?: RuntimeSqlBindParams, - ): Promise { - const result = await this.#actorSql(ctx).executeWrite( - sql, - toNapiSqlBindParams(params), - ); - return normalizeRuntimeSqlExecuteResult(result); - } - async actorSqlQuery( ctx: ActorContextHandle, sql: string, diff --git a/rivetkit-typescript/packages/rivetkit/src/registry/native.ts b/rivetkit-typescript/packages/rivetkit/src/registry/native.ts index c44a9bccea..a3b2e733f4 100644 --- a/rivetkit-typescript/packages/rivetkit/src/registry/native.ts +++ b/rivetkit-typescript/packages/rivetkit/src/registry/native.ts @@ -459,8 +459,6 @@ function getOrCreateNativeSqlDatabase( const database = wrapJsNativeDatabase({ exec: (sql) => runtime.actorSqlExec(ctx, sql), execute: (sql, params) => runtime.actorSqlExecute(ctx, sql, params), - executeWrite: (sql, params) => - runtime.actorSqlExecuteWrite(ctx, sql, params), query: (sql, params) => runtime.actorSqlQuery(ctx, sql, params), run: (sql, params) => runtime.actorSqlRun(ctx, sql, params), takeLastKvError: () => runtime.actorSqlTakeLastKvError(ctx), diff --git a/rivetkit-typescript/packages/rivetkit/src/registry/runtime.test.ts b/rivetkit-typescript/packages/rivetkit/src/registry/runtime.test.ts index 22ab9abe92..1921809190 100644 --- a/rivetkit-typescript/packages/rivetkit/src/registry/runtime.test.ts +++ b/rivetkit-typescript/packages/rivetkit/src/registry/runtime.test.ts @@ -3,7 +3,6 @@ import { normalizeRuntimeSqlExecuteResult, type RuntimeSqlBindParam, type RuntimeSqlBindParams, - type RuntimeSqlExecuteResult, } from "./runtime"; describe("runtime SQL boundary", () => { @@ -38,7 +37,7 @@ describe("runtime SQL boundary", () => { expect(invalidIntParam.kind).toBe("int"); }); - test("normalizes exact execute result routes", () => { + test("normalizes execute result metadata", () => { const base = { columns: ["value"], rows: [[1]], @@ -46,34 +45,6 @@ describe("runtime SQL boundary", () => { lastInsertRowId: null, }; - expect( - normalizeRuntimeSqlExecuteResult({ ...base, route: "read" }).route, - ).toBe("read"); - expect( - normalizeRuntimeSqlExecuteResult({ ...base, route: "write" }).route, - ).toBe("write"); - expect( - normalizeRuntimeSqlExecuteResult({ - ...base, - route: "writeFallback", - }).route, - ).toBe("writeFallback"); - expect(() => - normalizeRuntimeSqlExecuteResult({ ...base, route: "custom" }), - ).toThrow("unsupported runtime sqlite execute route: custom"); - }); - - test("rejects custom execute result routes at typecheck time", () => { - const invalidRouteResultCandidate = { - columns: [], - rows: [], - changes: 0, - route: "custom", - } as const; - // @ts-expect-error Runtime SQL execute routes are exact. - const invalidRouteResult: RuntimeSqlExecuteResult = - invalidRouteResultCandidate; - - expect(invalidRouteResult.route).toBe("custom"); + expect(normalizeRuntimeSqlExecuteResult(base)).toEqual(base); }); }); diff --git a/rivetkit-typescript/packages/rivetkit/src/registry/runtime.ts b/rivetkit-typescript/packages/rivetkit/src/registry/runtime.ts index fa5968b51a..3aba8b0a1f 100644 --- a/rivetkit-typescript/packages/rivetkit/src/registry/runtime.ts +++ b/rivetkit-typescript/packages/rivetkit/src/registry/runtime.ts @@ -156,34 +156,18 @@ export interface RuntimeSqlQueryResult { export type RuntimeSqlExecResult = RuntimeSqlQueryResult; -export type RuntimeSqlExecuteRoute = "read" | "write" | "writeFallback"; - export interface RuntimeSqlExecuteResult extends RuntimeSqlQueryResult { changes: number; lastInsertRowId?: number | null; - route: RuntimeSqlExecuteRoute; -} - -export function normalizeRuntimeSqlExecuteRoute( - route: string, -): RuntimeSqlExecuteRoute { - if (route === "read" || route === "write" || route === "writeFallback") { - return route; - } - throw new Error(`unsupported runtime sqlite execute route: ${route}`); } export function normalizeRuntimeSqlExecuteResult( result: RuntimeSqlQueryResult & { changes: number; lastInsertRowId?: number | null; - route: string; }, ): RuntimeSqlExecuteResult { - return { - ...result, - route: normalizeRuntimeSqlExecuteRoute(result.route), - }; + return result; } export interface RuntimeSqlRunResult { @@ -196,10 +180,6 @@ export interface RuntimeSqlDatabase { sql: string, params?: RuntimeSqlBindParams, ): Promise; - executeWrite( - sql: string, - params?: RuntimeSqlBindParams, - ): Promise; query( sql: string, params?: RuntimeSqlBindParams, @@ -456,11 +436,6 @@ export interface CoreRuntime { sql: string, params?: RuntimeSqlBindParams, ): Promise; - actorSqlExecuteWrite( - ctx: ActorContextHandle, - sql: string, - params?: RuntimeSqlBindParams, - ): Promise; actorSqlQuery( ctx: ActorContextHandle, sql: string, diff --git a/rivetkit-typescript/packages/rivetkit/src/registry/wasm-runtime.ts b/rivetkit-typescript/packages/rivetkit/src/registry/wasm-runtime.ts index 9628107047..d9498aef7c 100644 --- a/rivetkit-typescript/packages/rivetkit/src/registry/wasm-runtime.ts +++ b/rivetkit-typescript/packages/rivetkit/src/registry/wasm-runtime.ts @@ -642,17 +642,6 @@ export class WasmCoreRuntime implements CoreRuntime { return normalizeRuntimeSqlExecuteResult(result); } - async actorSqlExecuteWrite( - ctx: ActorContextHandle, - sql: string, - params?: RuntimeSqlBindParams, - ): Promise { - const result = await callWasm(() => - this.#actorSql(ctx).executeWrite(sql, params), - ); - return normalizeRuntimeSqlExecuteResult(result); - } - async actorSqlQuery( ctx: ActorContextHandle, sql: string, diff --git a/rivetkit-typescript/packages/rivetkit/tests/driver/shared-matrix.test.ts b/rivetkit-typescript/packages/rivetkit/tests/driver/shared-matrix.test.ts index e430ca232d..a45e9f7609 100644 --- a/rivetkit-typescript/packages/rivetkit/tests/driver/shared-matrix.test.ts +++ b/rivetkit-typescript/packages/rivetkit/tests/driver/shared-matrix.test.ts @@ -5,6 +5,33 @@ import { } from "./shared-matrix"; describe("driver matrix cells", () => { + function withEnv( + values: Record, + callback: () => T, + ): T { + const previous = new Map(); + for (const key of Object.keys(values)) { + previous.set(key, process.env[key]); + const value = values[key]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + try { + return callback(); + } finally { + for (const [key, value] of previous) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } + } + test("excludes wasm with local SQLite from the normal matrix", () => { const cells = getDriverMatrixCells(SQLITE_DRIVER_MATRIX_OPTIONS); @@ -59,4 +86,51 @@ describe("driver matrix cells", () => { "wasm/remote/bare", ]); }); + + test("honors driver matrix env filters", () => { + const cells = withEnv( + { + RIVETKIT_DRIVER_TEST_RUNTIME: "native", + RIVETKIT_DRIVER_TEST_SQLITE: "local", + RIVETKIT_DRIVER_TEST_ENCODING: "bare", + }, + () => getDriverMatrixCells(), + ); + + expect( + cells.map( + (cell) => + `${cell.runtime}/${cell.sqliteBackend}/${cell.encoding}`, + ), + ).toEqual(["native/local/bare"]); + }); + + test("applies driver matrix env filters to explicit suite options", () => { + const cells = withEnv( + { + RIVETKIT_DRIVER_TEST_RUNTIME: "native", + RIVETKIT_DRIVER_TEST_SQLITE: "local", + RIVETKIT_DRIVER_TEST_ENCODING: "bare", + }, + () => getDriverMatrixCells(SQLITE_DRIVER_MATRIX_OPTIONS), + ); + + expect( + cells.map( + (cell) => + `${cell.runtime}/${cell.sqliteBackend}/${cell.encoding}`, + ), + ).toEqual(["native/local/bare"]); + }); + + test("rejects invalid driver matrix env filters", () => { + expect(() => + withEnv( + { + RIVETKIT_DRIVER_TEST_RUNTIME: "native,browser", + }, + () => getDriverMatrixCells(), + ), + ).toThrow(/invalid RIVETKIT_DRIVER_TEST_RUNTIME value/); + }); }); diff --git a/rivetkit-typescript/packages/rivetkit/tests/driver/shared-matrix.ts b/rivetkit-typescript/packages/rivetkit/tests/driver/shared-matrix.ts index 1ad5e0b016..06ead03fab 100644 --- a/rivetkit-typescript/packages/rivetkit/tests/driver/shared-matrix.ts +++ b/rivetkit-typescript/packages/rivetkit/tests/driver/shared-matrix.ts @@ -45,9 +45,28 @@ export interface DriverMatrixCell { export function getDriverMatrixCells( options: DriverMatrixOptions = {}, ): DriverMatrixCell[] { - const encodings = options.encodings ?? ["bare", "cbor", "json"]; - const runtimes = options.runtimes ?? ["native", "wasm"]; - const sqliteBackends = options.sqliteBackends ?? ["local", "remote"]; + const encodings = applyDriverMatrixEnv( + "RIVETKIT_DRIVER_TEST_ENCODING", + options.encodings ?? ["bare", "cbor", "json"], + [ + "bare", + "cbor", + "json", + ], + ); + const runtimes = applyDriverMatrixEnv( + "RIVETKIT_DRIVER_TEST_RUNTIME", + options.runtimes ?? ["native", "wasm"], + ["native", "wasm"], + ); + const sqliteBackends = applyDriverMatrixEnv( + "RIVETKIT_DRIVER_TEST_SQLITE", + options.sqliteBackends ?? ["local", "remote"], + [ + "local", + "remote", + ], + ); const cells: DriverMatrixCell[] = []; for (const runtime of runtimes) { @@ -69,6 +88,33 @@ export function getDriverMatrixCells( return cells; } +function applyDriverMatrixEnv( + key: string, + base: readonly T[], + allowed: readonly T[], +): T[] { + const value = process.env[key]; + if (!value) { + return [...base]; + } + + const allowedSet = new Set(allowed); + const parsed = value + .split(",") + .map((part) => part.trim()) + .filter((part) => part.length > 0); + for (const entry of parsed) { + if (!allowedSet.has(entry)) { + throw new Error( + `invalid ${key} value ${JSON.stringify(entry)}. Expected one of: ${allowed.join(", ")}`, + ); + } + } + + const requested = new Set(parsed); + return base.filter((entry) => requested.has(entry)); +} + export function describeDriverMatrix( suiteName: string, defineTests: (driverTestConfig: DriverTestConfig) => void, diff --git a/rivetkit-typescript/packages/rivetkit/tests/wasm-host-smoke.test.ts b/rivetkit-typescript/packages/rivetkit/tests/wasm-host-smoke.test.ts index 3a6162ae92..b77574b71f 100644 --- a/rivetkit-typescript/packages/rivetkit/tests/wasm-host-smoke.test.ts +++ b/rivetkit-typescript/packages/rivetkit/tests/wasm-host-smoke.test.ts @@ -128,23 +128,6 @@ class SmokeSql { rows: [["ok"]], changes: 1, lastInsertRowId: 1, - route: "write", - }; - } - - async executeWrite(sql: string, params?: unknown) { - this.host.sql.push({ - method: "executeWrite", - sql, - params, - reconnects: [...this.host.reconnects], - }); - return { - columns: ["value"], - rows: [["ok"]], - changes: 1, - lastInsertRowId: 1, - route: "write", }; } @@ -570,7 +553,7 @@ describe("wasm edge host smoke coverage", () => { ]); expect(host.sql.map((entry) => entry.method)).toEqual([ "execute", - "executeWrite", + "execute", "execute", ]); expect(host.sql[1].reconnects).toContain("during-remote-write-sql");