diff --git a/README.md b/README.md index b89fca45..ac98fba7 100644 --- a/README.md +++ b/README.md @@ -52,44 +52,44 @@ session.close().await?; engine.shutdown()?; ``` -Insert, update, and delete rows by executing statements inside a transaction. +Insert, update, and delete rows through direct transaction methods. ```rust use doradb_storage::{SelectKey, UpdateCol, Val}; let mut trx = session.begin_trx()?; -trx.exec(async |stmt| { - stmt.table_insert_mvcc(table_id, vec![Val::from(1i32), Val::from("alice")]) - .await?; - Ok(()) -}) -.await?; +trx.table_insert_mvcc(table_id, vec![Val::from(1i32), Val::from("alice")]) + .await?; let key = SelectKey::new(0, vec![Val::from(1i32)]); -trx.exec(async |stmt| { - stmt.table_update_unique_mvcc( +let updated = trx + .table_update_unique_mvcc( table_id, - &key, + key.index_no, + &key.vals, vec![UpdateCol { idx: 1, val: Val::from("ada"), }], ) .await?; - Ok(()) -}) -.await?; +assert!(updated.is_updated()); -trx.exec(async |stmt| { - stmt.table_delete_unique_mvcc(table_id, &key, false).await?; - Ok(()) -}) -.await?; +let deleted = trx + .table_delete_unique_mvcc(table_id, key.index_no, &key.vals) + .await?; +assert!(deleted.is_deleted()); trx.commit().await?; ``` +DML validation is enabled by default. Call +`trx.disable_dml_validation(true)` only for input already proven against the +table metadata; the setting applies to subsequent direct and streaming +operations in that transaction. Call `disable_dml_validation(false)` to enable +validation again. + Scan rows, read one unique-key row, and scan matching rows through a secondary index. ```rust @@ -98,30 +98,20 @@ use doradb_storage::{SelectKey, Val}; let mut trx = session.begin_trx()?; let mut rows = Vec::new(); -trx.exec(async |stmt| { - stmt.table_scan_mvcc(table_id, &[0, 1], |vals| { +trx.table_scan_mvcc(table_id, &[0, 1], |vals| { rows.push(vals); true }) .await?; - Ok(()) -}) -.await?; let id_key = SelectKey::new(0, vec![Val::from(1i32)]); let _row = trx - .exec(async |stmt| { - stmt.table_lookup_unique_mvcc(table_id, &id_key, &[0, 1]) - .await - }) + .table_lookup_unique_mvcc(table_id, id_key.index_no, &id_key.vals, &[0, 1]) .await?; let name_key = SelectKey::new(1, vec![Val::from("ada")]); let _matching_rows = trx - .exec(async |stmt| { - stmt.table_index_scan_mvcc(table_id, &name_key, &[0, 1]) - .await - }) + .table_index_lookup_mvcc(table_id, name_key.index_no, &name_key.vals, &[0, 1]) .await? .unwrap_rows(); diff --git a/docs/backlogs/000186-statement-failure-rollback-before-error-return.md b/docs/backlogs/closed/000186-statement-failure-rollback-before-error-return.md similarity index 95% rename from docs/backlogs/000186-statement-failure-rollback-before-error-return.md rename to docs/backlogs/closed/000186-statement-failure-rollback-before-error-return.md index 6fb1b0c4..08dda57f 100644 --- a/docs/backlogs/000186-statement-failure-rollback-before-error-return.md +++ b/docs/backlogs/closed/000186-statement-failure-rollback-before-error-return.md @@ -29,3 +29,11 @@ A public Transaction::exec callback cannot invoke two DML attempts through one S ## Notes (Optional) Consume the public DML capability when an attempt begins, so admission or validation failure cannot be followed by a second DML in the same statement. Planning should decide whether multiple read-only operations before the single DML remain supported. One public DML call may still mutate many rows internally; the restriction is on public DML invocations, not physical row effects. + +## Close Reason + +- Type: implemented +- Detail: Implemented via docs/tasks/000274-retire-callback-statement-apis-and-complete-migration.md +- Closed By: backlog close +- Reference: User decision +- Closed At: 2026-08-20 diff --git a/docs/error-spec.md b/docs/error-spec.md index cc526331..f6f22301 100644 --- a/docs/error-spec.md +++ b/docs/error-spec.md @@ -151,8 +151,9 @@ Disclosure is approved only at one of these boundaries: - a public Doradb method returning the public `Result` alias; - an external trait whose signature is fixed to the public result; - a constrained carrier's disclosure implementation; or -- the three callback-mutation helpers that must forward an arbitrary public - `Error` returned by `Statement::table_mutate_mvcc`'s caller. +- the three row-mutation adapters that must forward an arbitrary public + `Error` returned by a direct `Transaction` mutation method's caller-supplied + row-decision callback. Reusable private helpers do not return public `Result` merely to make `?` compile. Test helpers follow the same rule: test a typed producer as typed, and @@ -299,10 +300,10 @@ The principal convergence owners are: | value and rows | public decode/access adapters and fixed external traits | | engine | public bootstrap, new-session admission, and shutdown facades | | session | public table, checkpoint, retention, and transaction operations | -| transaction | public lock, statement execution, commit, and rollback | -| statement/stream | public DML and stream iteration methods | +| transaction | public lock, direct no-op/read/DML/stream construction, commit, and rollback | +| stream | public iteration over an already-constructed MVCC stream | | log configuration | fixed `FromStr` adapter over typed validation | -| catalog/table | public semantic facades plus callback mutation error transport | +| catalog/table | public semantic facades plus row-decision callback error transport | | recovery/startup | typed recovery helpers beneath public Engine bootstrap | Lower buffer, file, log internals, index, table, purge, retention, recovery, and diff --git a/docs/lock-system.md b/docs/lock-system.md index 2aae5297..c1721984 100644 --- a/docs/lock-system.md +++ b/docs/lock-system.md @@ -531,11 +531,11 @@ abandoned session: -> release explicit session-owned logical locks ``` -Public statement-future cancellation composes with the same terminal proof -boundary: +Public direct-operation future cancellation composes with the same terminal +proof boundary: ```text -drop callback and pending acquisition +drop owned operation future and pending acquisition -> fold residual statement undo into transaction undo and discard statement redo -> check the complete transaction core in as CleanupReady -> worker rolls back transaction effects @@ -544,8 +544,8 @@ drop callback and pending acquisition -> consume ReleasedTransactionLocks at session rollback completion ``` -The callback future is destroyed before its `StmtState`, so a queued waiter or -promoted-but-unobserved request is cancelled by its call-local pending guard +The owned operation future is destroyed before its `StmtState`, so a queued +waiter or promoted-but-unobserved request is cancelled by its call-local pending guard before the core becomes cleanup-claimable. An accepted transaction claim is not released inline; it remains attached to `TrxInner` until whole-transaction rollback reaches the ordering above. diff --git a/docs/public-error-audit.csv b/docs/public-error-audit.csv index e9ec436e..86dcc43c 100644 --- a/docs/public-error-audit.csv +++ b/docs/public-error-audit.csv @@ -46,6 +46,7 @@ doradb-storage/src/table/index_mutate.rs,IndexMutator::mutate_index_candidate,1 doradb-storage/src/table/index_mutate.rs,IndexMutator::mutate_owned_hot_index_candidate,2 doradb-storage/src/table/index_mutate.rs,IndexMutator::unique_driver_key_changed,1 doradb-storage/src/table/index_mutate.rs,IndexMutator::update_owned_hot_row,5 +doradb-storage/src/trx/interface.rs,Transaction::table_index_scan_mvcc_stream,1 doradb-storage/src/trx/mod.rs,Transaction::commit,2 doradb-storage/src/trx/mod.rs,Transaction::exec,2 doradb-storage/src/trx/mod.rs,Transaction::lock_table,2 @@ -62,5 +63,5 @@ doradb-storage/src/trx/stmt.rs,Statement::table_scan_mvcc,2 doradb-storage/src/trx/stmt.rs,Statement::table_update_unique_mvcc,5 doradb-storage/src/trx/stmt.rs,Statement::table_upsert_unique_mvcc,5 doradb-storage/src/trx/stream_stmt.rs,IndexScanMvccStream::next,2 -doradb-storage/src/trx/stream_stmt.rs,StreamStmt::table_index_scan_mvcc,5 +doradb-storage/src/trx/stream_stmt.rs,StreamStmtState::table_index_scan_mvcc_stream,4 doradb-storage/src/value.rs,ValKind::try_from,1 diff --git a/docs/rfcs/0029-direct-transaction-statement-apis.md b/docs/rfcs/0029-direct-transaction-statement-apis.md index 651a074c..a1b2a873 100644 --- a/docs/rfcs/0029-direct-transaction-statement-apis.md +++ b/docs/rfcs/0029-direct-transaction-statement-apis.md @@ -26,16 +26,22 @@ rollback machinery, and cancellation ownership in `Transaction::exec`. During the additive phase, direct methods reuse that existing settlement path with an engine-controlled callback. During callback retirement, `exec` becomes transaction-module-private, accepts an owned one-shot `Statement`, and settles -the operation through `StmtState` after the facade is consumed. Every normal -no-op, read, and DML method consumes `Statement`, so internal code also cannot -compose two normal operations into one statement. Private catalog batching -uses a distinct reusable `CatalogStatement`. The RFC also adds an atomic -batch-insert DML so callers can deliberately insert many rows in one statement -after arbitrary multi-DML callbacks are removed. The program is split into two -phases: add the complete direct API beside the legacy API and migrate ordinary -tests, then establish the owned internal boundary, retire the public callback -surface, and migrate the remaining production code, runner-focused tests, -examples, benchmarks, and documentation. +the operation through a carrier after the facade is consumed. Every public and +private no-op, read, and DML method consumes `Statement`, so internal code also +cannot compose two high-level operations into one statement. Private catalog +DDL sequences one-shot operations through `PrivateTransaction`; intentional +same-table catalog groups use only purpose-built consuming batch DML. Public +and private ordinary operation errors both complete index-before-row rollback +before returning. Caller-controlled DML validation moves from the retired +statement facade to a transaction-local toggle that is enabled by default and +applies to subsequent direct and streaming operations. The RFC also adds an +atomic public batch-insert DML so callers +can deliberately insert many rows in one statement after arbitrary multi-DML +callbacks are removed. The program is split into two phases: add the complete +direct API beside the legacy API and migrate ordinary tests, then establish the +owned internal boundary, retire the public callback surface, and migrate the +remaining production code, runner-focused tests, examples, benchmarks, and +documentation. ## Context @@ -82,12 +88,14 @@ effect boundary. Once public callback retirement begins, `exec` therefore lends `Statement` by value, every normal operation consumes it, and `StmtState` settles the result after that owned operation ends. [C1] [C2] [U11] -Private catalog staging has intentionally different semantics. It batches -multiple catalog-row mutations through one held private transaction and merges -complete and partial undo into transaction effects even when a staging callback -returns an ordinary error, so whole-private-transaction rollback owns cleanup. -That crate-private behavior remains separate from the public direct statement -settlement path. [D2] [C1] [C6] +Private catalog staging historically had different semantics: it lent the same +reusable facade and merged complete and partial undo into transaction effects +even when a callback returned an ordinary error. Phase 2 replaces that split +with the same owned one-shot capability and rollback-before-return contract used +by public statements. Earlier successful catalog statements remain owned by the +private transaction for enclosing DDL rollback. Intentional multi-row catalog +work stays within narrowly purpose-built same-table batch operations rather +than a reusable catalog facade. [D2] [C1] [C6] Existing public examples and focused tests also demonstrate a real need for a deliberate multi-row DML. The quick-start example inserts two rows in one @@ -97,12 +105,12 @@ arbitrary multi-DML callback. The current row accessor, `StmtEffects`, and `RedoLogs` already support multiple row and index effects without a new persisted representation. [C4] [C5] [C9] [U3] -The benchmark crate and weak-handle baseline also deliberately execute empty -successful callbacks to isolate public statement checkout, statement-number -allocation, and ordinary check-in from table work. Removing `exec` without a -replacement would either retire that control or contaminate it with a read or -DML. A direct `Transaction::noop()` preserves the lifecycle baseline without -reintroducing caller-selected completion results. [D7] [C11] [U7] +The benchmark crate deliberately executes empty successful callbacks to +isolate public statement checkout, statement-number allocation, and ordinary +check-in from table work. Removing `exec` without a replacement would either +retire that control or contaminate it with a read or DML. A direct +`Transaction::noop()` preserves the lifecycle baseline without reintroducing +caller-selected completion results. [D7] [C11] [U7] Issue Labels: @@ -169,10 +177,8 @@ Issue Labels: - [C10] `doradb-storage/src/table/mod.rs` - existing test helpers that wrap one `Statement` operation in `Transaction::exec`, demonstrating the direct API's mechanical delegation shape. -- [C11] `doradb-bench/src/workload/noop.rs` and - `doradb-storage/examples/weak_handle_baseline.rs` - public empty-statement - lifecycle baselines that require an explicit direct no-op after callback - retirement. +- [C11] `doradb-bench/src/workload/noop.rs` - public empty-statement lifecycle + baseline that requires an explicit direct no-op after callback retirement. ### Conversation References @@ -207,18 +213,28 @@ Issue Labels: - [U11] During the two-phase revision, the user required the final owned `Statement` boundary to constrain internal use as well as external callers. The final private `exec` takes `Statement` by value, every normal operation - consumes it, settlement moves to `StmtState`, and intentional private catalog - batching uses a distinct reusable facade. + consumes it, and settlement moves to the carrier. - [U12] The user moved ordinary existing-test migration into Phase 1 so the additive direct API receives broad behavioral coverage and Phase 2 does not accumulate nearly all migration work. Only runner-focused tests remain for owned-boundary adaptation in Phase 2; alternatives remain limited to materially significant architectural directions. +- [U13] During Phase 2 task planning, the user selected one owned internal + `Statement` for public and private operations, private rollback-before-return, + and purpose-built consuming catalog batch DML instead of a reusable + `CatalogStatement`. +- [U14] During Phase 2 implementation, the user required preservation of the + validation opt-out by moving it to + `Transaction::disable_dml_validation(bool)`. Validation remains enabled by + default, and callers may disable or re-enable it for subsequent operations. +- [U15] During Phase 2 implementation, the user retired the standalone + `weak_handle_baseline` example. The benchmark crate's `stmt-noop` workload + remains the lifecycle-only no-op performance control. ### Source Backlogs - [B1] - `docs/backlogs/000186-statement-failure-rollback-before-error-return.md` + `docs/backlogs/closed/000186-statement-failure-rollback-before-error-return.md` ## Decision @@ -279,10 +295,11 @@ the transaction. This replaces callback-defined compound statement semantics with explicit transaction sequencing. [D2] [U5] `Statement` and `StreamStmt` cease to be public exports. The former becomes the -owned one-shot normal-operation facade described in Decision 2; intentional -catalog batching moves to a distinct `CatalogStatement` rather than retaining -a reusable normal facade. `IndexScanMvccStream` remains public because it is the -owned result of a direct streaming read. +transaction-module-private owned one-shot operation facade described in +Decision 2 and is shared by public and private carriers. Intentional catalog +batching moves to purpose-built consuming operations rather than retaining any +reusable facade. `IndexScanMvccStream` remains public because it is the owned +result of a direct streaming read. The streaming method borrows `&mut Transaction` for the returned stream's lifetime, so no later direct method or terminal transaction operation can begin until that stream completes or is dropped. Stream construction, iteration @@ -295,10 +312,16 @@ checkout-drop path, and leaves the transaction reusable. Construction and validation errors use the same ordinary check-in policy. The stream path does not terminally cancel the transaction because it owns no statement mutation effects; reuse remains subject to ordinary engine-health admission. [C3] [U9] -Public validation remains mandatory; the current public -`Statement::disable_dml_validation` escape hatch does not move onto -`Transaction`. Recovery and other proven internal callers retain their own -explicit validation policy. [C3] [C8] +`Statement::disable_dml_validation` is removed with the facade. Its capability +moves to `Transaction::disable_dml_validation(bool)`: validation is enabled on +new transactions, the selected transaction-local setting applies to subsequent +direct non-streaming and streaming operations, and passing `false` restores +validation. A non-streaming runner snapshots the setting into its owned +statement; the stream constructor snapshots it before checkout. Because both +operation forms exclusively borrow `&mut Transaction`, the setting cannot +change during an operation or while a returned stream is live. Recovery and +other proven internal callers retain their explicit validation policy. [C3] +[C8] [U14] ### 2. Existing `exec` becomes the owned private statement runner @@ -330,7 +353,9 @@ statement facade. [C1] [C2] [D3] [U11] `StmtState`, rather than the consumed `Statement`, becomes the normal settlement owner. The runner constructs `StmtState`, lends an owned `Statement` whose fields borrow the checked-out core and statement effects, and awaits the one -operation. The operation future and owned facade are destroyed before +operation. It also copies the transaction's current DML-validation setting into +that statement, so statement settlement never resets or mutates the caller's +transaction-level choice. The operation future and owned facade are destroyed before settlement regains access to `StmtState`. A consuming `StmtState` completion path then applies the result policy: @@ -363,14 +388,20 @@ the constructor future returns. This path exposes only the direct preserves ordinary transaction reuse when the constructor itself is cancelled after checkout. [C3] [U9] -`PrivateTransaction::stage_statement` remains a separate crate-private runner -with a reusable `CatalogStatement`. Its callback continues to borrow -`&mut CatalogStatement`, catalog storage helpers migrate to that type, and the -facade exposes only the catalog operations needed for intentional repeated -catalog-row mutations. Its merge-on-error and panic-settlement policy remains -unchanged and must not be generalized into normal `exec`. The two facades may -share lower-level implementation helpers, but a reusable normal user-table -statement capability is not retained. [C1] [C2] [C6] [U11] +`PrivateTransaction` retains a separate crate-private runner because it holds +one checkout continuously across catalog DDL. That runner also passes +`Statement` by value, allocates one statement number and effect buffer per +operation, and settles after the consuming operation ends. Success merges the +current effects; a Runtime error returns only after index-before-row rollback +and redo discard. Earlier successful private statements remain transaction- +owned for enclosing DDL rollback. Panic or cancellation first destroys the +owned operation state, folds residual undo into the held transaction, discards +current redo, and preserves the checkout for mandatory cleanup. Public and +private carriers share mechanical merge, rollback, fatal retention, and redo +discard without placing settlement methods on `Statement`. Catalog accessors +accept `&mut PrivateTransaction` and invoke one direct single-row or +purpose-built same-table batch operation; no `CatalogStatement` exists. [C1] +[C2] [C6] [U11] [U13] ### 3. Statement results are no longer caller-injected completion results @@ -430,8 +461,8 @@ successful statement. [C1] [C2] [U11] The existing `stmt-noop` workload retains its public identity, latency unit, counter semantics, and no-fixture requirement. Phase 1 measures the direct no-op against the legacy empty-`exec` baseline; Phase 2 changes that workload -and the weak-handle statement baseline to call `Transaction::noop()` while -retaining their lifecycle-only meaning. [C11] [U7] +to call `Transaction::noop()` while retaining its lifecycle-only meaning. +[C11] [U7] [U15] ### 5. Batch insert is one atomic public DML @@ -490,9 +521,9 @@ Adding direct methods beside the legacy API is an intermediate rollout step, not a compatibility commitment. The final phase removes public availability of `Transaction::exec` by making it transaction-module-private and owned, removes `Statement` and `StreamStmt` from crate exports, introduces the -separate reusable `CatalogStatement`, and migrates every repository consumer -and test according to its intended statement boundary. [C8] [U2] [U6] [U10] -[U11] +unified owned private boundary plus purpose-built catalog batches, and migrates +every repository consumer and test according to its intended statement +boundary. [C8] [U2] [U6] [U10] [U11] [U13] Migration begins in the additive phase rather than accumulating in callback retirement. Phase 1 reviews and migrates ordinary unit and integration tests to @@ -520,14 +551,22 @@ Migration follows these rules: facility. 6. Streaming callers use the direct stream-construction method and retain the same exclusive transaction borrow for the stream lifetime. -7. Private catalog staging migrates to reusable `CatalogStatement` and retains - its private batching and merge-on-error policy. +7. Private catalog accessors accept `&mut PrivateTransaction` and invoke one + owned operation. Repeated inserts, scoped deletes, and metadata replacement + that deliberately share a statement use only matching purpose-built + consuming batch DML; ordinary private errors roll back the current statement + before return. 8. Empty successful callbacks and lifecycle baselines become direct `noop()` calls; the `stmt-noop` benchmark remains a no-fixture statement-execution control rather than being removed or redefined as table work. 9. Internal helpers that accept `&mut Statement` migrate to direct transaction methods, consuming normal-operation helpers, lower-level implementation - functions, or `CatalogStatement` according to their actual boundary. + functions, or purpose-built catalog batches according to their actual + boundary. +10. Calls to the retired statement-level validation toggle move to the owning + transaction before the direct operation. Tests and callers that need to + restore validation call `disable_dml_validation(false)` before the next + operation. Phase 2 revisits the explicitly retained runner-focused tests while changing `exec` to its owned signature. Each remaining private invocation receives an @@ -610,18 +649,19 @@ compatibility shim remains after the phase completes. [D6] [D7] [U6] [U10] reopening arbitrary DML composition. - References: [C4], [C5], [C9], [U3] -### Alternative F: Retire the public statement no-op baseline +### Alternative F: Retire every public statement no-op baseline -- Summary: Remove `stmt-noop` and the weak-handle statement baseline, or - redefine them to execute a table read after callback retirement. +- Summary: Remove `stmt-noop` or redefine it to execute a table read after + callback retirement. - Analysis: Removal loses the established checkout/check-in control, while a table read measures admission, binding, and access work in addition to the statement carrier. Private `exec` is not callable from the standalone benchmark crate. -- Why Not Chosen: The small, engine-controlled `noop()` method retains a useful - public lifecycle and performance diagnostic without exposing arbitrary - callbacks. -- References: [D7], [C11], [U7] +- Why Not Chosen: The small, engine-controlled `noop()` method and benchmark + workload retain one useful public lifecycle and performance diagnostic + without exposing arbitrary callbacks. The redundant standalone weak-handle + example is retired. +- References: [D7], [C11], [U7], [U15] ### Alternative G: Stream-constructor cancellation discards the transaction @@ -645,11 +685,11 @@ compatibility shim remains after the phase completes. [D6] [D7] [U6] [U10] any future direct wrapper or focused internal caller could still invoke two normal reads or DML operations under one effect boundary. Visibility would rely on convention rather than encode the selected engine invariant. -- Why Not Chosen: The one-operation boundary applies to internal normal - statement construction as well as the public API. An owned consuming facade - makes accidental normal composition unrepresentable while the separate - reusable `CatalogStatement` preserves the one intentional batching policy. -- References: [B1], [C1], [C2], [C6], [U11] +- Why Not Chosen: The one-operation boundary applies to every high-level + statement construction path. An owned consuming facade makes accidental + composition unrepresentable, while purpose-built catalog batches preserve + only the concrete same-table groups required by DDL. +- References: [B1], [C1], [C2], [C6], [U11], [U13] ## Unsafe Considerations @@ -690,6 +730,8 @@ The program must cover: 7. Direct stream construction validation/error paths, unpolled construction, post-checkout constructor cancellation, exhaustion, iteration error, and drop preserve their defined checkout and transaction-reuse behavior. + Non-streaming and streaming operations both cover validation enabled by + default, transaction-local disabling, and later re-enabling. 8. Empty batch insert returns an empty `RowID` vector after checkout, `StmtNo` allocation, table admission, and `TableData(IX)` acquisition while creating no row, index, or redo effects. Nonempty coverage includes success across one @@ -697,23 +739,25 @@ The program must cover: within or outside the batch, write conflict, row/index/storage failure after a nonempty prefix, cancellation, fatal rollback, redo commit, restart recovery, and whole-transaction rollback after batch success. -9. Reusable `CatalogStatement` supports intentional repeated create/drop table - and index catalog mutations while retaining existing whole-private- - transaction rollback, ordinary-error merge, and panic-settlement semantics. +9. Private catalog single-row and purpose-built batch operations support the + intentional create/drop table and index groups. Runtime failure after a + partial batch rolls the complete current statement back before return; + earlier successful statements remain owned by enclosing DDL rollback, and + panic or cancellation preserves residual ownership for mandatory cleanup. 10. Public examples and benchmarks compile without importing `Statement` or - `StreamStmt`; `stmt-noop` and the weak-handle statement baseline call direct - `noop()` without changing their lifecycle-only meaning. Positive - compilation coverage is authoritative, and no new compile-fail harness is - introduced solely to prove removed exports. + `StreamStmt`; `stmt-noop` calls direct `noop()` without changing its + lifecycle-only meaning. Positive compilation coverage is authoritative, + and no new compile-fail harness is introduced solely to prove removed + exports. 11. Phase 1 migration review classifies every ordinary test callback and moves it to direct methods rather than mechanically changing syntax. Each test left on public `exec` records the runner invariant that requires Phase 2 adaptation. -12. Final compilation and visibility review prove that normal `exec` takes - `Statement` by value, every normal no-op/read/DML receiver consumes `self`, - no reusable `&mut Statement` helper remains, and only `CatalogStatement` - exposes the selected repeated-mutation capability. No new compile-fail - harness is required solely for this structural invariant. +12. Final compilation and visibility review prove that both runners take + `Statement` by value, every high-level user and catalog operation consumes + `self`, no reusable `&mut Statement` helper or `CatalogStatement` remains, + and catalog accessors expose only direct private-transaction operations. No + new compile-fail harness is required solely for this structural invariant. Phase tasks run focused tests during development, followed by `cargo nextest run --workspace`. The final retirement phase also runs the @@ -771,7 +815,7 @@ benchmark counters without an explicit workload change. [D7] [C9] [C11] [U7] - Phase Status: done - Implementation Summary: Implemented Phase 1 direct Transaction APIs and atomic batch insert, migrated ordinary storage tests, retained and classified runner-only coverage, and verified correctness and paired performance without changing settlement or persisted formats. [Task Resolve Sync: docs/tasks/000273-direct-transaction-apis-and-atomic-batch-insert.md @ 2026-08-19] - Related Backlogs: - - `docs/backlogs/000186-statement-failure-rollback-before-error-return.md` + - `docs/backlogs/closed/000186-statement-failure-rollback-before-error-return.md` - **Phase 2: Callback API Retirement And Complete Migration** - Prerequisites: Phase 1 direct APIs have feature parity, focused behavioral @@ -780,40 +824,45 @@ benchmark counters without an explicit workload change. [D7] [C9] [C11] [U7] classified. - Phase-local Choices: Retain the existing `exec` name but make it transaction-module-private and change its callback to receive owned - `Statement`; make every normal operation consume the facade; move normal - merge, rollback, fatal, and cancellation settlement authority to - `StmtState`; and migrate private catalog batching to reusable - `CatalogStatement`. + `Statement`; make every public and private operation consume the facade; + move merge, rollback, fatal, and cancellation settlement authority to the + matching carrier; give private Runtime errors the same rollback-before- + return contract; and retain intentional catalog groups only through + purpose-built consuming batch DML. Move validation control from the retired + statement facade to `Transaction::disable_dml_validation(bool)`. - Scope: Remove `Transaction::exec`, `Statement`, and `StreamStmt` from the public API; implement the owned private `exec` and consuming normal - operation receivers; split reusable catalog operations into - `CatalogStatement`; migrate all production code, examples, benchmarks, and - documentation plus the remaining runner-focused tests; migrate `stmt-noop` - and the weak-handle statement baseline to direct `noop()` without changing - their measurement contract; adapt each retained multi-operation or - callback-injection test to the owned boundary or lower-level statement - machinery; retain private owned `exec` access only in focused internal - ownership tests; remove callback-oriented public documentation. + operation receivers; migrate catalog accessors to direct + `PrivateTransaction` single-row and batch operations; migrate all production + code, examples, benchmarks, and documentation plus the remaining runner- + focused tests; migrate `stmt-noop` to direct `noop()` without changing its + measurement contract and retire the redundant standalone weak-handle + baseline; adapt each retained multi-operation or callback-injection test to + the owned boundary or lower-level statement machinery; retain private owned + `exec` access only in focused internal ownership tests; migrate validation + opt-out coverage to the transaction toggle; remove callback-oriented public + documentation. - Goals: Make direct `Transaction` methods the sole public statement boundary, eliminate caller-injected completion semantics, make a second normal operation unrepresentable for internal `exec` callers, finish repository-wide migration without reducing behavior coverage, and satisfy backlog 000186. - Non-goals: No removal or required renaming of private `exec`, no reusable - normal statement facade, no semantic change to private catalog batching, no - generic user-error channel, no new mutation family, and no persisted-format - or recovery-protocol change. + normal or catalog statement facade, no generic heterogeneous catalog batch, + no per-statement validation toggle, no generic user-error channel, no new + public mutation family, and no persisted-format or recovery-protocol + change. - After This Phase: The public callback API no longer exists, normal tests use direct methods, focused internal tests alone can access the owned normal - statement machinery, intentional repeated mutations exist only through - `CatalogStatement`, and the source backlog is ready for implemented closure - through task/RFC resolution. - - Task Doc: `docs/tasks/TBD.md` - - Task Issue: `#0` - - Phase Status: `pending` - - Implementation Summary: `pending` + statement machinery, intentional repeated catalog mutations exist only + through purpose-built consuming DML, and the source backlog is closed as + implemented by task 000274. + - Task Doc: `docs/tasks/000274-retire-callback-statement-apis-and-complete-migration.md` + - Task Issue: `#990` + - Phase Status: done + - Implementation Summary: Implemented RFC-0029 Phase 2 with direct `Transaction` methods as the sole public statement boundary, owned consuming statements for public and private execution, rollback-before-return for ordinary failures, purpose-built catalog batches, and complete repository migration. No persisted format, recovery protocol, or transaction atomicity behavior changed. [Task Resolve Sync: docs/tasks/000274-retire-callback-statement-apis-and-complete-migration.md @ 2026-08-20] - Related Backlogs: - - `docs/backlogs/000186-statement-failure-rollback-before-error-return.md` + - `docs/backlogs/closed/000186-statement-failure-rollback-before-error-return.md` ## Consequences @@ -829,8 +878,8 @@ benchmark counters without an explicit workload change. [D7] [C9] [C11] [U7] phase. - Owned consuming normal operations enforce the one-operation statement boundary for internal code as well as public callers. -- `CatalogStatement` makes the one intentional reusable batching policy - explicit instead of sharing the normal facade. +- Purpose-built catalog batch DML preserves concrete logical statement groups + without exposing a reusable facade. - Direct read-then-DML sequencing remains available at transaction level. - Direct `noop()` preserves a table-independent checkout/check-in lifecycle control for diagnostics and performance comparison. @@ -841,6 +890,8 @@ benchmark counters without an explicit workload change. [D7] [C9] [C11] [U7] - Migrating ordinary tests in the additive phase broadens direct-API coverage and prevents callback retirement from accumulating all migration risk. - Hiding `Statement` and `StreamStmt` narrows the public ownership surface. +- Moving validation control to `Transaction` preserves the existing opt-out + without reopening arbitrary statement callbacks or a stream facade. ### Negative @@ -855,9 +906,9 @@ benchmark counters without an explicit workload change. [D7] [C9] [C11] [U7] remains public until the retirement phase changes its visibility. - Phase 1 includes a repository-wide semantic test review rather than only new focused tests for the additive methods. -- The retirement phase must refactor settlement from `Statement` into - `StmtState` and migrate catalog helpers to a separate facade in addition to - the broad call-site migration. +- The retirement phase must refactor settlement from `Statement` into public + and private carriers and migrate catalog helpers to direct private operations + in addition to the broad call-site migration. - The public surface retains a lifecycle-only `noop()` method that has no data behavior outside diagnostics, measurement, and explicit empty statements. - Empty insert batches still acquire and retain table admission and @@ -869,10 +920,11 @@ benchmark counters without an explicit workload change. [D7] [C9] [C11] [U7] ## Open Questions -No blocking questions remain after Round 2. The direct no-op, empty-batch, and -stream-constructor cancellation contracts, owned normal facade, `StmtState` -settlement owner, and reusable catalog split are fixed by Decisions 1, 2, 4, -and 5. +No blocking questions remain. The direct no-op, empty-batch, and stream- +constructor cancellation contracts, unified owned internal facade, carrier- +owned settlement, private rollback-before-return, and purpose-built catalog +batch policy and transaction-local validation policy are fixed by Decisions 1, +2, 4, 5, and the approved Phase 2 revisions [U13] [U14]. ## Future Work @@ -887,7 +939,7 @@ and 5. ## References -- `docs/backlogs/000186-statement-failure-rollback-before-error-return.md` +- `docs/backlogs/closed/000186-statement-failure-rollback-before-error-return.md` - `docs/transaction-system.md` - `docs/index-design.md` - `docs/table-file.md` diff --git a/docs/tasks/000274-retire-callback-statement-apis-and-complete-migration.md b/docs/tasks/000274-retire-callback-statement-apis-and-complete-migration.md new file mode 100644 index 00000000..48665ced --- /dev/null +++ b/docs/tasks/000274-retire-callback-statement-apis-and-complete-migration.md @@ -0,0 +1,291 @@ +--- +id: 000274 +title: Retire Callback Statement APIs and Complete Migration +status: implemented +created: 2026-08-19 +github_issue: 990 +--- + +# Task: Retire Callback Statement APIs and Complete Migration + +## Summary + +RFC-0029 Phase 2 removed callback-style statement execution from Doradb's +public API and completed the repository-wide migration to direct +`Transaction` methods. The crate no longer exports `Statement` or +`StreamStmt`; direct no-op, read, DML, batch, and streaming methods are the +only public statement boundaries. + +Internally, public and private operations receive one owned, consuming +`Statement`. Carrier state, rather than the facade, owns merge, rollback, +fatal retention, and cancellation settlement. An ordinary operation failure +completes secondary-index rollback before row rollback and discards redo before +the initiating error is returned. + +Catalog DDL now composes one-shot operations through `PrivateTransaction`. +Intentional same-table groups use purpose-built insert, delete, or metadata +replacement operations rather than a reusable catalog facade. Caller-selected +DML validation remains available through the transaction-local +`Transaction::disable_dml_validation(bool)` toggle. + +## Context + +Parent RFC: + +- `docs/rfcs/0029-direct-transaction-statement-apis.md` + +RFC Relationship: + +- Phase 2: Callback API Retirement And Complete Migration. + +Source Backlogs: + +- `docs/backlogs/closed/000186-statement-failure-rollback-before-error-return.md` + +Issue Labels: + +- type:task +- priority:high +- codex + +Phase 1, implemented by +`docs/tasks/000273-direct-transaction-apis-and-atomic-batch-insert.md`, added +feature-complete direct methods and atomic batch insert while temporarily +retaining the callback API. It also classified 48 runner-focused test groups +that required semantic migration in Phase 2. + +The callback API let callers invoke several operations through one borrowed +`Statement`, catch an operation error after partial effects, and return an +unrelated completion result. Private catalog staging additionally merged +partial current-statement effects on ordinary errors for later transaction +rollback. Those behaviors made the statement success boundary caller-defined +and left backlog 000186 unresolved. + +Task 000247's cancellation-safe carrier state, residual-effect ownership, +index-before-row rollback order, poison handling, and fatal retention remained +authoritative. This task changed API and runtime ownership boundaries without +changing MVCC visibility, durable formats, redo encoding, recovery, DDL +publication, or transaction atomicity. + +The Phase 2 design amended RFC-0029 before implementation. In particular, it +rejected the earlier reusable `CatalogStatement` proposal in favor of one +owned internal facade for both public and private operations plus narrowly +scoped catalog batch DML. + +## Goals + +- Make direct `Transaction` methods the sole public statement API. +- Make every high-level user and catalog operation consume one internal + `Statement`. +- Keep settlement authority with public or private carrier state after the + operation future and facade have ended. +- Roll back complete public and private current-statement effects before + returning ordinary operation errors. +- Preserve rollback order, redo discard, residual ownership, poison behavior, + fatal precedence, and cancellation safety. +- Preserve intentional catalog logical statements through purpose-built + same-table batch operations. +- Preserve validation opt-out behavior as a transaction-local setting that can + be disabled and re-enabled. +- Migrate production code, tests, examples, benchmarks, documentation, and + generated public-error inventory to the direct API. +- Preserve successful-path behavior and performance within measured baseline + dispersion. +- Implement and close backlog 000186. + +## Non-Goals + +- No persisted table, index, catalog, redo, checkpoint, or recovery-format + change. +- No MVCC, commit/rollback atomicity, lock-lifetime, DDL publication, or + recovery-protocol change. +- No callback compatibility adapter, deprecation period, or caller-selected + statement completion channel. +- No reusable normal, catalog, or test-only statement facade. +- No generic heterogeneous catalog mutation list or arbitrary mixed-DML batch. +- No new public update, delete, or upsert batch family. +- No change to row-decision callbacks inside table and index mutation DML. +- No statement-level validation flag; recovery retains its explicit + no-transaction validation policy. +- No new unsafe ownership mechanism or successful-path shared coordination. + +## Plan + +The shipped architecture has five boundaries: + +1. **Public transaction interface.** Direct methods in `trx/interface.rs` + invoke exactly one engine-controlled operation. Non-streaming methods enter + the private `Transaction::exec`; stream construction retains its specialized + checkout path. Caller assertions, result mapping, and unrelated application + work occur only after the statement has settled. + +2. **Owned normal statement execution.** Private `Transaction::exec` passes + `Statement` by value. Every high-level operation consumes it. `StmtState` + owns the checkout, attachment, effects, operation future, and final + settlement. Success merges effects and checks in normally. Ordinary failure + rolls back indexes then rows, clears redo, and returns the initiating error. + Rollback failure transfers residual ownership to fatal retention, poisons + storage, and takes precedence over the initiating error. + +3. **Owned private catalog execution.** `PrivateTransaction` holds its checkout + continuously while `PrivateStmtState` lends one owned operation and applies + the same mechanical merge or rollback rules. Earlier successful statements + remain transaction-owned for enclosing DDL rollback. Catalog accessors accept + `&mut PrivateTransaction`; repeated inserts, scoped deletes, and + delete-then-insert metadata replacement stay within purpose-built consuming + operations. + +4. **Caller-driven streams.** `Transaction::table_index_scan_mvcc_stream` + captures the current validation policy before checkout, constructs a + `StreamStmtState`, and delegates to its consuming scan method. That state + owns table admission, validation, range encoding, cursor setup, and the + returned stream's checkout until exhaustion, error, or drop. + +5. **Repository migration.** Tests use direct methods, real private transaction + settlement, lower-level runtime/effect fixtures, or narrowly scoped consuming + operations according to the behavior under test. Examples, benchmarks, and + public documentation use only the direct surface. The lifecycle-only + `stmt-noop` benchmark remains, while the redundant + `weak_handle_baseline` example was removed. + +These boundaries intentionally reject a reusable internal borrowed facade. +Physical loops are allowed inside one purpose-built operation, but a caller +cannot compose two unrelated high-level operations into one statement. + +## Implementation Notes + +Implemented RFC-0029 Phase 2 with direct `Transaction` methods as the sole +public statement boundary, owned consuming statements for public and private +execution, rollback-before-return for ordinary failures, purpose-built catalog +batches, and complete repository migration. No persisted format, recovery +protocol, or transaction atomicity behavior changed. + +`Transaction::exec` is transaction-module-private and receives one owned +`Statement`. `Statement` exposes no settlement API and every high-level +operation consumes `self`. The carrier-owned merge and rollback machinery is +shared mechanically without erasing the ownership distinction: + +- `StmtState` owns a public session checkout and terminally transfers a + cancelled checked-out operation into whole-transaction cleanup. +- `PrivateStmtState` borrows the continuously held private checkout and + returns settled ownership to mandatory DDL supervision without checking the + core through the session entry between statements. + +Public and private ordinary errors now settle deferred index updates, roll back +secondary-index effects before row effects, and discard redo before returning. +Rollback failures preserve residual ownership through fatal retention and +retain existing poison and fatal-error precedence. + +Catalog storage accessors now take `&mut PrivateTransaction`. Column, index, +and index-column creation use same-table insert batches; scoped removal uses +primary-key delete batches; table metadata replacement uses one +delete-then-insert operation. Impossible Operation and Lifecycle errors are +asserted at their native catalog ownership boundaries instead of being combined +through a synthetic `QuadResult`; generic table deletion retains its existing +carrier and is narrowed immediately by the catalog caller. + +The original statement-level validation opt-out was preserved as +`Transaction::disable_dml_validation(bool)`. Validation starts enabled, each +later non-streaming operation copies the current setting into its statement, +stream construction reads it before checkout, and passing `false` re-enables +validation. The setting is transaction-local. Private catalog operations +continue to validate unconditionally. + +All 48 retained runner annotations were resolved. Settlement and ownership +tests use the real private runner only inside focused transaction-module tests. +Other tests reuse production execution paths or transaction-level operations; +setup and assertions no longer rely on arbitrary callback actions. Test-only +imports and operations live inside test modules, while obsolete inherent APIs, +identity-only `&Table` adapters, one-line MemTable forwarding helpers, duplicate +recovery/catalog DML helpers, and unnecessary validation wrappers were removed. + +The stream constructor was simplified after review. The public transaction +method owns validation-policy capture and checkout, while a consuming +`StreamStmtState` method owns admission, validation, range encoding, cursor +creation, and public stream construction. The obsolete facade and duplicate +free constructor were removed without changing stream lifetime or transaction +reuse semantics. + +Production consumers, the quick-start example, benchmarks, transaction and lock +documentation, error documentation, README examples, and the public-error audit +were migrated. The standalone weak-handle example was deleted; the benchmark +crate's direct `stmt-noop` workload remains the authoritative lifecycle +control. + +Alternating optimized release samples on the same aarch64 host measured Phase 1 +versus Phase 2 medians of approximately 44.9 versus 45.6 ns for no-op, 294 +versus 303 ns for unique point lookup, and 775 versus 763 ns for single-row +insert. Sample ranges overlapped, with no regression outside observed +dispersion. + +Final verification completed: + +- Workspace all-target check passed without warnings. +- Focused settlement, validation, stream, catalog, cancellation, rollback, + example, and benchmark coverage passed during implementation. +- Workspace nextest passed 1,735 tests. +- Alternate `libaio` nextest passed 1,666 tests. +- Strict workspace and `libaio` Clippy passed. +- Formatting, public-error audit, and diff hygiene passed. +- Resolve-time style audit passed for 29 branch-diff Rust files against + `origin/main`. + +## Impacts + +- Public API: `Statement`, `StreamStmt`, public `Transaction::exec`, and + `Transaction::stream_stmt` were removed. Direct transaction methods and + `IndexScanMvccStream` remain public. Validation control moved to + `Transaction::disable_dml_validation(bool)`. +- Transaction runtime: public and private carriers now settle consumed + operations; private catalog failures use rollback-before-return. +- Catalog: DDL and storage accessors use direct private transactions and + purpose-built same-table batch operations. +- Tests: callback-oriented fixtures were replaced by production paths, + transaction-level operations, or focused ownership fixtures; no reusable + compatibility facade remains. +- Consumers: README, examples, benchmarks, transaction/lock/error + documentation, and the generated public-error audit now describe the direct + interface. +- Compatibility: this is an intentional source-level public API break. There + is no data-format, schema, redo, recovery, or operational migration. +- Performance: successful statements add no new shared coordination or facade + allocation; catalog batch memory and rollback remain proportional to batch + size and successful prefix. + +## Test Cases + +1. Unpolled direct futures perform no checkout or statement-number allocation; + checked-out cancellation preserves exact residual ownership and terminal + cleanup rules. +2. Successful direct no-op, read, DML, batch, and mutation operations obtain one + statement number, merge only their own effects, and preserve typed results. +3. Public and private partial-effect failures roll back indexes before rows, + discard redo, and return the initiating error only after settlement. +4. Rollback failure retains every remaining effect, poisons storage, returns + Fatal in precedence, and prevents unsafe reuse. +5. Private success retains the continuous checkout; private error, panic, and + cancellation preserve ownership for enclosing mandatory cleanup. +6. Catalog insert batches preserve input order and one statement boundary; + scoped delete batches return exact idempotent counts; metadata replacement + restores the old row on statement or enclosing DDL rollback. +7. Create/drop table and index preserve catalog rows, DDL redo, publication + ordering, panic supervision, and restart recovery. +8. Validation is on by default, can be disabled and re-enabled for later direct + and stream operations, remains transaction-local, and stays mandatory for + private catalog operations. +9. Streams preserve projection, candidate visibility, exclusive transaction + borrowing, constructor cancellation, exhaustion, iteration error, drop, and + later transaction reuse. +10. All retained runner-focused tests have a direct, lower-level, + purpose-built, private-catalog, or intentionally retired replacement. +11. Structural review confirms no public or reusable statement facade, no + `CatalogStatement`, and no callback-oriented production, example, + benchmark, or public-documentation call sites. +12. Default and alternate-backend tests, strict lint, formatting, generated + audit, style audit, performance comparison, and diff validation pass. + +## Open Questions + +None. The owned statement model, public and private rollback-before-return, +catalog batch policy, validation toggle, stream ownership, and migration +boundary are fixed by the implemented task and synchronized RFC. diff --git a/docs/tasks/next-id b/docs/tasks/next-id index ee22f346..74de27d0 100644 --- a/docs/tasks/next-id +++ b/docs/tasks/next-id @@ -1 +1 @@ -000274 +000275 diff --git a/docs/transaction-system.md b/docs/transaction-system.md index 38177e68..2d24368f 100644 --- a/docs/transaction-system.md +++ b/docs/transaction-system.md @@ -175,8 +175,10 @@ MemIndex cleanup is the separate registered-reader case. Its it cannot mint `TrxReadProof`, and the captured root cannot outlive the active STS registration. -Each user statement runs through `Transaction::exec(async |stmt| { ... })`. -Every successfully checked-out public, private, or public stream statement +Each public user statement is one direct `Transaction` no-op, read, DML, or +stream-construction method. Non-streaming methods use a transaction-module- +private owned runner; streaming uses its specialized checkout/state path. +Every successfully checked-out public, private, or streaming statement receives one monotonically increasing transaction-local `StmtNo`. Read-only and failed statements consume numbers, and terminal transaction reset restarts the counter. `StmtNo` is runtime-only: each foreground row undo entry carries it so @@ -279,10 +281,12 @@ versioned page tokens through the catalog table's shared insert free list, so catalog insert capacity remains available across sessions without requiring a user-table runtime cache entry. -`StmtState` owns the per-operation checkout and statement effects while public -`Transaction::exec` is active. It lends one `Statement` facade with direct -disjoint borrows of the checked-out `TrxInner`, operation attachment, and -effects; DML methods therefore do not resolve the entry or unwrap the carrier. +`StmtState` owns the per-operation checkout and statement effects while one +direct non-streaming transaction method is active. The private runner passes +one owned internal `Statement` with direct disjoint borrows of the checked-out +`TrxInner`, operation attachment, and effects. Every high-level operation +consumes that capability, so the engine-controlled operation can issue exactly +one read or DML; DML methods do not resolve the entry or unwrap the carrier. Normal public statement finish returns the core to its checked-in payload position inside outer `Voluntary` ownership. This ends only the operation-local checkout, not the semantic transaction lifetime; the weak @@ -291,17 +295,17 @@ statements borrow the core and attachment directly from `PrivateTransaction`, settle their statement effects into the held `TrxInner`, and never check the core through the entry between logical catalog-table boundaries. -Dropping an unpolled `Transaction::exec` future performs no checkout. Once -checkout succeeds, dropping the future is terminal for that public -transaction. The callback and any pending acquisition guard are destroyed -first. `StmtState` then discards statement redo, appends residual row and index +Dropping an unpolled direct non-streaming method future performs no checkout. +Once checkout succeeds, dropping the future is terminal for that public +transaction. The owned operation future and any pending acquisition guard are +destroyed first. `StmtState` then discards statement redo, appends residual row and index undo after prior transaction undo, and returns the complete core directly as outer `CleanupReady`. It never exposes an intervening available payload position. The exact-identity cleanup job claims the core and performs whole-transaction rollback; later calls through the stale -public facade return `TransactionDiscarded`. An ordinary callback error is -different: statement-local rollback completes before ordinary check-in, so -the transaction remains reusable. +public facade return `TransactionDiscarded`. An ordinary operation error is +different: statement-local rollback completes before ordinary check-in, so the +transaction remains reusable. Explicit commit and rollback consume the public handle, suppress drop abandonment, and claim the same entry and core through @@ -375,24 +379,36 @@ duplicate hints are neutral. Registry resolution uses only the operation key; the cleanup claim atomically validates the message's `TrxID`, claimable state, and physical payload ownership under the entry mutex. -`Statement` is a borrowed facade over operation-local runtime access and -carrier-owned statement-local `StmtEffects`; callers cannot construct or -finish it directly. Public statements settle through `StmtState`; private -catalog statements use a fresh effect accumulator borrowed alongside the -continuously held checkout. Private ordinary errors merge complete and partial -undo for whole-transaction rollback, while panic settlement discards -incomplete statement redo and folds residual undo before resuming the unwind. +`Statement` is a transaction-module-private, owned one-shot facade over +operation-local runtime access and carrier-owned statement-local `StmtEffects`; +callers cannot name, construct, or finish it directly, and each high-level +operation consumes it. Public statements settle through `StmtState`; private +catalog statements use a fresh private carrier and effect accumulator borrowed +alongside the continuously held checkout. Public and private ordinary errors +roll back the current statement index effects before row effects and discard +its redo before returning. Panic settlement discards incomplete statement redo +and folds residual undo before resuming the unwind. Foreground table APIs receive `TrxRuntime` by value when they need pool guards, insert-page cache access, or runtime lock assertions, while pure row MVCC -helpers continue to receive `&TrxContext`. When the callback succeeds, +helpers continue to receive `&TrxContext`. When the owned operation succeeds, statement row undo, index undo, and redo effects merge into the active -transaction. When the callback returns an ordinary error, only the current +transaction. When the operation returns an ordinary error, only the current statement effects are rolled back and the original error is returned. If that rollback cannot access required storage, the rollback failure is fatal: storage is poisoned and the operation entry becomes `FailedRetained`. The retained entry stays registry-visible, blocks session reuse and shutdown, and makes later commit or rollback attempts return an error. +Each public `Transaction` starts with DML validation enabled. Calling +`disable_dml_validation(true)` changes the policy for subsequent direct reads, +DML, and stream construction; calling it with `false` restores validation. +Non-streaming statements copy the current setting into their owned internal +operation, and stream construction reads it before checkout. The setting is +transaction-local, survives ordinary statement settlement, and cannot change +while an operation or returned stream exclusively borrows the transaction. It +bypasses caller-input validation only: table/index admission, schema checks, +lock acquisition, MVCC ownership, and storage invariants remain mandatory. + Logical lock ownership is tracked outside `TrxContext`. One boxed `FamilyLockAuthority` is allocated per session and moves linearly into `TransactionLockState`, which pairs that root with the transaction @@ -405,9 +421,10 @@ Catalog DDL mutations are owned by `CatalogStorage` and use one private statement per logical catalog table, retaining same-table row batches in one effect boundary. `StmtEffects` carries only DML redo. After every catalog-table statement and invariant check succeeds, `PrivateTransaction` installs exactly -one `DDLRedo` marker directly in `TrxEffects`; an ordinary staging error leaves -all accumulated undo available for whole-transaction rollback and leaves the -transaction-level DDL slot empty. +one `DDLRedo` marker directly in `TrxEffects`; an ordinary staging error first +rolls back the current private statement, leaves prior successful statement +undo available for whole-transaction rollback, and leaves the transaction- +level DDL slot empty. Transaction locks close on commit, rollback, no-op discard, or fatal transaction discard. DDL and maintenance @@ -420,8 +437,9 @@ exact scope index and does not scan manager resources. See [Lock System](./lock-system.md) for the resource and mode model, the implemented manager structures, and the pre-RFC redesign study. -Foreground table access enters through lock-aware `Statement` APIs and a -positive transaction-lifetime `TransactionTableBinding`. A binding hit is +Foreground table access enters through direct `Transaction` APIs, backed by +lock-aware consuming internal `Statement` operations and a positive +transaction-lifetime `TransactionTableBinding`. A binding hit is checked before any new metadata-lock request and reuses the STS-visible metadata, current `Table`, current `TableRuntimeLayout`, and transaction-owned `TableMetadata(S)` already stored for that table. On first touch, admission diff --git a/doradb-bench/src/workload/insert.rs b/doradb-bench/src/workload/insert.rs index 26e6f9a1..ebbabaa6 100644 --- a/doradb-bench/src/workload/insert.rs +++ b/doradb-bench/src/workload/insert.rs @@ -355,11 +355,8 @@ async fn run_insert_operations( for key in batch { let payload = generate_payload(*key, spec.seed, spec.value_size); let row = vec![Val::from(*key), Val::from(&payload[..])]; - match trx - .exec(async |stmt| stmt.table_insert_mvcc(spec.table_id, row).await.map(|_| ())) - .await - { - Ok(()) => { + match trx.table_insert_mvcc(spec.table_id, row).await { + Ok(_) => { result.inserted_rows = checked(result.inserted_rows, 1, "inserted rows")?; batch_inserted = checked(batch_inserted, 1, "batch inserted rows")?; } diff --git a/doradb-bench/src/workload/lock.rs b/doradb-bench/src/workload/lock.rs index 2ed4dac0..515cc24d 100644 --- a/doradb-bench/src/workload/lock.rs +++ b/doradb-bench/src/workload/lock.rs @@ -370,9 +370,7 @@ async fn run_specialized_lifecycle( LockTableScenario::FirstTouch => { let table_id = stable_table(&spec.table_ids, plan)?; let mut trx = session.begin_trx()?; - let scan = trx - .exec(async |stmt| stmt.table_scan_mvcc(table_id, &[0], |_| true).await) - .await; + let scan = trx.table_scan_mvcc(table_id, &[0], |_| true).await; if let Err(error) = scan { let primary = BenchError::from(error); let _ = trx.rollback().await; diff --git a/doradb-bench/src/workload/noop.rs b/doradb-bench/src/workload/noop.rs index e0e46621..2b894560 100644 --- a/doradb-bench/src/workload/noop.rs +++ b/doradb-bench/src/workload/noop.rs @@ -10,7 +10,7 @@ use crate::workload::util::{ verify_simple_counters, }; use crate::workload::{RunCancellation, SessionPlan}; -use doradb_storage::{Engine, Error as StorageError, Session}; +use doradb_storage::{Engine, Session}; use std::future::Future; /// Statement-noop session executor. @@ -252,7 +252,7 @@ async fn run_stmt_noop_operations( }); } let started = clock.map(MeasurementClock::raw); - if let Err(error) = trx.exec(async |_stmt| Ok::<(), StorageError>(())).await { + if let Err(error) = trx.noop().await { let _ = trx.rollback().await; return Err(error.into()); } diff --git a/doradb-bench/src/workload/read.rs b/doradb-bench/src/workload/read.rs index 3700259b..c7b3d7fd 100644 --- a/doradb-bench/src/workload/read.rs +++ b/doradb-bench/src/workload/read.rs @@ -549,15 +549,7 @@ async fn lookup_keys( for key in batch { let select_key = SelectKey::new(0, vec![Val::from(*key)]); let lookup = trx - .exec(async |stmt| { - stmt.table_lookup_unique_mvcc( - table_id, - select_key.index_no, - &select_key.vals, - &[0, 1], - ) - .await - }) + .table_lookup_unique_mvcc(table_id, select_key.index_no, &select_key.vals, &[0, 1]) .await; match lookup { Ok(SelectMvcc::Found(_)) => { @@ -606,17 +598,14 @@ async fn table_scans( let mut trx = session.begin_trx()?; let mut batch = WorkloadCounters::default(); for _ in 0..count { + let mut rows = 0u64; let scan = trx - .exec(async |stmt| { - let mut rows = 0u64; - stmt.table_scan_mvcc(table_id, &[0, 1], |_| { - rows += 1; - true - }) - .await?; - Ok(rows) + .table_scan_mvcc(table_id, &[0, 1], |_| { + rows += 1; + true }) - .await; + .await + .map(|()| rows); match scan { Ok(rows) => { batch.operations = checked(batch.operations, 1, "operations")?; @@ -669,10 +658,7 @@ async fn index_scans( let lower = [Val::from(range.start)]; let upper = [Val::from(range.end()?)]; let scan = trx - .exec(async |stmt| { - stmt.table_index_scan_mvcc(spec.table_id, 0, &lower[..]..&upper[..], &[0, 1]) - .await - }) + .table_index_scan_mvcc(spec.table_id, 0, &lower[..]..&upper[..], &[0, 1]) .await; match scan { Ok(scan) => { @@ -726,8 +712,7 @@ async fn index_streams( let mut trx = session.begin_trx()?; let scan_result = async { let mut stream = trx - .stream_stmt() - .table_index_scan_mvcc(spec.table_id, 0, &lower[..]..&upper[..], &[0, 1]) + .table_index_scan_mvcc_stream(spec.table_id, 0, &lower[..]..&upper[..], &[0, 1]) .await?; let mut rows = 0u64; while stream.next().await?.is_some() { diff --git a/doradb-storage/examples/quick_start.rs b/doradb-storage/examples/quick_start.rs index de8fbf4e..c57d47db 100644 --- a/doradb-storage/examples/quick_start.rs +++ b/doradb-storage/examples/quick_start.rs @@ -40,59 +40,45 @@ async fn run() -> ExampleResult<()> { let mut write_trx = session.begin_trx()?; // Insert two rows in one statement. write_trx - .exec(async |stmt| { - stmt.table_insert_mvcc(table_id, vec![Val::from(1i32), Val::from("alice")]) - .await?; - stmt.table_insert_mvcc(table_id, vec![Val::from(2i32), Val::from("bob")]) - .await?; - Ok(()) - }) + .table_insert_batch_mvcc( + table_id, + vec![ + vec![Val::from(1i32), Val::from("alice")], + vec![Val::from(2i32), Val::from("bob")], + ], + ) .await?; let id_one = SelectKey::new(0, vec![Val::from(1i32)]); // Update one row by its unique id key. - write_trx - .exec(async |stmt| { - let res = stmt - .table_update_unique_mvcc( - table_id, - id_one.index_no, - &id_one.vals, - vec![UpdateCol { - idx: 1, - val: Val::from("ada"), - }], - ) - .await?; - assert!(res.is_updated()); - Ok(()) - }) + let updated = write_trx + .table_update_unique_mvcc( + table_id, + id_one.index_no, + &id_one.vals, + vec![UpdateCol { + idx: 1, + val: Val::from("ada"), + }], + ) .await?; + assert!(updated.is_updated()); let id_two = SelectKey::new(0, vec![Val::from(2i32)]); // Delete one row by its unique id key. - write_trx - .exec(async |stmt| { - let res = stmt - .table_delete_unique_mvcc(table_id, id_two.index_no, &id_two.vals) - .await?; - assert!(res.is_deleted()); - Ok(()) - }) + let deleted = write_trx + .table_delete_unique_mvcc(table_id, id_two.index_no, &id_two.vals) .await?; + assert!(deleted.is_deleted()); write_trx.commit().await?; let mut read_trx = session.begin_trx()?; let mut scanned_rows = Vec::new(); // Scan visible rows from the table. read_trx - .exec(async |stmt| { - stmt.table_scan_mvcc(table_id, &[0, 1], |vals| { - scanned_rows.push(row_pair(vals)); - true - }) - .await?; - Ok(()) + .table_scan_mvcc(table_id, &[0, 1], |vals| { + scanned_rows.push(row_pair(vals)); + true }) .await?; scanned_rows.sort_unstable(); @@ -100,10 +86,7 @@ async fn run() -> ExampleResult<()> { // Lookup one row through the unique id index. let found = read_trx - .exec(async |stmt| { - stmt.table_lookup_unique_mvcc(table_id, id_one.index_no, &id_one.vals, &[0, 1]) - .await - }) + .table_lookup_unique_mvcc(table_id, id_one.index_no, &id_one.vals, &[0, 1]) .await? .unwrap_found(); assert_eq!(row_pair(found), (1, String::from("ada"))); @@ -111,10 +94,7 @@ async fn run() -> ExampleResult<()> { let name_key = SelectKey::new(1, vec![Val::from("ada")]); // Scan rows that match one secondary-index key. let mut matching_rows = read_trx - .exec(async |stmt| { - stmt.table_index_lookup_mvcc(table_id, name_key.index_no, &name_key.vals, &[0, 1]) - .await - }) + .table_index_lookup_mvcc(table_id, name_key.index_no, &name_key.vals, &[0, 1]) .await? .unwrap_rows() .into_iter() @@ -125,8 +105,7 @@ async fn run() -> ExampleResult<()> { // Stream the same secondary-index match one row at a time. let mut stream = read_trx - .stream_stmt() - .table_index_scan_mvcc( + .table_index_scan_mvcc_stream( table_id, name_key.index_no, &name_key.vals[..]..=&name_key.vals[..], diff --git a/doradb-storage/examples/weak_handle_baseline.rs b/doradb-storage/examples/weak_handle_baseline.rs deleted file mode 100644 index 9b332efe..00000000 --- a/doradb-storage/examples/weak_handle_baseline.rs +++ /dev/null @@ -1,698 +0,0 @@ -// Baseline for public runtime handle boundaries that RFC-0019 phases can affect. -// Tasks that change session, transaction, statement, table lookup, or lifecycle -// admission paths should respect this example by running it before and after the -// task and comparing `baseline.csv` for performance impact. - -use doradb_storage::id::TableID; -use doradb_storage::{ - ColumnAttributes, ColumnSpec, Engine, EngineConfig, EvictableBufferPoolConfig, - FileSystemConfig, IndexAttributes, IndexKey, IndexSpec, Result as StorageResult, SelectKey, - TableSpec, TrxSysConfig, UpdateCol, Val, ValKind, -}; -use futures::executor; -use std::env; -use std::error::Error; -use std::fs; -use std::path::{Path, PathBuf}; -use std::process::exit; -use std::result::Result; -use std::time::{Duration, Instant}; -use tempfile::TempDir; - -const DEFAULT_ITERATIONS: usize = 1000; -const DEFAULT_SCAN_ROWS: usize = 10_000; -const DEFAULT_POOL_BYTES: usize = 64 * 1024 * 1024; -const DEFAULT_OUT_DIR: &str = "target/weak-handle-baseline"; - -type ToolResult = Result>; - -struct Args { - iterations: usize, - scan_rows: usize, - out_dir: PathBuf, - only: Option, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum BenchOperation { - SessionBegin, - StatementExec, - FirstResolutionEmptyScan, - CachedResolutionEmptyScan, - PointLookup, - Insert, - Update, - Delete, - TableScan, -} - -impl BenchOperation { - const ALL: [BenchOperation; 9] = [ - BenchOperation::SessionBegin, - BenchOperation::StatementExec, - BenchOperation::FirstResolutionEmptyScan, - BenchOperation::CachedResolutionEmptyScan, - BenchOperation::PointLookup, - BenchOperation::Insert, - BenchOperation::Update, - BenchOperation::Delete, - BenchOperation::TableScan, - ]; - - #[inline] - fn name(self) -> &'static str { - match self { - BenchOperation::SessionBegin => "session_begin", - BenchOperation::StatementExec => "statement_exec", - BenchOperation::FirstResolutionEmptyScan => "first_resolution_empty_scan", - BenchOperation::CachedResolutionEmptyScan => "cached_resolution_empty_scan", - BenchOperation::PointLookup => "point_lookup", - BenchOperation::Insert => "insert", - BenchOperation::Update => "update", - BenchOperation::Delete => "delete", - BenchOperation::TableScan => "table_scan", - } - } - - #[inline] - fn parse(value: &str) -> Option { - Self::ALL - .into_iter() - .find(|operation| operation.name() == value) - } - - #[inline] - fn valid_names() -> String { - Self::ALL - .into_iter() - .map(BenchOperation::name) - .collect::>() - .join(", ") - } -} - -struct BenchRow { - operation: &'static str, - iterations: usize, - elapsed: Duration, -} - -impl BenchRow { - #[inline] - fn avg_ns(&self) -> u128 { - self.elapsed.as_nanos() / self.iterations as u128 - } -} - -fn usage() -> &'static str { - "Usage: cargo run --example weak_handle_baseline -- [--iterations ] [--scan-rows ] [--out-dir ] [--only ]\n\ -\n\ -Measures current public operation boundaries that RFC-0019 weak-handle phases may affect.\n\ -Defaults: --iterations 1000 --scan-rows 10000 --out-dir target/weak-handle-baseline\n\ -Output: /baseline.csv" -} - -fn main() { - if let Err(err) = run() { - eprintln!("{err}"); - exit(1); - } -} - -fn run() -> ToolResult<()> { - let args = parse_args()?; - let rows = executor::block_on(run_baseline(&args))?; - let report_path = write_csv(&args.out_dir, &rows)?; - print_report(&rows, &report_path); - Ok(()) -} - -fn parse_args() -> ToolResult { - let mut iterations = DEFAULT_ITERATIONS; - let mut scan_rows = DEFAULT_SCAN_ROWS; - let mut out_dir = PathBuf::from(DEFAULT_OUT_DIR); - let mut only = None; - let mut args = env::args().skip(1); - - while let Some(arg) = args.next() { - match arg.as_str() { - "--iterations" | "--iters" => { - let value = args.next().ok_or("missing value for --iterations")?; - iterations = parse_positive_usize("--iterations", &value)?; - } - "--scan-rows" => { - let value = args.next().ok_or("missing value for --scan-rows")?; - scan_rows = parse_positive_usize("--scan-rows", &value)?; - } - "--out-dir" => { - let value = args.next().ok_or("missing value for --out-dir")?; - out_dir = PathBuf::from(value); - } - "--only" => { - let value = args.next().ok_or("missing value for --only")?; - only = Some(BenchOperation::parse(&value).ok_or_else(|| { - format!( - "unknown --only operation: {value}\nvalid operations: {}", - BenchOperation::valid_names() - ) - })?); - } - "--help" | "-h" => { - println!("{}", usage()); - println!("Valid --only operations: {}", BenchOperation::valid_names()); - exit(0); - } - _ => return Err(format!("unknown argument: {arg}\n{}", usage()).into()), - } - } - - Ok(Args { - iterations, - scan_rows, - out_dir, - only, - }) -} - -fn parse_positive_usize(flag: &'static str, value: &str) -> ToolResult { - let parsed = value.parse::()?; - if parsed == 0 { - return Err(format!("{flag} must be greater than zero").into()); - } - Ok(parsed) -} - -async fn run_baseline(args: &Args) -> StorageResult> { - let temp_dir = TempDir::new().expect("create weak-handle benchmark temp directory"); - let engine = Engine::bootstrap(baseline_engine_config(temp_dir.path())).await?; - let setup_rows = args.scan_rows.max(args.iterations); - let resolution_table_id = if should_run(args, BenchOperation::FirstResolutionEmptyScan) - || should_run(args, BenchOperation::CachedResolutionEmptyScan) - { - Some(create_baseline_table(&engine).await?) - } else { - None - }; - let data_table_id = if needs_data_table(args) { - Some(create_baseline_table(&engine).await?) - } else { - None - }; - - if let Some(table_id) = data_table_id { - let mut session = engine.new_session()?; - if should_run(args, BenchOperation::PointLookup) - || should_run(args, BenchOperation::TableScan) - { - let count = if should_run(args, BenchOperation::TableScan) { - setup_rows - } else { - 1 - }; - insert_range(&mut session, table_id, 0, count).await?; - } - if should_run(args, BenchOperation::Update) { - insert_range(&mut session, table_id, 20_000_000, args.iterations).await?; - } - if should_run(args, BenchOperation::Delete) { - insert_range(&mut session, table_id, 30_000_000, args.iterations).await?; - } - session.close().await?; - } - - let mut rows = Vec::new(); - if should_run(args, BenchOperation::SessionBegin) { - rows.push(measure_session_begin(&engine, args.iterations).await?); - } - if should_run(args, BenchOperation::StatementExec) { - rows.push(measure_statement_exec(&engine, args.iterations).await?); - } - if should_run(args, BenchOperation::FirstResolutionEmptyScan) { - rows.push( - measure_first_resolution_empty_scan( - &engine, - resolution_table_id.expect("resolution table required"), - args.iterations, - ) - .await?, - ); - } - if should_run(args, BenchOperation::CachedResolutionEmptyScan) { - rows.push( - measure_cached_resolution_empty_scan( - &engine, - resolution_table_id.expect("resolution table required"), - args.iterations, - ) - .await?, - ); - } - if should_run(args, BenchOperation::PointLookup) { - rows.push( - measure_point_lookup( - &engine, - data_table_id.expect("data table required"), - args.iterations, - ) - .await?, - ); - } - if should_run(args, BenchOperation::Insert) { - rows.push( - measure_insert( - &engine, - data_table_id.expect("data table required"), - args.iterations, - ) - .await?, - ); - } - if should_run(args, BenchOperation::Update) { - rows.push( - measure_update( - &engine, - data_table_id.expect("data table required"), - args.iterations, - ) - .await?, - ); - } - if should_run(args, BenchOperation::Delete) { - rows.push( - measure_delete( - &engine, - data_table_id.expect("data table required"), - args.iterations, - ) - .await?, - ); - } - if should_run(args, BenchOperation::TableScan) { - rows.push( - measure_table_scan( - &engine, - data_table_id.expect("data table required"), - args.iterations, - setup_rows, - ) - .await?, - ); - } - - engine.shutdown(); - Ok(rows) -} - -fn should_run(args: &Args, operation: BenchOperation) -> bool { - args.only.is_none_or(|only| only == operation) -} - -fn needs_data_table(args: &Args) -> bool { - [ - BenchOperation::PointLookup, - BenchOperation::Insert, - BenchOperation::Update, - BenchOperation::Delete, - BenchOperation::TableScan, - ] - .into_iter() - .any(|operation| should_run(args, operation)) -} - -fn baseline_engine_config(root: &Path) -> EngineConfig { - EngineConfig::default() - .storage_root(root) - .meta_buffer(DEFAULT_POOL_BYTES) - .index_buffer( - EvictableBufferPoolConfig::default() - .swap_file("index.swp") - .max_mem_size(DEFAULT_POOL_BYTES) - .max_file_size(128usize * 1024 * 1024), - ) - .data_buffer( - EvictableBufferPoolConfig::default() - .max_mem_size(DEFAULT_POOL_BYTES) - .max_file_size(128usize * 1024 * 1024), - ) - .file(FileSystemConfig::default().readonly_buffer_size(DEFAULT_POOL_BYTES)) - .trx(TrxSysConfig::default()) -} - -async fn create_baseline_table(engine: &doradb_storage::Engine) -> StorageResult { - let mut session = engine.new_session()?; - let table_id = session - .create_table( - TableSpec::new(vec![ - ColumnSpec::new("id", ValKind::I32, ColumnAttributes::empty()), - ColumnSpec::new("payload", ValKind::I32, ColumnAttributes::empty()), - ]), - vec![IndexSpec::new(vec![IndexKey::new(0)], IndexAttributes::UK)], - ) - .await?; - session.close().await?; - Ok(table_id) -} - -async fn insert_range( - session: &mut doradb_storage::Session, - table_id: TableID, - start: i32, - count: usize, -) -> StorageResult<()> { - let mut trx = session.begin_trx()?; - for offset in 0..count { - let id = start + offset as i32; - trx.exec(async |stmt| { - stmt.table_insert_mvcc(table_id, vec![Val::from(id), Val::from(id)]) - .await - .map(|_| ()) - }) - .await?; - } - trx.commit().await?; - Ok(()) -} - -async fn measure_session_begin( - engine: &doradb_storage::Engine, - iterations: usize, -) -> StorageResult { - let mut session = engine.new_session()?; - let mut elapsed = Duration::ZERO; - for _ in 0..iterations { - let start = Instant::now(); - let trx = session.begin_trx()?; - elapsed += start.elapsed(); - trx.rollback().await?; - } - session.close().await?; - Ok(BenchRow { - operation: "session_begin", - iterations, - elapsed, - }) -} - -async fn measure_statement_exec( - engine: &doradb_storage::Engine, - iterations: usize, -) -> StorageResult { - let mut session = engine.new_session()?; - let mut trx = session.begin_trx()?; - let mut elapsed = Duration::ZERO; - for _ in 0..iterations { - let start = Instant::now(); - trx.exec(async |_stmt| Ok(())).await?; - elapsed += start.elapsed(); - } - trx.rollback().await?; - session.close().await?; - Ok(BenchRow { - operation: "statement_exec", - iterations, - elapsed, - }) -} - -/// Measures first table-id resolution through an otherwise empty read statement. -/// -/// The timed window excludes session/transaction creation, but includes the -/// public statement boundary, statement read lock, table lifecycle/layout checks, -/// and empty MVCC scan scaffolding. -async fn measure_first_resolution_empty_scan( - engine: &doradb_storage::Engine, - table_id: TableID, - iterations: usize, -) -> StorageResult { - let read_set = []; - let mut elapsed = Duration::ZERO; - for _ in 0..iterations { - let mut session = engine.new_session()?; - let mut trx = session.begin_trx()?; - let start = Instant::now(); - trx.exec(async |stmt| stmt.table_scan_mvcc(table_id, &read_set, |_| true).await) - .await?; - elapsed += start.elapsed(); - trx.rollback().await?; - session.close().await?; - } - Ok(BenchRow { - operation: "first_resolution_empty_scan", - iterations, - elapsed, - }) -} - -/// Measures cached table-id resolution through an otherwise empty read statement. -/// -/// This row is not raw table-cache lookup cost. It keeps the same transaction -/// warm so the table cache is hot, then times the full read-statement path. -async fn measure_cached_resolution_empty_scan( - engine: &doradb_storage::Engine, - table_id: TableID, - iterations: usize, -) -> StorageResult { - let mut session = engine.new_session()?; - let mut trx = session.begin_trx()?; - let read_set = []; - trx.exec(async |stmt| stmt.table_scan_mvcc(table_id, &read_set, |_| true).await) - .await?; - let mut elapsed = Duration::ZERO; - for _ in 0..iterations { - let start = Instant::now(); - trx.exec(async |stmt| stmt.table_scan_mvcc(table_id, &read_set, |_| true).await) - .await?; - elapsed += start.elapsed(); - } - trx.rollback().await?; - session.close().await?; - Ok(BenchRow { - operation: "cached_resolution_empty_scan", - iterations, - elapsed, - }) -} - -async fn measure_point_lookup( - engine: &doradb_storage::Engine, - table_id: TableID, - iterations: usize, -) -> StorageResult { - let mut session = engine.new_session()?; - let mut trx = session.begin_trx()?; - let key = SelectKey::new(0, vec![Val::from(0i32)]); - let mut elapsed = Duration::ZERO; - for _ in 0..iterations { - let start = Instant::now(); - let res = trx - .exec(async |stmt| { - stmt.table_lookup_unique_mvcc(table_id, key.index_no, &key.vals, &[0, 1]) - .await - }) - .await?; - elapsed += start.elapsed(); - assert!(res.is_found(), "baseline point lookup key must exist"); - } - trx.rollback().await?; - session.close().await?; - Ok(BenchRow { - operation: "point_lookup", - iterations, - elapsed, - }) -} - -async fn measure_insert( - engine: &doradb_storage::Engine, - table_id: TableID, - iterations: usize, -) -> StorageResult { - let mut session = engine.new_session()?; - let mut trx = session.begin_trx()?; - let mut elapsed = Duration::ZERO; - for offset in 0..iterations { - let id = 10_000_000 + offset as i32; - let start = Instant::now(); - trx.exec(async |stmt| { - stmt.table_insert_mvcc(table_id, vec![Val::from(id), Val::from(id)]) - .await - }) - .await?; - elapsed += start.elapsed(); - } - trx.commit().await?; - session.close().await?; - Ok(BenchRow { - operation: "insert", - iterations, - elapsed, - }) -} - -async fn measure_update( - engine: &doradb_storage::Engine, - table_id: TableID, - iterations: usize, -) -> StorageResult { - let mut session = engine.new_session()?; - let mut trx = session.begin_trx()?; - let mut elapsed = Duration::ZERO; - for offset in 0..iterations { - let id = 20_000_000 + offset as i32; - let key = SelectKey::new(0, vec![Val::from(id)]); - let update = vec![UpdateCol { - idx: 1, - val: Val::from(-id), - }]; - let start = Instant::now(); - let res = trx - .exec(async |stmt| { - stmt.table_update_unique_mvcc(table_id, key.index_no, &key.vals, update) - .await - }) - .await?; - elapsed += start.elapsed(); - assert!(res.is_updated(), "baseline update key must exist"); - } - trx.commit().await?; - session.close().await?; - Ok(BenchRow { - operation: "update", - iterations, - elapsed, - }) -} - -async fn measure_delete( - engine: &doradb_storage::Engine, - table_id: TableID, - iterations: usize, -) -> StorageResult { - let mut session = engine.new_session()?; - let mut trx = session.begin_trx()?; - let mut elapsed = Duration::ZERO; - for offset in 0..iterations { - let id = 30_000_000 + offset as i32; - let key = SelectKey::new(0, vec![Val::from(id)]); - let start = Instant::now(); - trx.exec(async |stmt| { - stmt.table_delete_unique_mvcc(table_id, key.index_no, &key.vals) - .await - }) - .await?; - elapsed += start.elapsed(); - } - trx.commit().await?; - session.close().await?; - Ok(BenchRow { - operation: "delete", - iterations, - elapsed, - }) -} - -async fn measure_table_scan( - engine: &doradb_storage::Engine, - table_id: TableID, - iterations: usize, - expected_rows: usize, -) -> StorageResult { - let mut session = engine.new_session()?; - let mut trx = session.begin_trx()?; - let mut elapsed = Duration::ZERO; - for _ in 0..iterations { - let mut seen = 0usize; - let start = Instant::now(); - trx.exec(async |stmt| { - stmt.table_scan_mvcc(table_id, &[0, 1], |_| { - seen += 1; - true - }) - .await - }) - .await?; - elapsed += start.elapsed(); - assert!( - seen >= expected_rows, - "baseline scan should see at least the setup rows" - ); - } - trx.rollback().await?; - session.close().await?; - Ok(BenchRow { - operation: "table_scan", - iterations, - elapsed, - }) -} - -fn write_csv(out_dir: &Path, rows: &[BenchRow]) -> ToolResult { - fs::create_dir_all(out_dir)?; - let path = out_dir.join("baseline.csv"); - let mut out = String::from("operation,iterations,elapsed_ns,avg_ns\n"); - for row in rows { - out.push_str(&format!( - "{},{},{},{}\n", - row.operation, - row.iterations, - row.elapsed.as_nanos(), - row.avg_ns() - )); - } - fs::write(&path, out)?; - Ok(path) -} - -fn print_report(rows: &[BenchRow], report_path: &Path) { - println!("operation,iterations,elapsed_ns,avg_ns"); - for row in rows { - println!( - "{},{},{},{}", - row.operation, - row.iterations, - row.elapsed.as_nanos(), - row.avg_ns() - ); - } - print_derived_report(rows); - println!("wrote {}", report_path.display()); -} - -fn print_derived_report(rows: &[BenchRow]) { - let Some(statement_exec) = avg_ns_for(rows, "statement_exec") else { - return; - }; - let Some(first_empty_scan) = avg_ns_for(rows, "first_resolution_empty_scan") else { - return; - }; - let Some(cached_empty_scan) = avg_ns_for(rows, "cached_resolution_empty_scan") else { - return; - }; - let Some(point_lookup) = avg_ns_for(rows, "point_lookup") else { - return; - }; - - println!("derived_metric,delta_avg_ns"); - println!( - "first_resolution_miss_delta,{}", - delta_ns(first_empty_scan, cached_empty_scan) - ); - println!( - "cached_empty_scan_over_statement_exec,{}", - delta_ns(cached_empty_scan, statement_exec) - ); - println!( - "point_lookup_over_cached_empty_scan,{}", - delta_ns(point_lookup, cached_empty_scan) - ); -} - -fn avg_ns_for(rows: &[BenchRow], operation: &'static str) -> Option { - rows.iter() - .find(|row| row.operation == operation) - .map(BenchRow::avg_ns) -} - -fn delta_ns(left: u128, right: u128) -> i128 { - left as i128 - right as i128 -} diff --git a/doradb-storage/src/catalog/index.rs b/doradb-storage/src/catalog/index.rs index 6dbee11d..c848c03a 100644 --- a/doradb-storage/src/catalog/index.rs +++ b/doradb-storage/src/catalog/index.rs @@ -1785,19 +1785,16 @@ fn create_index_current_cold_row_is_deleted(table: &Table, row_id: RowID) -> Ope match table.deletion_buffer().get(row_id) { Some(DeleteMarker::Committed(_)) => Ok(true), Some(DeleteMarker::Ref(status)) if trx_is_committed(status.ts()) => Ok(true), - Some(DeleteMarker::Ref(_)) => Err(create_index_uncommitted_cold_delete(table, row_id)), + Some(DeleteMarker::Ref(_)) => Err(Report::new(OperationError::WriteConflict).attach( + format!( + "create index found uncommitted cold-row delete marker: table_id={}, row_id={row_id}", + table.table_id() + ), + )), None => Ok(false), } } -#[inline] -fn create_index_uncommitted_cold_delete(table: &Table, row_id: RowID) -> Report { - Report::new(OperationError::WriteConflict).attach(format!( - "create index found uncommitted cold-row delete marker: table_id={}, row_id={row_id}", - table.table_id() - )) -} - async fn build_create_index_disk_tree( mutable_file: &mut MutableTableFile, disk_runtime: &SecondaryDiskTreeRuntime, @@ -2024,18 +2021,20 @@ pub(crate) mod tests { TrxSysConfig, }; use crate::engine::Engine; - use crate::error::{LifecycleError, Result}; + use crate::error::LifecycleError; use crate::file::cow_file::tests::old_root_drop_count; use crate::file::table_file::ActiveRoot; use crate::index::IndexBatchStream; - use crate::row::ops::{DeleteMvcc, SelectKey, UpdateCol, UpdateMvcc}; + use crate::row::ops::{SelectKey, UpdateCol, UpdateMvcc}; use crate::session::Session; use crate::session::tests::{ SessionTestExt, active_operation_count, assert_checkpoint_published, remove_session_for_test, }; - use crate::table::tests::assert_freeze_created; - use crate::trx::{MAX_SNAPSHOT_TS, Transaction}; + use crate::table::tests::{ + assert_freeze_created, expect_delete_committed, insert_one_row, insert_rows, + }; + use crate::trx::MAX_SNAPSHOT_TS; use crate::value::{Val, ValKind}; use smol::{Timer, future::race}; use std::panic::{AssertUnwindSafe, catch_unwind}; @@ -2559,12 +2558,24 @@ pub(crate) mod tests { let table_id = table2(&engine).await; let table = table_for_internal_assertion(&engine, table_id); let mut session = engine.new_session().unwrap(); - let row1 = - insert_one_row(&table, &mut session, vec![Val::from(1), Val::from("alpha")]).await; - let _row2 = - insert_one_row(&table, &mut session, vec![Val::from(2), Val::from("beta")]).await; - let row3 = - insert_one_row(&table, &mut session, vec![Val::from(3), Val::from("alpha")]).await; + let row1 = insert_one_row( + table_id, + &mut session, + vec![Val::from(1), Val::from("alpha")], + ) + .await; + let _row2 = insert_one_row( + table_id, + &mut session, + vec![Val::from(2), Val::from("beta")], + ) + .await; + let row3 = insert_one_row( + table_id, + &mut session, + vec![Val::from(3), Val::from("alpha")], + ) + .await; let old_generation = table.layout_snapshot().generation(); let index_no = session @@ -2605,8 +2616,12 @@ pub(crate) mod tests { rows.sort_unstable(); assert_eq!(rows, vec![row1, row3]); - let row4 = - insert_one_row(&table, &mut session, vec![Val::from(4), Val::from("alpha")]).await; + let row4 = insert_one_row( + table_id, + &mut session, + vec![Val::from(4), Val::from("alpha")], + ) + .await; let mut rows = non_unique_runtime_lookup( &layout, root, @@ -2736,9 +2751,8 @@ pub(crate) mod tests { .await .unwrap(); let table_id = table2(&engine).await; - let table = table_for_internal_assertion(&engine, table_id); let mut ddl_session = engine.new_session().unwrap(); - insert_rows(&table, &mut ddl_session, 0, 129, "fairness").await; + insert_rows(table_id, &mut ddl_session, 0, 129, "fairness").await; let before = ddl_session.mandatory_runtime_stats().unwrap(); for iteration in 0..8 { @@ -2994,11 +3008,15 @@ pub(crate) mod tests { let table_id = table2(&engine).await; let table = table_for_internal_assertion(&engine, table_id); let mut session = engine.new_session().unwrap(); - let row_id = - insert_one_row(&table, &mut session, vec![Val::from(1), Val::from("alpha")]).await; + let row_id = insert_one_row( + table_id, + &mut session, + vec![Val::from(1), Val::from("alpha")], + ) + .await; assert_eq!( update_one_row( - &table, + table_id, &mut session, &single_key(1), vec![UpdateCol { @@ -3039,13 +3057,17 @@ pub(crate) mod tests { let table_id = table2(&engine).await; let table = table_for_internal_assertion(&engine, table_id); let mut session = engine.new_session().unwrap(); - let cold_row_id = - insert_one_row(&table, &mut session, vec![Val::from(1), Val::from("alpha")]).await; + let cold_row_id = insert_one_row( + table_id, + &mut session, + vec![Val::from(1), Val::from("alpha")], + ) + .await; assert_freeze_created(session.freeze_table(table_id, usize::MAX).await.unwrap()); assert_checkpoint_published(&mut session, table_id).await; let hot_row_id = update_one_row( - &table, + table_id, &mut session, &single_key(1), vec![UpdateCol { @@ -3119,11 +3141,15 @@ pub(crate) mod tests { let table_id = table2(&engine).await; let table = table_for_internal_assertion(&engine, table_id); let mut session = engine.new_session().unwrap(); - let row_id = - insert_one_row(&table, &mut session, vec![Val::from(1), Val::from("alpha")]).await; + let row_id = insert_one_row( + table_id, + &mut session, + vec![Val::from(1), Val::from("alpha")], + ) + .await; assert_eq!( update_one_row( - &table, + table_id, &mut session, &single_key(1), vec![UpdateCol { @@ -3166,7 +3192,7 @@ pub(crate) mod tests { let table_id = table2(&engine).await; let table = table_for_internal_assertion(&engine, table_id); let mut session = engine.new_session().unwrap(); - insert_rows(&table, &mut session, 10, 8, "cold").await; + insert_rows(table_id, &mut session, 10, 8, "cold").await; assert_freeze_created( session .freeze_table(table.table_id(), usize::MAX) @@ -3206,7 +3232,7 @@ pub(crate) mod tests { for primary_key in 0..4 { cold_rows.push( insert_one_row( - &table, + table_id, &mut session, vec![Val::from(primary_key), Val::from("boundary")], ) @@ -3224,7 +3250,7 @@ pub(crate) mod tests { for primary_key in 100..103 { hot_rows.push( insert_one_row( - &table, + table_id, &mut session, vec![Val::from(primary_key), Val::from("boundary")], ) @@ -3342,8 +3368,8 @@ pub(crate) mod tests { let table_id = table2(&engine).await; let table = table_for_internal_assertion(&engine, table_id); let mut session = engine.new_session().unwrap(); - insert_one_row(&table, &mut session, vec![Val::from(1), Val::from("dup")]).await; - insert_one_row(&table, &mut session, vec![Val::from(2), Val::from("dup")]).await; + insert_one_row(table_id, &mut session, vec![Val::from(1), Val::from("dup")]).await; + insert_one_row(table_id, &mut session, vec![Val::from(2), Val::from("dup")]).await; let before = index_ddl_snapshot(&engine, table_id, &table); let err = session @@ -3424,8 +3450,8 @@ pub(crate) mod tests { let table = table_for_internal_assertion(&engine, table_id); let mut session = engine.new_session().unwrap(); let row1 = - insert_one_row(&table, &mut session, vec![Val::from(1), Val::from("dup")]).await; - insert_one_row(&table, &mut session, vec![Val::from(2), Val::from("dup")]).await; + insert_one_row(table_id, &mut session, vec![Val::from(1), Val::from("dup")]).await; + insert_one_row(table_id, &mut session, vec![Val::from(2), Val::from("dup")]).await; assert_freeze_created( session .freeze_table(table.table_id(), usize::MAX) @@ -3433,7 +3459,7 @@ pub(crate) mod tests { .unwrap(), ); assert_checkpoint_published(&mut session, table.table_id()).await; - delete_one_row(&table, &mut session, &single_key(2)).await; + expect_delete_committed(table_id, &mut session, &single_key(2)).await; let index_no = session .create_index( @@ -3502,7 +3528,7 @@ pub(crate) mod tests { .unwrap(); let mut session = engine.new_session().unwrap(); let row_id = insert_one_row( - &table, + table_id, &mut session, vec![Val::from(1), Val::from("persisted")], ) @@ -3655,9 +3681,13 @@ pub(crate) mod tests { let temp_dir = TempDir::new().unwrap(); let engine = lightweight_test_engine(&temp_dir, "create_index_lightweight").await; let table_id = table2(&engine).await; - let table = table_for_internal_assertion(&engine, table_id); let mut session = engine.new_session().unwrap(); - insert_one_row(&table, &mut session, vec![Val::from(1), Val::from("same")]).await; + insert_one_row( + table_id, + &mut session, + vec![Val::from(1), Val::from("same")], + ) + .await; assert_eq!( session @@ -3670,11 +3700,16 @@ pub(crate) mod tests { 1 ); session.drop_index(table_id, 1).await.unwrap(); - insert_one_row(&table, &mut session, vec![Val::from(2), Val::from("same")]).await; + insert_one_row( + table_id, + &mut session, + vec![Val::from(2), Val::from("same")], + ) + .await; session.drop_index(table_id, 0).await.unwrap(); insert_one_row( - &table, + table_id, &mut session, vec![Val::from(1), Val::from("different")], ) @@ -3827,7 +3862,7 @@ pub(crate) mod tests { old_trx.rollback().await.unwrap(); insert_one_row( - &table, + table_id, &mut session, vec![Val::from(1), Val::from("current")], ) @@ -3905,50 +3940,15 @@ pub(crate) mod tests { ) } - async fn trx_insert_row(trx: &mut Transaction, table: &Table, cols: Vec) -> Result { - trx.table_insert_mvcc(table.table_id(), cols).await - } - - async fn insert_one_row(table: &Table, session: &mut Session, values: Vec) -> RowID { - let mut trx = session.begin_trx().unwrap(); - let insert = trx_insert_row(&mut trx, table, values).await; - let Ok(row_id) = insert else { - panic!("insert should succeed: {insert:?}"); - }; - trx.commit().await.unwrap(); - row_id - } - - async fn insert_rows(table: &Table, session: &mut Session, start: i32, count: i32, name: &str) { - let mut trx = session.begin_trx().unwrap(); - for i in 0..count { - let insert = vec![Val::from(start + i), Val::from(name)]; - let res = trx_insert_row(&mut trx, table, insert).await; - assert!(res.is_ok()); - } - trx.commit().await.unwrap(); - } - - async fn delete_one_row(table: &Table, session: &mut Session, key: &SelectKey) { - let mut trx = session.begin_trx().unwrap(); - let delete = trx - .table_delete_unique_mvcc(table.table_id(), key.index_no, &key.vals) - .await; - if !matches!(delete, Ok(DeleteMvcc::Deleted)) { - panic!("delete should succeed: {delete:?}"); - } - trx.commit().await.unwrap(); - } - async fn update_one_row( - table: &Table, + table_id: TableID, session: &mut Session, key: &SelectKey, update: Vec, ) -> RowID { let mut trx = session.begin_trx().unwrap(); let result = trx - .table_update_unique_mvcc(table.table_id(), key.index_no, &key.vals, update) + .table_update_unique_mvcc(table_id, key.index_no, &key.vals, update) .await; let Ok(UpdateMvcc::Updated(row_id)) = result else { panic!("update should succeed: {result:?}"); @@ -3969,7 +3969,7 @@ pub(crate) mod tests { let mut session = engine.new_session().unwrap(); for primary_key in 0..cold_count { insert_one_row( - &table, + table_id, &mut session, vec![Val::from(primary_key), Val::from("dup")], ) @@ -3978,7 +3978,7 @@ pub(crate) mod tests { assert_freeze_created(session.freeze_table(table_id, usize::MAX).await.unwrap()); for offset in 0..hot_count { insert_one_row( - &table, + table_id, &mut session, vec![Val::from(100 + offset), Val::from("dup")], ) @@ -4014,7 +4014,12 @@ pub(crate) mod tests { let table_id = table2(&engine).await; let table = table_for_internal_assertion(&engine, table_id); let mut session = engine.new_session().unwrap(); - insert_one_row(&table, &mut session, vec![Val::from(1), Val::from("alpha")]).await; + insert_one_row( + table_id, + &mut session, + vec![Val::from(1), Val::from("alpha")], + ) + .await; let before = index_ddl_snapshot(&engine, table_id, &table); let allocated_before = engine.inner().pools.index.allocated(); diff --git a/doradb-storage/src/catalog/storage/columns.rs b/doradb-storage/src/catalog/storage/columns.rs index bea7e45a..77503156 100644 --- a/doradb-storage/src/catalog/storage/columns.rs +++ b/doradb-storage/src/catalog/storage/columns.rs @@ -8,9 +8,10 @@ use crate::catalog::{ }; use crate::error::{MultiDomainResultExt, RuntimeError, RuntimeOrFatalResult, RuntimeResult}; use crate::id::TableID; +#[cfg(test)] use crate::row::ops::DeleteMvcc; use crate::row::{Row, RowRead}; -use crate::trx::stmt::Statement; +use crate::trx::PrivateTransaction; use crate::value::Val; use crate::value::ValKind; use error_stack::ResultExt; @@ -36,31 +37,16 @@ pub(crate) struct Columns<'a> { } impl Columns<'_> { - /// Insert a column in a freshly allocated table-id namespace. - /// - /// Column numbers are assigned by metadata order, so `(table_id, column_no)` - /// is unique by construction. Operation failures are invariant violations. - pub(crate) async fn insert( + /// Insert an ordered column batch through one private statement. + pub(crate) async fn insert_batch( &self, - stmt: &mut Statement<'_>, - obj: &ColumnObject, + trx: &mut PrivateTransaction, + objects: &[ColumnObject], ) -> RuntimeOrFatalResult<()> { - let cols = vec![ - Val::from(obj.table_id), - Val::from(obj.column_no), - Val::from(obj.column_name.as_str()), - Val::from(obj.column_type as u32), - Val::from(obj.column_attributes.bits()), - ]; - stmt.catalog_insert_mvcc(self.table, cols) + let rows = objects.iter().map(cols_from_column_object).collect(); + trx.catalog_insert_batch_mvcc(self.table, rows) .await - .map(|_| ()) - .attach_with(|| { - format!( - "operation=catalog_columns_insert, table_id={}, column_no={}", - obj.table_id, obj.column_no - ) - }) + .attach("operation=catalog_columns_insert_batch") } /// List all columns of one table from uncommitted-visible catalog rows. @@ -93,15 +79,16 @@ impl Columns<'_> { } /// Delete a column by (table_id, column_no). + #[cfg(test)] pub(crate) async fn delete_by_id( &self, - stmt: &mut Statement<'_>, + trx: &mut PrivateTransaction, table_id: TableID, column_no: u16, ) -> RuntimeOrFatalResult { - let key_vals = [Val::from(table_id), Val::from(column_no)]; - let res = stmt - .catalog_delete_primary_key_mvcc(self.table, PK_NO_COLUMNS, &key_vals, true) + let key_vals = vec![Val::from(table_id), Val::from(column_no)]; + let res = trx + .catalog_delete_primary_key_mvcc(self.table, PK_NO_COLUMNS, key_vals) .await .attach_with(|| { format!( @@ -114,22 +101,35 @@ impl Columns<'_> { /// Delete all columns for one table and return the number of deleted rows. pub(crate) async fn delete_by_table_id( &self, - stmt: &mut Statement<'_>, + trx: &mut PrivateTransaction, table_id: TableID, ) -> RuntimeOrFatalResult { let columns = self - .list_uncommitted_by_table_id(stmt.runtime().pool_guards(), table_id) + .list_uncommitted_by_table_id(trx.pool_guards(), table_id) .await?; - let mut deleted = 0; - for column in columns { - if self.delete_by_id(stmt, table_id, column.column_no).await? { - deleted += 1; - } - } - Ok(deleted) + let keys = columns + .into_iter() + .map(|column| vec![Val::from(table_id), Val::from(column.column_no)]) + .collect(); + trx.catalog_delete_primary_key_batch_mvcc(self.table, PK_NO_COLUMNS, keys) + .await + .attach_with(|| { + format!("operation=catalog_columns_delete_by_table, table_id={table_id}") + }) } } +#[inline] +fn cols_from_column_object(obj: &ColumnObject) -> Vec { + vec![ + Val::from(obj.table_id), + Val::from(obj.column_no), + Val::from(obj.column_name.as_str()), + Val::from(obj.column_type as u32), + Val::from(obj.column_attributes.bits()), + ] +} + /// Return static table definition of `catalog.columns`. pub(super) fn catalog_definition_of_columns() -> &'static CatalogDefinition { static DEF: OnceLock = OnceLock::new(); @@ -219,22 +219,19 @@ fn row_to_column_object(col_layout: &TableColumnLayout, row: Row<'_>) -> ColumnO #[cfg(test)] mod tests { use super::*; - use crate::catalog::storage::tests::mark_catalog_ddl; + use crate::catalog::storage::tests::begin_catalog_test_trx; use crate::catalog::tests::open_catalog_test_engine; - use crate::error::DiscloseResultExt; use crate::log::redo::DDLRedo; use crate::session::tests::SessionTestExt; use tempfile::TempDir; - // RFC-0029 Phase 2 runner coverage: private catalog row composition and - // same-statement delete assertions require the legacy statement facade. #[test] fn test_columns_delete_by_id() { smol::block_on(async { let temp_dir = TempDir::new().unwrap(); let main_dir = temp_dir.path().to_path_buf(); let engine = open_catalog_test_engine(main_dir, None).await; - let mut session = engine.new_session().unwrap(); + let session = engine.new_session().unwrap(); let col_42_0 = ColumnObject { table_id: TableID::new(42), @@ -258,72 +255,42 @@ mod tests { column_attributes: ColumnAttributes::empty(), }; - let mut trx = session.begin_trx().unwrap(); - trx.exec(async |stmt| { - engine - .inner() - .core - .catalog() - .storage - .columns() - .insert(stmt, &col_42_0) - .await - .disclose()?; + let mut trx = begin_catalog_test_trx(&session); + engine + .inner() + .core + .catalog() + .storage + .columns() + .insert_batch(trx.trx(), &[col_42_0, col_42_1, col_43_0]) + .await + .unwrap(); + trx.commit(DDLRedo::CreateTable(TableID::new(42))).await; + + let mut trx = begin_catalog_test_trx(&session); + assert!( engine .inner() .core .catalog() .storage .columns() - .insert(stmt, &col_42_1) + .delete_by_id(trx.trx(), TableID::new(42), 1) .await - .disclose()?; - engine + .unwrap() + ); + assert!( + !engine .inner() .core .catalog() .storage .columns() - .insert(stmt, &col_43_0) + .delete_by_id(trx.trx(), TableID::new(42), 9) .await - .disclose()?; - Ok(()) - }) - .await - .unwrap(); - mark_catalog_ddl(&mut trx, DDLRedo::CreateTable(TableID::new(42))); - trx.commit().await.unwrap(); - - let mut trx = session.begin_trx().unwrap(); - trx.exec(async |stmt| { - assert!( - engine - .inner() - .core - .catalog() - .storage - .columns() - .delete_by_id(stmt, TableID::new(42), 1) - .await - .disclose()? - ); - assert!( - !engine - .inner() - .core - .catalog() - .storage - .columns() - .delete_by_id(stmt, TableID::new(42), 9) - .await - .disclose()? - ); - Ok(()) - }) - .await - .unwrap(); - mark_catalog_ddl(&mut trx, DDLRedo::DropTable(TableID::new(42))); - trx.commit().await.unwrap(); + .unwrap() + ); + trx.commit(DDLRedo::DropTable(TableID::new(42))).await; let cols_42 = engine .inner() @@ -349,47 +316,41 @@ mod tests { assert_eq!(cols_43.len(), 1); assert_eq!(cols_43[0].column_no, 0); - let mut trx = session.begin_trx().unwrap(); - trx.exec(async |stmt| { - assert!( - !engine - .inner() - .core - .catalog() - .storage - .columns() - .delete_by_id(stmt, TableID::new(42), 1) - .await - .disclose()? - ); - assert!( - engine - .inner() - .core - .catalog() - .storage - .columns() - .delete_by_id(stmt, TableID::new(42), 0) - .await - .disclose()? - ); - assert!( - engine - .inner() - .core - .catalog() - .storage - .columns() - .delete_by_id(stmt, TableID::new(43), 0) - .await - .disclose()? - ); - Ok(()) - }) - .await - .unwrap(); - mark_catalog_ddl(&mut trx, DDLRedo::DropTable(TableID::new(42))); - trx.commit().await.unwrap(); + let mut trx = begin_catalog_test_trx(&session); + assert!( + !engine + .inner() + .core + .catalog() + .storage + .columns() + .delete_by_id(trx.trx(), TableID::new(42), 1) + .await + .unwrap() + ); + assert!( + engine + .inner() + .core + .catalog() + .storage + .columns() + .delete_by_id(trx.trx(), TableID::new(42), 0) + .await + .unwrap() + ); + assert!( + engine + .inner() + .core + .catalog() + .storage + .columns() + .delete_by_id(trx.trx(), TableID::new(43), 0) + .await + .unwrap() + ); + trx.commit(DDLRedo::DropTable(TableID::new(42))).await; assert!( engine @@ -421,15 +382,13 @@ mod tests { }); } - // RFC-0029 Phase 2 runner coverage: private catalog batch insert and - // delete composition requires the legacy statement facade. #[test] fn test_columns_delete_by_table_id_counts_and_is_idempotent() { smol::block_on(async { let temp_dir = TempDir::new().unwrap(); let main_dir = temp_dir.path().to_path_buf(); let engine = open_catalog_test_engine(main_dir, None).await; - let mut session = engine.new_session().unwrap(); + let session = engine.new_session().unwrap(); let columns = [ ColumnObject { @@ -455,58 +414,44 @@ mod tests { }, ]; - let mut trx = session.begin_trx().unwrap(); - trx.exec(async |stmt| { - for column in &columns { - engine - .inner() - .core - .catalog() - .storage - .columns() - .insert(stmt, column) - .await - .disclose()?; - } - Ok(()) - }) - .await - .unwrap(); - mark_catalog_ddl(&mut trx, DDLRedo::CreateTable(TableID::new(42))); - trx.commit().await.unwrap(); + let mut trx = begin_catalog_test_trx(&session); + engine + .inner() + .core + .catalog() + .storage + .columns() + .insert_batch(trx.trx(), &columns) + .await + .unwrap(); + trx.commit(DDLRedo::CreateTable(TableID::new(42))).await; - let mut trx = session.begin_trx().unwrap(); - trx.exec(async |stmt| { - assert_eq!( - engine - .inner() - .core - .catalog() - .storage - .columns() - .delete_by_table_id(stmt, TableID::new(42)) - .await - .unwrap(), - 2 - ); - assert_eq!( - engine - .inner() - .core - .catalog() - .storage - .columns() - .delete_by_table_id(stmt, TableID::new(42)) - .await - .unwrap(), - 0 - ); - Ok(()) - }) - .await - .unwrap(); - mark_catalog_ddl(&mut trx, DDLRedo::DropTable(TableID::new(42))); - trx.commit().await.unwrap(); + let mut trx = begin_catalog_test_trx(&session); + assert_eq!( + engine + .inner() + .core + .catalog() + .storage + .columns() + .delete_by_table_id(trx.trx(), TableID::new(42)) + .await + .unwrap(), + 2 + ); + assert_eq!( + engine + .inner() + .core + .catalog() + .storage + .columns() + .delete_by_table_id(trx.trx(), TableID::new(42)) + .await + .unwrap(), + 0 + ); + trx.commit(DDLRedo::DropTable(TableID::new(42))).await; assert!( engine diff --git a/doradb-storage/src/catalog/storage/ddl.rs b/doradb-storage/src/catalog/storage/ddl.rs index d8e8a91a..09a8f41c 100644 --- a/doradb-storage/src/catalog/storage/ddl.rs +++ b/doradb-storage/src/catalog/storage/ddl.rs @@ -63,32 +63,15 @@ impl CatalogStorage { }) .collect::>(); - trx.stage_statement(async |stmt| self.tables().insert(stmt, &table).await) - .await?; - trx.stage_statement(async |stmt| { - for column in &columns { - self.columns().insert(stmt, column).await?; - } - Ok(()) - }) - .await?; + self.tables().insert(trx, &table).await?; + self.columns().insert_batch(trx, &columns).await?; if !indexes.is_empty() { - trx.stage_statement(async |stmt| { - for index in &indexes { - self.indexes().insert(stmt, index).await?; - } - Ok(()) - }) - .await?; + self.indexes().insert_batch(trx, &indexes).await?; } if !index_columns.is_empty() { - trx.stage_statement(async |stmt| { - for index_column in &index_columns { - self.index_columns().insert(stmt, index_column).await?; - } - Ok(()) - }) - .await?; + self.index_columns() + .insert_batch(trx, &index_columns) + .await?; } trx.install_ddl_redo(DDLRedo::CreateTable(table_id)); Ok(()) @@ -103,12 +86,9 @@ impl CatalogStorage { ) -> RuntimeOrFatalResult<()> { validate_catalog_engine_health(trx, "stage_drop_table")?; - let index_columns_deleted = trx - .stage_statement(async |stmt| { - self.index_columns() - .delete_by_table_id(stmt, table_id) - .await - }) + let index_columns_deleted = self + .index_columns() + .delete_by_table_id(trx, table_id) .await?; let expected_index_columns = metadata .idx @@ -120,38 +100,29 @@ impl CatalogStorage { "drop-table catalog invariant violated: index-column delete count mismatch, table_id={table_id}" ); - let indexes_deleted = trx - .stage_statement(async |stmt| self.indexes().delete_by_table_id(stmt, table_id).await) - .await?; + let indexes_deleted = self.indexes().delete_by_table_id(trx, table_id).await?; assert_eq!( indexes_deleted, metadata.idx.active_index_count(), "drop-table catalog invariant violated: index delete count mismatch, table_id={table_id}" ); - let columns_deleted = trx - .stage_statement(async |stmt| self.columns().delete_by_table_id(stmt, table_id).await) - .await?; + let columns_deleted = self.columns().delete_by_table_id(trx, table_id).await?; assert_eq!( columns_deleted, metadata.col.col_count(), "drop-table catalog invariant violated: column delete count mismatch, table_id={table_id}" ); - let table_deleted = trx - .stage_statement(async |stmt| self.tables().delete_by_id(stmt, table_id).await) - .await?; + let table_deleted = self.tables().delete_by_id(trx, table_id).await?; assert!( table_deleted, "drop-table catalog invariant violated: validated table row is missing, table_id={table_id}" ); - trx.stage_statement(async |stmt| { - self.table_replay_silent_watermarks() - .delete_by_table_id(stmt, table_id) - .await - }) - .await?; + self.table_replay_silent_watermarks() + .delete_by_table_id(trx, table_id) + .await?; trx.install_ddl_redo(DDLRedo::DropTable(table_id)); Ok(()) @@ -186,55 +157,46 @@ impl CatalogStorage { ) }); - trx.stage_statement(async |stmt| { - let table_deleted = self.tables().delete_by_id(stmt, table_id).await?; - assert!( - table_deleted, - "create-index catalog invariant violated: validated table row is missing, table_id={table_id}" - ); - self.tables() - .insert( - stmt, - &TableObject { - table_id, - next_index_no: new_metadata.idx.next_index_no(), - }, - ) - .await - }) - .await?; - trx.stage_statement(async |stmt| { - self.indexes() - .insert( - stmt, - &IndexObject { - table_id, - index_no, - index_attributes: index_spec.attributes, - }, - ) - .await - }) - .await?; - if !index_spec.cols.is_empty() { - trx.stage_statement(async |stmt| { - for (index_column_no, index_key) in index_spec.cols.iter().enumerate() { - self.index_columns() - .insert( - stmt, - &IndexColumnObject { - table_id, - index_no, - index_column_no: index_column_no as u16, - column_no: index_key.col_no, - index_order: index_key.order, - }, - ) - .await?; - } - Ok(()) - }) + let table_deleted = self + .tables() + .replace( + trx, + &TableObject { + table_id, + next_index_no: new_metadata.idx.next_index_no(), + }, + ) .await?; + assert!( + table_deleted, + "create-index catalog invariant violated: validated table row is missing, table_id={table_id}" + ); + self.indexes() + .insert( + trx, + &IndexObject { + table_id, + index_no, + index_attributes: index_spec.attributes, + }, + ) + .await?; + if !index_spec.cols.is_empty() { + let index_columns = index_spec + .cols + .iter() + .enumerate() + .map(|(index_column_no, index_key)| IndexColumnObject { + table_id, + index_no, + index_column_no: index_column_no as u16, + column_no: index_key.col_no, + index_order: index_key.order, + }) + .collect::>(); + self.index_columns() + .insert_batch(trx, &index_columns) + .await?; } trx.install_ddl_redo(DDLRedo::CreateIndex { table_id, index_no }); @@ -265,12 +227,9 @@ impl CatalogStorage { ) }); - let deleted_columns = trx - .stage_statement(async |stmt| { - self.index_columns() - .delete_by_index(stmt, table_id, index_no) - .await - }) + let deleted_columns = self + .index_columns() + .delete_by_index(trx, table_id, index_no) .await?; assert_eq!( deleted_columns, @@ -278,11 +237,7 @@ impl CatalogStorage { "drop-index catalog invariant violated: index-column delete count mismatch, table_id={table_id}, index_no={index_no}" ); - let index_deleted = trx - .stage_statement(async |stmt| { - self.indexes().delete_by_id(stmt, table_id, index_no).await - }) - .await?; + let index_deleted = self.indexes().delete_by_id(trx, table_id, index_no).await?; assert!( index_deleted, "drop-index catalog invariant violated: validated index row is missing, table_id={table_id}, index_no={index_no}" diff --git a/doradb-storage/src/catalog/storage/indexes.rs b/doradb-storage/src/catalog/storage/indexes.rs index d5a56b9f..77c09213 100644 --- a/doradb-storage/src/catalog/storage/indexes.rs +++ b/doradb-storage/src/catalog/storage/indexes.rs @@ -11,7 +11,7 @@ use crate::error::{MultiDomainResultExt, RuntimeError, RuntimeOrFatalResult, Run use crate::id::TableID; use crate::row::ops::DeleteMvcc; use crate::row::{Row, RowRead}; -use crate::trx::stmt::Statement; +use crate::trx::PrivateTransaction; use crate::value::Val; use crate::value::ValKind; use error_stack::ResultExt; @@ -57,15 +57,10 @@ impl Indexes<'_> { /// asserted at the statement boundary. pub(crate) async fn insert( &self, - stmt: &mut Statement<'_>, + trx: &mut PrivateTransaction, obj: &IndexObject, ) -> RuntimeOrFatalResult<()> { - let cols = vec![ - Val::from(obj.table_id), - Val::from(obj.index_no), - Val::from(obj.index_attributes.bits()), - ]; - stmt.catalog_insert_mvcc(self.table, cols) + trx.catalog_insert_mvcc(self.table, cols_from_index_object(obj)) .await .map(|_| ()) .attach_with(|| { @@ -76,16 +71,28 @@ impl Indexes<'_> { }) } + /// Insert an ordered index batch through one private statement. + pub(crate) async fn insert_batch( + &self, + trx: &mut PrivateTransaction, + objects: &[IndexObject], + ) -> RuntimeOrFatalResult<()> { + let rows = objects.iter().map(cols_from_index_object).collect(); + trx.catalog_insert_batch_mvcc(self.table, rows) + .await + .attach("operation=catalog_indexes_insert_batch") + } + /// Delete an index by (table_id, index_no). pub(crate) async fn delete_by_id( &self, - stmt: &mut Statement<'_>, + trx: &mut PrivateTransaction, table_id: TableID, index_no: u16, ) -> RuntimeOrFatalResult { - let key_vals = [Val::from(table_id), Val::from(index_no)]; - let res = stmt - .catalog_delete_primary_key_mvcc(self.table, PK_NO_INDEXES, &key_vals, true) + let key_vals = vec![Val::from(table_id), Val::from(index_no)]; + let res = trx + .catalog_delete_primary_key_mvcc(self.table, PK_NO_INDEXES, key_vals) .await .attach_with(|| { format!( @@ -98,19 +105,21 @@ impl Indexes<'_> { /// Delete all indexes for one table and return the number of deleted rows. pub(crate) async fn delete_by_table_id( &self, - stmt: &mut Statement<'_>, + trx: &mut PrivateTransaction, table_id: TableID, ) -> RuntimeOrFatalResult { let indexes = self - .list_uncommitted_by_table_id(stmt.runtime().pool_guards(), table_id) + .list_uncommitted_by_table_id(trx.pool_guards(), table_id) .await?; - let mut deleted = 0; - for index in indexes { - if self.delete_by_id(stmt, table_id, index.index_no).await? { - deleted += 1; - } - } - Ok(deleted) + let keys = indexes + .into_iter() + .map(|index| vec![Val::from(table_id), Val::from(index.index_no)]) + .collect(); + trx.catalog_delete_primary_key_batch_mvcc(self.table, PK_NO_INDEXES, keys) + .await + .attach_with(|| { + format!("operation=catalog_indexes_delete_by_table, table_id={table_id}") + }) } /// List all indexes by given table id. @@ -153,19 +162,13 @@ impl IndexColumns<'_> { /// /// The enumerated `index_column_no` makes the composite primary key unique /// by construction. Operation failures are invariant violations. + #[cfg(test)] pub(crate) async fn insert( &self, - stmt: &mut Statement<'_>, + trx: &mut PrivateTransaction, obj: &IndexColumnObject, ) -> RuntimeOrFatalResult<()> { - let cols = vec![ - Val::from(obj.table_id), - Val::from(obj.index_no), - Val::from(obj.index_column_no), - Val::from(obj.column_no), - Val::from(obj.index_order as u8), - ]; - stmt.catalog_insert_mvcc(self.table, cols) + trx.catalog_insert_mvcc(self.table, cols_from_index_column_object(obj)) .await .map(|_| ()) .attach_with(|| { @@ -176,78 +179,72 @@ impl IndexColumns<'_> { }) } - async fn delete_by_id( + /// Insert an ordered index-column batch through one private statement. + pub(crate) async fn insert_batch( &self, - stmt: &mut Statement<'_>, - table_id: TableID, - index_no: u16, - index_column_no: u16, - ) -> RuntimeOrFatalResult { - let key_vals = [ - Val::from(table_id), - Val::from(index_no), - Val::from(index_column_no), - ]; - let res = stmt - .catalog_delete_primary_key_mvcc(self.table, PK_NO_INDEX_COLUMNS, &key_vals, true) + trx: &mut PrivateTransaction, + objects: &[IndexColumnObject], + ) -> RuntimeOrFatalResult<()> { + let rows = objects.iter().map(cols_from_index_column_object).collect(); + trx.catalog_insert_batch_mvcc(self.table, rows) .await - .attach_with(|| { - format!( - "operation=catalog_index_columns_delete, table_id={table_id}, index_no={index_no}, index_column_no={index_column_no}" - ) - })?; - Ok(matches!(res, DeleteMvcc::Deleted)) + .attach("operation=catalog_index_columns_insert_batch") } /// Delete all index-column rows by `(table_id, index_no)`. pub(crate) async fn delete_by_index( &self, - stmt: &mut Statement<'_>, + trx: &mut PrivateTransaction, table_id: TableID, index_no: u16, ) -> RuntimeOrFatalResult { let index_columns = self - .list_uncommitted_by_table_id(stmt.runtime().pool_guards(), table_id) + .list_uncommitted_by_table_id(trx.pool_guards(), table_id) .await?; - let mut deleted = 0; - for index_column in index_columns + let keys = index_columns .into_iter() .filter(|index_column| index_column.index_no == index_no) - { - if self - .delete_by_id(stmt, table_id, index_no, index_column.index_column_no) - .await? - { - deleted += 1; - } - } - Ok(deleted) + .map(|index_column| { + vec![ + Val::from(table_id), + Val::from(index_no), + Val::from(index_column.index_column_no), + ] + }) + .collect(); + trx.catalog_delete_primary_key_batch_mvcc(self.table, PK_NO_INDEX_COLUMNS, keys) + .await + .attach_with(|| { + format!( + "operation=catalog_index_columns_delete_by_index, table_id={table_id}, index_no={index_no}" + ) + }) } /// Delete all index-column rows for one table and return the number of deleted rows. pub(crate) async fn delete_by_table_id( &self, - stmt: &mut Statement<'_>, + trx: &mut PrivateTransaction, table_id: TableID, ) -> RuntimeOrFatalResult { let index_columns = self - .list_uncommitted_by_table_id(stmt.runtime().pool_guards(), table_id) + .list_uncommitted_by_table_id(trx.pool_guards(), table_id) .await?; - let mut deleted = 0; - for index_column in index_columns { - if self - .delete_by_id( - stmt, - table_id, - index_column.index_no, - index_column.index_column_no, - ) - .await? - { - deleted += 1; - } - } - Ok(deleted) + let keys = index_columns + .into_iter() + .map(|index_column| { + vec![ + Val::from(table_id), + Val::from(index_column.index_no), + Val::from(index_column.index_column_no), + ] + }) + .collect(); + trx.catalog_delete_primary_key_batch_mvcc(self.table, PK_NO_INDEX_COLUMNS, keys) + .await + .attach_with(|| { + format!("operation=catalog_index_columns_delete_by_table, table_id={table_id}") + }) } /// List all index-column rows of one table from uncommitted-visible rows. @@ -279,6 +276,26 @@ impl IndexColumns<'_> { } } +#[inline] +fn cols_from_index_object(obj: &IndexObject) -> Vec { + vec![ + Val::from(obj.table_id), + Val::from(obj.index_no), + Val::from(obj.index_attributes.bits()), + ] +} + +#[inline] +fn cols_from_index_column_object(obj: &IndexColumnObject) -> Vec { + vec![ + Val::from(obj.table_id), + Val::from(obj.index_no), + Val::from(obj.index_column_no), + Val::from(obj.column_no), + Val::from(obj.index_order as u8), + ] +} + /// Return static table definition of `catalog.indexes`. pub(super) fn catalog_definition_of_indexes() -> &'static CatalogDefinition { static DEF: OnceLock = OnceLock::new(); @@ -429,22 +446,19 @@ fn row_to_index_column_object(col_layout: &TableColumnLayout, row: Row<'_>) -> I #[cfg(test)] mod tests { use super::*; - use crate::catalog::storage::tests::mark_catalog_ddl; + use crate::catalog::storage::tests::begin_catalog_test_trx; use crate::catalog::tests::open_catalog_test_engine; - use crate::error::DiscloseResultExt; use crate::log::redo::DDLRedo; use crate::session::tests::SessionTestExt; use tempfile::TempDir; - // RFC-0029 Phase 2 runner coverage: private catalog row composition and - // same-statement delete assertions require the legacy statement facade. #[test] fn test_indexes_delete_by_id() { smol::block_on(async { let temp_dir = TempDir::new().unwrap(); let main_dir = temp_dir.path().to_path_buf(); let engine = open_catalog_test_engine(main_dir, None).await; - let mut session = engine.new_session().unwrap(); + let session = engine.new_session().unwrap(); let idx_42_0 = IndexObject { table_id: TableID::new(42), @@ -462,72 +476,42 @@ mod tests { index_attributes: IndexAttributes::PK, }; - let mut trx = session.begin_trx().unwrap(); - trx.exec(async |stmt| { - engine - .inner() - .core - .catalog() - .storage - .indexes() - .insert(stmt, &idx_42_0) - .await - .disclose()?; + let mut trx = begin_catalog_test_trx(&session); + engine + .inner() + .core + .catalog() + .storage + .indexes() + .insert_batch(trx.trx(), &[idx_42_0, idx_42_1, idx_43_0]) + .await + .unwrap(); + trx.commit(DDLRedo::CreateTable(TableID::new(42))).await; + + let mut trx = begin_catalog_test_trx(&session); + assert!( engine .inner() .core .catalog() .storage .indexes() - .insert(stmt, &idx_42_1) + .delete_by_id(trx.trx(), TableID::new(42), 1) .await - .disclose()?; - engine + .unwrap() + ); + assert!( + !engine .inner() .core .catalog() .storage .indexes() - .insert(stmt, &idx_43_0) + .delete_by_id(trx.trx(), TableID::new(42), 9) .await - .disclose()?; - Ok(()) - }) - .await - .unwrap(); - mark_catalog_ddl(&mut trx, DDLRedo::CreateTable(TableID::new(42))); - trx.commit().await.unwrap(); - - let mut trx = session.begin_trx().unwrap(); - trx.exec(async |stmt| { - assert!( - engine - .inner() - .core - .catalog() - .storage - .indexes() - .delete_by_id(stmt, TableID::new(42), 1) - .await - .disclose()? - ); - assert!( - !engine - .inner() - .core - .catalog() - .storage - .indexes() - .delete_by_id(stmt, TableID::new(42), 9) - .await - .disclose()? - ); - Ok(()) - }) - .await - .unwrap(); - mark_catalog_ddl(&mut trx, DDLRedo::DropTable(TableID::new(42))); - trx.commit().await.unwrap(); + .unwrap() + ); + trx.commit(DDLRedo::DropTable(TableID::new(42))).await; let idx_42 = engine .inner() @@ -553,47 +537,41 @@ mod tests { assert_eq!(idx_43.len(), 1); assert_eq!(idx_43[0].index_no, 0); - let mut trx = session.begin_trx().unwrap(); - trx.exec(async |stmt| { - assert!( - !engine - .inner() - .core - .catalog() - .storage - .indexes() - .delete_by_id(stmt, TableID::new(42), 1) - .await - .disclose()? - ); - assert!( - engine - .inner() - .core - .catalog() - .storage - .indexes() - .delete_by_id(stmt, TableID::new(42), 0) - .await - .disclose()? - ); - assert!( - engine - .inner() - .core - .catalog() - .storage - .indexes() - .delete_by_id(stmt, TableID::new(43), 0) - .await - .disclose()? - ); - Ok(()) - }) - .await - .unwrap(); - mark_catalog_ddl(&mut trx, DDLRedo::DropTable(TableID::new(42))); - trx.commit().await.unwrap(); + let mut trx = begin_catalog_test_trx(&session); + assert!( + !engine + .inner() + .core + .catalog() + .storage + .indexes() + .delete_by_id(trx.trx(), TableID::new(42), 1) + .await + .unwrap() + ); + assert!( + engine + .inner() + .core + .catalog() + .storage + .indexes() + .delete_by_id(trx.trx(), TableID::new(42), 0) + .await + .unwrap() + ); + assert!( + engine + .inner() + .core + .catalog() + .storage + .indexes() + .delete_by_id(trx.trx(), TableID::new(43), 0) + .await + .unwrap() + ); + trx.commit(DDLRedo::DropTable(TableID::new(42))).await; assert!( engine @@ -625,15 +603,13 @@ mod tests { }); } - // RFC-0029 Phase 2 runner coverage: private catalog batch insert and - // delete composition requires the legacy statement facade. #[test] fn test_indexes_delete_by_table_id_counts_and_is_idempotent() { smol::block_on(async { let temp_dir = TempDir::new().unwrap(); let main_dir = temp_dir.path().to_path_buf(); let engine = open_catalog_test_engine(main_dir, None).await; - let mut session = engine.new_session().unwrap(); + let session = engine.new_session().unwrap(); let indexes = [ IndexObject { @@ -653,58 +629,44 @@ mod tests { }, ]; - let mut trx = session.begin_trx().unwrap(); - trx.exec(async |stmt| { - for index in &indexes { - engine - .inner() - .core - .catalog() - .storage - .indexes() - .insert(stmt, index) - .await - .disclose()?; - } - Ok(()) - }) - .await - .unwrap(); - mark_catalog_ddl(&mut trx, DDLRedo::CreateTable(TableID::new(42))); - trx.commit().await.unwrap(); - - let mut trx = session.begin_trx().unwrap(); - trx.exec(async |stmt| { - assert_eq!( - engine - .inner() - .core - .catalog() - .storage - .indexes() - .delete_by_table_id(stmt, TableID::new(42)) - .await - .unwrap(), - 2 - ); - assert_eq!( - engine - .inner() - .core - .catalog() - .storage - .indexes() - .delete_by_table_id(stmt, TableID::new(42)) - .await - .unwrap(), - 0 - ); - Ok(()) - }) - .await - .unwrap(); - mark_catalog_ddl(&mut trx, DDLRedo::DropTable(TableID::new(42))); - trx.commit().await.unwrap(); + let mut trx = begin_catalog_test_trx(&session); + engine + .inner() + .core + .catalog() + .storage + .indexes() + .insert_batch(trx.trx(), &indexes) + .await + .unwrap(); + trx.commit(DDLRedo::CreateTable(TableID::new(42))).await; + + let mut trx = begin_catalog_test_trx(&session); + assert_eq!( + engine + .inner() + .core + .catalog() + .storage + .indexes() + .delete_by_table_id(trx.trx(), TableID::new(42)) + .await + .unwrap(), + 2 + ); + assert_eq!( + engine + .inner() + .core + .catalog() + .storage + .indexes() + .delete_by_table_id(trx.trx(), TableID::new(42)) + .await + .unwrap(), + 0 + ); + trx.commit(DDLRedo::DropTable(TableID::new(42))).await; assert!( engine @@ -735,15 +697,13 @@ mod tests { }); } - // RFC-0029 Phase 2 runner coverage: private catalog batch insert and - // delete composition requires the legacy statement facade. #[test] fn test_index_columns_delete_by_index_and_table_id() { smol::block_on(async { let temp_dir = TempDir::new().unwrap(); let main_dir = temp_dir.path().to_path_buf(); let engine = open_catalog_test_engine(main_dir, None).await; - let mut session = engine.new_session().unwrap(); + let session = engine.new_session().unwrap(); let index_columns = [ IndexColumnObject { @@ -776,58 +736,44 @@ mod tests { }, ]; - let mut trx = session.begin_trx().unwrap(); - trx.exec(async |stmt| { - for index_column in &index_columns { - engine - .inner() - .core - .catalog() - .storage - .index_columns() - .insert(stmt, index_column) - .await - .disclose()?; - } - Ok(()) - }) - .await - .unwrap(); - mark_catalog_ddl(&mut trx, DDLRedo::CreateTable(TableID::new(42))); - trx.commit().await.unwrap(); - - let mut trx = session.begin_trx().unwrap(); - trx.exec(async |stmt| { - assert_eq!( - engine - .inner() - .core - .catalog() - .storage - .index_columns() - .delete_by_index(stmt, TableID::new(42), 1) - .await - .unwrap(), - 2 - ); - assert_eq!( - engine - .inner() - .core - .catalog() - .storage - .index_columns() - .delete_by_index(stmt, TableID::new(42), 1) - .await - .unwrap(), - 0 - ); - Ok(()) - }) - .await - .unwrap(); - mark_catalog_ddl(&mut trx, DDLRedo::DropTable(TableID::new(42))); - trx.commit().await.unwrap(); + let mut trx = begin_catalog_test_trx(&session); + engine + .inner() + .core + .catalog() + .storage + .index_columns() + .insert_batch(trx.trx(), &index_columns) + .await + .unwrap(); + trx.commit(DDLRedo::CreateTable(TableID::new(42))).await; + + let mut trx = begin_catalog_test_trx(&session); + assert_eq!( + engine + .inner() + .core + .catalog() + .storage + .index_columns() + .delete_by_index(trx.trx(), TableID::new(42), 1) + .await + .unwrap(), + 2 + ); + assert_eq!( + engine + .inner() + .core + .catalog() + .storage + .index_columns() + .delete_by_index(trx.trx(), TableID::new(42), 1) + .await + .unwrap(), + 0 + ); + trx.commit(DDLRedo::DropTable(TableID::new(42))).await; let remaining_42 = engine .inner() @@ -841,38 +787,32 @@ mod tests { assert_eq!(remaining_42.len(), 1); assert_eq!(remaining_42[0].index_no, 0); - let mut trx = session.begin_trx().unwrap(); - trx.exec(async |stmt| { - assert_eq!( - engine - .inner() - .core - .catalog() - .storage - .index_columns() - .delete_by_table_id(stmt, TableID::new(42)) - .await - .unwrap(), - 1 - ); - assert_eq!( - engine - .inner() - .core - .catalog() - .storage - .index_columns() - .delete_by_table_id(stmt, TableID::new(42)) - .await - .unwrap(), - 0 - ); - Ok(()) - }) - .await - .unwrap(); - mark_catalog_ddl(&mut trx, DDLRedo::DropTable(TableID::new(42))); - trx.commit().await.unwrap(); + let mut trx = begin_catalog_test_trx(&session); + assert_eq!( + engine + .inner() + .core + .catalog() + .storage + .index_columns() + .delete_by_table_id(trx.trx(), TableID::new(42)) + .await + .unwrap(), + 1 + ); + assert_eq!( + engine + .inner() + .core + .catalog() + .storage + .index_columns() + .delete_by_table_id(trx.trx(), TableID::new(42)) + .await + .unwrap(), + 0 + ); + trx.commit(DDLRedo::DropTable(TableID::new(42))).await; assert!( engine diff --git a/doradb-storage/src/catalog/storage/mod.rs b/doradb-storage/src/catalog/storage/mod.rs index 6d4dfc8c..8e9e9323 100644 --- a/doradb-storage/src/catalog/storage/mod.rs +++ b/doradb-storage/src/catalog/storage/mod.rs @@ -1292,15 +1292,62 @@ pub(crate) mod tests { use crate::index::{ColumnBlockIndex, ColumnDeleteDeltaPatch}; use crate::log::redo::{DDLRedo, RowRedoKind}; use crate::row::ops::{SelectKey, UpdateCol}; - use crate::trx::Transaction; - use crate::trx::tests::install_transaction_ddl_redo; + use crate::session::tests::begin_test_mandatory_private_trx; + use crate::session::{MandatoryOperationGuard, Session}; + use crate::trx::PrivateTransaction; use crate::value::{Val, ValKind}; use tempfile::TempDir; - /// Attach one catalog DDL marker after test catalog DML has merged. - pub(crate) fn mark_catalog_ddl(trx: &mut Transaction, ddl: DDLRedo) { - install_transaction_ddl_redo(trx, ddl) - .expect("test catalog transaction must remain available"); + /// Focused mandatory/private ownership harness for catalog accessor tests. + pub(crate) struct CatalogTestTransaction { + operation: MandatoryOperationGuard, + trx: Option, + } + + impl CatalogTestTransaction { + /// Return the active private transaction. + pub(crate) fn trx(&mut self) -> &mut PrivateTransaction { + self.trx + .as_mut() + .expect("catalog test transaction must remain active") + } + + /// Commit catalog changes and finish the mandatory test operation. + pub(crate) async fn commit(mut self, ddl: DDLRedo) -> TrxID { + let mut trx = self + .trx + .take() + .expect("catalog test transaction must remain active"); + trx.install_ddl_redo(ddl); + let cts = trx + .commit_catalog_ddl() + .await + .expect("catalog test transaction must commit"); + self.operation.assert_finish_ready(); + self.operation.finish(); + cts + } + + /// Roll back catalog changes and finish the mandatory test operation. + pub(crate) async fn rollback(mut self) { + self.trx + .take() + .expect("catalog test transaction must remain active") + .rollback_catalog_ddl() + .await + .expect("catalog test transaction must roll back"); + self.operation.assert_finish_ready(); + self.operation.finish(); + } + } + + /// Begin one focused catalog accessor transaction. + pub(crate) fn begin_catalog_test_trx(session: &Session) -> CatalogTestTransaction { + let (operation, trx) = begin_test_mandatory_private_trx(session); + CatalogTestTransaction { + operation, + trx: Some(trx), + } } fn expect_runtime_report(error: RuntimeOrFatalError) -> Report { diff --git a/doradb-storage/src/catalog/storage/table_replay_silent_watermarks.rs b/doradb-storage/src/catalog/storage/table_replay_silent_watermarks.rs index 87ef6032..54ae20d4 100644 --- a/doradb-storage/src/catalog/storage/table_replay_silent_watermarks.rs +++ b/doradb-storage/src/catalog/storage/table_replay_silent_watermarks.rs @@ -14,7 +14,7 @@ use crate::id::{TableID, TrxID}; use crate::row::ops::DeleteMvcc; use crate::row::{Row, RowRead}; use crate::table::NoTrxUpsertChange; -use crate::trx::stmt::Statement; +use crate::trx::PrivateTransaction; use crate::value::Val; use crate::value::ValKind; use error_stack::{Report, ResultExt}; @@ -106,16 +106,14 @@ impl TableReplaySilentWatermarks<'_> { /// Delete one watermark row by user table id. pub(crate) async fn delete_by_table_id( &self, - stmt: &mut Statement<'_>, + trx: &mut PrivateTransaction, table_id: TableID, ) -> RuntimeOrFatalResult { - let key_vals = [Val::from(table_id)]; - let res = stmt + let res = trx .catalog_delete_primary_key_mvcc( self.table, PK_NO_TABLE_REPLAY_SILENT_WATERMARKS, - &key_vals, - true, + vec![Val::from(table_id)], ) .await .attach_with(|| { diff --git a/doradb-storage/src/catalog/storage/tables.rs b/doradb-storage/src/catalog/storage/tables.rs index 90ac82d4..255d2a78 100644 --- a/doradb-storage/src/catalog/storage/tables.rs +++ b/doradb-storage/src/catalog/storage/tables.rs @@ -10,7 +10,7 @@ use crate::error::{MultiDomainResultExt, RuntimeError, RuntimeOrFatalResult, Run use crate::id::TableID; use crate::row::ops::DeleteMvcc; use crate::row::{Row, RowRead}; -use crate::trx::stmt::Statement; +use crate::trx::PrivateTransaction; use crate::value::Val; use crate::value::ValKind; use error_stack::ResultExt; @@ -74,11 +74,11 @@ impl Tables<'_> { /// boundary asserts if storage reports an Operation failure. pub(crate) async fn insert( &self, - stmt: &mut Statement<'_>, + trx: &mut PrivateTransaction, obj: &TableObject, ) -> RuntimeOrFatalResult<()> { let cols = vec![Val::from(obj.table_id), Val::from(obj.next_index_no)]; - stmt.catalog_insert_mvcc(self.table, cols) + trx.catalog_insert_mvcc(self.table, cols) .await .map(|_| ()) .attach_with(|| format!("operation=catalog_tables_insert, table_id={}", obj.table_id)) @@ -87,16 +87,35 @@ impl Tables<'_> { /// Delete a table by id. pub(crate) async fn delete_by_id( &self, - stmt: &mut Statement<'_>, + trx: &mut PrivateTransaction, id: TableID, ) -> RuntimeOrFatalResult { - let key_vals = [Val::from(id)]; - let res = stmt - .catalog_delete_primary_key_mvcc(self.table, PK_NO_TABLES, &key_vals, true) + let res = trx + .catalog_delete_primary_key_mvcc(self.table, PK_NO_TABLES, vec![Val::from(id)]) .await .attach_with(|| format!("operation=catalog_tables_delete, table_id={id}"))?; Ok(matches!(res, DeleteMvcc::Deleted)) } + + /// Replace the table metadata row through one delete-then-insert statement. + pub(crate) async fn replace( + &self, + trx: &mut PrivateTransaction, + obj: &TableObject, + ) -> RuntimeOrFatalResult { + let key_vals = vec![Val::from(obj.table_id)]; + let cols = vec![Val::from(obj.table_id), Val::from(obj.next_index_no)]; + let res = trx + .catalog_replace_primary_key_mvcc(self.table, PK_NO_TABLES, key_vals, cols) + .await + .attach_with(|| { + format!( + "operation=catalog_tables_replace, table_id={}", + obj.table_id + ) + })?; + Ok(matches!(res, DeleteMvcc::Deleted)) + } } /// Return static table definition of `catalog.tables`. @@ -151,22 +170,19 @@ fn row_to_table_object(col_layout: &TableColumnLayout, row: Row<'_>) -> TableObj mod tests { use super::*; use crate::buffer::{BufferPool, PoolGuards, PoolRole}; - use crate::catalog::storage::tests::mark_catalog_ddl; + use crate::catalog::storage::tests::begin_catalog_test_trx; use crate::catalog::tests::{open_catalog_test_engine, table1}; - use crate::error::DiscloseResultExt; use crate::log::redo::DDLRedo; use crate::session::tests::SessionTestExt; use tempfile::TempDir; - // RFC-0029 Phase 2 runner coverage: private catalog row composition and - // same-statement delete assertions require the legacy statement facade. #[test] fn test_tables_delete_by_id() { smol::block_on(async { let temp_dir = TempDir::new().unwrap(); let main_dir = temp_dir.path().to_path_buf(); let engine = open_catalog_test_engine(main_dir, None).await; - let mut session = engine.new_session().unwrap(); + let session = engine.new_session().unwrap(); let table100 = TableObject { table_id: TableID::new(100), @@ -176,63 +192,51 @@ mod tests { table_id: TableID::new(101), next_index_no: 0, }; - let mut trx = session.begin_trx().unwrap(); - trx.exec(async |stmt| { + let mut trx = begin_catalog_test_trx(&session); + engine + .inner() + .core + .catalog() + .storage + .tables() + .insert(trx.trx(), &table100) + .await + .unwrap(); + engine + .inner() + .core + .catalog() + .storage + .tables() + .insert(trx.trx(), &table101) + .await + .unwrap(); + trx.commit(DDLRedo::CreateTable(table100.table_id)).await; + + let mut trx = begin_catalog_test_trx(&session); + assert!( engine .inner() .core .catalog() .storage .tables() - .insert(stmt, &table100) + .delete_by_id(trx.trx(), table100.table_id) .await - .disclose()?; - engine + .unwrap() + ); + assert!( + !engine .inner() .core .catalog() .storage .tables() - .insert(stmt, &table101) + .delete_by_id(trx.trx(), TableID::new(999)) .await - .disclose()?; - Ok(()) - }) - .await - .unwrap(); - mark_catalog_ddl(&mut trx, DDLRedo::CreateTable(table100.table_id)); - trx.commit().await.unwrap(); - - let mut trx = session.begin_trx().unwrap(); - trx.exec(async |stmt| { - assert!( - engine - .inner() - .core - .catalog() - .storage - .tables() - .delete_by_id(stmt, table100.table_id) - .await - .disclose()? - ); - assert!( - !engine - .inner() - .core - .catalog() - .storage - .tables() - .delete_by_id(stmt, TableID::new(999)) - .await - .disclose()? - ); - Ok(()) - }) - .await - .unwrap(); - mark_catalog_ddl(&mut trx, DDLRedo::DropTable(table100.table_id)); - trx.commit().await.unwrap(); + .unwrap() + ); + trx.commit(DDLRedo::DropTable(table100.table_id)).await; assert!( engine diff --git a/doradb-storage/src/catalog/table.rs b/doradb-storage/src/catalog/table.rs index 8d12f658..1d37f7f6 100644 --- a/doradb-storage/src/catalog/table.rs +++ b/doradb-storage/src/catalog/table.rs @@ -1951,7 +1951,7 @@ fn validate_primary_key_contract( pub(crate) mod tests { use super::*; use crate::catalog::storage::tables::TABLE_ID_TABLES; - use crate::catalog::storage::tests::mark_catalog_ddl; + use crate::catalog::storage::tests::begin_catalog_test_trx; use crate::catalog::tests::{ assert_dropped_table_floor, assert_dropped_table_runtime, assert_no_dropped_table_operational_state, wait_for_dropped_table_floor, @@ -1963,8 +1963,8 @@ pub(crate) mod tests { }; use crate::engine::Engine; use crate::error::{ - DiscloseError, DiscloseResultExt, Error, ErrorKind, FatalError, IoError, LifecycleError, - OperationError, RuntimeError, + DiscloseError, Error, ErrorKind, FatalError, IoError, LifecycleError, OperationError, + RuntimeError, }; use crate::id::{SessionID, TrxID}; use crate::io::install_storage_backend_test_hook; @@ -1978,7 +1978,6 @@ pub(crate) mod tests { use crate::table::tests::*; use crate::trx::MAX_SNAPSHOT_TS; use crate::trx::purge::PurgeTestEvent; - use crate::trx::stmt::tests as stmt_tests; use crate::trx::tests as trx_tests; use crate::value::{Val, ValKind}; use std::path::Path; @@ -2735,29 +2734,23 @@ pub(crate) mod tests { let mut trx = session.begin_trx().unwrap(); let trx_owner = trx_tests::lock_owner(&trx).unwrap(); - // RFC-0029 Phase 2 runner coverage: statement and transaction - // logical-lock ownership identity is inspected through the facade. - trx.exec(async |stmt| { - assert_eq!(stmt_tests::transaction_lock_owner(stmt), trx_owner); - let key = single_key(0i32); - let selected = stmt - .table_lookup_unique_mvcc(table_id, key.index_no, &key.vals, &[0, 1]) - .await?; - assert!(selected.is_found()); - let repeated = stmt - .table_lookup_unique_mvcc(table_id, key.index_no, &key.vals, &[0, 1]) - .await?; - assert!(repeated.is_found()); - assert_eq!(lock_entry_count(&engine, trx_owner), 1); - assert!(!has_lock_resource( - &engine, - trx_owner, - LockResource::TableData(table_id), - )); - Ok(()) - }) - .await - .unwrap(); + let key = single_key(0i32); + let selected = trx + .table_lookup_unique_mvcc(table_id, key.index_no, &key.vals, &[0, 1]) + .await + .unwrap(); + assert!(selected.is_found()); + let repeated = trx + .table_lookup_unique_mvcc(table_id, key.index_no, &key.vals, &[0, 1]) + .await + .unwrap(); + assert!(repeated.is_found()); + assert_eq!(lock_entry_count(&engine, trx_owner), 1); + assert!(!has_lock_resource( + &engine, + trx_owner, + LockResource::TableData(table_id), + )); assert_eq!(lock_entry_count(&engine, trx_owner), 1); assert!(has_lock_entry( @@ -3228,12 +3221,9 @@ pub(crate) mod tests { .send_async(trx_tests::lock_owner(&writer_trx).unwrap()) .await .unwrap(); - trx_insert_row_by_id( - &mut writer_trx, - table_id, - vec![Val::from(31_001i32), Val::from("blocked")], - ) - .await?; + writer_trx + .table_insert_mvcc(table_id, vec![Val::from(31_001i32), Val::from("blocked")]) + .await?; writer_trx.commit().await?; Ok::<(), Error>(()) }); @@ -3318,13 +3308,13 @@ pub(crate) mod tests { read_trx.commit().await.unwrap(); let mut write_trx = session.begin_trx().unwrap(); - let err = trx_insert_row_by_id( - &mut write_trx, - table_id, - vec![Val::from(31_101i32), Val::from("same-session-s")], - ) - .await - .unwrap_err(); + let err = write_trx + .table_insert_mvcc( + table_id, + vec![Val::from(31_101i32), Val::from("same-session-s")], + ) + .await + .unwrap_err(); assert_eq!( err.report().downcast_ref::().copied(), Some(OperationError::LockFamilyConflict) @@ -3351,8 +3341,7 @@ pub(crate) mod tests { let mut session = engine.new_session().unwrap(); let mut trx = session.begin_trx().unwrap(); - trx_insert_row_by_id( - &mut trx, + trx.table_insert_mvcc( table_id, vec![Val::from(31_301i32), Val::from("same-session-ix")], ) @@ -3541,12 +3530,9 @@ pub(crate) mod tests { .send_async(trx_tests::lock_owner(&writer_trx).unwrap()) .await .unwrap(); - trx_insert_row_by_id( - &mut writer_trx, - table_id, - vec![Val::from(31_201i32), Val::from("external")], - ) - .await?; + writer_trx + .table_insert_mvcc(table_id, vec![Val::from(31_201i32), Val::from("external")]) + .await?; writer_trx.commit().await?; Ok::<(), Error>(()) }); @@ -3562,13 +3548,10 @@ pub(crate) mod tests { let mut same_session_trx = session.begin_trx().unwrap(); let same_session_owner = trx_tests::lock_owner(&same_session_trx).unwrap(); - trx_insert_row_by_id( - &mut same_session_trx, - table_id, - vec![Val::from(31_202i32), Val::from("covered")], - ) - .await - .unwrap(); + same_session_trx + .table_insert_mvcc(table_id, vec![Val::from(31_202i32), Val::from("covered")]) + .await + .unwrap(); assert!(has_lock_entry( &engine, same_session_owner, @@ -3692,29 +3675,19 @@ pub(crate) mod tests { let temp_dir = TempDir::new().unwrap(); let engine = lightweight_test_engine(&temp_dir, "redo_testsys_lightweight").await; let table_id = create_table2_for_test(&engine).await; - let mut corrupt_session = engine.new_session().unwrap(); - let mut corrupt_trx = corrupt_session.begin_trx().unwrap(); - - // RFC-0029 Phase 2 runner coverage: raw private catalog mutation - // intentionally injects metadata corruption in one statement. - corrupt_trx - .exec(async |stmt| { - let deleted = engine - .inner() - .core - .catalog() - .storage - .tables() - .delete_by_id(stmt, table_id) - .await - .disclose()?; - assert!(deleted); - Ok(()) - }) + let corrupt_session = engine.new_session().unwrap(); + let mut corrupt_trx = begin_catalog_test_trx(&corrupt_session); + let deleted = engine + .inner() + .core + .catalog() + .storage + .tables() + .delete_by_id(corrupt_trx.trx(), table_id) .await .unwrap(); - mark_catalog_ddl(&mut corrupt_trx, DDLRedo::DropTable(table_id)); - corrupt_trx.commit().await.unwrap(); + assert!(deleted); + corrupt_trx.commit(DDLRedo::DropTable(table_id)).await; let mut drop_session = engine.new_session().unwrap(); let table = table_for_internal_assertion(&engine, table_id); @@ -4648,13 +4621,10 @@ pub(crate) mod tests { assert_eq!(stale_read.commit().await.unwrap(), TrxID::new(0)); let mut stale_write = session.begin_trx().unwrap(); - let err = trx_insert_row_by_id( - &mut stale_write, - table_id, - vec![Val::from(2), Val::from("blocked")], - ) - .await - .unwrap_err(); + let err = stale_write + .table_insert_mvcc(table_id, vec![Val::from(2), Val::from("blocked")]) + .await + .unwrap_err(); assert_eq!( err.report().downcast_ref::().copied(), Some(OperationError::TableNotFound) @@ -4868,31 +4838,10 @@ pub(crate) mod tests { let table_id = create_table2_for_test(&engine).await; let mut reader_session = engine.new_session().unwrap(); let mut reader_trx = reader_session.begin_trx().unwrap(); - let (held_tx, held_rx) = flume::bounded(1); - let (release_tx, release_rx) = flume::bounded(1); - // RFC-0029 Phase 2 runner coverage: checked-out callback - // cancellation while holding a raw metadata claim. - let mut reader_fut = Box::pin(reader_trx.exec(async |stmt| { - stmt_tests::acquire_transaction_lock( - stmt, - LockResource::TableMetadata(table_id), - LockMode::Shared, - ) - .await?; - held_tx.send_async(()).await.unwrap(); - release_rx.recv_async().await.unwrap(); - Ok(()) - })); - - loop { - if held_rx.try_recv().is_ok() { - break; - } - assert!(matches!( - futures::poll!(reader_fut.as_mut()), - std::task::Poll::Pending - )); - } + reader_trx + .table_scan_mvcc(table_id, &[0], |_| true) + .await + .unwrap(); let mut drop_session = engine.new_session().unwrap(); let mut drop_fut = Box::pin(drop_session.drop_table(table_id)); @@ -4901,12 +4850,6 @@ pub(crate) mod tests { std::task::Poll::Pending )); - release_tx.send_async(()).await.unwrap(); - reader_fut.await.unwrap(); - assert!(matches!( - futures::poll!(drop_fut.as_mut()), - std::task::Poll::Pending - )); assert_eq!(reader_trx.commit().await.unwrap(), TrxID::new(0)); drop_fut.await.unwrap(); }); @@ -4920,13 +4863,10 @@ pub(crate) mod tests { let table_id = create_table2_for_test(&engine).await; let mut writer_session = engine.new_session().unwrap(); let mut writer_trx = writer_session.begin_trx().unwrap(); - trx_insert_row_by_id( - &mut writer_trx, - table_id, - vec![Val::from(91), Val::from("writer")], - ) - .await - .unwrap(); + writer_trx + .table_insert_mvcc(table_id, vec![Val::from(91), Val::from("writer")]) + .await + .unwrap(); let mut drop_session = engine.new_session().unwrap(); let mut drop_fut = Box::pin(drop_session.drop_table(table_id)); @@ -4985,12 +4925,12 @@ pub(crate) mod tests { let (table_spec, index_specs) = drop_table_test_spec(); let table_id = session.create_table(table_spec, index_specs).await.unwrap(); let mut trx = session.begin_trx().unwrap(); - let insert = trx_insert_row_by_id( - &mut trx, - table_id, - vec![Val::from(7), Val::from("checkpoint-covered")], - ) - .await; + let insert = trx + .table_insert_mvcc( + table_id, + vec![Val::from(7), Val::from("checkpoint-covered")], + ) + .await; let Ok(_) = insert else { panic!("insert should succeed: {insert:?}"); }; diff --git a/doradb-storage/src/engine.rs b/doradb-storage/src/engine.rs index 0bac7338..61f6cb1a 100644 --- a/doradb-storage/src/engine.rs +++ b/doradb-storage/src/engine.rs @@ -818,9 +818,8 @@ mod tests { session_registry_len, }; use crate::thread::{SpawnTestEvent, fail_spawn_named_with_observer, observe_spawn_named}; - use crate::trx::tests::add_pseudo_redo_log_entry; + use crate::trx::tests::{add_pseudo_redo_log_entry, pending_statement}; use std::fs; - use std::future::pending; use std::io::Error as StdIoError; use std::os::unix::fs::symlink; use std::panic::{self, AssertUnwindSafe}; @@ -2057,12 +2056,7 @@ mod tests { smol::block_on(Engine::bootstrap(test_engine_config_for(root.path()))).unwrap(); let mut session = engine.new_session().unwrap(); let mut trx = session.begin_trx().unwrap(); - // RFC-0029 Phase 2 runner coverage: engine shutdown waits for a - // deterministically cancelled checked-out legacy callback. - let mut exec = Box::pin(trx.exec(async |_| { - pending::<()>().await; - Ok::<(), Error>(()) - })); + let mut exec = Box::pin(pending_statement(&mut trx)); smol::block_on(async { assert!(matches!( futures::poll!(exec.as_mut()), diff --git a/doradb-storage/src/lib.rs b/doradb-storage/src/lib.rs index c5289628..464641c4 100644 --- a/doradb-storage/src/lib.rs +++ b/doradb-storage/src/lib.rs @@ -66,5 +66,5 @@ pub use table::{ FrozenPageBatchInfo, LazyRow, MemIndexCleanupDelay, MemIndexCleanupOutcome, MemIndexCleanupStats, SecondaryMemIndexCleanupIndexStats, }; -pub use trx::{IndexScanMvccStream, Statement, StreamStmt, Transaction}; +pub use trx::{IndexScanMvccStream, Transaction}; pub use value::{MemVar, Val, ValKind, ValType}; diff --git a/doradb-storage/src/recovery/mod.rs b/doradb-storage/src/recovery/mod.rs index e6440bc8..a1b3d889 100644 --- a/doradb-storage/src/recovery/mod.rs +++ b/doradb-storage/src/recovery/mod.rs @@ -1254,7 +1254,7 @@ mod tests { validate_create_table_reloaded_root_ts, }; use crate::catalog::storage::publish_first_redo_log_seq_for_test; - use crate::catalog::storage::tests::mark_catalog_ddl; + use crate::catalog::storage::tests::begin_catalog_test_trx; use crate::catalog::{ ActiveIndexSpec, ColumnAttributes, ColumnSpec, IndexAttributes, IndexColumnObject, IndexKey, IndexObject, IndexOrder, IndexSpec, TableMetadata, TableObject, TableSpec, @@ -1263,10 +1263,7 @@ mod tests { use crate::component::EnginePools; use crate::conf::{EngineConfig, EvictableBufferPoolConfig, FileSystemConfig, TrxSysConfig}; use crate::engine::Engine; - use crate::error::{ - CompletionErrorBridge, DataIntegrityError, DiscloseResultExt, Error, ErrorKind, Result, - RuntimeError, - }; + use crate::error::{CompletionErrorBridge, DataIntegrityError, Error, ErrorKind, RuntimeError}; use crate::file::block_integrity::{BLOCK_INTEGRITY_HEADER_SIZE, write_block_checksum}; use crate::file::cow_file::{COW_FILE_PAGE_SIZE, SUPER_BLOCK_ID}; use crate::file::table_file::MutableTableFile; @@ -1285,9 +1282,12 @@ mod tests { use crate::row::ops::{DeleteMvcc, RowMutation, SelectKey, SelectMvcc, UpdateCol, UpdateMvcc}; use crate::serde::Ser; use crate::session::tests::{SessionTestExt, assert_checkpoint_published}; - use crate::table::tests::assert_freeze_created; - use crate::table::{DeleteMarker, Table, TableRedoReplayFloor}; - use crate::trx::{MIN_SNAPSHOT_TS, Transaction}; + use crate::table::tests::{ + assert_freeze_created, trx_delete_row_by_id, trx_select_row_mvcc_by_id, + trx_update_row_by_id, + }; + use crate::table::{DeleteMarker, TableRedoReplayFloor}; + use crate::trx::MIN_SNAPSHOT_TS; use crate::value::Val; use crate::value::ValKind; use error_stack::Report; @@ -1504,7 +1504,7 @@ mod tests { let mut session = engine.new_session().unwrap(); for value in 0..32 { let mut trx = session.begin_trx().unwrap(); - trx_insert_row_by_id(&mut trx, table_id, vec![Val::from(value), Val::from(value)]) + trx.table_insert_mvcc(table_id, vec![Val::from(value), Val::from(value)]) .await .unwrap(); trx.commit().await.unwrap(); @@ -1527,13 +1527,10 @@ mod tests { assert_checkpoint_published(&mut session, table.table_id()).await; drop(table); let mut durability_trx = session.begin_trx().unwrap(); - trx_insert_row_by_id( - &mut durability_trx, - table_id, - vec![Val::from(10_001), Val::from(10_001)], - ) - .await - .unwrap(); + durability_trx + .table_insert_mvcc(table_id, vec![Val::from(10_001), Val::from(10_001)]) + .await + .unwrap(); durability_trx.commit().await.unwrap(); drop(session); engine @@ -1738,55 +1735,6 @@ mod tests { ) } - async fn trx_insert_row(trx: &mut Transaction, table: &Table, cols: Vec) -> Result { - trx_insert_row_by_id(trx, table.table_id(), cols).await - } - - async fn trx_insert_row_by_id( - trx: &mut Transaction, - table_id: TableID, - cols: Vec, - ) -> Result { - trx.table_insert_mvcc(table_id, cols).await - } - - async fn trx_delete_row( - trx: &mut Transaction, - table: &Table, - key: &SelectKey, - ) -> Result { - trx_delete_row_by_id(trx, table.table_id(), key).await - } - - async fn trx_delete_row_by_id( - trx: &mut Transaction, - table_id: TableID, - key: &SelectKey, - ) -> Result { - trx.table_delete_unique_mvcc(table_id, key.index_no, &key.vals) - .await - } - - async fn trx_update_row_by_id( - trx: &mut Transaction, - table_id: TableID, - key: &SelectKey, - update: Vec, - ) -> Result { - trx.table_update_unique_mvcc(table_id, key.index_no, &key.vals, update) - .await - } - - async fn trx_select_row_mvcc( - trx: &mut Transaction, - table: &Table, - key: &SelectKey, - user_read_set: &[usize], - ) -> Result { - trx.table_lookup_unique_mvcc(table.table_id(), key.index_no, &key.vals, user_read_set) - .await - } - fn index_ddl_columns() -> Vec { vec![ ColumnSpec::new("id", ValKind::I32, ColumnAttributes::empty()), @@ -1838,128 +1786,101 @@ mod tests { } async fn commit_create_index_catalog_ddl(engine: &Engine, table_id: TableID) -> TrxID { - let mut session = engine.new_session().unwrap(); - let mut trx = session.begin_trx().unwrap(); - // RFC-0029 Phase 2 runner coverage: private catalog DDL composes - // multiple catalog mutations in one statement. - trx.exec(async |stmt| { - assert!( - engine - .inner() - .core - .catalog() - .storage - .tables() - .delete_by_id(stmt, table_id) - .await - .disclose()? - ); + let session = engine.new_session().unwrap(); + let mut trx = begin_catalog_test_trx(&session); + assert!( engine .inner() .core .catalog() .storage .tables() - .insert( - stmt, + .replace( + trx.trx(), &TableObject { table_id, next_index_no: 2, }, ) .await - .disclose()?; + .unwrap() + ); + engine + .inner() + .core + .catalog() + .storage + .indexes() + .insert( + trx.trx(), + &IndexObject { + table_id, + index_no: 1, + index_attributes: IndexAttributes::empty(), + }, + ) + .await + .unwrap(); + engine + .inner() + .core + .catalog() + .storage + .index_columns() + .insert( + trx.trx(), + &IndexColumnObject { + table_id, + index_no: 1, + index_column_no: 0, + column_no: 1, + index_order: IndexOrder::Asc, + }, + ) + .await + .unwrap(); + let cts = trx + .commit(DDLRedo::CreateIndex { + table_id, + index_no: 1, + }) + .await; + drop(session); + cts + } + + async fn commit_drop_index_catalog_ddl(engine: &Engine, table_id: TableID) -> TrxID { + let session = engine.new_session().unwrap(); + let mut trx = begin_catalog_test_trx(&session); + assert_eq!( engine .inner() .core .catalog() .storage - .indexes() - .insert( - stmt, - &IndexObject { - table_id, - index_no: 1, - index_attributes: IndexAttributes::empty(), - }, - ) + .index_columns() + .delete_by_index(trx.trx(), table_id, 1) .await - .disclose()?; + .unwrap(), + 1 + ); + assert!( engine .inner() .core .catalog() .storage - .index_columns() - .insert( - stmt, - &IndexColumnObject { - table_id, - index_no: 1, - index_column_no: 0, - column_no: 1, - index_order: IndexOrder::Asc, - }, - ) + .indexes() + .delete_by_id(trx.trx(), table_id, 1) .await - .disclose()?; - Ok(()) - }) - .await - .unwrap(); - mark_catalog_ddl( - &mut trx, - DDLRedo::CreateIndex { - table_id, - index_no: 1, - }, + .unwrap() ); - let cts = trx.commit().await.unwrap(); - drop(session); - cts - } - - async fn commit_drop_index_catalog_ddl(engine: &Engine, table_id: TableID) -> TrxID { - let mut session = engine.new_session().unwrap(); - let mut trx = session.begin_trx().unwrap(); - // RFC-0029 Phase 2 runner coverage: private catalog DDL composes - // multiple catalog mutations in one statement. - trx.exec(async |stmt| { - assert_eq!( - engine - .inner() - .core - .catalog() - .storage - .index_columns() - .delete_by_index(stmt, table_id, 1) - .await - .disclose()?, - 1 - ); - assert!( - engine - .inner() - .core - .catalog() - .storage - .indexes() - .delete_by_id(stmt, table_id, 1) - .await - .disclose()? - ); - Ok(()) - }) - .await - .unwrap(); - mark_catalog_ddl( - &mut trx, - DDLRedo::DropIndex { + let cts = trx + .commit(DDLRedo::DropIndex { table_id, index_no: 1, - }, - ); - let cts = trx.commit().await.unwrap(); + }) + .await; drop(session); cts } @@ -2711,13 +2632,9 @@ mod tests { let mut session = engine.new_session().unwrap(); let mut trx = session.begin_trx().unwrap(); - trx_insert_row_by_id( - &mut trx, - table_id, - vec![Val::from(10_000), Val::from(10_000)], - ) - .await - .unwrap(); + trx.table_insert_mvcc(table_id, vec![Val::from(10_000), Val::from(10_000)]) + .await + .unwrap(); trx.commit().await.unwrap(); drop(session); @@ -2890,12 +2807,9 @@ mod tests { for i in (0..DML_SIZE).step_by(INS_STEP) { let mut trx = session.begin_trx().unwrap(); for j in i..i + INS_STEP { - let res = trx_insert_row_by_id( - &mut trx, - table_id, - vec![Val::from(j as u32), Val::from(&s[..])], - ) - .await; + let res = trx + .table_insert_mvcc(table_id, vec![Val::from(j as u32), Val::from(&s[..])]) + .await; assert!(res.is_ok()); } trx.commit().await.unwrap(); @@ -3077,13 +2991,10 @@ mod tests { assert!(watermark_floor.deletion_cutoff_ts > root_floor.deletion_cutoff_ts); if checkpoint_catalog { let mut durability_trx = session.begin_trx().unwrap(); - trx_insert_row_by_id( - &mut durability_trx, - table_id, - vec![Val::from(1i32), Val::from(1i32)], - ) - .await - .unwrap(); + durability_trx + .table_insert_mvcc(table_id, vec![Val::from(1i32), Val::from(1i32)]) + .await + .unwrap(); durability_trx.commit().await.unwrap(); session.checkpoint_catalog().await.unwrap(); assert_eq!( @@ -3242,12 +3153,12 @@ mod tests { .await .unwrap(); let mut trx = session.begin_trx().unwrap(); - let insert = trx_insert_row( - &mut trx, - &table, - vec![Val::from(7u32), Val::from("cold-row")], - ) - .await; + let insert = trx + .table_insert_mvcc( + table.table_id(), + vec![Val::from(7u32), Val::from("cold-row")], + ) + .await; let cold_row_id = match insert { Ok(row_id) => row_id, other => panic!("expected cold insert success, got {other:?}"), @@ -3318,7 +3229,7 @@ mod tests { ); } let mut trx = session.begin_trx().unwrap(); - let row = trx_select_row_mvcc(&mut trx, &table, &key, &[0, 1]).await; + let row = trx_select_row_mvcc_by_id(&mut trx, table.table_id(), &key, &[0, 1]).await; assert_eq!( row.unwrap().unwrap_found(), vec![Val::from(7u32), Val::from("cold-row")] @@ -3377,12 +3288,12 @@ mod tests { .await .unwrap(); let mut trx = session.begin_trx().unwrap(); - let insert = trx_insert_row( - &mut trx, - &table, - vec![Val::from(7u32), Val::from("cold-row")], - ) - .await; + let insert = trx + .table_insert_mvcc( + table.table_id(), + vec![Val::from(7u32), Val::from("cold-row")], + ) + .await; let Ok(cold_row_id) = insert else { panic!("cold insert should succeed"); }; @@ -3400,17 +3311,17 @@ mod tests { let key = SelectKey::new(0, vec![Val::from(7u32)]); let mut trx = session.begin_trx().unwrap(); - let delete = trx_delete_row(&mut trx, &table, &key).await; + let delete = trx_delete_row_by_id(&mut trx, table.table_id(), &key).await; assert!(matches!(delete, Ok(DeleteMvcc::Deleted))); trx.commit().await.unwrap(); let mut trx = session.begin_trx().unwrap(); - let insert = trx_insert_row( - &mut trx, - &table, - vec![Val::from(7u32), Val::from("hot-row")], - ) - .await; + let insert = trx + .table_insert_mvcc( + table.table_id(), + vec![Val::from(7u32), Val::from("hot-row")], + ) + .await; let Ok(hot_row_id) = insert else { panic!("hot insert should reclaim deleted cold key"); }; @@ -3470,7 +3381,7 @@ mod tests { } let mut trx = session.begin_trx().unwrap(); - let row = trx_select_row_mvcc(&mut trx, &table, &key, &[0, 1]).await; + let row = trx_select_row_mvcc_by_id(&mut trx, table.table_id(), &key, &[0, 1]).await; assert_eq!( row.unwrap().unwrap_found(), vec![Val::from(7u32), Val::from("hot-row")] @@ -3528,12 +3439,12 @@ mod tests { let mut same_row_ids = Vec::new(); let mut trx = session.begin_trx().unwrap(); for id in [1u32, 2, 3] { - let insert = trx_insert_row( - &mut trx, - &table, - vec![Val::from(id), Val::from("same-name")], - ) - .await; + let insert = trx + .table_insert_mvcc( + table.table_id(), + vec![Val::from(id), Val::from("same-name")], + ) + .await; let Ok(row_id) = insert else { panic!("same-name insert should succeed"); }; @@ -3557,7 +3468,7 @@ mod tests { let delete_key = SelectKey::new(0, vec![Val::from(2u32)]); let mut trx = session.begin_trx().unwrap(); - let delete = trx_delete_row(&mut trx, &table, &delete_key).await; + let delete = trx_delete_row_by_id(&mut trx, table.table_id(), &delete_key).await; assert!(matches!(delete, Ok(DeleteMvcc::Deleted))); trx.commit().await.unwrap(); @@ -3627,7 +3538,8 @@ mod tests { vec![Val::from(3u32), Val::from("same-name")], ] ); - let deleted = trx_select_row_mvcc(&mut trx, &table, &delete_key, &[0, 1]).await; + let deleted = + trx_select_row_mvcc_by_id(&mut trx, table.table_id(), &delete_key, &[0, 1]).await; assert!(matches!(deleted, Ok(SelectMvcc::NotFound))); trx.commit().await.unwrap(); @@ -3672,7 +3584,7 @@ mod tests { let mut trx = session.begin_trx().unwrap(); for id in 0u32..3 { - trx_insert_row_by_id(&mut trx, table_id, vec![Val::from(id), Val::from("cold")]) + trx.table_insert_mvcc(table_id, vec![Val::from(id), Val::from("cold")]) .await .unwrap(); } @@ -3682,7 +3594,7 @@ mod tests { let mut trx = session.begin_trx().unwrap(); for id in [10u32, 11] { - trx_insert_row_by_id(&mut trx, table_id, vec![Val::from(id), Val::from("hot")]) + trx.table_insert_mvcc(table_id, vec![Val::from(id), Val::from("hot")]) .await .unwrap(); } @@ -3813,12 +3725,12 @@ mod tests { .unwrap(); let mut trx = session.begin_trx().unwrap(); - let insert = trx_insert_row( - &mut trx, - &table, - vec![Val::from(7u32), Val::from("cold-row")], - ) - .await; + let insert = trx + .table_insert_mvcc( + table.table_id(), + vec![Val::from(7u32), Val::from("cold-row")], + ) + .await; assert!(insert.is_ok()); trx.commit().await.unwrap(); @@ -3834,12 +3746,12 @@ mod tests { assert!(root_after_checkpoint.heap_redo_start_ts > catalog_replay_start_ts); let mut trx = session.begin_trx().unwrap(); - let insert = trx_insert_row( - &mut trx, - &table, - vec![Val::from(8u32), Val::from("hot-row")], - ) - .await; + let insert = trx + .table_insert_mvcc( + table.table_id(), + vec![Val::from(8u32), Val::from("hot-row")], + ) + .await; assert!(insert.is_ok()); trx.commit().await.unwrap(); @@ -3874,14 +3786,16 @@ mod tests { let mut trx = session.begin_trx().unwrap(); let cold_key = SelectKey::new(0, vec![Val::from(7u32)]); - let cold_row = trx_select_row_mvcc(&mut trx, &table, &cold_key, &[0, 1]).await; + let cold_row = + trx_select_row_mvcc_by_id(&mut trx, table.table_id(), &cold_key, &[0, 1]).await; assert_eq!( cold_row.unwrap().unwrap_found(), vec![Val::from(7u32), Val::from("cold-row")] ); let hot_key = SelectKey::new(0, vec![Val::from(8u32)]); - let hot_row = trx_select_row_mvcc(&mut trx, &table, &hot_key, &[0, 1]).await; + let hot_row = + trx_select_row_mvcc_by_id(&mut trx, table.table_id(), &hot_key, &[0, 1]).await; assert_eq!( hot_row.unwrap().unwrap_found(), vec![Val::from(8u32), Val::from("hot-row")] @@ -3938,13 +3852,13 @@ mod tests { let mut row_ids = Vec::with_capacity(200); for id in 0..200u32 { row_ids.push( - trx_insert_row( - &mut insert_trx, - &table, - vec![Val::from(id), Val::from(payload.as_str())], - ) - .await - .unwrap(), + insert_trx + .table_insert_mvcc( + table.table_id(), + vec![Val::from(id), Val::from(payload.as_str())], + ) + .await + .unwrap(), ); } let insert_cts = insert_trx.commit().await.unwrap(); @@ -3958,9 +3872,9 @@ mod tests { let cold_row_id = row_ids[1]; let hot_row_id = *row_ids.last().unwrap(); let mut delete_trx = setup_session.begin_trx().unwrap(); - let delete = trx_delete_row( + let delete = trx_delete_row_by_id( &mut delete_trx, - &table, + table.table_id(), &SelectKey::new(0, vec![Val::from(0u32)]), ) .await; @@ -4014,17 +3928,17 @@ mod tests { drop(guards); let mut trx = session.begin_trx().unwrap(); - let deleted_row = trx_select_row_mvcc( + let deleted_row = trx_select_row_mvcc_by_id( &mut trx, - &table, + table.table_id(), &SelectKey::new(0, vec![Val::from(0u32)]), &[0, 1], ) .await; assert!(matches!(deleted_row, Ok(SelectMvcc::NotFound))); - let cold_row = trx_select_row_mvcc( + let cold_row = trx_select_row_mvcc_by_id( &mut trx, - &table, + table.table_id(), &SelectKey::new(0, vec![Val::from(1u32)]), &[0, 1], ) @@ -4033,9 +3947,9 @@ mod tests { cold_row.unwrap().unwrap_found(), vec![Val::from(1u32), Val::from(payload.as_str())] ); - let hot_row = trx_select_row_mvcc( + let hot_row = trx_select_row_mvcc_by_id( &mut trx, - &table, + table.table_id(), &SelectKey::new(0, vec![Val::from(199u32)]), &[0, 1], ) @@ -4099,7 +4013,9 @@ mod tests { .unwrap(); let mut trx = session.begin_trx().unwrap(); for i in 0..10u32 { - let insert = trx_insert_row(&mut trx, &table, vec![Val::from(i)]).await; + let insert = trx + .table_insert_mvcc(table.table_id(), vec![Val::from(i)]) + .await; assert!(insert.is_ok()); } trx.commit().await.unwrap(); @@ -4115,7 +4031,7 @@ mod tests { let mut trx = session.begin_trx().unwrap(); let key0 = SelectKey::new(0, vec![Val::from(0u32)]); - let delete = trx_delete_row(&mut trx, &table, &key0).await; + let delete = trx_delete_row_by_id(&mut trx, table.table_id(), &key0).await; assert!(matches!(delete, Ok(DeleteMvcc::Deleted))); trx.commit().await.unwrap(); @@ -4130,7 +4046,7 @@ mod tests { let mut trx = session.begin_trx().unwrap(); let key1 = SelectKey::new(0, vec![Val::from(1u32)]); - let delete = trx_delete_row(&mut trx, &table, &key1).await; + let delete = trx_delete_row_by_id(&mut trx, table.table_id(), &key1).await; assert!(matches!(delete, Ok(DeleteMvcc::Deleted))); trx.commit().await.unwrap(); let marker1_ts = match table.deletion_buffer().get(RowID::new(1)).unwrap() { @@ -4140,7 +4056,9 @@ mod tests { assert!(marker1_ts >= checkpointed_cutoff); let mut trx = session.begin_trx().unwrap(); - let insert = trx_insert_row(&mut trx, &table, vec![Val::from(100u32)]).await; + let insert = trx + .table_insert_mvcc(table.table_id(), vec![Val::from(100u32)]) + .await; assert!(insert.is_ok()); trx.commit().await.unwrap(); @@ -4182,27 +4100,27 @@ mod tests { let mut session = engine.new_session().unwrap(); let mut trx = session.begin_trx().unwrap(); - let row0 = trx_select_row_mvcc( + let row0 = trx_select_row_mvcc_by_id( &mut trx, - &table, + table.table_id(), &SelectKey::new(0, vec![Val::from(0u32)]), &[0], ) .await; assert!(matches!(row0, Ok(SelectMvcc::NotFound))); - let row1 = trx_select_row_mvcc( + let row1 = trx_select_row_mvcc_by_id( &mut trx, - &table, + table.table_id(), &SelectKey::new(0, vec![Val::from(1u32)]), &[0], ) .await; assert!(matches!(row1, Ok(SelectMvcc::NotFound))); - let row100 = trx_select_row_mvcc( + let row100 = trx_select_row_mvcc_by_id( &mut trx, - &table, + table.table_id(), &SelectKey::new(0, vec![Val::from(100u32)]), &[0], ) @@ -4287,12 +4205,12 @@ mod tests { .unwrap(); let mut trx = session.begin_trx().unwrap(); - let insert = trx_insert_row( - &mut trx, - &checkpointed_table, - vec![Val::from(7u32), Val::from("persisted-row")], - ) - .await; + let insert = trx + .table_insert_mvcc( + checkpointed_table.table_id(), + vec![Val::from(7u32), Val::from("persisted-row")], + ) + .await; assert!(insert.is_ok()); trx.commit().await.unwrap(); @@ -4307,12 +4225,12 @@ mod tests { .await; let mut trx = session.begin_trx().unwrap(); - let insert = trx_insert_row( - &mut trx, - &replay_only_table, - vec![Val::from(8u32), Val::from("replayed-row")], - ) - .await; + let insert = trx + .table_insert_mvcc( + replay_only_table.table_id(), + vec![Val::from(8u32), Val::from("replayed-row")], + ) + .await; assert!(insert.is_ok()); trx.commit().await.unwrap(); @@ -4406,17 +4324,26 @@ mod tests { let mut trx = session.begin_trx().unwrap(); let checkpointed_key = SelectKey::new(0, vec![Val::from(7u32)]); - let checkpointed_row = - trx_select_row_mvcc(&mut trx, &checkpointed_table, &checkpointed_key, &[0, 1]) - .await; + let checkpointed_row = trx_select_row_mvcc_by_id( + &mut trx, + checkpointed_table.table_id(), + &checkpointed_key, + &[0, 1], + ) + .await; assert_eq!( checkpointed_row.unwrap().unwrap_found(), vec![Val::from(7u32), Val::from("persisted-row")] ); let replay_only_key = SelectKey::new(0, vec![Val::from(8u32)]); - let replay_only_row = - trx_select_row_mvcc(&mut trx, &replay_only_table, &replay_only_key, &[0, 1]).await; + let replay_only_row = trx_select_row_mvcc_by_id( + &mut trx, + replay_only_table.table_id(), + &replay_only_key, + &[0, 1], + ) + .await; assert_eq!( replay_only_row.unwrap().unwrap_found(), vec![Val::from(8u32), Val::from("replayed-row")] @@ -4476,12 +4403,12 @@ mod tests { .await .unwrap(); let mut trx = session.begin_trx().unwrap(); - let insert = trx_insert_row( - &mut trx, - &table, - vec![Val::from(7u32), Val::from("persisted-row")], - ) - .await; + let insert = trx + .table_insert_mvcc( + table.table_id(), + vec![Val::from(7u32), Val::from("persisted-row")], + ) + .await; assert!(insert.is_ok()); trx.commit().await.unwrap(); @@ -4546,7 +4473,7 @@ mod tests { let mut session = engine.new_session().unwrap(); let mut trx = session.begin_trx().unwrap(); let key = SelectKey::new(0, vec![Val::from(7u32)]); - let res = trx_select_row_mvcc(&mut trx, &table, &key, &[0, 1]).await; + let res = trx_select_row_mvcc_by_id(&mut trx, table.table_id(), &key, &[0, 1]).await; let err = match res { Err(err) => err, other => panic!("expected persisted LWC corruption on read, got {other:?}"), @@ -4612,7 +4539,9 @@ mod tests { .unwrap(); let mut trx = session.begin_trx().unwrap(); for i in 0..80u32 { - let insert = trx_insert_row(&mut trx, &table, vec![Val::from(i)]).await; + let insert = trx + .table_insert_mvcc(table.table_id(), vec![Val::from(i)]) + .await; assert!(insert.is_ok()); } trx.commit().await.unwrap(); @@ -4629,7 +4558,7 @@ mod tests { let mut trx = session.begin_trx().unwrap(); for i in 0..64u32 { let key = SelectKey::new(0, vec![Val::from(i)]); - let delete = trx_delete_row(&mut trx, &table, &key).await; + let delete = trx_delete_row_by_id(&mut trx, table.table_id(), &key).await; assert!(matches!(delete, Ok(DeleteMvcc::Deleted))); } trx.commit().await.unwrap(); @@ -4642,7 +4571,9 @@ mod tests { session.wait_for_gc_horizon_after(marker_ts).await.unwrap(); let mut trx = session.begin_trx().unwrap(); - let insert = trx_insert_row(&mut trx, &table, vec![Val::from(1000u32)]).await; + let insert = trx + .table_insert_mvcc(table.table_id(), vec![Val::from(1000u32)]) + .await; assert!(insert.is_ok()); trx.commit().await.unwrap(); diff --git a/doradb-storage/src/session.rs b/doradb-storage/src/session.rs index 90ed877a..e8846167 100644 --- a/doradb-storage/src/session.rs +++ b/doradb-storage/src/session.rs @@ -3404,7 +3404,9 @@ pub(crate) mod tests { use crate::trx::retention::{ RedoTruncationBlocker, tests::install_redo_cleanup_before_unlink_hook, }; - use crate::trx::tests::{private_transaction_inner_ptr, trx_inner}; + use crate::trx::tests::{ + private_noop, private_transaction_inner_ptr, session_operation_entry_inner_ptr, trx_inner, + }; use crate::trx::{MIN_ACTIVE_TRX_ID, MIN_SNAPSHOT_TS, SessionOperationSnapshot, TrxInner}; use crate::value::{Val, ValKind}; use futures::task::noop_waker; @@ -3956,6 +3958,20 @@ pub(crate) mod tests { fn save_active_insert_page(&mut self, table_id: TableID, page_id: VersionedPageID); } + /// Begin one test-owned mandatory private transaction for focused catalog storage tests. + pub(crate) fn begin_test_mandatory_private_trx( + session: &Session, + ) -> (MandatoryOperationGuard, PrivateTransaction) { + let mut operation = session + .pin_operation(SessionOperationKind::Ddl) + .expect("catalog test operation must be admitted") + .into_mandatory(); + let trx = operation + .begin_private_trx() + .expect("catalog test private transaction must begin"); + (operation, trx) + } + impl SessionTestExt for Session { #[inline] fn in_trx(&self) -> Result { @@ -5160,7 +5176,7 @@ pub(crate) mod tests { "private transaction must use a core distinct from the parked public cache" ); assert_eq!( - entry.inner_ptr_for_test(), + session_operation_entry_inner_ptr(&entry), None, "running private transaction must hold its core outside the entry" ); @@ -5176,10 +5192,10 @@ pub(crate) mod tests { *nested_begin_err.current_context(), LifecycleError::ExistingTransaction ); - trx.stage_statement(async |_stmt| Ok(())).await.unwrap(); + private_noop(&mut trx).await.unwrap(); assert_eq!(private_transaction_inner_ptr(&trx), first_inner); - assert_eq!(entry.inner_ptr_for_test(), None); - trx.stage_statement(async |_stmt| Ok(())).await.unwrap(); + assert_eq!(session_operation_entry_inner_ptr(&entry), None); + private_noop(&mut trx).await.unwrap(); assert_eq!(private_transaction_inner_ptr(&trx), first_inner); assert_eq!( entry.inspect().state, @@ -5232,7 +5248,7 @@ pub(crate) mod tests { second_inner, public_cache_ptr, "each private transaction must remain separate from the public cache" ); - assert_eq!(entry.inner_ptr_for_test(), None); + assert_eq!(session_operation_entry_inner_ptr(&entry), None); assert_eq!( state .lifecycle diff --git a/doradb-storage/src/table/access.rs b/doradb-storage/src/table/access.rs index ef448be3..eb5d89bd 100644 --- a/doradb-storage/src/table/access.rs +++ b/doradb-storage/src/table/access.rs @@ -4899,8 +4899,8 @@ mod tests { use crate::conf::{EngineConfig, EvictableBufferPoolConfig, TrxSysConfig}; use crate::engine::Engine; use crate::error::{ - DataIntegrityError, DiscloseError, DiscloseResultExt, Error, ErrorKind, FatalError, - InternalError, IoError, OperationError, Result, RuntimeError, + DataIntegrityError, DiscloseError, Error, ErrorKind, FatalError, IoError, OperationError, + Result, RuntimeError, }; use crate::file::cow_file::SUPER_BLOCK_ID; use crate::id::{PageID, RowID, TableID, TrxID}; @@ -4909,33 +4909,27 @@ mod tests { use crate::latch::LatchFallbackMode; use crate::lock::tests::LockDebugEntryState; use crate::lock::{LockMode, LockResource}; - use crate::log::redo::RowRedoKind; - use crate::poison::PoisonAwareListener; use crate::row::RowPage; use crate::row::ops::{ - DeleteMvcc, RowMutation, RowUpdateInput, ScanMvcc, SelectKey, SelectMvcc, - TableMutationOutcome, UpdateCol, UpdateMvcc, UpsertMvcc, + DeleteMvcc, RowMutation, ScanMvcc, SelectKey, SelectMvcc, TableMutationOutcome, UpdateCol, + UpdateMvcc, UpsertMvcc, }; use crate::session::Session; use crate::session::tests::{ SessionTestExt, assert_checkpoint_published, remove_session_for_test, wait_for_checkpoint_purge, wait_for_checkpoint_root_ready, }; - use crate::table::hot::{ - DeleteInternal, HotRowMutator, InsertRowIntoPage, RowInserter, UpdateRowInplace, - }; use crate::table::tests::*; use crate::table::{CheckpointOutcome, FreezeOutcome}; use crate::table::{ColumnDeletionBuffer, DeleteMarker, Table}; - use crate::trx::row::LockRowForWrite; - use crate::trx::stmt::tests as stmt_tests; use crate::trx::sys::tests::fatal_rollback_retention_count; use crate::trx::tests::{ - commit_preparing_shared_trx_status, prepare_event_is_installed, prepare_shared_trx_status, - prepare_transaction, rollback_preparing_shared_trx_status, - rollback_production_prepared_for_test, shared_trx_status, transaction_status_for_test, + commit_preparing_shared_trx_status, lock_hot_row_then_wait_and_error, + prepare_event_is_installed, prepare_shared_trx_status, prepare_transaction, + rollback_preparing_shared_trx_status, rollback_production_prepared_for_test, + shared_trx_status, transaction_redo_kind_counts, transaction_status_for_test, + transition_delete, transition_insert_update, }; - use crate::trx::undo::RowUndoKind; use crate::trx::ver_map::RowPageState; use crate::trx::{MAX_SNAPSHOT_TS, MIN_ACTIVE_TRX_ID, Transaction}; use crate::value::{Val, ValKind}; @@ -5077,7 +5071,7 @@ mod tests { // insert [1, "world"] let insert = vec![Val::from(1i32), Val::from("world")]; let mut trx = session.begin_trx().unwrap(); - let res = trx_insert_row_by_id(&mut trx, table_id, insert).await; + let res = trx.table_insert_mvcc(table_id, insert).await; let err = res.unwrap_err(); assert_eq!( err.report().downcast_ref::().copied(), @@ -5090,14 +5084,14 @@ mod tests { // insert [2, "hello"], but not commit let insert1 = vec![Val::from(2i32), Val::from("hello")]; let mut trx1 = session.begin_trx().unwrap(); - let res = trx_insert_row_by_id(&mut trx1, table_id, insert1).await; + let res = trx1.table_insert_mvcc(table_id, insert1).await; assert!(res.is_ok()); // begin concurrent transaction and insert [2, "world"] let mut session2 = engine.new_session().unwrap(); let insert2 = vec![Val::from(2i32), Val::from("world")]; let mut trx2 = session2.begin_trx().unwrap(); - let res = trx_insert_row_by_id(&mut trx2, table_id, insert2).await; + let res = trx2.table_insert_mvcc(table_id, insert2).await; // still dup key because circuit breaker on index search. let err = res.unwrap_err(); assert_eq!( @@ -6804,67 +6798,25 @@ mod tests { .await .unwrap(); let insert = vec![Val::from(2i32), Val::from("insert")]; - // RFC-0029 Phase 2 runner coverage: raw TableAccessor insertion - // injects page state and statement effects under explicit locks. - let res: Result<()> = trx - .exec(async |stmt| { - stmt.acquire_table_write_metadata_lock(table_id) - .await - .disclose()?; - stmt.acquire_table_write_data_lock(table_id) - .await - .disclose()?; - let (rt, effects) = stmt_tests::runtime_and_effects_mut(stmt); - let table = table_for_internal_assertion(&engine, table_id); - let layout = table.layout_snapshot(); - let accessor = table.accessor_with_layout(&layout); - let insert_res = RowInserter::new(accessor.table_id(), accessor.metadata(), rt) - .insert_to_page( - effects, - insert_page_guard, - insert, - RowUndoKind::Insert, - vec![], - ); - assert!(matches!( - insert_res, - InsertRowIntoPage::NoSpaceOrFrozen(_, _, _) - )); - - let update = vec![UpdateCol { - idx: 1, - val: Val::from("world"), - }]; - let table = table_for_internal_assertion(&engine, table_id); - let layout = table.layout_snapshot(); - let accessor = table.accessor_with_layout(&layout); - let res = HotRowMutator::new( - accessor.table_id(), - accessor.metadata(), - rt, - &page_guard, - row_id, - ) - .update_inplace( - effects, - key.index_no, - &key.vals, - RowUpdateInput::Sparse(update), - false, - ) - .await - .disclose()?; - assert!(matches!(res, UpdateRowInplace::RetryInTransition(_))); - Err(Report::new(OperationError::InvalidDmlInput).disclose()) - }) - .await; - assert_eq!( - res.unwrap_err() - .report() - .downcast_ref::() - .copied(), - Some(OperationError::InvalidDmlInput) - ); + let table = table_for_internal_assertion(&engine, table_id); + let update = vec![UpdateCol { + idx: 1, + val: Val::from("world"), + }]; + let (insert_retry, update_retry) = transition_insert_update( + &mut trx, + &table, + insert_page_guard, + insert, + &page_guard, + row_id, + &key, + update, + ) + .await + .unwrap(); + assert!(insert_retry); + assert!(update_retry); trx.rollback().await.unwrap(); let mut trx = session.begin_trx().unwrap(); @@ -6882,41 +6834,9 @@ mod tests { .lock_shared_async() .await .unwrap(); - // RFC-0029 Phase 2 runner coverage: raw TableAccessor update - // injects page state and statement effects under explicit locks. - let res: Result<()> = trx - .exec(async |stmt| { - stmt.acquire_table_write_metadata_lock(table_id) - .await - .disclose()?; - stmt.acquire_table_write_data_lock(table_id) - .await - .disclose()?; - let (rt, effects) = stmt_tests::runtime_and_effects_mut(stmt); - let table = table_for_internal_assertion(&engine, table_id); - let layout = table.layout_snapshot(); - let accessor = table.accessor_with_layout(&layout); - let res = HotRowMutator::new( - accessor.table_id(), - accessor.metadata(), - rt, - &page_guard, - row_id, - ) - .delete(effects, key.index_no, &key.vals, false) - .await - .disclose()?; - assert!(matches!(res, DeleteInternal::RetryInTransition)); - Err(Report::new(OperationError::InvalidDmlInput).disclose()) - }) - .await; - assert_eq!( - res.unwrap_err() - .report() - .downcast_ref::() - .copied(), - Some(OperationError::InvalidDmlInput) - ); + let table = table_for_internal_assertion(&engine, table_id); + let delete_retry = transition_delete(&mut trx, &table, &page_guard, row_id, &key).await; + assert!(delete_retry.unwrap()); trx.rollback().await.unwrap(); }); } @@ -7311,7 +7231,7 @@ mod tests { { let insert = vec![Val::from(&s[..0])]; let mut trx = session.begin_trx().unwrap(); - let res = trx_insert_row_by_id(&mut trx, table_id, insert).await; + let res = trx.table_insert_mvcc(table_id, insert).await; assert!(res.is_ok()); trx.commit().await.unwrap(); } @@ -7351,7 +7271,7 @@ mod tests { for i in 0usize..COUNT { let insert = vec![Val::from(&s[..BASE + i])]; let mut trx = session.begin_trx().unwrap(); - let res = trx_insert_row_by_id(&mut trx, table_id, insert).await; + let res = trx.table_insert_mvcc(table_id, insert).await; assert!(res.is_ok()); trx.commit().await.unwrap(); } @@ -7807,33 +7727,6 @@ mod tests { }); } - #[test] - fn test_prepare_completion_won_registration_rechecks_poison() { - smol::block_on(async { - let temp_dir = TempDir::new().unwrap(); - let engine = lightweight_test_engine(&temp_dir, "prepare_completion_won_poison").await; - let mut session = engine.new_session().unwrap(); - let mut trx = session.begin_trx().unwrap(); - // RFC-0029 Phase 2 runner coverage: raw statement runtime poison - // injection verifies completion-wins wait precedence. - let result: Result<()> = trx - .exec(async |stmt| { - stmt.runtime().engine().poisoner.poison( - Report::new(FatalError::StorageIo) - .attach("unrelated foreground wait poison: completion won"), - ); - stmt.runtime() - .wait_prepare_or_poison(PoisonAwareListener::recheck_only()) - .await - .disclose() - }) - .await; - - assert_unrelated_poison_fatal(&result.unwrap_err()); - trx.rollback().await.unwrap(); - }); - } - #[test] fn test_cold_point_update_returns_fatal_on_unrelated_poison() { smol::block_on(async { @@ -8291,42 +8184,19 @@ mod tests { insert_rows(table_id, &mut session, 10, 1, "hot").await; let mut trx = session.begin_trx().unwrap(); - // RFC-0029 Phase 2 runner coverage: same-statement raw redo - // inspection distinguishes physical cold and hot deletes. - trx.exec(async |stmt| { - for id in [0, 10] { - let key = single_key(id); - assert_eq!( - stmt.table_delete_unique_mvcc(table_id, key.index_no, &key.vals) - .await?, - DeleteMvcc::Deleted - ); - } - - let rows = &stmt_tests::statement_redo(stmt) - .dml - .get(&table_id) - .unwrap() - .rows; - let mut cold_deletes = 0; - let mut hot_deletes = 0; - for row in rows.values() { - match row.kind { - RowRedoKind::Delete(None) => cold_deletes += 1, - RowRedoKind::Delete(Some(_)) => hot_deletes += 1, - RowRedoKind::Insert(..) - | RowRedoKind::Update(..) - | RowRedoKind::DeleteByPrimaryKey(_) - | RowRedoKind::UpdateByPrimaryKey(..) => { - panic!("user-table delete must emit physical delete redo") - } - } - } - assert_eq!((cold_deletes, hot_deletes), (1, 1)); - Ok(()) - }) - .await - .unwrap(); + for id in [0, 10] { + let key = single_key(id); + assert_eq!( + trx.table_delete_unique_mvcc(table_id, key.index_no, &key.vals) + .await + .unwrap(), + DeleteMvcc::Deleted + ); + } + assert_eq!( + transaction_redo_kind_counts(&mut trx, table_id).unwrap(), + (1, 1, 0, 0) + ); trx.commit().await.unwrap(); }); } @@ -8345,52 +8215,23 @@ mod tests { insert_rows(table_id, &mut session, 10, 3, "hot").await; let mut trx = session.begin_trx().unwrap(); - // RFC-0029 Phase 2 runner coverage: same-statement raw redo - // inspection classifies mixed full-table mutation effects. let outcome = trx - .exec(async |stmt| { - let outcome = stmt - .table_mutate_mvcc(table_id, |row| { - let id = row.val(0)?.as_i32().unwrap(); - Ok(match id { - 0 | 10 => RowMutation::Delete, - 1 => RowMutation::Update(vec![UpdateCol { - idx: 1, - val: Val::from("cold-updated"), - }]), - 11 => RowMutation::Update(vec![UpdateCol { - idx: 1, - val: Val::from("hot-updated"), - }]), - 12 => RowMutation::Update(Vec::new()), - 2 => RowMutation::Skip, - _ => unreachable!(), - }) - }) - .await?; - let rows = &stmt_tests::statement_redo(stmt) - .dml - .get(&table_id) - .unwrap() - .rows; - let mut cold_deletes = 0; - let mut hot_deletes = 0; - let mut inserts = 0; - let mut updates = 0; - for row in rows.values() { - match row.kind { - RowRedoKind::Delete(None) => cold_deletes += 1, - RowRedoKind::Delete(Some(_)) => hot_deletes += 1, - RowRedoKind::Insert(..) => inserts += 1, - RowRedoKind::Update(..) => updates += 1, - RowRedoKind::DeleteByPrimaryKey(_) - | RowRedoKind::UpdateByPrimaryKey(..) => { - panic!("user-table mutation must emit physical redo") - } - } - } - assert_eq!((cold_deletes, hot_deletes, inserts, updates), (2, 1, 1, 1)); - Ok(outcome) + .table_mutate_mvcc(table_id, |row| { + let id = row.val(0)?.as_i32().unwrap(); + Ok(match id { + 0 | 10 => RowMutation::Delete, + 1 => RowMutation::Update(vec![UpdateCol { + idx: 1, + val: Val::from("cold-updated"), + }]), + 11 => RowMutation::Update(vec![UpdateCol { + idx: 1, + val: Val::from("hot-updated"), + }]), + 12 => RowMutation::Update(Vec::new()), + 2 => RowMutation::Skip, + _ => unreachable!(), + }) }) .await .unwrap(); @@ -8401,6 +8242,10 @@ mod tests { update_count: 3, } ); + assert_eq!( + transaction_redo_kind_counts(&mut trx, table_id).unwrap(), + (2, 1, 1, 1) + ); trx.commit().await.unwrap(); let mut trx = session.begin_trx().unwrap(); @@ -9492,66 +9337,37 @@ mod tests { let (lock_installed_tx, lock_installed_rx) = flume::bounded(1); let (return_error_tx, return_error_rx) = flume::bounded(1); - // RFC-0029 Phase 2 runner coverage: callback error injection after - // raw row-lock installation pauses statement rollback. - let mut statement = Box::pin(trx.exec(async |stmt| { - let page_guard = engine - .inner() - .pools - .mem - .get_page::( - writer_session.pool_guards().mem_guard(), - page_id, - LatchFallbackMode::Shared, - ) - .await - .expect("buffer-pool read failed in test") - .lock_shared_async() - .await - .unwrap(); - stmt.acquire_table_write_metadata_lock(table_id) - .await - .disclose()?; - stmt.acquire_table_write_data_lock(table_id) - .await - .disclose()?; - let (rt, effects) = stmt_tests::runtime_and_effects_mut(stmt); - let table = table_for_internal_assertion(&engine, table_id); - let layout = table.layout_snapshot(); - let accessor = table.accessor_with_layout(&layout); - // Hot-row writes acquire ownership by installing a `Lock` - // undo entry at the row's undo head. That entry is the - // row-level write lock and the rollback anchor that is - // later rewritten to Insert/Update/Delete. - let mut lock_row = HotRowMutator::new( - accessor.table_id(), - accessor.metadata(), - rt, - &page_guard, - row_id, + let page_guard = engine + .inner() + .pools + .mem + .get_page::( + writer_session.pool_guards().mem_guard(), + page_id, + LatchFallbackMode::Shared, ) - .lock_for_write(effects, Some((key.index_no, &key.vals))) + .await + .expect("buffer-pool read failed in test") + .lock_shared_async() .await .unwrap(); - match &mut lock_row { - LockRowForWrite::Ok(access) => { - drop(access.take()); - } - _ => panic!("lock should succeed"), - } - drop(lock_row); - drop(page_guard); - lock_installed_tx.send_async(()).await.unwrap(); - return_error_rx.recv_async().await.unwrap(); - Err(Report::new(OperationError::InvalidDmlInput).disclose()) - })); + let table = table_for_internal_assertion(&engine, table_id); + let mut statement = Box::pin(lock_hot_row_then_wait_and_error( + &mut trx, + &table, + page_guard, + row_id, + &key, + lock_installed_tx, + return_error_rx, + )); let lock_installed = lock_installed_rx.recv_async().fuse(); futures::pin_mut!(lock_installed); futures::select! { result = statement.as_mut().fuse() => { panic!("statement completed before installing row lock: {result:?}"); } - result = lock_installed => result.unwrap(), + result = lock_installed => assert!(result.unwrap()), } let (transition_entered_tx, transition_entered_rx) = flume::bounded(1); @@ -9612,12 +9428,8 @@ mod tests { let mut trx = session.begin_trx().unwrap(); let _row_id = unwrap_insert_result( - trx_insert_row_by_id( - &mut trx, - table_id, - vec![Val::from(1), Val::from("cached-row")], - ) - .await, + trx.table_insert_mvcc(table_id, vec![Val::from(1), Val::from("cached-row")]) + .await, ); trx.commit().await.unwrap(); @@ -9634,12 +9446,8 @@ mod tests { let mut trx = session.begin_trx().unwrap(); let next_row_id = unwrap_insert_result( - trx_insert_row_by_id( - &mut trx, - table_id, - vec![Val::from(2), Val::from("still-cached")], - ) - .await, + trx.table_insert_mvcc(table_id, vec![Val::from(2), Val::from("still-cached")]) + .await, ); trx.commit().await.unwrap(); @@ -9678,12 +9486,8 @@ mod tests { let mut trx = session.begin_trx().unwrap(); let first_row_id = unwrap_insert_result( - trx_insert_row_by_id( - &mut trx, - table_id, - vec![Val::from(1), Val::from("first-session")], - ) - .await, + trx.table_insert_mvcc(table_id, vec![Val::from(1), Val::from("first-session")]) + .await, ); trx.commit().await.unwrap(); let first_page_id = match table_for_internal_assertion(&engine, table_id) @@ -9705,12 +9509,8 @@ mod tests { let mut next_session = engine.new_session().unwrap(); let mut trx = next_session.begin_trx().unwrap(); let next_row_id = unwrap_insert_result( - trx_insert_row_by_id( - &mut trx, - table_id, - vec![Val::from(2), Val::from("next-session")], - ) - .await, + trx.table_insert_mvcc(table_id, vec![Val::from(2), Val::from("next-session")]) + .await, ); trx.commit().await.unwrap(); let next_page_id = match table_for_internal_assertion(&engine, table_id) @@ -9751,12 +9551,8 @@ mod tests { let mut trx = session.begin_trx().unwrap(); let _row_id = unwrap_insert_result( - trx_insert_row_by_id( - &mut trx, - table_id, - vec![Val::from(1), Val::from("cached-row")], - ) - .await, + trx.table_insert_mvcc(table_id, vec![Val::from(1), Val::from("cached-row")]) + .await, ); trx.commit().await.unwrap(); @@ -9786,12 +9582,8 @@ mod tests { let mut trx = session.begin_trx().unwrap(); let _post_gc_row_id = unwrap_insert_result( - trx_insert_row_by_id( - &mut trx, - table_id, - vec![Val::from(2), Val::from("post-gc-row")], - ) - .await, + trx.table_insert_mvcc(table_id, vec![Val::from(2), Val::from("post-gc-row")]) + .await, ); trx.commit().await.unwrap(); @@ -9817,12 +9609,8 @@ mod tests { let mut trx = session.begin_trx().unwrap(); let stale_row_id = unwrap_insert_result( - trx_insert_row_by_id( - &mut trx, - table_id, - vec![Val::from(1), Val::from("cached-row")], - ) - .await, + trx.table_insert_mvcc(table_id, vec![Val::from(1), Val::from("cached-row")]) + .await, ); trx.commit().await.unwrap(); @@ -9854,12 +9642,8 @@ mod tests { for key in 2..258 { let mut trx = session.begin_trx().unwrap(); let row_id = unwrap_insert_result( - trx_insert_row_by_id( - &mut trx, - table_id, - vec![Val::from(key), Val::from(&large[..])], - ) - .await, + trx.table_insert_mvcc(table_id, vec![Val::from(key), Val::from(&large[..])]) + .await, ); trx.commit().await.unwrap(); match table_for_internal_assertion(&engine, table_id) @@ -9926,12 +9710,8 @@ mod tests { let large = "r".repeat(48 * 1024); let mut trx = session.begin_trx().unwrap(); let _row_id = unwrap_insert_result( - trx_insert_row_by_id( - &mut trx, - table_id, - vec![Val::from(1), Val::from(&large[..])], - ) - .await, + trx.table_insert_mvcc(table_id, vec![Val::from(1), Val::from(&large[..])]) + .await, ); trx.commit().await.unwrap(); @@ -9983,12 +9763,9 @@ mod tests { let expected_error_kind = StdIoError::from_raw_os_error(libc::EIO).kind(); let mut trx = session.begin_trx().unwrap(); - let res = trx_insert_row_by_id( - &mut trx, - table_id, - vec![Val::from(100), Val::from("reload-fails")], - ) - .await; + let res = trx + .table_insert_mvcc(table_id, vec![Val::from(100), Val::from("reload-fails")]) + .await; trx.rollback().await.unwrap(); assert!( res.as_ref().is_err_and(|err| err @@ -10016,12 +9793,9 @@ mod tests { let large = "r".repeat(48 * 1024); let mut trx = session.begin_trx().unwrap(); - let _row_id = match trx_insert_row_by_id( - &mut trx, - table_id, - vec![Val::from(1), Val::from(&large[..])], - ) - .await + let _row_id = match trx + .table_insert_mvcc(table_id, vec![Val::from(1), Val::from(&large[..])]) + .await { Ok(row_id) => row_id, res => panic!("res={res:?}"), @@ -10142,7 +9916,7 @@ mod tests { } #[test] - fn test_statement_rollback_poisons_runtime_on_row_page_reload_error() { + fn test_transaction_rollback_poisons_runtime_on_row_page_reload_error() { smol::block_on(async { let temp_dir = TempDir::new().unwrap(); let engine = evictable_test_engine(&temp_dir, 9u64 * 1024 * 1024, "redo_testsys").await; @@ -10151,76 +9925,56 @@ mod tests { let large = "r".repeat(48 * 1024); let mut writer = engine.new_session().unwrap(); let mut trx = session.begin_trx().unwrap(); - let mut hook_guard = None; - let mut read_hook = None; - - // RFC-0029 Phase 2 runner coverage: raw statement runtime/effects - // install a deterministic buffer read-failure hook mid-operation. - let res: Result<()> = trx - .exec(async |stmt| { - let _row_id = match stmt_insert_row_by_id( - stmt, - table_id, - vec![Val::from(1), Val::from(&large[..])], - ) - .await - { - Ok(row_id) => row_id, - res => panic!("res={res:?}"), - }; - - let cached_page = session.load_active_insert_page(table_id).unwrap(); - - for i in 2..258 { - expect_insert_committed( - table_id, - &mut writer, - vec![Val::from(i), Val::from(&large[..])], - ) - .await; - if test_frame_kind( - &table_for_internal_assertion(&engine, table_id).mem.mem_pool, - cached_page.page_id, - ) == FrameKind::Evicted - { - break; - } - } - // Timer audit: buffer-eviction/I/O test coordination. - let mut evicted = false; - for _ in 0..20 { - if test_frame_kind( - &table_for_internal_assertion(&engine, table_id).mem.mem_pool, - cached_page.page_id, - ) == FrameKind::Evicted - { - evicted = true; - break; - } - Timer::after(Duration::from_millis(50)).await; - } - assert!( - evicted, - "statement rollback page should be evicted before repro" - ); - - let mem_pool_file = - StorageBackendFileIdentity::from_path(temp_dir.path().join("data.swp")) - .unwrap(); - let hook = Arc::new(FailingPageReadHook::for_page( - mem_pool_file, - cached_page.page_id, - libc::EIO, - )); - hook_guard = Some(install_storage_backend_test_hook(hook.clone())); - read_hook = Some(hook); + trx.table_insert_mvcc(table_id, vec![Val::from(1), Val::from(&large[..])]) + .await + .unwrap(); - Err(Report::new(OperationError::InvalidDmlInput).disclose()) - }) + let cached_page = session.load_active_insert_page(table_id).unwrap(); + for i in 2..258 { + expect_insert_committed( + table_id, + &mut writer, + vec![Val::from(i), Val::from(&large[..])], + ) .await; + if test_frame_kind( + &table_for_internal_assertion(&engine, table_id).mem.mem_pool, + cached_page.page_id, + ) == FrameKind::Evicted + { + break; + } + } + // Timer audit: buffer-eviction/I/O test coordination. + let mut evicted = false; + for _ in 0..20 { + if test_frame_kind( + &table_for_internal_assertion(&engine, table_id).mem.mem_pool, + cached_page.page_id, + ) == FrameKind::Evicted + { + evicted = true; + break; + } + Timer::after(Duration::from_millis(50)).await; + } + assert!( + evicted, + "transaction rollback page should be evicted before repro" + ); + + let mem_pool_file = + StorageBackendFileIdentity::from_path(temp_dir.path().join("data.swp")).unwrap(); + let read_hook = Arc::new(FailingPageReadHook::for_page( + mem_pool_file, + cached_page.page_id, + libc::EIO, + )); + let _hook_guard = install_storage_backend_test_hook(read_hook.clone()); + let res = trx.rollback().await; let rollback_error = - res.expect_err("row-page reload failure must fail statement rollback"); + res.expect_err("row-page reload failure must fail transaction rollback"); let expected_io_kind = StdIoError::from_raw_os_error(libc::EIO).kind(); assert_eq!(rollback_error.kind(), ErrorKind::Fatal); assert_eq!( @@ -10254,16 +10008,14 @@ mod tests { 1 ); assert!( - read_hook - .as_ref() - .is_some_and(|hook: &Arc| hook.call_count() > 0), - "statement rollback should reload the evicted page" + read_hook.call_count() > 0, + "transaction rollback should reload the evicted page" ); let poison_error = engine .inner() .poisoner .poison_error() - .expect("statement rollback failure must poison the runtime"); + .expect("transaction rollback failure must poison the runtime"); assert_eq!(poison_error.current_context(), &FatalError::RollbackAccess); assert_eq!( poison_error.downcast_ref::().copied(), @@ -10283,8 +10035,6 @@ mod tests { "failed-retained operation must keep the session unavailable" ); - let err = trx.rollback().await.unwrap_err(); - assert!(err.report().downcast_ref::().is_none()); remove_session_for_test(&engine.inner().session_registry, session.id()); }); } @@ -10346,12 +10096,9 @@ mod tests { let row_id = (batch * 64 + i) as i32; let seed = format!("{:08x}", row_id); let key = seed.repeat(64); - let res = trx_insert_row_by_id( - &mut trx, - table_id, - vec![Val::from(row_id), Val::from(&key[..])], - ) - .await; + let res = trx + .table_insert_mvcc(table_id, vec![Val::from(row_id), Val::from(&key[..])]) + .await; assert!(res.is_ok(), "res={res:?}"); inserted.push((row_id, key)); } @@ -10450,9 +10197,9 @@ mod tests { let mut session = engine.new_session().unwrap(); let mut insert = session.begin_trx().unwrap(); - let res = - trx_insert_row_by_id(&mut insert, table_id, vec![Val::from(10), Val::from(7)]) - .await; + let res = insert + .table_insert_mvcc(table_id, vec![Val::from(10), Val::from(7)]) + .await; assert!(res.is_ok()); insert.commit().await.unwrap(); @@ -10494,7 +10241,7 @@ mod tests { } #[test] - fn test_stream_stmt_validation_opt_out_is_stream_local() { + fn test_transaction_stream_validation_opt_out_can_be_reenabled_and_is_transaction_local() { smol::block_on(async { let temp_dir = TempDir::new().unwrap(); let engine = Engine::bootstrap( @@ -10509,9 +10256,9 @@ mod tests { let mut session = engine.new_session().unwrap(); let mut insert = session.begin_trx().unwrap(); - let res = - trx_insert_row_by_id(&mut insert, table_id, vec![Val::from(10), Val::from(7)]) - .await; + let res = insert + .table_insert_mvcc(table_id, vec![Val::from(10), Val::from(7)]) + .await; assert!(res.is_ok()); insert.commit().await.unwrap(); @@ -10529,17 +10276,16 @@ mod tests { Some(OperationError::InvalidDmlInput) ); - // RFC-0029 Phase 2 runner coverage: stream validation opt-out - // remains available only through the legacy StreamStmt facade. + trx.disable_dml_validation(true); + trx.noop().await.unwrap(); let mut stream = trx - .stream_stmt() - .disable_validation() - .table_index_scan_mvcc(table_id, 1, &key_vals[..]..=&key_vals[..], &[]) + .table_index_scan_mvcc_stream(table_id, 1, &key_vals[..]..=&key_vals[..], &[]) .await .unwrap(); assert_eq!(stream.next().await.unwrap(), Some(Vec::new())); assert_eq!(stream.next().await.unwrap(), None); drop(stream); + trx.disable_dml_validation(false); let err = match trx .table_index_scan_mvcc_stream(table_id, 1, &key_vals[..]..=&key_vals[..], &[]) @@ -10553,6 +10299,20 @@ mod tests { Some(OperationError::InvalidDmlInput) ); trx.commit().await.unwrap(); + + let mut trx = session.begin_trx().unwrap(); + let err = match trx + .table_index_scan_mvcc_stream(table_id, 1, &key_vals[..]..=&key_vals[..], &[]) + .await + { + Ok(_) => panic!("empty read set should fail in a new transaction"), + Err(err) => err, + }; + assert_eq!( + err.report().downcast_ref::().copied(), + Some(OperationError::InvalidDmlInput) + ); + trx.rollback().await.unwrap(); }); } @@ -10575,9 +10335,9 @@ mod tests { let user_read_set = &[0usize, 1]; let mut trx = session.begin_trx().unwrap(); for i in 0i32..5i32 { - let res = - trx_insert_row_by_id(&mut trx, table_id, vec![Val::from(i), Val::from(i)]) - .await; + let res = trx + .table_insert_mvcc(table_id, vec![Val::from(i), Val::from(i)]) + .await; assert!(res.is_ok()); } trx.commit().await.unwrap(); @@ -10799,9 +10559,9 @@ mod tests { let mut session = engine.new_session().unwrap(); let mut insert = session.begin_trx().unwrap(); - let res = - trx_insert_row_by_id(&mut insert, table_id, vec![Val::from(10), Val::from(7)]) - .await; + let res = insert + .table_insert_mvcc(table_id, vec![Val::from(10), Val::from(7)]) + .await; assert!(res.is_ok()); insert.commit().await.unwrap(); @@ -10851,9 +10611,9 @@ mod tests { let mut session = engine.new_session().unwrap(); let mut insert = session.begin_trx().unwrap(); - let res = - trx_insert_row_by_id(&mut insert, table_id, vec![Val::from(10), Val::from(7)]) - .await; + let res = insert + .table_insert_mvcc(table_id, vec![Val::from(10), Val::from(7)]) + .await; assert!(res.is_ok()); insert.commit().await.unwrap(); diff --git a/doradb-storage/src/table/index_mutate.rs b/doradb-storage/src/table/index_mutate.rs index 2d75b2c6..158d79ea 100644 --- a/doradb-storage/src/table/index_mutate.rs +++ b/doradb-storage/src/table/index_mutate.rs @@ -448,8 +448,12 @@ impl<'a, 'op, 'r, 'ctx> IndexMutator<'a, 'op, 'r, 'ctx> { /// Applies every cached key-changing update after index traversal ends. pub(super) async fn apply_deferred_index_updates(&mut self) -> Result<()> { #[cfg(test)] - if self.effects.has_deferred_index_updates() { - tests::maybe_pause_before_deferred_application().await; + { + use crate::trx::stmt::tests::has_deferred_index_updates; + + if has_deferred_index_updates(self.effects) { + tests::maybe_pause_before_deferred_application().await; + } } self.effects.begin_deferred_index_update_application(); while let Some((row_id, update)) = self.effects.activate_next_deferred_index_update() { @@ -1363,7 +1367,7 @@ mod tests { } #[test] - fn test_table_index_mutate_mvcc_skips_same_statement_but_not_later_statement() { + fn test_table_index_mutate_mvcc_visits_row_inserted_by_prior_direct_operation() { smol::block_on(async { let temp_dir = TempDir::new().unwrap(); let engine = lightweight_test_engine(&temp_dir, "index_mutate_stmt_identity").await; @@ -1371,20 +1375,9 @@ mod tests { let mut session = engine.new_session().unwrap(); let mut trx = session.begin_trx().unwrap(); - // RFC-0029 Phase 2 runner coverage: intentional same-statement - // insert plus index mutation proves replacement-row exclusion. - let outcome = trx - .exec(async |stmt| { - stmt.table_insert_mvcc(table_id, vec![Val::from(7i32), Val::from("inserted")]) - .await?; - stmt.table_index_mutate_mvcc(table_id, 0, .., |_| { - panic!("a row produced by this statement must not reach the callback") - }) - .await - }) + trx.table_insert_mvcc(table_id, vec![Val::from(7i32), Val::from("inserted")]) .await .unwrap(); - assert_eq!(outcome, TableMutationOutcome::default()); let mut callbacks = 0usize; let outcome = trx @@ -1397,6 +1390,18 @@ mod tests { .unwrap(); assert_eq!(callbacks, 1); assert_eq!(outcome, TableMutationOutcome::default()); + + let mut later_callbacks = 0usize; + let outcome = trx + .table_index_mutate_mvcc(table_id, 0, .., |row| { + later_callbacks += 1; + assert_eq!(row.val(0)?.as_i32(), Some(7)); + Ok(RowMutation::Skip) + }) + .await + .unwrap(); + assert_eq!(later_callbacks, 1); + assert_eq!(outcome, TableMutationOutcome::default()); trx.commit().await.unwrap(); }); } diff --git a/doradb-storage/src/table/mem_table.rs b/doradb-storage/src/table/mem_table.rs index 46d21cee..d065a149 100644 --- a/doradb-storage/src/table/mem_table.rs +++ b/doradb-storage/src/table/mem_table.rs @@ -2177,12 +2177,13 @@ impl MemTable { } } + /// Update one row through a unique index in a standalone memory table. #[inline] #[cfg_attr( not(test), expect(dead_code, reason = "reserved for future memory-only user tables") )] - async fn update_unique_mvcc( + pub(crate) async fn update_unique_mvcc( &self, rt: TrxRuntime<'_>, effects: &mut StmtEffects, @@ -2734,8 +2735,9 @@ impl MemTable { Ok(()) } + /// Move one unique-index key between row versions without changing row data. #[inline] - async fn update_unique_index_only_key_change( + pub(crate) async fn update_unique_index_only_key_change( &self, rt: TrxRuntime<'_>, effects: &mut StmtEffects, @@ -3302,8 +3304,8 @@ mod tests { }; use crate::engine::Engine; use crate::error::{ - DataIntegrityError, DiscloseResultExt, InternalError, LifecycleError, OperationError, - OperationOrRuntimeError, ResourceError, RuntimeError, + DataIntegrityError, InternalError, LifecycleError, OperationError, ResourceError, + RuntimeError, }; use crate::file::cow_file::SUPER_BLOCK_ID; use crate::id::{RowID, TableID, TrxID}; @@ -3315,8 +3317,10 @@ mod tests { tests::{SessionTestExt, assert_checkpoint_published, wait_for_session_idle}, }; use crate::table::tests::*; - use crate::trx::stmt::tests as stmt_tests; - use crate::trx::tests::shared_trx_status; + use crate::trx::tests::{ + mem_table_delete_unique_mvcc, mem_table_duplicate_index_key_change, mem_table_insert_mvcc, + mem_table_update_unique_mvcc, mem_table_upsert_unique_mvcc, shared_trx_status, + }; use crate::trx::undo::{OwnedRowUndo, RowUndoHead, RowUndoKind, RowUndoRollbackAttempt}; use crate::trx::ver_map::RowPageState; use crate::trx::{MIN_ACTIVE_TRX_ID, MIN_SNAPSHOT_TS, NON_FOREGROUND_STMT_NO}; @@ -3425,26 +3429,13 @@ mod tests { vec![Val::from(id), Val::from(name), Val::from(payload)] } - // RFC-0029 Phase 2 runner coverage: raw MemTable tests require direct - // runtime/effect access and explicit logical-lock injection. async fn insert_mem_mvcc( session: &mut Session, - table_id: TableID, mem_table: &TestMemTable, cols: Vec, ) -> RowID { let mut trx = session.begin_trx().unwrap(); - let row_id = trx - .exec(async |stmt| { - stmt.acquire_table_write_metadata_lock(table_id) - .await - .disclose()?; - stmt.acquire_table_write_data_lock(table_id) - .await - .disclose()?; - let (rt, effects) = stmt_tests::runtime_and_effects_mut(stmt); - mem_table.insert_mvcc(rt, effects, cols).await.disclose() - }) + let row_id = mem_table_insert_mvcc(&mut trx, mem_table, cols) .await .unwrap(); trx.commit().await.unwrap(); @@ -3453,26 +3444,12 @@ mod tests { async fn update_mem_unique_mvcc( session: &mut Session, - table_id: TableID, mem_table: &TestMemTable, key: SelectKey, update: Vec, ) -> UpdateMvcc { let mut trx = session.begin_trx().unwrap(); - let updated = trx - .exec(async |stmt| { - stmt.acquire_table_write_metadata_lock(table_id) - .await - .disclose()?; - stmt.acquire_table_write_data_lock(table_id) - .await - .disclose()?; - let (rt, effects) = stmt_tests::runtime_and_effects_mut(stmt); - mem_table - .update_unique_mvcc(rt, effects, key.index_no, &key.vals, update, false) - .await - .disclose() - }) + let updated = mem_table_update_unique_mvcc(&mut trx, mem_table, &key, update) .await .unwrap(); trx.commit().await.unwrap(); @@ -3555,8 +3532,6 @@ mod tests { }); } - // RFC-0029 Phase 2 runner coverage: raw MemTable upsert uses injected - // runtime/effects outside the catalog-owned public table boundary. #[test] fn test_mem_table_upsert_unique_insert_and_update() { smol::block_on(async { @@ -3568,28 +3543,13 @@ mod tests { let mut session = engine.new_session().unwrap(); let mut trx = session.begin_trx().unwrap(); - let inserted = trx - .exec(async |stmt| { - stmt.acquire_table_write_metadata_lock(mem_table_id) - .await - .disclose()?; - stmt.acquire_table_write_data_lock(mem_table_id) - .await - .disclose()?; - let (rt, effects) = stmt_tests::runtime_and_effects_mut(stmt); - mem_table - .upsert_unique_mvcc( - rt, - effects, - 0, - vec![Val::from(1i32), Val::from("hello")], - false, - ) - .await - .disclose() - }) - .await - .unwrap(); + let inserted = mem_table_upsert_unique_mvcc( + &mut trx, + &mem_table, + vec![Val::from(1i32), Val::from("hello")], + ) + .await + .unwrap(); let inserted_row_id = match inserted { UpsertMvcc::Inserted(row_id) => row_id, UpsertMvcc::Updated(row_id) => panic!("unexpected update row_id={row_id}"), @@ -3597,28 +3557,13 @@ mod tests { trx.commit().await.unwrap(); let mut trx = session.begin_trx().unwrap(); - let updated = trx - .exec(async |stmt| { - stmt.acquire_table_write_metadata_lock(mem_table_id) - .await - .disclose()?; - stmt.acquire_table_write_data_lock(mem_table_id) - .await - .disclose()?; - let (rt, effects) = stmt_tests::runtime_and_effects_mut(stmt); - mem_table - .upsert_unique_mvcc( - rt, - effects, - 0, - vec![Val::from(1i32), Val::from("world")], - false, - ) - .await - .disclose() - }) - .await - .unwrap(); + let updated = mem_table_upsert_unique_mvcc( + &mut trx, + &mem_table, + vec![Val::from(1i32), Val::from("world")], + ) + .await + .unwrap(); assert_eq!(updated, UpsertMvcc::Updated(inserted_row_id)); trx.commit().await.unwrap(); @@ -3636,8 +3581,6 @@ mod tests { }); } - // RFC-0029 Phase 2 runner coverage: raw MemTable write-conflict setup uses - // injected runtime/effects and explicit transaction locks. #[test] fn test_mem_table_upsert_unique_missing_key_write_conflict() { smol::block_on(async { @@ -3650,25 +3593,11 @@ mod tests { let mut trx1 = session1.begin_trx().unwrap(); assert!(matches!( - trx1.exec(async |stmt| { - stmt.acquire_table_write_metadata_lock(mem_table_id) - .await - .disclose()?; - stmt.acquire_table_write_data_lock(mem_table_id) - .await - .disclose()?; - let (rt, effects) = stmt_tests::runtime_and_effects_mut(stmt); - mem_table - .upsert_unique_mvcc( - rt, - effects, - 0, - vec![Val::from(2i32), Val::from("first")], - false, - ) - .await - .disclose() - }) + mem_table_upsert_unique_mvcc( + &mut trx1, + &mem_table, + vec![Val::from(2i32), Val::from("first")], + ) .await .unwrap(), UpsertMvcc::Inserted(_) @@ -3676,28 +3605,13 @@ mod tests { let mut session2 = engine.new_session().unwrap(); let mut trx2 = session2.begin_trx().unwrap(); - let err = trx2 - .exec(async |stmt| { - stmt.acquire_table_write_metadata_lock(mem_table_id) - .await - .disclose()?; - stmt.acquire_table_write_data_lock(mem_table_id) - .await - .disclose()?; - let (rt, effects) = stmt_tests::runtime_and_effects_mut(stmt); - mem_table - .upsert_unique_mvcc( - rt, - effects, - 0, - vec![Val::from(2i32), Val::from("second")], - false, - ) - .await - .disclose() - }) - .await - .unwrap_err(); + let err = mem_table_upsert_unique_mvcc( + &mut trx2, + &mem_table, + vec![Val::from(2i32), Val::from("second")], + ) + .await + .unwrap_err(); assert_eq!( err.report().downcast_ref::().copied(), Some(OperationError::WriteConflict) @@ -4310,8 +4224,6 @@ mod tests { }); } - // RFC-0029 Phase 2 runner coverage: raw MemTable delete inspects and - // injects statement-local effects directly. #[test] fn test_mem_table_delete_unique_mvcc_marks_non_unique_index() { smol::block_on(async { @@ -4326,7 +4238,6 @@ mod tests { let row_id = insert_mem_mvcc( &mut session, - mem_table_id, &mem_table, indexed_payload_row(10, "delete", b"payload"), ) @@ -4341,21 +4252,7 @@ mod tests { .await; let mut trx = session.begin_trx().unwrap(); - let deleted = trx - .exec(async |stmt| { - stmt.acquire_table_write_metadata_lock(mem_table_id) - .await - .disclose()?; - stmt.acquire_table_write_data_lock(mem_table_id) - .await - .disclose()?; - let (rt, effects) = stmt_tests::runtime_and_effects_mut(stmt); - let key = single_key(10i32); - mem_table - .delete_unique_mvcc(rt, effects, key.index_no, &key.vals, false) - .await - .disclose() - }) + let deleted = mem_table_delete_unique_mvcc(&mut trx, &mem_table, &single_key(10i32)) .await .unwrap(); assert_eq!(deleted, DeleteMvcc::Deleted); @@ -4394,8 +4291,6 @@ mod tests { }); } - // RFC-0029 Phase 2 runner coverage: raw MemTable update composes physical - // index mutation with statement-local effect inspection. #[test] fn test_mem_table_update_key_change_updates_unique_and_non_unique_indexes() { smol::block_on(async { @@ -4410,14 +4305,12 @@ mod tests { let row_id = insert_mem_mvcc( &mut session, - mem_table_id, &mem_table, indexed_payload_row(1, "old", b"payload"), ) .await; insert_mem_mvcc( &mut session, - mem_table_id, &mem_table, indexed_payload_row(20, "other", b"payload"), ) @@ -4425,7 +4318,6 @@ mod tests { let updated = update_mem_unique_mvcc( &mut session, - mem_table_id, &mem_table, single_key(1i32), vec![ @@ -4479,52 +4371,34 @@ mod tests { ) .await; + let page_id = match mem_table + .find_row(&session.pool_guards(), row_id) + .await + .unwrap() + { + RowLocation::RowPage(page_id) => page_id, + RowLocation::NotFound => panic!("updated row should exist"), + RowLocation::LwcBlock(..) => panic!("standalone MemTable should not use LWC"), + }; + let page_guard = mem_table + .must_get_row_page_shared(&session.pool_guards(), page_id) + .await + .unwrap(); let mut trx = session.begin_trx().unwrap(); - trx.exec(async |stmt| { - stmt.acquire_table_write_metadata_lock(mem_table_id) - .await - .disclose()?; - stmt.acquire_table_write_data_lock(mem_table_id) - .await - .disclose()?; - let (rt, effects) = stmt_tests::runtime_and_effects_mut(stmt); - let page_id = match mem_table - .find_row(rt.pool_guards(), row_id) - .await - .disclose()? - { - RowLocation::RowPage(page_id) => page_id, - RowLocation::NotFound => panic!("updated row should exist"), - RowLocation::LwcBlock(..) => { - panic!("standalone MemTable should not use LWC") - } - }; - let page_guard = mem_table - .must_get_row_page_shared(rt.pool_guards(), page_id) - .await - .disclose()?; - let err = mem_table - .update_unique_index_only_key_change( - rt, - effects, - single_key(10i32), - single_key(20i32), - row_id, - &page_guard, - ) - .await - .expect_err("duplicate unique key must be terminal"); - let OperationOrRuntimeError::Operation(report) = err else { - panic!("duplicate unique key must stay in the Operation domain") - }; - assert_eq!( - report.downcast_ref::().copied(), - Some(OperationError::DuplicateKey) - ); - Ok(()) - }) + let err = mem_table_duplicate_index_key_change( + &mut trx, + &mem_table, + page_guard, + row_id, + single_key(10i32), + single_key(20i32), + ) .await - .unwrap(); + .unwrap_err(); + assert_eq!( + err.report().downcast_ref::().copied(), + Some(OperationError::DuplicateKey) + ); trx.rollback().await.unwrap(); assert_unique_row( @@ -4558,7 +4432,6 @@ mod tests { let name = format!("name{id}"); let row_id = insert_mem_mvcc( &mut session, - mem_table_id, &mem_table, indexed_payload_row(id, &name, &base_payload), ) @@ -4570,7 +4443,6 @@ mod tests { let old_row0 = row_ids[0]; let updated = update_mem_unique_mvcc( &mut session, - mem_table_id, &mem_table, single_key(0i32), vec![UpdateCol { @@ -4619,7 +4491,6 @@ mod tests { let old_row1 = row_ids[1]; let updated = update_mem_unique_mvcc( &mut session, - mem_table_id, &mem_table, single_key(1i32), vec![ @@ -4700,8 +4571,6 @@ mod tests { }); } - // RFC-0029 Phase 2 runner coverage: raw MemTable transition-state panic - // injection verifies callback panic cancellation and cleanup. #[test] fn test_mem_table_transition_update_and_delete_panic() { smol::block_on(async { @@ -4713,7 +4582,6 @@ mod tests { let mut session = engine.new_session().unwrap(); let row_id = insert_mem_mvcc( &mut session, - mem_table_id, &mem_table, vec![Val::from(1i32), Val::from("transition")], ) @@ -4738,29 +4606,15 @@ mod tests { let key = single_key(1i32); let mut trx = session.begin_trx().unwrap(); - let panic = AssertUnwindSafe(trx.exec(async |stmt| { - stmt.acquire_table_write_metadata_lock(mem_table_id) - .await - .disclose()?; - stmt.acquire_table_write_data_lock(mem_table_id) - .await - .disclose()?; - let (rt, effects) = stmt_tests::runtime_and_effects_mut(stmt); - mem_table - .update_unique_mvcc( - rt, - effects, - key.index_no, - &key.vals, - vec![UpdateCol { - idx: 1, - val: Val::from("updated"), - }], - false, - ) - .await - .disclose() - })) + let panic = AssertUnwindSafe(mem_table_update_unique_mvcc( + &mut trx, + &mem_table, + &key, + vec![UpdateCol { + idx: 1, + val: Val::from("updated"), + }], + )) .catch_unwind() .await .expect_err("standalone MemTable update must reject a transition page"); @@ -4781,22 +4635,10 @@ mod tests { wait_for_session_idle(&engine.inner().session_registry, session.id()).await; let mut trx = session.begin_trx().unwrap(); - let panic = AssertUnwindSafe(trx.exec(async |stmt| { - stmt.acquire_table_write_metadata_lock(mem_table_id) - .await - .disclose()?; - stmt.acquire_table_write_data_lock(mem_table_id) - .await - .disclose()?; - let (rt, effects) = stmt_tests::runtime_and_effects_mut(stmt); - mem_table - .delete_unique_mvcc(rt, effects, key.index_no, &key.vals, false) - .await - .disclose() - })) - .catch_unwind() - .await - .expect_err("standalone MemTable delete must reject a transition page"); + let panic = AssertUnwindSafe(mem_table_delete_unique_mvcc(&mut trx, &mem_table, &key)) + .catch_unwind() + .await + .expect_err("standalone MemTable delete must reject a transition page"); let message = panic .downcast_ref::() .map(String::as_str) @@ -4827,7 +4669,6 @@ mod tests { let mut session = engine.new_session().unwrap(); let row_id = insert_mem_mvcc( &mut session, - table_id, &mem_table, vec![Val::from(1i32), Val::from("rollback")], ) diff --git a/doradb-storage/src/table/mod.rs b/doradb-storage/src/table/mod.rs index 9d1709e5..d3e03f1a 100644 --- a/doradb-storage/src/table/mod.rs +++ b/doradb-storage/src/table/mod.rs @@ -1155,6 +1155,7 @@ fn unique_key_from_full_row( #[cfg(test)] pub(crate) mod tests { use super::lifecycle::{CheckpointPublishLease, TableCheckpointRootMutationScope}; + use crate::buffer::guard::PageSharedGuard; use crate::buffer::page::PAGE_SIZE; use crate::buffer::{PoolGuard, PoolGuards, ReadonlyBufferPool}; use crate::catalog::tests::table2; @@ -1165,8 +1166,8 @@ pub(crate) mod tests { use crate::conf::{EngineConfig, EvictableBufferPoolConfig, FileSystemConfig, TrxSysConfig}; use crate::engine::Engine; use crate::error::{ - CompletionErrorBridge, DataIntegrityError, Error, FatalError, OperationError, Result, - RuntimeResult, + CompletionErrorBridge, DataIntegrityError, DiscloseError, DiscloseResultExt, Error, + FatalError, OperationError, Result, RuntimeResult, }; use crate::file::block_integrity::{BLOCK_INTEGRITY_HEADER_SIZE, write_block_checksum}; use crate::file::cow_file::{COW_FILE_PAGE_SIZE, SUPER_BLOCK_ID}; @@ -1183,14 +1184,18 @@ pub(crate) mod tests { use crate::lock::tests::{LockDebugEntryState, debug_snapshot}; use crate::lock::{LockFamily, LockMode, LockOwner, LockResource}; use crate::quiescent::QuiescentGuard; + use crate::row::RowPage; use crate::row::ops::{DeleteMvcc, SelectKey, SelectMvcc, UpdateCol, UpdateMvcc}; use crate::session::{Session, tests::SessionTestExt}; + use crate::table::hot::{ + DeleteInternal, HotRowMutator, InsertRowIntoPage, RowInserter, UpdateRowInplace, + }; use crate::table::{ DeleteMarker, DmlValidationError, FreezeOutcome, FrozenPageBatchInfo, Table, TableRuntimeLayout, }; - use crate::trx::Transaction; - use crate::trx::stmt::Statement; + use crate::trx::stmt::StmtEffects; + use crate::trx::{Transaction, TrxRuntime}; use crate::value::{Val, ValKind}; use smol::Timer; use std::fs::OpenOptions; @@ -1896,33 +1901,6 @@ pub(crate) mod tests { ); } - // RFC-0029 Phase 2 runner coverage: raw statement-effect and intentional - // same-statement composition tests still require statement-taking helpers. - pub(crate) async fn stmt_insert_row_by_id( - stmt: &mut Statement<'_>, - table_id: TableID, - cols: Vec, - ) -> Result { - stmt.table_insert_mvcc(table_id, cols).await - } - - pub(crate) async fn stmt_delete_row_by_id( - stmt: &mut Statement<'_>, - table_id: TableID, - key: &SelectKey, - ) -> Result { - stmt.table_delete_unique_mvcc(table_id, key.index_no, &key.vals) - .await - } - - pub(crate) async fn trx_insert_row_by_id( - trx: &mut Transaction, - table_id: TableID, - cols: Vec, - ) -> Result { - trx.table_insert_mvcc(table_id, cols).await - } - pub(crate) async fn trx_delete_row_by_id( trx: &mut Transaction, table_id: TableID, @@ -1942,6 +1920,113 @@ pub(crate) mod tests { .await } + /// Run the raw transition-page insert and update primitives for one test operation. + #[allow(clippy::too_many_arguments)] + pub(crate) async fn transition_insert_update_operation( + rt: TrxRuntime<'_>, + effects: &mut StmtEffects, + table: &Table, + insert_page_guard: PageSharedGuard, + insert: Vec, + page_guard: &PageSharedGuard, + row_id: RowID, + key: &SelectKey, + update: Vec, + ) -> Result<(bool, bool)> { + let layout = table.layout_snapshot(); + let metadata = layout.metadata(); + let insert_retry = matches!( + RowInserter::new(table.mem.table_id(), metadata, rt).insert_to_page( + effects, + insert_page_guard, + insert, + crate::trx::undo::RowUndoKind::Insert, + vec![], + ), + InsertRowIntoPage::NoSpaceOrFrozen(_, _, _) + ); + let update_retry = matches!( + HotRowMutator::new(table.mem.table_id(), metadata, rt, page_guard, row_id,) + .update_inplace( + effects, + key.index_no, + &key.vals, + crate::row::ops::RowUpdateInput::Sparse(update), + false, + ) + .await + .disclose()?, + UpdateRowInplace::RetryInTransition(_) + ); + Ok((insert_retry, update_retry)) + } + + /// Run the raw transition-page delete primitive for one test operation. + pub(crate) async fn transition_delete_operation( + rt: TrxRuntime<'_>, + effects: &mut StmtEffects, + table: &Table, + page_guard: &PageSharedGuard, + row_id: RowID, + key: &SelectKey, + ) -> Result { + let layout = table.layout_snapshot(); + let metadata = layout.metadata(); + Ok(matches!( + HotRowMutator::new(table.mem.table_id(), metadata, rt, page_guard, row_id,) + .delete(effects, key.index_no, &key.vals, false) + .await + .disclose()?, + DeleteInternal::RetryInTransition + )) + } + + /// Install one hot-row write lock, pause, and then force operation rollback. + #[allow(clippy::too_many_arguments)] + pub(crate) async fn lock_hot_row_then_wait_and_error_operation( + rt: TrxRuntime<'_>, + effects: &mut StmtEffects, + table: &Table, + page_guard: PageSharedGuard, + row_id: RowID, + key: &SelectKey, + lock_installed: flume::Sender, + return_error: flume::Receiver<()>, + ) -> Result<()> { + use crate::trx::row::LockRowForWrite; + + let layout = table.layout_snapshot(); + let locked = { + match HotRowMutator::new( + table.mem.table_id(), + layout.metadata(), + rt, + &page_guard, + row_id, + ) + .lock_for_write(effects, Some((key.index_no, &key.vals))) + .await + .disclose()? + { + LockRowForWrite::Ok(mut access) => { + drop(access.take()); + true + } + LockRowForWrite::WriteConflict + | LockRowForWrite::InvalidIndex + | LockRowForWrite::RetryInTransition => false, + } + }; + drop(page_guard); + if lock_installed.send_async(locked).await.is_err() || !locked { + return Err(error_stack::Report::new(OperationError::InvalidDmlInput).disclose()); + } + if return_error.recv_async().await.is_err() { + return Err(error_stack::Report::new(OperationError::InvalidDmlInput).disclose()); + } + Err(error_stack::Report::new(OperationError::InvalidDmlInput).disclose()) + } + pub(crate) async fn trx_select_row_mvcc_by_id( trx: &mut Transaction, table_id: TableID, @@ -2220,37 +2305,6 @@ pub(crate) mod tests { }); } - #[test] - fn test_statement_dml_validation_opt_out_is_statement_local() { - smol::block_on(async { - let temp_dir = TempDir::new().unwrap(); - let engine = lightweight_test_engine(&temp_dir, "stmt_dml_validation_opt_out").await; - let table_id = create_table2_for_test(&engine).await; - let mut session = engine.new_session().unwrap(); - - let mut trx = session.begin_trx().unwrap(); - // RFC-0029 Phase 2 runner coverage: validation opt-out remains - // available only through the legacy statement facade. - trx.exec(async |stmt| { - stmt.disable_dml_validation() - .table_insert_mvcc(table_id, vec![Val::from(1i32), Val::from("name")]) - .await?; - Ok(()) - }) - .await - .unwrap(); - trx.commit().await.unwrap(); - - let mut trx = session.begin_trx().unwrap(); - let err = trx - .table_insert_mvcc(table_id, vec![Val::from(2i32)]) - .await - .unwrap_err(); - assert_invalid_dml_input(err); - trx.rollback().await.unwrap(); - }); - } - #[test] fn test_statement_unique_dml_validation_default_on() { smol::block_on(async { @@ -2381,7 +2435,7 @@ pub(crate) mod tests { mut trx: Transaction, insert: Vec, ) -> Transaction { - let res = trx_insert_row_by_id(&mut trx, table_id, insert).await; + let res = trx.table_insert_mvcc(table_id, insert).await; if res.is_err() { panic!("res={:?}", res); } @@ -2493,7 +2547,7 @@ pub(crate) mod tests { values: Vec, ) -> RowID { let mut trx = session.begin_trx().unwrap(); - let insert = trx_insert_row_by_id(&mut trx, table_id, values).await; + let insert = trx.table_insert_mvcc(table_id, values).await; let Ok(row_id) = insert else { panic!("insert should succeed: {insert:?}"); }; @@ -3121,22 +3175,6 @@ pub(crate) mod tests { trx.commit().await.unwrap(); } - pub(crate) async fn insert_rows_direct( - table_id: TableID, - session: &mut Session, - start: i32, - count: i32, - name: &str, - ) { - let mut trx = session.begin_trx().unwrap(); - for i in 0..count { - let insert = vec![Val::from(start + i), Val::from(name)]; - let res = trx_insert_row_by_id(&mut trx, table_id, insert).await; - assert!(res.is_ok()); - } - trx.commit().await.unwrap(); - } - pub(crate) async fn delete_key_range_and_wait_gc_cutoff( table_id: TableID, session: &mut Session, diff --git a/doradb-storage/src/table/persistence.rs b/doradb-storage/src/table/persistence.rs index 98a27f0c..c9b319bb 100644 --- a/doradb-storage/src/table/persistence.rs +++ b/doradb-storage/src/table/persistence.rs @@ -2376,13 +2376,12 @@ mod tests { use crate::conf::TrxSysConfig; use crate::engine::Engine; use crate::error::{ - DiscloseError, Error, FatalError, LifecycleError, OperationError, ResourceError, - RuntimeError, RuntimeOrFatalError, + Error, FatalError, LifecycleError, ResourceError, RuntimeError, RuntimeOrFatalError, }; use crate::file::cow_file::tests::old_root_drop_count; use crate::index::RowLocation; use crate::io::install_storage_backend_test_hook; - use crate::row::ops::{SelectKey, SelectMvcc, UpdateCol, UpdateMvcc}; + use crate::row::ops::{DeleteMvcc, SelectKey, SelectMvcc, UpdateCol, UpdateMvcc}; use crate::runtime::mandatory::MandatoryInternalTask; use crate::session::{ Session, @@ -2413,14 +2412,13 @@ mod tests { use crate::table::tests::*; use crate::table::{DeleteMarker, TableTerminal}; use crate::trx::purge::PurgeTestEvent; - use crate::trx::stmt::tests as stmt_tests; use crate::trx::sys::tests::{ - fatal_rollback_retention_count, has_active_sts, install_abandoned_cleanup_test_hook, - retains_active_row_undo, + fatal_rollback_retention_count, has_active_sts, retains_active_row_undo, }; use crate::trx::tests::{ - discard_transaction_after_fatal_rollback, lock_owner, prepare_transaction, - shared_trx_status, transaction_entry, transaction_status_for_test, + discard_transaction_after_fatal_rollback, lock_owner, observe_table_root_snapshot, + prepare_transaction, shared_trx_status, transaction_delete_undo_observation, + transaction_entry, transaction_status_for_test, }; use crate::trx::undo::{ OwnedRowUndo, RowUndoHead, RowUndoKind, RowUndoLogs, RowUndoRollbackContext, UndoStatus, @@ -2923,8 +2921,6 @@ mod tests { }); } - // RFC-0029 Phase 2 runner coverage: raw statement runtime inspection - // captures the transaction read-proof root snapshot. #[test] fn test_trx_read_proof_root_snapshot_captures_active_root() { smol::block_on(async { @@ -2947,39 +2943,23 @@ mod tests { assert_checkpoint_published(&mut session, table_id).await; let mut trx = session.begin_trx().unwrap(); - trx.exec(async |stmt| { - let (rt, effects) = stmt_tests::runtime_and_effects_mut(stmt); - let proof = rt.read_proof(); - let snapshot = - table_for_internal_assertion(&engine, table_id).root_snapshot(&proof); - let _effects_addr = effects as *mut _; - table_for_internal_assertion(&engine, table_id).with_active_root( - &proof, - |active_root| { - assert_eq!(snapshot.root_ts(), active_root.root_ts); - assert_eq!(snapshot.pivot_row_id(), active_root.pivot_row_id); - assert_eq!( - snapshot.column_block_index_root(), - active_root.column_block_index_root - ); - assert_eq!( - snapshot.deletion_cutoff_ts(), - active_root.deletion_cutoff_ts - ); - assert_eq!( - snapshot.secondary_index_root(0), - active_root.secondary_index_roots[0] - ); - assert_eq!( - snapshot.root_is_visible_to(rt.sts()), - active_root.effective_ts() < rt.sts() - ); - }, - ); - Ok(()) - }) - .await - .unwrap(); + let table = table_for_internal_assertion(&engine, table_id); + let observed = observe_table_root_snapshot(&mut trx, &table, 0) + .await + .unwrap(); + let active_root = table.file().active_root_unchecked(); + assert_eq!(observed.root_ts, active_root.root_ts); + assert_eq!(observed.pivot_row_id, active_root.pivot_row_id); + assert_eq!( + observed.column_block_index_root, + active_root.column_block_index_root + ); + assert_eq!(observed.deletion_cutoff_ts, active_root.deletion_cutoff_ts); + assert_eq!( + observed.secondary_index_root, + active_root.secondary_index_roots[0] + ); + assert_eq!(observed.visible, active_root.effective_ts() < observed.sts); trx.rollback().await.unwrap(); }); } @@ -4646,22 +4626,13 @@ mod tests { .lock() .take() .expect("reader hook should install an active transaction"); - // RFC-0029 Phase 2 runner coverage: raw statement runtime - // inspection captures a delayed checkpoint read proof. - reader - .exec(async |stmt| { - let (rt, effects) = stmt_tests::runtime_and_effects_mut(stmt); - let proof = rt.read_proof(); - let snapshot = - table_for_internal_assertion(&engine, table_id).root_snapshot(&proof); - let _effects_addr = effects as *mut _; - assert!(snapshot.root_ts() < rt.sts()); - assert_eq!(snapshot.effective_ts(), effective_ts); - assert!(!snapshot.root_is_visible_to(rt.sts())); - Ok(()) - }) + let table = table_for_internal_assertion(&engine, table_id); + let observed = observe_table_root_snapshot(&mut reader, &table, 0) .await .unwrap(); + assert!(observed.root_ts < observed.sts); + assert_eq!(observed.effective_ts, effective_ts); + assert!(!observed.visible); reader.commit().await.unwrap(); session .wait_for_gc_horizon_after(effective_ts) @@ -6093,7 +6064,7 @@ mod tests { } #[test] - fn test_statement_row_rollback_waits_for_transition_route_publication() { + fn test_transaction_row_rollback_waits_for_transition_route_publication() { smol::block_on(async { let temp_dir = TempDir::new().unwrap(); let engine = lightweight_test_engine(&temp_dir, "transition-row-rollback").await; @@ -6122,32 +6093,13 @@ mod tests { let mut writer_session = engine.new_session().unwrap(); let mut writer = writer_session.begin_trx().unwrap(); let writer_status = transaction_status_for_test(&writer); - let (delete_done_tx, delete_done_rx) = flume::bounded(1); - let (return_error_tx, return_error_rx) = flume::bounded(1); - let statement_key = key.clone(); - // RFC-0029 Phase 2 runner coverage: callback error injection pauses - // statement row rollback at transition-route publication. - let mut statement = Box::pin(writer.exec(async move |stmt| { - let deleted = stmt - .table_delete_unique_mvcc(table_id, statement_key.index_no, &statement_key.vals) - .await?; - assert_eq!(deleted, crate::row::ops::DeleteMvcc::Deleted); - let effects = stmt_tests::statement_effects_mut(stmt); - assert_eq!(effects.undo_counts(), (1, 1)); - let undo = effects.last_row_undo(); - let undo_addr = from_ref(&**undo).addr(); - delete_done_tx.send_async(undo_addr).await.unwrap(); - return_error_rx.recv_async().await.unwrap(); - Err::<(), Error>(Report::new(OperationError::InvalidDmlInput).disclose()) - })); - let delete_done = delete_done_rx.recv_async().fuse(); - futures::pin_mut!(delete_done); - let owned_undo_addr = futures::select! { - result = statement.as_mut().fuse() => { - panic!("statement completed before rollback was requested: {result:?}"); - } - result = delete_done => result.unwrap(), - }; + let deleted = writer + .table_delete_unique_mvcc(table_id, key.index_no, &key.vals) + .await + .unwrap(); + assert_eq!(deleted, DeleteMvcc::Deleted); + let (undo_counts, owned_undo_addr) = transaction_delete_undo_observation(&writer); + assert_eq!(undo_counts, (1, 1)); let (transition_entered_tx, transition_entered_rx) = flume::bounded(1); let (publish_route_tx, publish_route_rx) = flume::bounded(1); @@ -6166,11 +6118,12 @@ mod tests { } let before_wait = engine.inner().poisoner.test_observation_counts(); - return_error_tx.send_async(()).await.unwrap(); + let mut rollback = Box::pin(writer.rollback()); assert!(matches!( - futures::poll!(statement.as_mut()), + futures::poll!(rollback.as_mut()), std::task::Poll::Pending )); + wait_for_route_listener(&engine, before_wait.1).await; let after_wait = engine.inner().poisoner.test_observation_counts(); assert!(after_wait.0 > before_wait.0); assert!(after_wait.1 > before_wait.1); @@ -6226,27 +6179,21 @@ mod tests { publish_route_tx.send_async(()).await.unwrap(); let outcome = checkpoint.await.unwrap(); assert!(matches!(outcome, CheckpointOutcome::Published { .. })); - let statement_error = statement.await.unwrap_err(); + rollback.await.unwrap(); assert_eq!( engine.inner().poisoner.test_observation_counts().1, before_wait.1 + 1, "one route publication must require exactly one registered wait" ); - assert_eq!( - statement_error - .report() - .downcast_ref::() - .copied(), - Some(OperationError::InvalidDmlInput) - ); assert!(row_id < table.mem.pivot_row_id()); assert!(table.deletion_buffer().get(row_id).is_none()); - let selected = trx_select_row_mvcc_by_id(&mut writer, table_id, &key, &[0, 1]) + let mut reader = reader_session.begin_trx().unwrap(); + let selected = trx_select_row_mvcc_by_id(&mut reader, table_id, &key, &[0, 1]) .await .unwrap(); assert!(matches!(selected, SelectMvcc::Found(_))); - writer.rollback().await.unwrap(); + reader.commit().await.unwrap(); }); } @@ -6407,124 +6354,6 @@ mod tests { }); } - #[test] - fn test_statement_cancellation_during_transition_rollback_resumes_in_cleanup() { - smol::block_on(async { - let temp_dir = TempDir::new().unwrap(); - let engine = lightweight_test_engine(&temp_dir, "statement-transition-cancel").await; - let mut checkpoint_session = engine.new_session().unwrap(); - let (table_id, table, key, row_id, page_id) = - setup_frozen_transition_row(&engine, &mut checkpoint_session, "cancel").await; - - let mut writer_session = engine.new_session().unwrap(); - let mut writer = writer_session.begin_trx().unwrap(); - let entry = transaction_entry(&writer); - let trx_id = writer.trx_id(); - let writer_status = transaction_status_for_test(&writer); - let (delete_done_tx, delete_done_rx) = flume::bounded(1); - let (return_error_tx, return_error_rx) = flume::bounded(1); - let statement_key = key.clone(); - // RFC-0029 Phase 2 runner coverage: cancelling a callback during - // row rollback transfers residual transition cleanup ownership. - let mut statement = Box::pin(writer.exec(async move |stmt| { - let deleted = stmt - .table_delete_unique_mvcc(table_id, statement_key.index_no, &statement_key.vals) - .await?; - assert_eq!(deleted, crate::row::ops::DeleteMvcc::Deleted); - let undo = stmt_tests::statement_effects_mut(stmt).last_row_undo(); - delete_done_tx - .send_async(from_ref(&**undo).addr()) - .await - .unwrap(); - return_error_rx.recv_async().await.unwrap(); - Err::<(), Error>(Report::new(OperationError::InvalidDmlInput).disclose()) - })); - let deleted = delete_done_rx.recv_async().fuse(); - futures::pin_mut!(deleted); - let owned_undo_addr = futures::select! { - result = statement.as_mut().fuse() => { - panic!("statement completed before transition setup: {result:?}"); - } - result = deleted => result.unwrap(), - }; - - let (transition_entered, publish_route) = install_transition_publication_pause(&engine); - let mut checkpoint = Box::pin(checkpoint_session.checkpoint_table(table_id).fuse()); - let entered = transition_entered.recv_async().fuse(); - futures::pin_mut!(entered); - futures::select! { - result = checkpoint.as_mut() => { - panic!("checkpoint completed before statement cancellation: {result:?}"); - } - result = entered => result.unwrap(), - } - - return_error_tx.send_async(()).await.unwrap(); - assert!(matches!( - futures::poll!(statement.as_mut()), - std::task::Poll::Pending - )); - let (cleanup_entered_tx, cleanup_entered_rx) = flume::bounded(1); - let (cleanup_release_tx, cleanup_release_rx) = flume::bounded(1); - let _cleanup_hook = install_abandoned_cleanup_test_hook(Arc::new(move |observed| { - if observed == trx_id { - cleanup_entered_tx.send(()).unwrap(); - cleanup_release_rx.recv().unwrap(); - } - })); - drop(statement); - cleanup_entered_rx.recv_async().await.unwrap(); - assert_eq!(entry.inspect().state, SessionOperationState::CleanupReady); - assert!(entry.inspect().cleanup_requested); - - let old_guard = table - .mem - .must_get_row_page_shared(&writer_session.pool_guards(), page_id.page_id) - .await - .unwrap(); - assert_eq!( - old_guard.unwrap_vmap().inspect_state(), - RowPageState::Transition - ); - let old_idx = old_guard.page().row_idx(row_id); - assert!(old_guard.page().is_deleted(old_idx)); - let old_undo = old_guard.unwrap_vmap().read_latch(old_idx); - let old_head = old_undo - .as_ref() - .expect("cancelled statement must leave its row undo linked"); - assert_eq!( - from_ref(old_head.next.main.entry.as_ref()).addr(), - owned_undo_addr - ); - drop(old_undo); - drop(old_guard); - let Some(DeleteMarker::Ref(marker_status)) = table.deletion_buffer().get(row_id) else { - panic!("cancelled transition rollback must retain marker ownership"); - }; - assert!(Arc::ptr_eq(&marker_status, &writer_status)); - - cleanup_release_tx.send(()).unwrap(); - wait_for_operation_state(&entry, SessionOperationState::Completing).await; - drop(writer); - publish_route.send_async(()).await.unwrap(); - assert!(matches!( - checkpoint.await.unwrap(), - CheckpointOutcome::Published { .. } - )); - wait_for_session_idle(&engine.inner().session_registry, writer_session.id()).await; - assert_eq!(entry.inspect().state, SessionOperationState::Terminal); - assert!(table.deletion_buffer().get(row_id).is_none()); - assert_eq!(fatal_rollback_retention_count(&engine.inner().trx_sys), 0); - - let mut reader = writer_session.begin_trx().unwrap(); - let visible = trx_select_row_mvcc_by_id(&mut reader, table_id, &key, &[0, 1]) - .await - .unwrap(); - assert!(matches!(visible, SelectMvcc::Found(_))); - reader.commit().await.unwrap(); - }); - } - #[test] fn test_terminal_rollback_waiter_cancellation_does_not_cancel_transition_cleanup() { smol::block_on(async { @@ -7159,7 +6988,7 @@ mod tests { let mut write_trx = write_session.begin_trx().unwrap(); { let insert = vec![Val::from(10_000i32), Val::from("new")]; - let res = trx_insert_row_by_id(&mut write_trx, table_id, insert).await; + let res = write_trx.table_insert_mvcc(table_id, insert).await; assert!(res.is_ok()); } @@ -7244,7 +7073,7 @@ mod tests { .expect("test table should exist"); let mut session = engine.new_session().unwrap(); let name = "z".repeat(512); - insert_rows_direct(table_id, &mut session, 0, 150, &name).await; + insert_rows(table_id, &mut session, 0, 150, &name).await; assert_freeze_created( session @@ -7794,13 +7623,9 @@ mod tests { let mut writer = engine.new_session().unwrap(); wait_for_checkpoint_root_ready(&mut writer, table_id).await; let mut trx = writer.begin_trx().unwrap(); - trx_insert_row_by_id( - &mut trx, - table_id, - vec![Val::from(1i32), Val::from("blocked")], - ) - .await - .unwrap(); + trx.table_insert_mvcc(table_id, vec![Val::from(1i32), Val::from("blocked")]) + .await + .unwrap(); let mut checkpoint_session = engine.new_session().unwrap(); let table = table_for_internal_assertion(&engine, table_id); diff --git a/doradb-storage/src/table/rollback.rs b/doradb-storage/src/table/rollback.rs index 8ab654a5..f26751cb 100644 --- a/doradb-storage/src/table/rollback.rs +++ b/doradb-storage/src/table/rollback.rs @@ -433,7 +433,6 @@ mod tests { use crate::catalog::tests::table4; use crate::conf::{EngineConfig, EvictableBufferPoolConfig, TrxSysConfig}; use crate::engine::Engine; - use crate::error::{DiscloseError, OperationError, Result}; use crate::id::RowID; use crate::index::RowLocation; use crate::row::ops::{DeleteMvcc, SelectKey, SelectMvcc, UpdateCol, UpdateMvcc}; @@ -442,11 +441,8 @@ mod tests { use crate::table::tests::*; use crate::trx::MAX_SNAPSHOT_TS; use crate::value::Val; - use error_stack::Report; use tempfile::TempDir; - // RFC-0029 Phase 2 runner coverage: same-statement index inspection before - // callback return verifies delete rollback ordering. #[test] fn test_column_delete_rollback() { smol::block_on(async { @@ -472,22 +468,17 @@ mod tests { trx.commit().await.unwrap(); let mut trx = session.begin_trx().unwrap(); - trx.exec(async |stmt| { - let res = stmt_delete_row_by_id(stmt, table_id, &key).await; - assert!(matches!(res, Ok(DeleteMvcc::Deleted))); - assert_unique_index_entry( - &table_for_internal_assertion(&engine, table_id), - &session.pool_guards(), - &key, - stmt.runtime().sts(), - old_row_id, - true, - ) - .await; - Ok(()) - }) - .await - .unwrap(); + let res = trx_delete_row_by_id(&mut trx, table_id, &key).await; + assert!(matches!(res, Ok(DeleteMvcc::Deleted))); + assert_unique_index_entry( + &table_for_internal_assertion(&engine, table_id), + &session.pool_guards(), + &key, + trx.sts(), + old_row_id, + true, + ) + .await; trx.rollback().await.unwrap(); assert_unique_index_entry( &table_for_internal_assertion(&engine, table_id), @@ -554,8 +545,6 @@ mod tests { }); } - // RFC-0029 Phase 2 runner coverage: callback error injection after raw - // index inspection drives statement-local insert rollback. #[test] fn test_unique_insert_rollback_restores_deleted_owner_even_when_row_missing() { smol::block_on(async { @@ -610,36 +599,20 @@ mod tests { .await; let mut trx = session.begin_trx().unwrap(); - let res: Result<()> = trx - .exec(async |stmt| { - let new_row_id = unwrap_insert_result( - stmt_insert_row_by_id( - stmt, - table_id, - vec![Val::from(10_001i32), Val::from("reborn")], - ) - .await, - ); - assert_ne!(new_row_id, RowID::new(stale_row_id)); - assert_unique_index_entry( - &table_for_internal_assertion(&engine, table_id), - &session.pool_guards(), - &key, - stmt.runtime().sts(), - new_row_id, - false, - ) - .await; - Err(Report::new(OperationError::InvalidDmlInput).disclose()) - }) - .await; - assert_eq!( - res.unwrap_err() - .report() - .downcast_ref::() - .copied(), - Some(OperationError::InvalidDmlInput) + let new_row_id = unwrap_insert_result( + trx.table_insert_mvcc(table_id, vec![Val::from(10_001i32), Val::from("reborn")]) + .await, ); + assert_ne!(new_row_id, RowID::new(stale_row_id)); + assert_unique_index_entry( + &table_for_internal_assertion(&engine, table_id), + &session.pool_guards(), + &key, + trx.sts(), + new_row_id, + false, + ) + .await; trx.rollback().await.unwrap(); assert_unique_index_entry( @@ -665,8 +638,6 @@ mod tests { }); } - // RFC-0029 Phase 2 runner coverage: callback error injection after raw - // stale-owner inspection drives statement-local insert rollback. #[test] fn test_unique_insert_rollback_restores_delete_marked_stale_hot_owner() { smol::block_on(async { @@ -730,36 +701,20 @@ mod tests { ); let mut trx = session.begin_trx().unwrap(); - let res: Result<()> = trx - .exec(async |stmt| { - let new_row_id = unwrap_insert_result( - stmt_insert_row_by_id( - stmt, - table_id, - vec![Val::from(2i32), Val::from("two")], - ) - .await, - ); - assert_ne!(new_row_id, old_row_id); - assert_unique_index_entry( - &table_for_internal_assertion(&engine, table_id), - &session.pool_guards(), - &stale_key, - stmt.runtime().sts(), - new_row_id, - false, - ) - .await; - Err(Report::new(OperationError::InvalidDmlInput).disclose()) - }) - .await; - assert_eq!( - res.unwrap_err() - .report() - .downcast_ref::() - .copied(), - Some(OperationError::InvalidDmlInput) + let new_row_id = unwrap_insert_result( + trx.table_insert_mvcc(table_id, vec![Val::from(2i32), Val::from("two")]) + .await, ); + assert_ne!(new_row_id, old_row_id); + assert_unique_index_entry( + &table_for_internal_assertion(&engine, table_id), + &session.pool_guards(), + &stale_key, + trx.sts(), + new_row_id, + false, + ) + .await; trx.rollback().await.unwrap(); assert_unique_index_entry( @@ -862,20 +817,17 @@ mod tests { let user_read_set = &[0usize, 1]; let mut trx = session.begin_trx().unwrap(); for i in 0i32..5i32 { - let res = - trx_insert_row_by_id(&mut trx, table_id, vec![Val::from(i), Val::from(i)]) - .await; + let res = trx + .table_insert_mvcc(table_id, vec![Val::from(i), Val::from(i)]) + .await; assert!(res.is_ok()); } trx.commit().await.unwrap(); let mut trx = session.begin_trx().unwrap(); - let res = trx_insert_row_by_id( - &mut trx, - table_id, - vec![Val::from(5i32), Val::from(5i32)], - ) - .await; + let res = trx + .table_insert_mvcc(table_id, vec![Val::from(5i32), Val::from(5i32)]) + .await; assert!(res.is_ok()); trx.rollback().await.unwrap(); @@ -921,9 +873,9 @@ mod tests { let key = SelectKey::new(0, vec![Val::from(3i32)]); let res = trx_delete_row_by_id(&mut trx, table_id, &key).await; assert!(matches!(res, Ok(DeleteMvcc::Deleted))); - let res = - trx_insert_row_by_id(&mut trx, table_id, vec![Val::from(3), Val::from(3)]) - .await; + let res = trx + .table_insert_mvcc(table_id, vec![Val::from(3), Val::from(3)]) + .await; assert!(res.is_ok()); trx.rollback().await.unwrap(); diff --git a/doradb-storage/src/trx/interface.rs b/doradb-storage/src/trx/interface.rs index bf62ee17..72bada46 100644 --- a/doradb-storage/src/trx/interface.rs +++ b/doradb-storage/src/trx/interface.rs @@ -1,14 +1,16 @@ -use crate::error::Result; +use crate::error::{DiscloseResultExt, MultiDomainResultExt, Result}; use crate::id::{RowID, TableID}; use crate::row::ops::{ DeleteMvcc, RowMutation, ScanMvcc, SelectMvcc, TableMutationOutcome, UpdateCol, UpdateMvcc, UpsertMvcc, }; use crate::table::LazyRow; -use crate::trx::{IndexScanMvccStream, StreamStmt, Transaction}; +use crate::trx::{IndexScanMvccStream, Transaction}; use crate::value::Val; use std::ops::RangeBounds; +use super::stream_stmt::{INDEX_SCAN_STREAM_OPERATION, StreamStmtState}; + impl Transaction { /// Executes one empty statement through the normal transaction settlement path. #[inline] @@ -192,8 +194,13 @@ impl Transaction { where R: RangeBounds<&'r [Val]>, { - StreamStmt::new(self) - .table_index_scan_mvcc(table_id, index_no, range, read_set) + let dml_validation_disabled = self.dml_validation_disabled; + let checkout = self + .checkout() + .attach_with(|| format!("operation={INDEX_SCAN_STREAM_OPERATION}")) + .disclose()?; + StreamStmtState::new(checkout, dml_validation_disabled) + .table_index_scan_mvcc_stream(table_id, index_no, range, read_set) .await } } diff --git a/doradb-storage/src/trx/mod.rs b/doradb-storage/src/trx/mod.rs index b9280ef8..b1dc63a7 100644 --- a/doradb-storage/src/trx/mod.rs +++ b/doradb-storage/src/trx/mod.rs @@ -38,7 +38,7 @@ pub(crate) use sys_trx::{RetiredRowPageBatch, SysTrxPayload}; use crate::buffer::PoolGuards; use crate::buffer::page::VersionedPageID; -use crate::catalog::{TableCache, is_catalog_table}; +use crate::catalog::{CatalogTable, TableCache, is_catalog_table}; use crate::completion::Completion; use crate::engine::EngineCore; use crate::error::{ @@ -47,7 +47,7 @@ use crate::error::{ MultiDomainResultExt, OperationOrFatalResult, ResourceError, Result, RuntimeError, RuntimeOrFatalError, RuntimeOrFatalResult, SharedFatalError, }; -use crate::id::{SessionID, SessionOperationKey, TableID, TrxID}; +use crate::id::{RowID, SessionID, SessionOperationKey, TableID, TrxID}; use crate::lock::{ FamilyLockAuthority, FreshClaimsGuard, LockMode, LockResource, LockScope, LockScopeState, TableLockMode, TransactionLockState, @@ -58,10 +58,12 @@ use crate::map::FastHashMap; use crate::notify::EventNotifyOnDrop; use crate::obs; use crate::poison::PoisonAwareListener; +use crate::row::ops::DeleteMvcc; use crate::session::{SessionRuntime, TrxAttachment, WeakSessionRef}; use crate::trx::undo::{ IndexPurgeEntry, IndexUndoLogs, RowUndoHead, RowUndoLogs, RowUndoRollbackContext, UndoStatus, }; +use crate::value::Val; use error_stack::{Report, ResultExt}; use event_listener::{Event, EventListener}; use futures::FutureExt; @@ -75,9 +77,8 @@ use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; pub(crate) use admission::TableAdmissionRequest; -pub use stmt::Statement; -use stmt::{StmtEffects, StmtState}; -pub use stream_stmt::{IndexScanMvccStream, StreamStmt}; +use stmt::{PrivateStmtState, Statement, StmtState}; +pub use stream_stmt::IndexScanMvccStream; /// Minimum snapshot timestamp assigned by the transaction system. pub(crate) const MIN_SNAPSHOT_TS: TrxID = TrxID::new(1); /// Exclusive upper bound for snapshot timestamps. @@ -129,6 +130,7 @@ pub struct Transaction { sts: TrxID, operation_key: SessionOperationKey, session: WeakSessionRef, + dml_validation_disabled: bool, terminal_started: bool, } @@ -146,6 +148,7 @@ impl Transaction { sts, operation_key, session, + dml_validation_disabled: false, terminal_started: false, } } @@ -233,6 +236,19 @@ impl Transaction { self.sts } + /// Enable or disable validation for subsequent direct table and stream operations. + /// + /// Validation is enabled by default. Disable it only after proving row + /// shapes, value types, nullability, sparse updates, lookup keys, index + /// ranges, and read sets against the target table metadata. The setting is + /// transaction-local and remains in effect until changed again. Invalid + /// trusted input may surface as a debug assertion or internal error instead + /// of `InvalidDmlInput`. + #[inline] + pub fn disable_dml_validation(&mut self, disable: bool) { + self.dml_validation_disabled = disable; + } + /// Acquires an explicit transaction-lifetime table lock. #[inline] pub async fn lock_table(&mut self, table_id: TableID, mode: TableLockMode) -> Result<()> { @@ -244,49 +260,35 @@ impl Transaction { checkout.lock_table(table_id, mode).await.disclose() } - /// Creates a statement facade for public caller-driven transaction streams. + /// Executes one engine-selected owned statement operation. #[inline] - pub fn stream_stmt(&mut self) -> StreamStmt<'_> { - StreamStmt::new(self) - } - - /// Executes one scoped statement callback inside this active transaction. - /// - /// Successful callbacks merge statement-local row undo, index undo, and - /// redo effects into the transaction. Ordinary callback errors roll back - /// only the current statement and leave previous successful statements - /// transaction-owned. Dropping the future before its first poll performs no - /// checkout. Dropping it after checkout synchronously settles - /// statement-local ownership, terminally cancels the transaction, and - /// queues whole-transaction rollback. The public transaction facade is - /// discarded after that cancellation. - #[inline] - pub async fn exec(&mut self, f: F) -> Result + async fn exec(&mut self, action: F) -> Result where - F: for<'borrow> AsyncFnOnce(&'borrow mut Statement<'_>) -> Result, + F: for<'stmt> AsyncFnOnce(Statement<'stmt>) -> Result, { let checkout = self .checkout() .attach("operation=execute_statement") .disclose()?; - let mut stmt_state = StmtState::public(checkout); + let mut stmt_state = StmtState::public(checkout, self.dml_validation_disabled); enum ExecOutcome { Success(T), StatementError(Error), FatalRollback(Report), } - let outcome = { - let mut stmt = stmt_state.statement(); - match f(&mut stmt).await { - Ok(value) => { - stmt.merge_effects(); - ExecOutcome::Success(value) - } - Err(err) => match stmt.rollback_effects().await { - Ok(()) => ExecOutcome::StatementError(err), - Err(rollback_err) => ExecOutcome::FatalRollback(rollback_err), - }, + let stmt_result = { + let stmt = stmt_state.statement(); + action(stmt).await + }; + let outcome = match stmt_result { + Ok(value) => { + stmt_state.merge_effects(); + ExecOutcome::Success(value) } + Err(err) => match stmt_state.rollback_effects().await { + Ok(()) => ExecOutcome::StatementError(err), + Err(rollback_err) => ExecOutcome::FatalRollback(rollback_err), + }, }; match outcome { ExecOutcome::Success(value) => { @@ -399,38 +401,107 @@ impl PrivateTransaction { .ensure_healthy() } - /// Execute one private statement without returning the core to its entry. - /// - /// Ordinary errors retain all complete and partial undo in transaction - /// effects for whole-transaction rollback. A callback panic discards - /// incomplete redo, folds residual undo into the transaction, and resumes - /// the original unwind while this facade still owns the checkout. + /// Execute one owned private statement without returning the core to its entry. #[inline] - pub(crate) async fn stage_statement(&mut self, f: F) -> RuntimeOrFatalResult + async fn exec(&mut self, operation: F) -> RuntimeOrFatalResult where - F: for<'borrow> AsyncFnOnce(&'borrow mut Statement<'_>) -> RuntimeOrFatalResult, + F: for<'stmt> AsyncFnOnce(Statement<'stmt>) -> RuntimeOrFatalResult, { - let checkout = self.checkout_mut(); - let stmt_no = checkout.inner_mut().next_stmt_no(); - let mut effects = StmtEffects::new(stmt_no); - let outcome = AssertUnwindSafe(async { - let (inner, attachment) = checkout.inner_and_attachment_mut(); - let mut stmt = Statement::new(inner, attachment, &mut effects); - let result = f(&mut stmt).await; - stmt.merge_effects(); - result - }) - .catch_unwind() - .await; + let mut stmt_state = PrivateStmtState::new(self.checkout_mut()); + let outcome = { + let stmt = stmt_state.statement(); + AssertUnwindSafe(operation(stmt)).catch_unwind().await + }; match outcome { - Ok(result) => result, + Ok(Ok(value)) => { + stmt_state.merge_effects(); + Ok(value) + } + Ok(Err(err)) => match stmt_state.rollback_effects().await { + Ok(()) => Err(err), + Err(rollback_err) => Err(RuntimeOrFatalError::Fatal(rollback_err)), + }, Err(panic) => { - effects.fold_cancelled_into_trx_effects(checkout.inner_mut().effects_mut()); + stmt_state.fold_cancelled_into_transaction(); resume_unwind(panic); } } } + /// Return the retained private transaction's buffer-pool guards. + #[inline] + pub(crate) fn pool_guards(&self) -> &PoolGuards { + self.checkout().attachment().pool_guards() + } + + /// Insert one catalog row through one owned private statement. + #[inline] + pub(crate) async fn catalog_insert_mvcc( + &mut self, + table: &CatalogTable, + cols: Vec, + ) -> RuntimeOrFatalResult { + self.exec(async move |stmt| stmt.catalog_insert_mvcc(table, cols).await) + .await + } + + /// Insert one ordered catalog row batch through one owned private statement. + #[inline] + pub(crate) async fn catalog_insert_batch_mvcc( + &mut self, + table: &CatalogTable, + rows: Vec>, + ) -> RuntimeOrFatalResult<()> { + self.exec(async move |stmt| stmt.catalog_insert_batch_mvcc(table, rows).await) + .await + } + + /// Delete one exact catalog primary-key row through one owned statement. + #[inline] + pub(crate) async fn catalog_delete_primary_key_mvcc( + &mut self, + table: &CatalogTable, + index_no: usize, + key_vals: Vec, + ) -> RuntimeOrFatalResult { + self.exec(async move |stmt| { + stmt.catalog_delete_primary_key_mvcc(table, index_no, &key_vals, true) + .await + }) + .await + } + + /// Delete an ordered catalog primary-key batch through one owned statement. + #[inline] + pub(crate) async fn catalog_delete_primary_key_batch_mvcc( + &mut self, + table: &CatalogTable, + index_no: usize, + keys: Vec>, + ) -> RuntimeOrFatalResult { + self.exec(async move |stmt| { + stmt.catalog_delete_primary_key_batch_mvcc(table, index_no, keys) + .await + }) + .await + } + + /// Replace one catalog metadata row through one delete-then-insert statement. + #[inline] + pub(crate) async fn catalog_replace_primary_key_mvcc( + &mut self, + table: &CatalogTable, + index_no: usize, + key_vals: Vec, + cols: Vec, + ) -> RuntimeOrFatalResult { + self.exec(async move |stmt| { + stmt.catalog_replace_primary_key_mvcc(table, index_no, &key_vals, cols) + .await + }) + .await + } + /// Install the exact catalog DDL marker after every catalog statement succeeds. #[inline] pub(crate) fn install_ddl_redo(&mut self, ddl: DDLRedo) { @@ -1065,13 +1136,6 @@ pub(crate) enum SessionOperationState { } impl SessionOperationState { - /// Returns whether this state still blocks operation admission and shutdown. - #[cfg(test)] - #[inline] - pub(crate) const fn active(self) -> bool { - !matches!(self, Self::Terminal) - } - /// Returns the stable snake-case diagnostic label. #[inline] pub(crate) const fn label(self) -> &'static str { @@ -1225,17 +1289,6 @@ impl SessionOperationEntry { } } - /// Returns the checked-in core allocation address for lifecycle tests. - #[cfg(test)] - #[inline] - pub(crate) fn inner_ptr_for_test(&self) -> Option { - self.inner - .lock() - .trx_inner - .as_deref() - .map(|inner| inner as *const TrxInner as usize) - } - /// Validate that accepted mandatory execution may start a private transaction. #[inline] pub(crate) fn validate_private_transaction_begin(&self) -> LifecycleResult<()> { @@ -2156,16 +2209,6 @@ impl SessionOperationCompletionClaim { .expect("active completion claim retains terminal attachment") .engine() } - - /// Returns the exact transaction identity retained by this claim. - #[cfg(test)] - #[inline] - pub(crate) const fn trx_id(&self) -> TrxID { - self.attachment - .as_ref() - .expect("active completion claim retains terminal attachment") - .trx_id() - } } /// Abandoned transaction cleanup job. @@ -3376,15 +3419,6 @@ impl CommittedTrx { } } - #[inline] - #[cfg(test)] - fn retired_row_pages(&self) -> Option<&RetiredRowPageBatch> { - match self.payload.as_ref() { - Some(CommittedTrxPayload::System(payload)) => Some(&payload.retired_row_pages), - Some(CommittedTrxPayload::User { .. }) | None => None, - } - } - #[inline] fn into_retired_row_pages(mut self) -> Option { match self.payload.take() { @@ -3469,7 +3503,9 @@ fn is_catalog_metadata_ddl(ddl: Option<&DDLRedo>) -> bool { #[cfg(test)] pub(crate) mod tests { use super::*; + use crate::buffer::EvictableBufferPool; use crate::buffer::frame::FrameKind; + use crate::buffer::guard::PageSharedGuard; use crate::buffer::page::PAGE_SIZE; use crate::buffer::test_frame_kind; use crate::catalog::storage::tables::TABLE_ID_TABLES; @@ -3480,7 +3516,7 @@ pub(crate) mod tests { use crate::error::{InternalError, OperationError}; use crate::file::cow_file::tests::old_root_drop_count; use crate::file::table_file::{MutableTableFile, TableFile}; - use crate::id::{OperationID, PageID, RowID, SessionID}; + use crate::id::{BlockID, OperationID, PageID, RowID, SessionID}; use crate::io::{ IOKind, StdIoResult, StorageBackendFileIdentity, StorageBackendOp, StorageBackendTestHook, install_storage_backend_test_hook, @@ -3489,7 +3525,8 @@ pub(crate) mod tests { use crate::lock::{LockManager, LockOwner}; use crate::log::redo::{RowRedo, RowRedoKind}; use crate::quiescent::QuiescentGuard; - use crate::row::ops::SelectKey; + use crate::row::RowPage; + use crate::row::ops::{DeleteMvcc, SelectKey, UpdateCol, UpdateMvcc, UpsertMvcc}; use crate::session::{ Session, SessionRegistry, SessionShutdownWait, tests::{ @@ -3499,7 +3536,7 @@ pub(crate) mod tests { session_has_public_trx_cache, session_registry_len, wait_for_session_idle, }, }; - use crate::table::test_user_table_id; + use crate::table::{MemTable, Table, test_user_table_id}; use crate::trx::stmt::tests as stmt_tests; use crate::trx::sys::tests::{ TerminalRollbackTestHookGuard, fatal_rollback_retention_count, @@ -3518,6 +3555,7 @@ pub(crate) mod tests { use std::io::Error as IoError; use std::panic::{AssertUnwindSafe, catch_unwind}; use std::pin::Pin; + use std::ptr::from_ref; use std::sync::atomic::AtomicUsize; use std::sync::{Arc, Condvar, Mutex, OnceLock, mpsc}; use std::thread::{scope, sleep, spawn}; @@ -3562,6 +3600,168 @@ pub(crate) mod tests { } } + /// Hold one checked-out statement pending for cancellation tests. + pub(crate) async fn pending_statement(trx: &mut Transaction) -> Result<()> { + trx.exec(async |stmt| { + let _stmt = stmt; + pending::<()>().await; + Ok(()) + }) + .await + } + + /// Insert into a standalone MemTable through production statement settlement. + pub(crate) async fn mem_table_insert_mvcc( + trx: &mut Transaction, + mem_table: &MemTable, + cols: Vec, + ) -> Result { + trx.exec(async move |stmt| stmt_tests::mem_table_insert_mvcc(stmt, mem_table, cols).await) + .await + } + + /// Upsert into a standalone MemTable through production statement settlement. + pub(crate) async fn mem_table_upsert_unique_mvcc( + trx: &mut Transaction, + mem_table: &MemTable, + cols: Vec, + ) -> Result { + trx.exec(async move |stmt| { + stmt_tests::mem_table_upsert_unique_mvcc(stmt, mem_table, cols).await + }) + .await + } + + /// Update a standalone MemTable through production statement settlement. + pub(crate) async fn mem_table_update_unique_mvcc( + trx: &mut Transaction, + mem_table: &MemTable, + key: &SelectKey, + update: Vec, + ) -> Result { + trx.exec(async move |stmt| { + stmt_tests::mem_table_update_unique_mvcc(stmt, mem_table, key, update).await + }) + .await + } + + /// Delete from a standalone MemTable through production statement settlement. + pub(crate) async fn mem_table_delete_unique_mvcc( + trx: &mut Transaction, + mem_table: &MemTable, + key: &SelectKey, + ) -> Result { + trx.exec(async move |stmt| { + stmt_tests::mem_table_delete_unique_mvcc(stmt, mem_table, key).await + }) + .await + } + + /// Apply one index-only key change through production statement settlement. + #[allow(clippy::too_many_arguments)] + pub(crate) async fn mem_table_duplicate_index_key_change( + trx: &mut Transaction, + mem_table: &MemTable, + page_guard: PageSharedGuard, + row_id: RowID, + old_key: SelectKey, + new_key: SelectKey, + ) -> Result<()> { + trx.exec(async move |stmt| { + stmt_tests::mem_table_duplicate_index_key_change( + stmt, mem_table, page_guard, row_id, old_key, new_key, + ) + .await + }) + .await + } + + /// Run the focused transition-page insert/update operation through settlement. + #[allow(clippy::too_many_arguments)] + pub(crate) async fn transition_insert_update( + trx: &mut Transaction, + table: &Table, + insert_page_guard: PageSharedGuard, + insert: Vec, + page_guard: &PageSharedGuard, + row_id: RowID, + key: &SelectKey, + update: Vec, + ) -> Result<(bool, bool)> { + trx.exec(async move |stmt| { + stmt_tests::transition_insert_update( + stmt, + table, + insert_page_guard, + insert, + page_guard, + row_id, + key, + update, + ) + .await + }) + .await + } + + /// Run the focused transition-page delete operation through settlement. + pub(crate) async fn transition_delete( + trx: &mut Transaction, + table: &Table, + page_guard: &PageSharedGuard, + row_id: RowID, + key: &SelectKey, + ) -> Result { + trx.exec(async move |stmt| { + stmt_tests::transition_delete(stmt, table, page_guard, row_id, key).await + }) + .await + } + + /// Install one hot-row lock and pause before forcing statement rollback. + #[allow(clippy::too_many_arguments)] + pub(crate) async fn lock_hot_row_then_wait_and_error( + trx: &mut Transaction, + table: &Table, + page_guard: PageSharedGuard, + row_id: RowID, + key: &SelectKey, + lock_installed: flume::Sender, + return_error: flume::Receiver<()>, + ) -> Result<()> { + trx.exec(async move |stmt| { + stmt_tests::lock_hot_row_then_wait_and_error( + stmt, + table, + page_guard, + row_id, + key, + lock_installed, + return_error, + ) + .await + }) + .await + } + + /// Execute one empty private statement without returning its held checkout. + pub(crate) async fn private_noop(trx: &mut PrivateTransaction) -> RuntimeOrFatalResult<()> { + trx.exec(async |_| Ok(())).await + } + + /// Return the checked-in transaction core address retained by one entry. + #[inline] + pub(crate) fn session_operation_entry_inner_ptr( + entry: &SessionOperationEntry, + ) -> Option { + entry + .inner + .lock() + .trx_inner + .as_deref() + .map(|inner| inner as *const TrxInner as usize) + } + /// Publish one committed result on a test-controlled shared status. #[inline] pub(crate) fn commit_shared_trx_status(status: &SharedTrxStatus, cts: TrxID) { @@ -3621,21 +3821,79 @@ pub(crate) mod tests { inner } - /// Install one transaction-level DDL marker for catalog and recovery tests. - pub(crate) fn install_transaction_ddl_redo( - trx: &mut Transaction, - ddl: DDLRedo, - ) -> LifecycleOrFatalResult<()> { - let mut checkout = trx.checkout()?; - checkout.inner_mut().effects_mut().install_ddl_redo(ddl); - Ok(()) - } - /// Return the core allocation held by one running private transaction. pub(crate) fn private_transaction_inner_ptr(trx: &PrivateTransaction) -> usize { trx.checkout().inner() as *const TrxInner as usize } + /// Owned observation returned by the proof-bound root snapshot test operation. + pub(crate) struct RootSnapshotObservation { + pub(crate) root_ts: TrxID, + pub(crate) effective_ts: TrxID, + pub(crate) pivot_row_id: RowID, + pub(crate) column_block_index_root: BlockID, + pub(crate) deletion_cutoff_ts: TrxID, + pub(crate) secondary_index_root: BlockID, + pub(crate) visible: bool, + pub(crate) sts: TrxID, + } + + /// Capture one proof-bound table root through the production statement runner. + pub(crate) async fn observe_table_root_snapshot( + trx: &mut Transaction, + table: &Table, + index_no: usize, + ) -> Result { + trx.exec(async |stmt| { + let rt = stmt.runtime(); + let proof = rt.read_proof(); + let snapshot = table.root_snapshot(&proof); + Ok(RootSnapshotObservation { + root_ts: snapshot.root_ts(), + effective_ts: snapshot.effective_ts(), + pivot_row_id: snapshot.pivot_row_id(), + column_block_index_root: snapshot.column_block_index_root(), + deletion_cutoff_ts: snapshot.deletion_cutoff_ts(), + secondary_index_root: snapshot.secondary_index_root(index_no), + visible: snapshot.root_is_visible_to(rt.sts()), + sts: rt.sts(), + }) + }) + .await + } + + /// Count physical row redo kinds currently owned by one transaction. + pub(crate) fn transaction_redo_kind_counts( + trx: &mut Transaction, + table_id: TableID, + ) -> Result<(usize, usize, usize, usize)> { + let checkout = trx.checkout().disclose()?; + let rows = &checkout + .inner() + .effects + .redo + .dml + .get(&table_id) + .expect("test transaction must own table redo") + .rows; + let mut cold_deletes = 0; + let mut hot_deletes = 0; + let mut inserts = 0; + let mut updates = 0; + for row in rows.values() { + match row.kind { + RowRedoKind::Delete(None) => cold_deletes += 1, + RowRedoKind::Delete(Some(_)) => hot_deletes += 1, + RowRedoKind::Insert(..) => inserts += 1, + RowRedoKind::Update(..) => updates += 1, + RowRedoKind::DeleteByPrimaryKey(_) | RowRedoKind::UpdateByPrimaryKey(..) => { + panic!("user-table transaction must contain physical redo") + } + } + } + Ok((cold_deletes, hot_deletes, inserts, updates)) + } + /// Return the exact number of snapshot timestamps registered for GC. pub(crate) fn active_sts_count(trx_sys: &sys::TransactionSystem) -> usize { trx_sys @@ -4217,26 +4475,6 @@ pub(crate) mod tests { assert_eq!(snapshot.state, SessionOperationState::CleanupReady); } - #[test] - fn test_operation_state_activity_labels() { - for state in [ - SessionOperationState::Voluntary(None), - SessionOperationState::Voluntary(Some(InternalTrxState::Running)), - SessionOperationState::CleanupReady, - SessionOperationState::Completing, - SessionOperationState::Mandatory(None), - SessionOperationState::Mandatory(Some(InternalTrxState::Completing)), - SessionOperationState::FailedRetained, - ] { - assert!( - state.active(), - "state should block shutdown: {}", - state.label() - ); - } - assert!(!SessionOperationState::Terminal.active()); - } - #[test] fn test_failed_entry_remains_an_active_operation_blocker() { smol::block_on(async { @@ -4439,6 +4677,25 @@ pub(crate) mod tests { .expect("test transaction must be active") } + /// Return the transaction-level undo state installed by one completed delete. + #[inline] + pub(crate) fn transaction_delete_undo_observation( + trx: &Transaction, + ) -> ((usize, usize), usize) { + with_transaction_inner(trx, "query_test_delete_undo", |inner| { + let undo = inner + .effects + .row_undo + .last() + .expect("completed delete must retain one row undo entry"); + ( + (inner.effects.row_undo.len(), inner.effects.index_undo.len()), + from_ref(&**undo).addr(), + ) + }) + .expect("test transaction must be active") + } + #[inline] fn begin_production_test_transaction(engine: &Engine) -> (Session, Transaction) { let mut session = engine.new_session().unwrap(); @@ -4518,12 +4775,12 @@ pub(crate) mod tests { static PSEUDO_SYSBENCH_VAR1: [u8; 60] = [3; 60]; static PSEUDO_SYSBENCH_VAR2: [u8; 120] = [4; 120]; - // RFC-0029 Phase 2 runner coverage: tests inject raw redo without a - // physical row operation through the legacy statement effects. - trx.exec(async |stmt| { + // Focused owned-runner tests inject raw redo without a physical row + // operation. + trx.exec(async |mut stmt| { // Simulate one sysbench record: // uint64 + int32 + int32 + char(60) + char(120) - stmt_tests::statement_effects_mut(stmt).insert_row_redo( + stmt_tests::statement_effects_mut(&mut stmt).insert_row_redo( USER_TABLE_ID_START, RowRedo { row_id: RowID::new(0), @@ -5032,6 +5289,35 @@ pub(crate) mod tests { assert!(status.prepare_ev.lock().is_none()); } + #[test] + fn test_prepare_completion_won_registration_rechecks_poison() { + smol::block_on(async { + let (_temp_dir, engine) = test_engine("prepare_completion_won_poison").await; + let (_session, mut trx) = begin_production_test_transaction(&engine); + let result: Result<()> = trx + .exec(async |stmt| { + stmt.runtime().engine().poisoner.poison( + Report::new(FatalError::StorageIo) + .attach("unrelated foreground wait poison: completion won"), + ); + stmt.runtime() + .wait_prepare_or_poison(PoisonAwareListener::recheck_only()) + .await + .disclose() + }) + .await; + + let err = result.unwrap_err(); + assert_eq!(err.kind(), crate::error::ErrorKind::Fatal); + assert_eq!( + err.report().downcast_ref::().copied(), + Some(FatalError::StorageIo) + ); + assert!(format!("{err:?}").contains("unrelated foreground wait poison")); + trx.rollback().await.unwrap(); + }); + } + #[test] fn test_cancelled_prepare_listener_leaves_event_for_completion() { let status = shared_trx_status(MIN_ACTIVE_TRX_ID + 90_003); @@ -5108,15 +5394,15 @@ pub(crate) mod tests { }); } - // RFC-0029 Phase 2 runner coverage: raw statement effects verify success - // merge into transaction-owned undo and redo. + // Focused owned-runner coverage verifies raw statement effects merge into + // transaction-owned undo and redo. #[test] fn test_statement_success_merges_statement_effects_into_transaction_effects() { smol::block_on(async { let (_temp_dir, engine) = test_engine("redo_stmt_effect_merge").await; let (_session, mut trx) = begin_production_test_transaction(&engine); - trx.exec(async |stmt| { - let effects = stmt_tests::statement_effects_mut(stmt); + trx.exec(async |mut stmt| { + let effects = stmt_tests::statement_effects_mut(&mut stmt); effects.push_row_undo(OwnedRowUndo::new( effects.stmt_no(), TableID::new(12), @@ -5154,8 +5440,7 @@ pub(crate) mod tests { }); } - // RFC-0029 Phase 2 runner coverage: an unpolled legacy callback future - // must not check out the transaction core. + // An unpolled direct operation must not check out the transaction core. #[test] fn test_unpolled_statement_future_leaves_transaction_reusable() { smol::block_on(async { @@ -5163,19 +5448,19 @@ pub(crate) mod tests { let (_session, mut trx) = begin_production_test_transaction(&engine); let entry = transaction_entry(&trx); - let exec = trx.exec(async |_| Ok::<(), Error>(())); + let exec = trx.noop(); drop(exec); let snapshot = entry.inspect(); assert_eq!(snapshot.state, SessionOperationState::Voluntary(None)); assert!(!snapshot.cleanup_requested); - trx.exec(async |_| Ok::<(), Error>(())).await.unwrap(); + trx.noop().await.unwrap(); trx.rollback().await.unwrap(); }); } - // RFC-0029 Phase 2 runner coverage: dropping a checked-out legacy callback - // terminally transfers transaction cleanup ownership. + // Dropping a checked-out direct operation terminally transfers transaction + // cleanup ownership. #[test] fn test_dropped_polled_statement_future_terminally_cancels_transaction() { smol::block_on(async { @@ -5183,10 +5468,7 @@ pub(crate) mod tests { let (session, mut trx) = begin_production_test_transaction(&engine); let session_id = session.id(); let entry = transaction_entry(&trx); - let mut exec = Box::pin(trx.exec(async |_| { - pending::<()>().await; - Ok::<(), Error>(()) - })); + let mut exec = Box::pin(pending_statement(&mut trx)); assert!(matches!( futures::poll!(exec.as_mut()), @@ -5212,8 +5494,8 @@ pub(crate) mod tests { }); } - // RFC-0029 Phase 2 runner coverage: cancellation folds raw effects and - // transaction locks into terminal cleanup. + // Focused owned-runner cancellation folds raw effects and transaction + // locks into terminal cleanup. #[test] fn test_dropped_effectful_statement_discards_redo_and_terminally_releases_locks() { smol::block_on(async { @@ -5222,9 +5504,9 @@ pub(crate) mod tests { let session_id = session.id(); let trx_owner = lock_owner(&trx).unwrap(); let resource = LockResource::TableMetadata(TableID::new(91_430)); - let mut exec = Box::pin(trx.exec(async |stmt| { - stmt_tests::acquire_transaction_lock(stmt, resource, LockMode::Shared).await?; - stmt_tests::statement_effects_mut(stmt).insert_row_redo( + let mut exec = Box::pin(trx.exec(async |mut stmt| { + stmt_tests::acquire_transaction_lock(&mut stmt, resource, LockMode::Shared).await?; + stmt_tests::statement_effects_mut(&mut stmt).insert_row_redo( TableID::new(91_430), RowRedo { row_id: RowID::new(1), @@ -5291,8 +5573,8 @@ pub(crate) mod tests { } else { pause_next_row_rollback(); } - // RFC-0029 Phase 2 runner coverage: cancellation during legacy - // statement rollback folds residual row and index undo into cleanup. + // Focused owned-runner cancellation during statement rollback folds + // residual row and index undo into cleanup. let mut exec = Box::pin(trx.exec(async |stmt| { stmt.table_insert_mvcc(table_id, vec![Val::from(value), Val::from("cancelled")]) .await?; @@ -5373,10 +5655,10 @@ pub(crate) mod tests { .await .unwrap(); let mut blocker = Some(blocker); - // RFC-0029 Phase 2 runner coverage: dropping a legacy callback while - // its raw logical-lock request waits or is provisionally promoted. - let mut exec = Box::pin(trx.exec(async |stmt| { - stmt_tests::acquire_transaction_lock(stmt, resource, LockMode::Shared).await?; + // Focused owned-runner cancellation while a raw logical-lock request + // waits or is provisionally promoted. + let mut exec = Box::pin(trx.exec(async |mut stmt| { + stmt_tests::acquire_transaction_lock(&mut stmt, resource, LockMode::Shared).await?; Ok::<(), Error>(()) })); @@ -5471,16 +5753,16 @@ pub(crate) mod tests { effects.install_ddl_redo(DDLRedo::DropTable(TableID::new(42))); } - // RFC-0029 Phase 2 runner coverage: raw redo injection distinguishes - // successful effect merge from callback-error rollback. + // Focused raw redo injection distinguishes successful effect merge from + // operation-error rollback. #[test] fn test_statement_error_rolls_back_only_statement_effects() { smol::block_on(async { let (_temp_dir, engine) = test_engine("redo_stmt_error_rollback").await; let (_session, mut trx) = begin_production_test_transaction(&engine); - trx.exec(async |stmt| { - stmt_tests::statement_effects_mut(stmt).insert_row_redo( + trx.exec(async |mut stmt| { + stmt_tests::statement_effects_mut(&mut stmt).insert_row_redo( TableID::new(12), RowRedo { row_id: RowID::new(23), @@ -5493,8 +5775,8 @@ pub(crate) mod tests { .unwrap(); let res: Result<()> = trx - .exec(async |stmt| { - stmt_tests::statement_effects_mut(stmt).insert_row_redo( + .exec(async |mut stmt| { + stmt_tests::statement_effects_mut(&mut stmt).insert_row_redo( TableID::new(12), RowRedo { row_id: RowID::new(24), @@ -5521,8 +5803,8 @@ pub(crate) mod tests { }); } - // RFC-0029 Phase 2 runner coverage: raw statement lock acquisition proves - // callback completion retains transaction-lifetime claims. + // Focused raw statement lock acquisition proves operation completion + // retains transaction-lifetime claims. #[test] fn test_statement_completion_retains_transaction_locks_until_terminal_cleanup() { smol::block_on(async { @@ -5534,56 +5816,58 @@ pub(crate) mod tests { acquire_transaction_lock_immediate(&mut trx, trx_resource, LockMode::IntentExclusive) .unwrap(); - trx.exec(async |stmt| { - assert_eq!(stmt_tests::transaction_lock_owner(stmt), trx_owner); - stmt_tests::acquire_transaction_lock( - stmt, - LockResource::TableMetadata(TableID::new(91_210)), - LockMode::Shared, - ) - .await?; - stmt_tests::acquire_transaction_lock( - stmt, - LockResource::TableMetadata(TableID::new(91_210)), - LockMode::Shared, - ) - .await?; - assert_eq!(lock_entry_count(&engine, trx_owner), 2); - Ok(()) - }) - .await - .unwrap(); + let (owner, count) = trx + .exec(async |mut stmt| { + let owner = stmt_tests::transaction_lock_owner(&stmt); + stmt_tests::acquire_transaction_lock( + &mut stmt, + LockResource::TableMetadata(TableID::new(91_210)), + LockMode::Shared, + ) + .await?; + stmt_tests::acquire_transaction_lock( + &mut stmt, + LockResource::TableMetadata(TableID::new(91_210)), + LockMode::Shared, + ) + .await?; + Ok((owner, lock_entry_count(&engine, trx_owner))) + }) + .await + .unwrap(); + assert_eq!(owner, trx_owner); + assert_eq!(count, 2); - trx.exec(async |stmt| { - assert_eq!(stmt_tests::transaction_lock_owner(stmt), trx_owner); - stmt_tests::acquire_transaction_lock( - stmt, - LockResource::TableMetadata(TableID::new(91_211)), - LockMode::Shared, - ) - .await?; - stmt_tests::acquire_transaction_lock( - stmt, - LockResource::TableMetadata(TableID::new(91_211)), - LockMode::Shared, - ) - .await?; - assert_eq!(lock_entry_count(&engine, trx_owner), 3); - Ok(()) - }) - .await - .unwrap(); + let (owner, count) = trx + .exec(async |mut stmt| { + let owner = stmt_tests::transaction_lock_owner(&stmt); + stmt_tests::acquire_transaction_lock( + &mut stmt, + LockResource::TableMetadata(TableID::new(91_211)), + LockMode::Shared, + ) + .await?; + stmt_tests::acquire_transaction_lock( + &mut stmt, + LockResource::TableMetadata(TableID::new(91_211)), + LockMode::Shared, + ) + .await?; + Ok((owner, lock_entry_count(&engine, trx_owner))) + }) + .await + .unwrap(); + assert_eq!(owner, trx_owner); + assert_eq!(count, 3); let res: Result<()> = trx - .exec(async |stmt| { - assert_eq!(stmt_tests::transaction_lock_owner(stmt), trx_owner); + .exec(async |mut stmt| { stmt_tests::acquire_transaction_lock( - stmt, + &mut stmt, LockResource::TableMetadata(TableID::new(91_212)), LockMode::Shared, ) .await?; - assert_eq!(lock_entry_count(&engine, trx_owner), 4); Err(Report::new(OperationError::InvalidDmlInput).disclose()) }) .await; diff --git a/doradb-storage/src/trx/purge.rs b/doradb-storage/src/trx/purge.rs index 8971d057..7b415d55 100644 --- a/doradb-storage/src/trx/purge.rs +++ b/doradb-storage/src/trx/purge.rs @@ -1941,11 +1941,12 @@ mod tests { bucket.get_purge_list(TrxID::new(11), &mut purge); assert_eq!(purge.len(), 1); assert_eq!(purge[0].sts(), None); + let Some(CommittedTrxPayload::System(payload)) = purge[0].payload.as_ref() else { + panic!("purge entry must retain its system transaction payload"); + }; assert_eq!( - purge[0] - .retired_row_pages() - .map(|batch| batch.page_ids.as_ref()), - Some(&[PageID::new(19)][..]) + payload.retired_row_pages.page_ids.as_ref(), + &[PageID::new(19)] ); } diff --git a/doradb-storage/src/trx/stmt.rs b/doradb-storage/src/trx/stmt.rs index df6566b6..47373711 100644 --- a/doradb-storage/src/trx/stmt.rs +++ b/doradb-storage/src/trx/stmt.rs @@ -4,8 +4,9 @@ use crate::id::{RowID, TableID, TrxID}; use crate::catalog::{CatalogTable, TableCache}; use crate::error::{ DiscloseResultExt, FatalError, FatalResult, MultiDomainResultExt, OperationError, - OperationOrFatalResult, QuadError, QuadResult, Result, RuntimeError, RuntimeOrFatalError, - RuntimeOrFatalResult, RuntimeResult, + OperationOrFatalError, OperationOrFatalResult, OperationOrRuntimeError, + OperationOrRuntimeResult, OperationResult, QuadError, QuadResult, Result, RuntimeError, + RuntimeOrFatalError, RuntimeOrFatalResult, RuntimeResult, }; use crate::lock::{LockMode, LockResource}; use crate::log::redo::{RedoLogs, RowRedo}; @@ -32,28 +33,6 @@ use std::sync::Arc; use super::admission::admit_user_table; -/// Catalog statement adapters preserve semantic Operation errors while adding -/// a catalog integration context only to Runtime failures. -/// -/// `change_runtime_context` is a domain-preserving carrier primitive and owns -/// no operation identity. Its semantic callers chain `attach_with` immediately -/// after reclassification. -trait QuadResultExt: MultiDomainResultExt { - fn change_runtime_context(self, context: RuntimeError) -> Self; -} - -impl QuadResultExt for QuadResult { - #[inline] - fn change_runtime_context(self, context: RuntimeError) -> Self { - self.map_err(|error| match error { - QuadError::Operation(report) => QuadError::Operation(report), - QuadError::Runtime(report) => QuadError::Runtime(report.change_context(context)), - QuadError::Lifecycle(report) => QuadError::Lifecycle(report), - QuadError::Fatal(report) => QuadError::Fatal(report), - }) - } -} - /// Cached unique-driver update whose provisional row lock remains installed. struct DeferredIndexUpdate { row_id: RowID, @@ -91,19 +70,6 @@ impl StmtEffects { } } - /// Create an empty synthetic accumulator for unit tests. - #[cfg(test)] - #[inline] - pub(crate) fn empty() -> Self { - StmtEffects { - stmt_no: NON_FOREGROUND_STMT_NO, - row_undo: RowUndoLogs::empty(), - deferred_index_updates: Vec::new(), - index_undo: IndexUndoLogs::empty(), - redo: RedoLogs::default(), - } - } - /// Returns this transaction-local statement identity. #[inline] pub(crate) fn stmt_no(&self) -> StmtNo { @@ -202,13 +168,6 @@ impl StmtEffects { .expect("owned row mutation requires a newest ordinary row undo") } - /// Returns statement-owned row and index undo counts for tests. - #[cfg(test)] - #[inline] - pub(crate) fn undo_counts(&self) -> (usize, usize) { - (self.row_undo.len(), self.index_undo.len()) - } - /// Requires that no operation-local deferred ownership remains. #[inline] pub(crate) fn assert_no_deferred_index_updates(&self) { @@ -218,13 +177,6 @@ impl StmtEffects { ); } - /// Returns whether unique-driver updates are awaiting physical application. - #[cfg(test)] - #[inline] - pub(crate) fn has_deferred_index_updates(&self) -> bool { - !self.deferred_index_updates.is_empty() - } - /// Restores every pending lock to ordinary row rollback ownership. #[inline] pub(crate) fn settle_deferred_index_updates(&mut self) { @@ -421,10 +373,11 @@ enum StmtDropAction { /// Lifetime-free owner of one checked-out statement operation. /// /// The carrier keeps the transaction core and statement effects together -/// across callback await points. It lends direct disjoint borrows to -/// [`Statement`] and owns the final policy when that callback future is dropped. -pub(crate) struct StmtState { +/// across owned-operation await points. It lends direct disjoint borrows to +/// [`Statement`] and owns the final policy when that operation future is dropped. +pub(super) struct StmtState { effects: StmtEffects, + dml_validation_disabled: bool, drop_action: StmtDropAction, checkout: Option, } @@ -432,20 +385,27 @@ pub(crate) struct StmtState { impl StmtState { /// Arms public statement cancellation after a successful checkout. #[inline] - pub(crate) fn public(mut checkout: SessionOperationCheckout) -> Self { + pub(super) fn public( + mut checkout: SessionOperationCheckout, + dml_validation_disabled: bool, + ) -> Self { let stmt_no = checkout.inner_mut().next_stmt_no(); Self { effects: StmtEffects::new(stmt_no), + dml_validation_disabled, drop_action: StmtDropAction::CancelPublicTransaction, checkout: Some(checkout), } } - /// Lends one direct callback-facing statement facade. + /// Lends one owned statement facade for the selected operation. #[inline] - pub(crate) fn statement(&mut self) -> Statement<'_> { + pub(super) fn statement(&mut self) -> Statement<'_> { let Self { - effects, checkout, .. + effects, + dml_validation_disabled, + checkout, + .. } = self; let checkout = checkout .as_mut() @@ -455,20 +415,45 @@ impl StmtState { inner, attachment, effects, - disable_dml_validation: false, + dml_validation_disabled: *dml_validation_disabled, } } + /// Merge a successful statement into the checked-out transaction. + #[inline] + pub(super) fn merge_effects(&mut self) { + let Self { + effects, checkout, .. + } = self; + let checkout = checkout + .as_mut() + .expect("active statement state must own its transaction checkout"); + effects.merge_into_trx_effects(checkout.inner_mut().effects_mut()); + } + + /// Roll back a failed statement before its initiating error is returned. + #[inline] + pub(super) async fn rollback_effects(&mut self) -> FatalResult<()> { + let Self { + effects, checkout, .. + } = self; + let checkout = checkout + .as_mut() + .expect("active statement state must own its transaction checkout"); + let (inner, attachment) = checkout.inner_and_attachment_mut(); + rollback_effects(inner, attachment, effects).await + } + /// Ordinarily checks the core back in. #[inline] - pub(crate) fn return_ordinary(mut self) { + pub(super) fn return_ordinary(mut self) { self.drop_action = StmtDropAction::Settled; self.checkout = None; } /// Publishes fatal rollback retention after statement effects were retained. #[inline] - pub(crate) fn discard_after_fatal_rollback(mut self) { + pub(super) fn discard_after_fatal_rollback(mut self) { self.drop_action = StmtDropAction::Settled; if let Some(checkout) = self.checkout.as_mut() { checkout.discard_after_fatal_rollback(); @@ -502,50 +487,84 @@ impl Drop for StmtState { } } -/// Statement-scoped facade for one operation inside an active transaction. -/// -/// `Transaction::exec` owns the statement lifecycle. It passes this facade to the -/// callback with transaction context and statement-local effects. Logical -/// locks acquired by statement operations belong directly to the transaction. -/// The enclosing statement state settles effects on every completion or -/// cancellation path. -pub struct Statement<'stmt> { - inner: &'stmt mut TrxInner, - attachment: &'stmt TrxAttachment, - effects: &'stmt mut StmtEffects, - disable_dml_validation: bool, +/// Carrier for one private statement over a continuously held checkout. +pub(super) struct PrivateStmtState<'checkout> { + effects: StmtEffects, + checkout: &'checkout mut SessionOperationCheckout, + settled: bool, } -impl<'stmt> Statement<'stmt> { - /// Create a callback-facing statement over borrowed transaction ownership. +impl<'checkout> PrivateStmtState<'checkout> { + /// Create one private statement carrier and allocate its statement number. #[inline] - pub(crate) fn new( - inner: &'stmt mut TrxInner, - attachment: &'stmt TrxAttachment, - effects: &'stmt mut StmtEffects, - ) -> Self { + pub(super) fn new(checkout: &'checkout mut SessionOperationCheckout) -> Self { + let stmt_no = checkout.inner_mut().next_stmt_no(); Self { + effects: StmtEffects::new(stmt_no), + checkout, + settled: false, + } + } + + /// Lend the one owned statement operation. + #[inline] + pub(super) fn statement(&mut self) -> Statement<'_> { + let (inner, attachment) = self.checkout.inner_and_attachment_mut(); + Statement { inner, attachment, - effects, - disable_dml_validation: false, + effects: &mut self.effects, + dml_validation_disabled: false, } } - /// Disable default DML shape, type, nullability, sparse-update, key, and - /// index-scan validation for this statement. - /// - /// Validation is enabled by default. Disable it only when the caller has - /// already validated full-row payload shape, value types, nullability, - /// sparse-update ordering/range/type compatibility, and DML lookup keys - /// including primary keys against the target table metadata for this - /// statement. + /// Merge one successful private statement into transaction effects. + #[inline] + pub(super) fn merge_effects(mut self) { + self.effects + .merge_into_trx_effects(self.checkout.inner_mut().effects_mut()); + self.settled = true; + } + + /// Roll back one failed private statement before returning its Runtime error. + #[inline] + pub(super) async fn rollback_effects(mut self) -> FatalResult<()> { + let result = { + let (inner, attachment) = self.checkout.inner_and_attachment_mut(); + rollback_effects(inner, attachment, &mut self.effects).await + }; + self.settled = true; + result + } + + /// Preserve residual undo and discard redo before resuming a private panic. + #[inline] + pub(super) fn fold_cancelled_into_transaction(mut self) { + self.effects + .fold_cancelled_into_trx_effects(self.checkout.inner_mut().effects_mut()); + self.settled = true; + } +} + +impl Drop for PrivateStmtState<'_> { #[inline] - pub fn disable_dml_validation(&mut self) -> &mut Self { - self.disable_dml_validation = true; - self + fn drop(&mut self) { + if !self.settled { + self.effects + .fold_cancelled_into_trx_effects(self.checkout.inner_mut().effects_mut()); + } } +} + +/// Owned one-shot facade for one internal transaction operation. +pub(super) struct Statement<'stmt> { + inner: &'stmt mut TrxInner, + attachment: &'stmt TrxAttachment, + effects: &'stmt mut StmtEffects, + dml_validation_disabled: bool, +} +impl<'stmt> Statement<'stmt> { /// Returns this statement's operation-local transaction runtime. #[inline] pub(crate) fn runtime(&self) -> TrxRuntime<'_> { @@ -649,8 +668,8 @@ impl<'stmt> Statement<'stmt> { /// method. The public caller supplies the stable [`TableID`], not a table /// runtime handle. #[inline] - pub async fn table_scan_mvcc( - &mut self, + pub(super) async fn table_scan_mvcc( + mut self, table_id: TableID, read_set: &[usize], row_action: F, @@ -686,8 +705,8 @@ impl<'stmt> Statement<'stmt> { /// reports delete and update decisions independently after all actions /// succeed. #[inline] - pub async fn table_mutate_mvcc( - &mut self, + pub(super) async fn table_mutate_mvcc( + mut self, table_id: TableID, mutate_row: F, ) -> Result @@ -703,7 +722,7 @@ impl<'stmt> Statement<'stmt> { .await .attach_with(|| format!("operation={OPERATION}, table_id={table_id}")) .disclose()?; - let validate_updates = !self.disable_dml_validation; + let validate_updates = !self.dml_validation_disabled; let (rt, effects) = self.runtime_and_effects_mut(); table .accessor_with_layout(&layout) @@ -724,8 +743,8 @@ impl<'stmt> Statement<'stmt> { /// callbacks have run. Deferred updates are memory-only and intentionally /// uncapped; callbacks must not depend on candidate-order physical effects. #[inline] - pub async fn table_index_mutate_mvcc<'r, R, F>( - &mut self, + pub(super) async fn table_index_mutate_mvcc<'r, R, F>( + mut self, table_id: TableID, index_no: usize, range: R, @@ -745,7 +764,7 @@ impl<'stmt> Statement<'stmt> { ) .await .disclose()?; - if !self.disable_dml_validation { + if !self.dml_validation_disabled { DmlValidator::new(layout.metadata()) .validate_index_range(index_no, &range) .change_context(OperationError::InvalidDmlInput) @@ -756,7 +775,7 @@ impl<'stmt> Statement<'stmt> { .await .attach_with(|| format!("operation={OPERATION}, table_id={table_id}")) .disclose()?; - let validate_updates = !self.disable_dml_validation; + let validate_updates = !self.dml_validation_disabled; let result = { let (rt, effects) = self.runtime_and_effects_mut(); table @@ -776,8 +795,8 @@ impl<'stmt> Statement<'stmt> { /// /// Strong table-runtime access is internal and operation-local. #[inline] - pub async fn table_lookup_unique_mvcc( - &mut self, + pub(super) async fn table_lookup_unique_mvcc( + mut self, table_id: TableID, index_no: usize, key_vals: &[Val], @@ -807,8 +826,8 @@ impl<'stmt> Statement<'stmt> { /// /// Strong table-runtime access is internal and operation-local. #[inline] - pub async fn table_index_lookup_mvcc( - &mut self, + pub(super) async fn table_index_lookup_mvcc( + mut self, table_id: TableID, index_no: usize, key_vals: &[Val], @@ -838,8 +857,8 @@ impl<'stmt> Statement<'stmt> { /// /// Strong table-runtime access is internal and operation-local. #[inline] - pub async fn table_index_scan_mvcc<'r, R>( - &mut self, + pub(super) async fn table_index_scan_mvcc<'r, R>( + mut self, table_id: TableID, index_no: usize, range: R, @@ -857,7 +876,7 @@ impl<'stmt> Statement<'stmt> { ) .await .disclose()?; - if !self.disable_dml_validation { + if !self.dml_validation_disabled { DmlValidator::new(layout.metadata()) .validate_index_scan(index_no, &range, read_set) .change_context(OperationError::InvalidDmlInput) @@ -879,13 +898,17 @@ impl<'stmt> Statement<'stmt> { /// /// Strong table-runtime access is internal and operation-local. #[inline] - pub async fn table_insert_mvcc(&mut self, table_id: TableID, cols: Vec) -> Result { + pub(super) async fn table_insert_mvcc( + mut self, + table_id: TableID, + cols: Vec, + ) -> Result { const OPERATION: &str = "table_insert_mvcc"; let (table, layout) = self .admit_user_table(table_id, TableAdmissionRequest::TableWrite, OPERATION) .await .disclose()?; - if !self.disable_dml_validation { + if !self.dml_validation_disabled { DmlValidator::new(layout.metadata()) .validate_full_row(&cols) .change_context(OperationError::InvalidDmlInput) @@ -908,7 +931,7 @@ impl<'stmt> Statement<'stmt> { /// Atomically inserts one validated batch into a catalog-owned user table. #[inline] pub(super) async fn table_insert_batch_mvcc( - &mut self, + mut self, table_id: TableID, rows: Vec>, ) -> Result> { @@ -917,15 +940,19 @@ impl<'stmt> Statement<'stmt> { .admit_user_table(table_id, TableAdmissionRequest::TableWrite, OPERATION) .await .disclose()?; - let validator = DmlValidator::new(layout.metadata()); - for (batch_index, row) in rows.iter().enumerate() { - validator - .validate_full_row(row) - .change_context(OperationError::InvalidDmlInput) - .attach_with(|| { - format!("operation={OPERATION}, table_id={table_id}, batch_index={batch_index}") - }) - .disclose()?; + if !self.dml_validation_disabled { + let validator = DmlValidator::new(layout.metadata()); + for (batch_index, row) in rows.iter().enumerate() { + validator + .validate_full_row(row) + .change_context(OperationError::InvalidDmlInput) + .attach_with(|| { + format!( + "operation={OPERATION}, table_id={table_id}, batch_index={batch_index}" + ) + }) + .disclose()?; + } } self.acquire_table_write_data_lock(table_id) .await @@ -951,8 +978,8 @@ impl<'stmt> Statement<'stmt> { /// /// Strong table-runtime access is internal and operation-local. #[inline] - pub async fn table_upsert_unique_mvcc( - &mut self, + pub(super) async fn table_upsert_unique_mvcc( + mut self, table_id: TableID, unique_index_no: usize, cols: Vec, @@ -968,7 +995,7 @@ impl<'stmt> Statement<'stmt> { ) .await .disclose()?; - if !self.disable_dml_validation { + if !self.dml_validation_disabled { let validator = DmlValidator::new(layout.metadata()); validator .validate_full_row(&cols) @@ -998,8 +1025,8 @@ impl<'stmt> Statement<'stmt> { /// /// Strong table-runtime access is internal and operation-local. #[inline] - pub async fn table_update_unique_mvcc( - &mut self, + pub(super) async fn table_update_unique_mvcc( + mut self, table_id: TableID, index_no: usize, key_vals: &[Val], @@ -1014,7 +1041,7 @@ impl<'stmt> Statement<'stmt> { ) .await .disclose()?; - if !self.disable_dml_validation { + if !self.dml_validation_disabled { let validator = DmlValidator::new(layout.metadata()); validator .validate_unique_key(index_no, key_vals) @@ -1044,8 +1071,8 @@ impl<'stmt> Statement<'stmt> { /// /// Strong table-runtime access is internal and operation-local. #[inline] - pub async fn table_delete_unique_mvcc( - &mut self, + pub(super) async fn table_delete_unique_mvcc( + mut self, table_id: TableID, index_no: usize, key_vals: &[Val], @@ -1059,7 +1086,7 @@ impl<'stmt> Statement<'stmt> { ) .await .disclose()?; - if !self.disable_dml_validation { + if !self.dml_validation_disabled { DmlValidator::new(layout.metadata()) .validate_unique_key(index_no, key_vals) .change_context(OperationError::InvalidDmlInput) @@ -1081,65 +1108,102 @@ impl<'stmt> Statement<'stmt> { /// Inserts one catalog-table row through the foreground lock-aware path. #[inline] - pub(crate) async fn catalog_insert_mvcc( - &mut self, + pub(super) async fn catalog_insert_mvcc( + mut self, table: &CatalogTable, cols: Vec, ) -> RuntimeOrFatalResult { - let table_id = table.table_id(); - let result = self.catalog_insert_mvcc_inner(table, cols).await; - assert_catalog_mutation_invariant(table_id, result) + self.catalog_insert_mvcc_inner(table, cols).await } - /// Performs the catalog insert before the caller asserts catalog-operation - /// invariants and narrows the result to the Runtime domain. + /// Performs one catalog insert while narrowing each native error carrier at + /// its owning boundary. #[inline] async fn catalog_insert_mvcc_inner( &mut self, table: &CatalogTable, cols: Vec, - ) -> QuadResult { + ) -> RuntimeOrFatalResult { const OPERATION: &str = "catalog_insert_mvcc"; let table_id = table.table_id(); - self.acquire_table_write_metadata_lock(table_id) + let metadata_lock = self + .acquire_table_write_metadata_lock(table_id) .await - .attach_with(|| format!("operation={OPERATION}, table_id={table_id}"))?; - if !self.disable_dml_validation { - DmlValidator::new(table.metadata()) - .validate_full_row(&cols) + .attach_with(|| format!("operation={OPERATION}, table_id={table_id}")); + narrow_catalog_operation_or_fatal(table_id, metadata_lock)?; + let validation = DmlValidator::new(table.metadata()) + .validate_full_row(&cols) + .change_context(OperationError::InvalidDmlInput) + .attach_with(|| format!("operation={OPERATION}, table_id={table_id}")); + assert_catalog_operation_invariant(table_id, validation); + let data_lock = self + .acquire_table_write_data_lock(table_id) + .await + .attach_with(|| format!("operation={OPERATION}, table_id={table_id}")); + narrow_catalog_operation_or_fatal(table_id, data_lock)?; + let (rt, effects) = self.runtime_and_effects_mut(); + let result = table.insert_mvcc(rt, effects, cols).await; + Ok(narrow_catalog_operation_or_runtime( + table_id, + result, + || format!("operation={OPERATION}, table_id={table_id}"), + )?) + } + + /// Inserts an ordered catalog batch through one consumed statement. + #[inline] + pub(super) async fn catalog_insert_batch_mvcc( + mut self, + table: &CatalogTable, + rows: Vec>, + ) -> RuntimeOrFatalResult<()> { + const OPERATION: &str = "catalog_insert_batch_mvcc"; + let table_id = table.table_id(); + let metadata_lock = self + .acquire_table_write_metadata_lock(table_id) + .await + .attach_with(|| format!("operation={OPERATION}, table_id={table_id}")); + narrow_catalog_operation_or_fatal(table_id, metadata_lock)?; + let validator = DmlValidator::new(table.metadata()); + for (batch_index, row) in rows.iter().enumerate() { + let validation = validator + .validate_full_row(row) .change_context(OperationError::InvalidDmlInput) - .attach_with(|| format!("operation={OPERATION}, table_id={table_id}"))?; + .attach_with(|| { + format!("operation={OPERATION}, table_id={table_id}, batch_index={batch_index}") + }); + assert_catalog_operation_invariant(table_id, validation); } - self.acquire_table_write_data_lock(table_id) + let data_lock = self + .acquire_table_write_data_lock(table_id) .await - .attach_with(|| format!("operation={OPERATION}, table_id={table_id}"))?; + .attach_with(|| format!("operation={OPERATION}, table_id={table_id}")); + narrow_catalog_operation_or_fatal(table_id, data_lock)?; let (rt, effects) = self.runtime_and_effects_mut(); - table - .insert_mvcc(rt, effects, cols) - .await - .map_err(QuadError::from) - .change_runtime_context(RuntimeError::CatalogAccess) - .attach_with(|| format!("operation={OPERATION}, table_id={table_id}")) + for (batch_index, row) in rows.into_iter().enumerate() { + let result = table.insert_mvcc(rt, effects, row).await; + narrow_catalog_operation_or_runtime(table_id, result, || { + format!("operation={OPERATION}, table_id={table_id}, batch_index={batch_index}") + })?; + } + Ok(()) } /// Deletes one catalog-table row through the foreground lock-aware path. #[inline] - pub(crate) async fn catalog_delete_primary_key_mvcc( - &mut self, + pub(super) async fn catalog_delete_primary_key_mvcc( + mut self, table: &CatalogTable, index_no: usize, key_vals: &[Val], log_by_key: bool, ) -> RuntimeOrFatalResult { - let table_id = table.table_id(); - let result = self - .catalog_delete_primary_key_mvcc_inner(table, index_no, key_vals, log_by_key) - .await; - assert_catalog_mutation_invariant(table_id, result) + self.catalog_delete_primary_key_mvcc_inner(table, index_no, key_vals, log_by_key) + .await } - /// Performs the catalog delete before the caller asserts catalog-operation - /// invariants and narrows the result to the Runtime domain. + /// Performs one catalog delete while narrowing each native error carrier at + /// its owning boundary. #[inline] async fn catalog_delete_primary_key_mvcc_inner( &mut self, @@ -1147,122 +1211,269 @@ impl<'stmt> Statement<'stmt> { index_no: usize, key_vals: &[Val], log_by_key: bool, - ) -> QuadResult { + ) -> RuntimeOrFatalResult { const OPERATION: &str = "catalog_delete_primary_key_mvcc"; let table_id = table.table_id(); - self.acquire_table_write_metadata_lock(table_id) + let metadata_lock = self + .acquire_table_write_metadata_lock(table_id) .await - .attach_with(|| format!("operation={OPERATION}, table_id={table_id}"))?; - if !self.disable_dml_validation { - DmlValidator::new(table.metadata()) - .validate_primary_key(index_no, key_vals) - .change_context(OperationError::InvalidDmlInput) - .attach_with(|| format!("operation={OPERATION}, table_id={table_id}"))?; - } - self.acquire_table_write_data_lock(table_id) + .attach_with(|| format!("operation={OPERATION}, table_id={table_id}")); + narrow_catalog_operation_or_fatal(table_id, metadata_lock)?; + let validation = DmlValidator::new(table.metadata()) + .validate_primary_key(index_no, key_vals) + .change_context(OperationError::InvalidDmlInput) + .attach_with(|| format!("operation={OPERATION}, table_id={table_id}")); + assert_catalog_operation_invariant(table_id, validation); + let data_lock = self + .acquire_table_write_data_lock(table_id) .await - .attach_with(|| format!("operation={OPERATION}, table_id={table_id}"))?; + .attach_with(|| format!("operation={OPERATION}, table_id={table_id}")); + narrow_catalog_operation_or_fatal(table_id, data_lock)?; let (rt, effects) = self.runtime_and_effects_mut(); - table + let result = table .delete_unique_mvcc(rt, effects, index_no, key_vals, log_by_key) - .await - .change_runtime_context(RuntimeError::CatalogAccess) - .attach_with(|| { - format!("operation={OPERATION}, table_id={table_id}, index_no={index_no}") - }) + .await; + narrow_catalog_quad_result(table_id, result, || { + format!("operation={OPERATION}, table_id={table_id}, index_no={index_no}") + }) } - /// Moves successful statement effects into transaction effects. + /// Deletes an ordered catalog primary-key batch through one consumed statement. #[inline] - pub(crate) fn merge_effects(&mut self) { - self.effects - .merge_into_trx_effects(self.inner.effects_mut()); + pub(super) async fn catalog_delete_primary_key_batch_mvcc( + mut self, + table: &CatalogTable, + index_no: usize, + keys: Vec>, + ) -> RuntimeOrFatalResult { + const OPERATION: &str = "catalog_delete_primary_key_batch_mvcc"; + let table_id = table.table_id(); + let metadata_lock = self + .acquire_table_write_metadata_lock(table_id) + .await + .attach_with(|| format!("operation={OPERATION}, table_id={table_id}")); + narrow_catalog_operation_or_fatal(table_id, metadata_lock)?; + let validator = DmlValidator::new(table.metadata()); + for (batch_index, key_vals) in keys.iter().enumerate() { + let validation = validator + .validate_primary_key(index_no, key_vals) + .change_context(OperationError::InvalidDmlInput) + .attach_with(|| { + format!("operation={OPERATION}, table_id={table_id}, batch_index={batch_index}") + }); + assert_catalog_operation_invariant(table_id, validation); + } + let data_lock = self + .acquire_table_write_data_lock(table_id) + .await + .attach_with(|| format!("operation={OPERATION}, table_id={table_id}")); + narrow_catalog_operation_or_fatal(table_id, data_lock)?; + let (rt, effects) = self.runtime_and_effects_mut(); + let mut deleted = 0; + for (batch_index, key_vals) in keys.iter().enumerate() { + let result = table + .delete_unique_mvcc(rt, effects, index_no, key_vals, true) + .await; + let result = narrow_catalog_quad_result(table_id, result, || { + format!( + "operation={OPERATION}, table_id={table_id}, index_no={index_no}, batch_index={batch_index}" + ) + })?; + deleted += usize::from(matches!(result, DeleteMvcc::Deleted)); + } + Ok(deleted) } - /// Rolls back statement-local effects after an ordinary callback error. - /// - /// Index effects roll back before row effects so index entries stop - /// pointing at uncommitted row state before row undo is unwound. Statement - /// locks stay held until this method returns and the carrier finalizes. + /// Replaces one catalog row through one delete-then-insert statement. #[inline] - pub(crate) async fn rollback_effects(&mut self) -> FatalResult<()> { - let sts = self.inner.sts(); - let engine = self.attachment.engine(); - let pool_guards = self.attachment.pool_guards(); - let rollback_context = RowUndoRollbackContext::new(pool_guards, &engine.poisoner); - let mut table_cache = TableCache::new(engine.catalog()); - if let Err(err) = self - .effects - .rollback_index(&mut table_cache, pool_guards, sts) + pub(super) async fn catalog_replace_primary_key_mvcc( + mut self, + table: &CatalogTable, + index_no: usize, + key_vals: &[Val], + cols: Vec, + ) -> RuntimeOrFatalResult { + const OPERATION: &str = "catalog_replace_primary_key_mvcc"; + let table_id = table.table_id(); + let metadata_lock = self + .acquire_table_write_metadata_lock(table_id) .await - { - let retention = self.effects.take_for_fatal_retention(); - engine.trx_sys.retain_fatal_rollback(retention); - let report = err - .change_context(FatalError::RollbackAccess) - .attach("statement index rollback failed"); - obs::error!( - "event=engine_poison component=trx action=poison result=error error={:?}", - report - ); - return Err(engine.poisoner.poison(report).into_report()); - } - if let Err(err) = self - .effects - .rollback_row(&mut table_cache, rollback_context) + .attach_with(|| format!("operation={OPERATION}, table_id={table_id}")); + narrow_catalog_operation_or_fatal(table_id, metadata_lock)?; + let validator = DmlValidator::new(table.metadata()); + let key_validation = validator + .validate_primary_key(index_no, key_vals) + .change_context(OperationError::InvalidDmlInput) + .attach_with(|| format!("operation={OPERATION}, table_id={table_id}")); + assert_catalog_operation_invariant(table_id, key_validation); + let row_validation = validator + .validate_full_row(&cols) + .change_context(OperationError::InvalidDmlInput) + .attach_with(|| format!("operation={OPERATION}, table_id={table_id}")); + assert_catalog_operation_invariant(table_id, row_validation); + let data_lock = self + .acquire_table_write_data_lock(table_id) .await - { - let retention = self.effects.take_for_fatal_retention(); - engine.trx_sys.retain_fatal_rollback(retention); - return match err { - RuntimeOrFatalError::Runtime(report) => { - let report = report - .change_context(FatalError::RollbackAccess) - .attach("statement row rollback failed"); - obs::error!( - "event=engine_poison component=trx action=poison result=error error={:?}", - report - ); - Err(engine.poisoner.poison(report).into_report()) - } - RuntimeOrFatalError::Fatal(report) => { - Err(report.attach("statement row rollback failed")) - } - }; + .attach_with(|| format!("operation={OPERATION}, table_id={table_id}")); + narrow_catalog_operation_or_fatal(table_id, data_lock)?; + let (rt, effects) = self.runtime_and_effects_mut(); + let delete_result = table + .delete_unique_mvcc(rt, effects, index_no, key_vals, true) + .await; + let deleted = narrow_catalog_quad_result(table_id, delete_result, || { + format!("operation={OPERATION}, table_id={table_id}, phase=delete") + })?; + let insert_result = table.insert_mvcc(rt, effects, cols).await; + narrow_catalog_operation_or_runtime(table_id, insert_result, || { + format!("operation={OPERATION}, table_id={table_id}, phase=insert") + })?; + Ok(deleted) + } +} + +/// Roll back one statement's effects using shared public/private mechanics. +#[inline] +async fn rollback_effects( + inner: &mut TrxInner, + attachment: &TrxAttachment, + effects: &mut StmtEffects, +) -> FatalResult<()> { + let sts = inner.sts(); + let engine = attachment.engine(); + let pool_guards = attachment.pool_guards(); + let rollback_context = RowUndoRollbackContext::new(pool_guards, &engine.poisoner); + let mut table_cache = TableCache::new(engine.catalog()); + if let Err(err) = effects + .rollback_index(&mut table_cache, pool_guards, sts) + .await + { + let retention = effects.take_for_fatal_retention(); + engine.trx_sys.retain_fatal_rollback(retention); + let report = err + .change_context(FatalError::RollbackAccess) + .attach("statement index rollback failed"); + obs::error!( + "event=engine_poison component=trx action=poison result=error error={:?}", + report + ); + return Err(engine.poisoner.poison(report).into_report()); + } + if let Err(err) = effects + .rollback_row(&mut table_cache, rollback_context) + .await + { + let retention = effects.take_for_fatal_retention(); + engine.trx_sys.retain_fatal_rollback(retention); + return match err { + RuntimeOrFatalError::Runtime(report) => { + let report = report + .change_context(FatalError::RollbackAccess) + .attach("statement row rollback failed"); + obs::error!( + "event=engine_poison component=trx action=poison result=error error={:?}", + report + ); + Err(engine.poisoner.poison(report).into_report()) + } + RuntimeOrFatalError::Fatal(report) => { + Err(report.attach("statement row rollback failed")) + } + }; + } + effects.clear_redo(); + Ok(()) +} + +/// Assert one catalog-only Operation result at its immediate owning boundary. +#[inline] +fn assert_catalog_operation_invariant(table_id: TableID, result: OperationResult) -> T { + match result { + Ok(value) => value, + Err(report) => { + panic!("catalog mutation invariant violated: table_id={table_id}, error={report:?}") } - self.effects.clear_redo(); - Ok(()) } } -/// Catalog mutations use internally derived keys and validated row shapes. -/// An Operation failure therefore means a catalog key, row shape, transaction, -/// or lock invariant was violated; only Runtime and Fatal failures may leave -/// this boundary. +/// Assert the impossible Operation arm of a catalog lock result and preserve +/// Fatal without widening through a synthetic carrier. +#[inline] +fn narrow_catalog_operation_or_fatal( + table_id: TableID, + result: OperationOrFatalResult, +) -> FatalResult { + match result { + Ok(value) => Ok(value), + Err(OperationOrFatalError::Operation(report)) => { + panic!("catalog mutation invariant violated: table_id={table_id}, error={report:?}") + } + Err(OperationOrFatalError::Fatal(report)) => Err(report), + } +} + +/// Assert the impossible Operation arm of a catalog insert result and assign +/// catalog Runtime ownership before returning it. +#[inline] +fn narrow_catalog_operation_or_runtime( + table_id: TableID, + result: OperationOrRuntimeResult, + attachment: F, +) -> RuntimeResult +where + F: FnOnce() -> String, +{ + match result { + Ok(value) => Ok(value), + Err(OperationOrRuntimeError::Operation(report)) => { + let report = report.attach(attachment()); + panic!("catalog mutation invariant violated: table_id={table_id}, error={report:?}") + } + Err(OperationOrRuntimeError::Runtime(report)) => Err(report + .change_context(RuntimeError::CatalogAccess) + .attach(attachment())), + } +} + +/// Narrow the generic table-delete carrier immediately at the catalog boundary. #[inline] -fn assert_catalog_mutation_invariant( +fn narrow_catalog_quad_result( table_id: TableID, result: QuadResult, -) -> RuntimeOrFatalResult { + attachment: F, +) -> RuntimeOrFatalResult +where + F: FnOnce() -> String, +{ match result { Ok(value) => Ok(value), Err(QuadError::Operation(report)) => { + let report = report.attach(attachment()); panic!("catalog mutation invariant violated: table_id={table_id}, error={report:?}") } - Err(QuadError::Runtime(report)) => Err(RuntimeOrFatalError::Runtime(report)), + Err(QuadError::Runtime(report)) => Err(RuntimeOrFatalError::Runtime( + report + .change_context(RuntimeError::CatalogAccess) + .attach(attachment()), + )), Err(QuadError::Lifecycle(report)) => { + let report = report.attach(attachment()); panic!( "catalog mutation lifecycle invariant violated: table_id={table_id}, error={report:?}" ) } - Err(QuadError::Fatal(report)) => Err(RuntimeOrFatalError::Fatal(report)), + Err(QuadError::Fatal(report)) => { + Err(RuntimeOrFatalError::Fatal(report.attach(attachment()))) + } } } #[cfg(test)] pub(crate) mod tests { use super::*; + use crate::buffer::EvictableBufferPool; + use crate::buffer::guard::PageSharedGuard; use crate::catalog::storage::tables::TABLE_ID_TABLES; + use crate::catalog::storage::tests::begin_catalog_test_trx; use crate::conf::{EngineConfig, EvictableBufferPoolConfig, TrxSysConfig}; use crate::engine::Engine; use crate::error::{ @@ -1273,7 +1484,13 @@ pub(crate) mod tests { use crate::lock::LockOwner; use crate::lock::tests::debug_snapshot; use crate::log::redo::RowRedoKind; + use crate::row::RowPage; use crate::session::{SessionState, tests as session_tests}; + use crate::table::MemTable; + use crate::table::tests::{ + lock_hot_row_then_wait_and_error_operation, transition_delete_operation, + transition_insert_update_operation, + }; use crate::trx::sys::tests as sys_tests; use crate::trx::undo::tests::{pause_next_index_rollback, pause_next_row_rollback}; use crate::trx::undo::{OwnedRowUndo, RowUndoKind}; @@ -1303,12 +1520,12 @@ pub(crate) mod tests { } #[inline] - pub(crate) fn transaction_lock_owner(stmt: &Statement<'_>) -> LockOwner { + pub(in crate::trx) fn transaction_lock_owner(stmt: &Statement<'_>) -> LockOwner { stmt.inner.checked_lock_state().owner() } #[inline] - pub(crate) async fn acquire_transaction_lock( + pub(in crate::trx) async fn acquire_transaction_lock( stmt: &mut Statement<'_>, resource: LockResource, mode: LockMode, @@ -1324,22 +1541,198 @@ pub(crate) mod tests { } #[inline] - pub(crate) fn runtime_and_effects_mut<'borrow>( + pub(in crate::trx) fn statement_effects_mut<'borrow>( stmt: &'borrow mut Statement<'_>, - ) -> (TrxRuntime<'borrow>, &'borrow mut StmtEffects) { - stmt.runtime_and_effects_mut() + ) -> &'borrow mut StmtEffects { + stmt.effects } + /// Return whether one statement retains deferred index updates. #[inline] - pub(crate) fn statement_effects_mut<'borrow>( - stmt: &'borrow mut Statement<'_>, - ) -> &'borrow mut StmtEffects { - stmt.effects + pub(crate) fn has_deferred_index_updates(effects: &StmtEffects) -> bool { + !effects.deferred_index_updates.is_empty() + } + + #[inline] + fn empty_stmt_effects() -> StmtEffects { + StmtEffects { + stmt_no: NON_FOREGROUND_STMT_NO, + row_undo: RowUndoLogs::empty(), + deferred_index_updates: Vec::new(), + index_undo: IndexUndoLogs::empty(), + redo: RedoLogs::default(), + } } #[inline] - pub(crate) fn statement_redo<'borrow>(stmt: &'borrow Statement<'_>) -> &'borrow RedoLogs { - &stmt.effects.redo + async fn prepare_raw_table_write(stmt: &mut Statement<'_>, table_id: TableID) -> Result<()> { + stmt.acquire_table_write_metadata_lock(table_id) + .await + .disclose()?; + stmt.acquire_table_write_data_lock(table_id) + .await + .disclose() + } + + /// Insert through a standalone MemTable using production statement settlement. + pub(in crate::trx) async fn mem_table_insert_mvcc( + mut stmt: Statement<'_>, + mem_table: &MemTable, + cols: Vec, + ) -> Result { + let table_id = mem_table.table_id(); + prepare_raw_table_write(&mut stmt, table_id).await?; + let (rt, effects) = stmt.runtime_and_effects_mut(); + mem_table.insert_mvcc(rt, effects, cols).await.disclose() + } + + /// Upsert through a standalone MemTable using production statement settlement. + pub(in crate::trx) async fn mem_table_upsert_unique_mvcc( + mut stmt: Statement<'_>, + mem_table: &MemTable, + cols: Vec, + ) -> Result { + let table_id = mem_table.table_id(); + prepare_raw_table_write(&mut stmt, table_id).await?; + let (rt, effects) = stmt.runtime_and_effects_mut(); + mem_table + .upsert_unique_mvcc(rt, effects, 0, cols, false) + .await + .disclose() + } + + /// Update through a standalone MemTable using production statement settlement. + pub(in crate::trx) async fn mem_table_update_unique_mvcc( + mut stmt: Statement<'_>, + mem_table: &MemTable, + key: &SelectKey, + update: Vec, + ) -> Result { + let table_id = mem_table.table_id(); + prepare_raw_table_write(&mut stmt, table_id).await?; + let (rt, effects) = stmt.runtime_and_effects_mut(); + mem_table + .update_unique_mvcc(rt, effects, key.index_no, &key.vals, update, false) + .await + .disclose() + } + + /// Delete through a standalone MemTable using production statement settlement. + pub(in crate::trx) async fn mem_table_delete_unique_mvcc( + mut stmt: Statement<'_>, + mem_table: &MemTable, + key: &SelectKey, + ) -> Result { + let table_id = mem_table.table_id(); + prepare_raw_table_write(&mut stmt, table_id).await?; + let (rt, effects) = stmt.runtime_and_effects_mut(); + mem_table + .delete_unique_mvcc(rt, effects, key.index_no, &key.vals, false) + .await + .disclose() + } + + /// Apply one standalone MemTable index-only key change. + #[allow(clippy::too_many_arguments)] + pub(in crate::trx) async fn mem_table_duplicate_index_key_change( + mut stmt: Statement<'_>, + mem_table: &MemTable, + page_guard: PageSharedGuard, + row_id: RowID, + old_key: SelectKey, + new_key: SelectKey, + ) -> Result<()> { + let table_id = mem_table.table_id(); + prepare_raw_table_write(&mut stmt, table_id).await?; + let (rt, effects) = stmt.runtime_and_effects_mut(); + mem_table + .update_unique_index_only_key_change(rt, effects, old_key, new_key, row_id, &page_guard) + .await + .disclose() + } + + /// Run the focused transition-page insert/update operation. + #[allow(clippy::too_many_arguments)] + pub(in crate::trx) async fn transition_insert_update( + mut stmt: Statement<'_>, + table: &Table, + insert_page_guard: PageSharedGuard, + insert: Vec, + page_guard: &PageSharedGuard, + row_id: RowID, + key: &SelectKey, + update: Vec, + ) -> Result<(bool, bool)> { + let table_id = table.table_id(); + prepare_raw_table_write(&mut stmt, table_id).await?; + let (rt, effects) = stmt.runtime_and_effects_mut(); + transition_insert_update_operation( + rt, + effects, + table, + insert_page_guard, + insert, + page_guard, + row_id, + key, + update, + ) + .await + } + + /// Run the focused transition-page delete operation. + pub(in crate::trx) async fn transition_delete( + mut stmt: Statement<'_>, + table: &Table, + page_guard: &PageSharedGuard, + row_id: RowID, + key: &SelectKey, + ) -> Result { + let table_id = table.table_id(); + prepare_raw_table_write(&mut stmt, table_id).await?; + let (rt, effects) = stmt.runtime_and_effects_mut(); + transition_delete_operation(rt, effects, table, page_guard, row_id, key).await + } + + /// Install one hot-row lock and pause before forcing operation rollback. + #[allow(clippy::too_many_arguments)] + pub(in crate::trx) async fn lock_hot_row_then_wait_and_error( + mut stmt: Statement<'_>, + table: &Table, + page_guard: PageSharedGuard, + row_id: RowID, + key: &SelectKey, + lock_installed: flume::Sender, + return_error: flume::Receiver<()>, + ) -> Result<()> { + let table_id = table.table_id(); + prepare_raw_table_write(&mut stmt, table_id).await?; + let (rt, effects) = stmt.runtime_and_effects_mut(); + lock_hot_row_then_wait_and_error_operation( + rt, + effects, + table, + page_guard, + row_id, + key, + lock_installed, + return_error, + ) + .await + } + + /// Insert a catalog prefix before injecting one private Runtime error. + async fn catalog_insert_prefix_then_runtime_error( + mut stmt: Statement<'_>, + table: &CatalogTable, + rows: Vec>, + ) -> RuntimeOrFatalResult<()> { + for row in rows { + stmt.catalog_insert_mvcc_inner(table, row).await?; + } + Err(Report::new(RuntimeError::CatalogAccess) + .attach("operation=test_catalog_insert_prefix_then_runtime_error") + .into()) } #[inline] @@ -1414,51 +1807,60 @@ pub(crate) mod tests { assert!(effects.redo.is_empty()); } + fn assert_catalog_runtime_stack(err: &Report, operation: &str) { + assert_eq!(*err.current_context(), RuntimeError::CatalogAccess); + assert_eq!( + err.downcast_ref::().copied(), + Some(ResourceError::BufferPoolFull) + ); + let rendered = format!("{err:?}"); + assert!(rendered.contains("pool_role=Meta")); + assert!(rendered.contains(operation)); + } + #[test] fn test_stmt_effects_empty() { - let effects = StmtEffects::empty(); + let effects = empty_stmt_effects(); assert_stmt_effects_empty(&effects); } - // RFC-0029 Phase 2 runner coverage: exact statement-number allocation is - // inspected through raw statement effects across success and failure. + // Owned-runner coverage: exact statement-number allocation is inspected + // through raw statement effects across success and failure. #[test] fn test_public_statements_consume_monotonic_statement_numbers() { smol::block_on(async { let (_temp_dir, engine) = test_engine("stmt_number_sequence").await; let mut session = engine.new_session().unwrap(); let mut trx = session.begin_trx().unwrap(); - let mut observed = Vec::new(); - - trx.exec(async |stmt| { - observed.push(statement_effects_mut(stmt).stmt_no()); - Ok(()) - }) - .await - .unwrap(); - let failed: Result<()> = trx - .exec(async |stmt| { - observed.push(statement_effects_mut(stmt).stmt_no()); - Err(Report::new(OperationError::InvalidDmlInput).disclose()) + let first = trx + .exec(async |mut stmt| Ok(statement_effects_mut(&mut stmt).stmt_no())) + .await + .unwrap(); + let failed: Result = trx + .exec(async |mut stmt| { + let stmt_no = statement_effects_mut(&mut stmt).stmt_no(); + Err(Report::new(OperationError::InvalidDmlInput) + .attach(format!("stmt_no={stmt_no}")) + .disclose()) }) .await; - assert!(failed.is_err()); - trx.exec(async |stmt| { - observed.push(statement_effects_mut(stmt).stmt_no()); - Ok(()) - }) - .await - .unwrap(); - assert_eq!(observed, vec![1, 2, 3]); + let failed = failed.unwrap_err(); + let second = format!("{failed:?}"); + let third = trx + .exec(async |mut stmt| Ok(statement_effects_mut(&mut stmt).stmt_no())) + .await + .unwrap(); + assert_eq!(first, 1); + assert!(second.contains("stmt_no=2")); + assert_eq!(third, 3); trx.commit().await.unwrap(); let mut next = session.begin_trx().unwrap(); - next.exec(async |stmt| { - assert_eq!(statement_effects_mut(stmt).stmt_no(), 1); - Ok(()) - }) - .await - .unwrap(); + let first = next + .exec(async |mut stmt| Ok(statement_effects_mut(&mut stmt).stmt_no())) + .await + .unwrap(); + assert_eq!(first, 1); next.commit().await.unwrap(); }); } @@ -1479,7 +1881,7 @@ pub(crate) mod tests { kind: IndexUndoKind::DeferDelete(SelectKey::new(0, vec![]), true), }); - let mut effects = StmtEffects::empty(); + let mut effects = empty_stmt_effects(); effects.push_row_undo(OwnedRowUndo::new( NON_FOREGROUND_STMT_NO, TableID::new(42), @@ -1528,7 +1930,7 @@ pub(crate) mod tests { let mut table_cache = TableCache::new(engine.inner().core.catalog()); let table_id = TableID::new(99_999_998); let row_id = RowID::new(23); - let mut effects = StmtEffects::empty(); + let mut effects = empty_stmt_effects(); effects.push_delete_index_undo(table_id, row_id, SelectKey::new(0, vec![]), true); effects.push_row_undo(OwnedRowUndo::new( NON_FOREGROUND_STMT_NO, @@ -1561,49 +1963,163 @@ pub(crate) mod tests { } #[test] - fn test_catalog_mutation_operation_errors_violate_invariant() { - for error in [OperationError::DuplicateKey, OperationError::WriteConflict] { - let panic = catch_unwind(|| { - let result: QuadResult<()> = Err(Report::new(error).into()); - let _ = assert_catalog_mutation_invariant(TableID::new(42), result); - }); - assert!(panic.is_err(), "operation error did not assert: {error:?}"); - } + fn test_catalog_native_impossible_domains_violate_invariant() { + let table_id = TableID::new(42); + + let operation: OperationResult<()> = Err(Report::new(OperationError::InvalidDmlInput)); + assert!( + catch_unwind(AssertUnwindSafe(|| { + assert_catalog_operation_invariant(table_id, operation) + })) + .is_err() + ); + + let lock: OperationOrFatalResult<()> = + Err(Report::new(OperationError::LockFamilyConflict).into()); + assert!( + catch_unwind(AssertUnwindSafe(|| { + let _ = narrow_catalog_operation_or_fatal(table_id, lock); + })) + .is_err() + ); + + let insert: OperationOrRuntimeResult<()> = + Err(Report::new(OperationError::DuplicateKey).into()); + assert!( + catch_unwind(AssertUnwindSafe(|| { + let _ = narrow_catalog_operation_or_runtime(table_id, insert, || { + "operation=test_catalog_insert".to_owned() + }); + })) + .is_err() + ); + + let delete_operation: QuadResult<()> = + Err(Report::new(OperationError::WriteConflict).into()); + assert!( + catch_unwind(AssertUnwindSafe(|| { + let _ = narrow_catalog_quad_result(table_id, delete_operation, || { + "operation=test_catalog_delete".to_owned() + }); + })) + .is_err() + ); + + let delete_lifecycle: QuadResult<()> = Err(Report::new(LifecycleError::Shutdown).into()); + assert!( + catch_unwind(AssertUnwindSafe(|| { + let _ = narrow_catalog_quad_result(table_id, delete_lifecycle, || { + "operation=test_catalog_delete".to_owned() + }); + })) + .is_err() + ); } #[test] - fn test_catalog_mutation_runtime_error_preserves_stack() { - let result: QuadResult<()> = Err(Report::new(ResourceError::BufferPoolFull) + fn test_catalog_native_runtime_errors_preserve_stack() { + let table_id = TableID::new(42); + let insert: OperationOrRuntimeResult<()> = Err(OperationOrRuntimeError::Runtime( + Report::new(ResourceError::BufferPoolFull) + .attach("pool_role=Meta") + .change_context(RuntimeError::TableAccess), + )); + let err = narrow_catalog_operation_or_runtime(table_id, insert, || { + "operation=test_catalog_insert".to_owned() + }) + .unwrap_err(); + assert_catalog_runtime_stack(&err, "operation=test_catalog_insert"); + + let delete: QuadResult<()> = Err(Report::new(ResourceError::BufferPoolFull) .attach("pool_role=Meta") - .change_context(RuntimeError::CatalogAccess) + .change_context(RuntimeError::TableAccess) .into()); - - let err = assert_catalog_mutation_invariant(TableID::new(42), result).unwrap_err(); + let err = narrow_catalog_quad_result(table_id, delete, || { + "operation=test_catalog_delete".to_owned() + }) + .unwrap_err(); let RuntimeOrFatalError::Runtime(err) = err else { panic!("runtime catalog failure changed domain") }; - - assert_eq!(*err.current_context(), RuntimeError::CatalogAccess); - assert_eq!( - err.downcast_ref::().copied(), - Some(ResourceError::BufferPoolFull) - ); - assert!(format!("{err:?}").contains("pool_role=Meta")); + assert_catalog_runtime_stack(&err, "operation=test_catalog_delete"); } #[test] - fn test_catalog_mutation_fatal_error_preserves_first_source() { - let result: QuadResult<()> = Err(Report::new(FatalError::StorageIo) + fn test_catalog_native_fatal_errors_preserve_first_source() { + let table_id = TableID::new(42); + let lock: OperationOrFatalResult<()> = Err(OperationOrFatalError::Fatal( + Report::new(FatalError::StorageIo).attach("first catalog mutation poison source"), + )); + let err = narrow_catalog_operation_or_fatal(table_id, lock).unwrap_err(); + assert_eq!(*err.current_context(), FatalError::StorageIo); + assert!(format!("{err:?}").contains("first catalog mutation poison source")); + + let delete: QuadResult<()> = Err(Report::new(FatalError::StorageIo) .attach("first catalog mutation poison source") .into()); - - let err = assert_catalog_mutation_invariant(TableID::new(42), result).unwrap_err(); + let err = narrow_catalog_quad_result(table_id, delete, || { + "operation=test_catalog_delete".to_owned() + }) + .unwrap_err(); let RuntimeOrFatalError::Fatal(err) = err else { panic!("fatal catalog failure changed domain") }; - assert_eq!(*err.current_context(), FatalError::StorageIo); assert!(format!("{err:?}").contains("first catalog mutation poison source")); + assert!(format!("{err:?}").contains("operation=test_catalog_delete")); + } + + #[test] + fn test_private_statement_runtime_error_rolls_back_current_catalog_prefix() { + smol::block_on(async { + let (_temp_dir, engine) = test_engine("redo_private_stmt_prefix_rollback").await; + let storage = &engine.inner().core.catalog().storage; + let table = storage.get_catalog_table(TABLE_ID_TABLES).unwrap(); + let session = engine.new_session().unwrap(); + let mut trx = begin_catalog_test_trx(&session); + + trx.trx() + .catalog_insert_mvcc( + table.as_ref(), + vec![Val::from(TableID::new(42)), Val::from(0u16)], + ) + .await + .unwrap(); + let err = trx + .trx() + .exec(async move |stmt| { + catalog_insert_prefix_then_runtime_error( + stmt, + table.as_ref(), + vec![ + vec![Val::from(TableID::new(43)), Val::from(0u16)], + vec![Val::from(TableID::new(44)), Val::from(0u16)], + ], + ) + .await + }) + .await + .unwrap_err(); + let RuntimeOrFatalError::Runtime(err) = err else { + panic!("private statement Runtime error changed domain") + }; + assert_eq!(*err.current_context(), RuntimeError::CatalogAccess); + + let table_ids = storage + .tables() + .list_uncommitted(trx.trx().pool_guards()) + .await + .unwrap() + .into_iter() + .map(|table| table.table_id) + .collect::>(); + assert!(table_ids.contains(&TableID::new(42))); + assert!(!table_ids.contains(&TableID::new(43))); + assert!(!table_ids.contains(&TableID::new(44))); + + trx.rollback().await; + engine.shutdown(); + }); } #[test] @@ -1617,23 +2133,15 @@ pub(crate) mod tests { .storage .get_catalog_table(TABLE_ID_TABLES) .unwrap(); - let mut session = engine.new_session().unwrap(); - let mut trx = session.begin_trx().unwrap(); - - // RFC-0029 Phase 2 runner coverage: private catalog panic - // injection verifies the legacy callback panic boundary. - let panic = AssertUnwindSafe(trx.exec(async |stmt| { - let key = SelectKey::new(1, vec![Val::from(TableID::new(42))]); - stmt.catalog_delete_primary_key_mvcc( - catalog_table.as_ref(), - key.index_no, - &key.vals, - true, - ) - .await - .disclose()?; - Ok(()) - })) + let session = engine.new_session().unwrap(); + let mut trx = begin_catalog_test_trx(&session); + let key = SelectKey::new(1, vec![Val::from(TableID::new(42))]); + + let panic = AssertUnwindSafe(trx.trx().catalog_delete_primary_key_mvcc( + catalog_table.as_ref(), + key.index_no, + key.vals, + )) .catch_unwind() .await .expect_err("non-primary catalog delete must violate the catalog invariant"); @@ -1646,13 +2154,7 @@ pub(crate) mod tests { message.contains("catalog mutation invariant violated"), "unexpected panic: {message}" ); - let err = trx.rollback().await.unwrap_err(); - assert_eq!( - err.report().downcast_ref::().copied(), - Some(LifecycleError::TransactionDiscarded) - ); - session_tests::wait_for_session_idle(&engine.inner().session_registry, session.id()) - .await; + trx.rollback().await; engine.shutdown(); }); } @@ -1695,13 +2197,13 @@ pub(crate) mod tests { LockMode::IntentExclusive, ) .unwrap(); - // RFC-0029 Phase 2 runner coverage: raw-effect injection forces - // index-before-row rollback and fatal residual retention. + // Owned-runner raw-effect injection forces index-before-row + // rollback and fatal residual retention. + set_test_force_stmt_index_rollback_error(true); let res: Result<()> = trx - .exec(async |stmt| { - assert_eq!(transaction_lock_owner(stmt), trx_owner); + .exec(async |mut stmt| { acquire_transaction_lock( - stmt, + &mut stmt, LockResource::TableMetadata(TableID::new(91_250)), LockMode::Shared, ) @@ -1710,7 +2212,7 @@ pub(crate) mod tests { // statement rollback ever runs row rollback before index // rollback, this test fails before the injected index // rollback error can discard the statement safely. - let effects = statement_effects_mut(stmt); + let effects = statement_effects_mut(&mut stmt); effects.push_row_undo(OwnedRowUndo::new( effects.stmt_no(), TableID::new(99_999_999), @@ -1724,7 +2226,6 @@ pub(crate) mod tests { SelectKey::new(0, vec![]), true, ); - set_test_force_stmt_index_rollback_error(true); Err(Report::new(OperationError::InvalidDmlInput).disclose()) }) .await; diff --git a/doradb-storage/src/trx/stream_stmt.rs b/doradb-storage/src/trx/stream_stmt.rs index f198c26c..016cd693 100644 --- a/doradb-storage/src/trx/stream_stmt.rs +++ b/doradb-storage/src/trx/stream_stmt.rs @@ -1,7 +1,6 @@ use crate::buffer::EvictableBufferPool; use crate::error::{ - DiscloseResultExt, MultiDomainResultExt, OperationError, OperationOrFatalResult, Result, - RuntimeResult, + DiscloseResultExt, OperationError, OperationOrFatalResult, Result, RuntimeResult, }; use crate::id::TableID; use crate::index::{ @@ -22,20 +21,25 @@ use std::sync::Arc; use super::admission::admit_user_table; -const INDEX_SCAN_STREAM_OPERATION: &str = "table_index_scan_mvcc"; +pub(super) const INDEX_SCAN_STREAM_OPERATION: &str = "table_index_scan_mvcc"; -struct StreamStmtState { +pub(super) struct StreamStmtState { checkout: SessionOperationCheckout, _stmt_no: StmtNo, + dml_validation_disabled: bool, } impl StreamStmtState { #[inline] - fn new(mut checkout: SessionOperationCheckout) -> Self { + pub(super) fn new( + mut checkout: SessionOperationCheckout, + dml_validation_disabled: bool, + ) -> Self { let stmt_no = checkout.inner_mut().next_stmt_no(); Self { checkout, _stmt_no: stmt_no, + dml_validation_disabled, } } @@ -65,6 +69,57 @@ impl StreamStmtState { ) .await } + + /// Creates a validated MVCC secondary-index row stream for a user table. + #[inline] + pub(super) async fn table_index_scan_mvcc_stream<'trx, 'r, R>( + mut self, + table_id: TableID, + index_no: usize, + range: R, + read_set: &[usize], + ) -> Result> + where + R: RangeBounds<&'r [Val]>, + { + let (table, layout) = self + .admit_user_table(table_id, TableAdmissionRequest::IndexRead { index_no }) + .await + .disclose()?; + if !self.dml_validation_disabled { + DmlValidator::new(layout.metadata()) + .validate_index_scan(index_no, &range, read_set) + .change_context(OperationError::InvalidDmlInput) + .attach_with(|| { + format!("operation={INDEX_SCAN_STREAM_OPERATION}, table_id={table_id}") + }) + .disclose()?; + } + let index = layout.secondary_index(index_no).disclose()?; + let unique = index.is_unique(); + let encoder = index.key_encoder_arc(); + let range = if unique { + encoder.encode_range(range) + } else { + encoder.encode_non_unique_range(range) + }; + let rt = self.runtime(); + let accessor = table.accessor_with_layout(&layout); + let candidate_stream = accessor + .index_scan_candidates(rt, index_no, range) + .disclose()?; + let state = IndexScanMvccStreamState { + candidate_stream, + table, + layout, + index_no, + unique, + encoder, + read_set: read_set.to_vec(), + stmt_state: self, + }; + Ok(IndexScanMvccStream::new(state)) + } } struct IndexScanMvccStreamState { @@ -203,96 +258,3 @@ impl Drop for IndexScanMvccStream<'_> { self.close(); } } - -/// Statement facade for public caller-driven transaction streams. -pub struct StreamStmt<'trx> { - trx: &'trx mut Transaction, - disable_validation: bool, -} - -impl<'trx> StreamStmt<'trx> { - #[inline] - pub(super) fn new(trx: &'trx mut Transaction) -> Self { - Self { - trx, - disable_validation: false, - } - } - - /// Disable default DML shape, type, and read-set validation for this stream. - /// - /// Validation is enabled by default. Disable it only when the caller has - /// already validated every `table_index_scan_mvcc` argument against the - /// target table metadata for this statement: - /// - /// - `index_no` names an active secondary index on the target table. - /// - Every bounded range side has exactly the target index key column count. - /// - Every bounded range value matches the corresponding indexed column type. - /// - `read_set` is non-empty, strictly increasing, and contains only - /// in-range table column numbers. - /// - /// Violating these preconditions may surface as debug assertions or - /// internal errors instead of `InvalidDmlInput`. - #[inline] - pub fn disable_validation(mut self) -> Self { - self.disable_validation = true; - self - } - - /// Creates a public MVCC secondary-index row stream for a user table. - #[inline] - pub async fn table_index_scan_mvcc<'r, R>( - self, - table_id: TableID, - index_no: usize, - range: R, - read_set: &[usize], - ) -> Result> - where - R: RangeBounds<&'r [Val]>, - { - let checkout = self - .trx - .checkout() - .attach_with(|| format!("operation={INDEX_SCAN_STREAM_OPERATION}")) - .disclose()?; - let mut stmt_state = StreamStmtState::new(checkout); - let (table, layout) = stmt_state - .admit_user_table(table_id, TableAdmissionRequest::IndexRead { index_no }) - .await - .disclose()?; - if !self.disable_validation { - DmlValidator::new(layout.metadata()) - .validate_index_scan(index_no, &range, read_set) - .change_context(OperationError::InvalidDmlInput) - .attach_with(|| { - format!("operation={INDEX_SCAN_STREAM_OPERATION}, table_id={table_id}") - }) - .disclose()?; - } - let index = layout.secondary_index(index_no).disclose()?; - let unique = index.is_unique(); - let encoder = index.key_encoder_arc(); - let range = if unique { - encoder.encode_range(range) - } else { - encoder.encode_non_unique_range(range) - }; - let rt = stmt_state.runtime(); - let accessor = table.accessor_with_layout(&layout); - let candidate_stream = accessor - .index_scan_candidates(rt, index_no, range) - .disclose()?; - let state = IndexScanMvccStreamState { - candidate_stream, - table, - layout, - index_no, - unique, - encoder, - read_set: read_set.to_vec(), - stmt_state, - }; - Ok(IndexScanMvccStream::new(state)) - } -} diff --git a/doradb-storage/src/trx/sys.rs b/doradb-storage/src/trx/sys.rs index 3a7f6047..d834ad66 100644 --- a/doradb-storage/src/trx/sys.rs +++ b/doradb-storage/src/trx/sys.rs @@ -435,7 +435,12 @@ impl TerminalRollbackCleanupJob { #[inline] async fn run(&mut self) { #[cfg(test)] - let trx_id = self.claim.trx_id(); + let trx_id = self + .claim + .attachment + .as_ref() + .expect("active completion claim retains terminal attachment") + .trx_id(); let trx_sys = self.claim.engine().trx_sys.clone(); #[cfg(test)] tests::run_terminal_rollback_test_hook(trx_id, self.operation); @@ -1756,8 +1761,6 @@ fn recovery_initial_trx_ts(max_recovered_cts: TrxID) -> DataIntegrityResult; - type AbandonedCleanupTestHook = Arc; - fn terminal_rollback_test_hook_slot() -> &'static Mutex> { static HOOK: OnceLock>> = OnceLock::new(); HOOK.get_or_init(|| Mutex::new(None)) } - fn abandoned_cleanup_test_hook_slot() -> &'static Mutex> { - static HOOK: OnceLock>> = OnceLock::new(); - HOOK.get_or_init(|| Mutex::new(None)) - } - /// Guard that restores the previous terminal rollback test hook on drop. pub(crate) struct TerminalRollbackTestHookGuard { previous: Option, @@ -1917,18 +1913,6 @@ pub(crate) mod tests { } } - /// Guard that restores the previous abandoned-cleanup hook on drop. - pub(crate) struct AbandonedCleanupTestHookGuard { - previous: Option, - } - - impl Drop for AbandonedCleanupTestHookGuard { - #[inline] - fn drop(&mut self) { - *abandoned_cleanup_test_hook_slot().lock() = self.previous.take(); - } - } - /// Install a test-only hook invoked after terminal rollback worker ownership. #[inline] pub(crate) fn install_terminal_rollback_test_hook( @@ -1947,24 +1931,6 @@ pub(crate) mod tests { } } - /// Install a test-only hook before abandoned cleanup claims ownership. - #[inline] - pub(crate) fn install_abandoned_cleanup_test_hook( - hook: AbandonedCleanupTestHook, - ) -> AbandonedCleanupTestHookGuard { - let mut slot = abandoned_cleanup_test_hook_slot().lock(); - let previous = slot.replace(hook); - AbandonedCleanupTestHookGuard { previous } - } - - #[inline] - pub(crate) fn run_abandoned_cleanup_test_hook(trx_id: TrxID) { - let hook = abandoned_cleanup_test_hook_slot().lock().clone(); - if let Some(hook) = hook { - hook(trx_id); - } - } - /// Returns retained fatal rollback payload count for tests. pub(crate) fn fatal_rollback_retention_count(trx_sys: &TransactionSystem) -> usize { trx_sys.fatal_rollback_retention.lock().len()