Skip to content

Commit 6838bff

Browse files
joaoh82claude
andauthored
feat(engine): Phase 11.2 logical clock + active-tx registry (SQLR-22) (#123)
Second slice of Phase 11 (concurrent writes via MVCC + BEGIN CONCURRENT). Adds the MVCC primitives that 11.3 will plug an in-memory version index into and 11.4 will hand begin/commit timestamps to. New `sqlrite::mvcc` module: - MvccClock — process-wide monotonic u64 over AtomicU64. tick(), now(), observe(value). The observe-CAS-loop guarantees the clock never moves backwards under contention; used at WAL replay to bring the in-memory clock up to the persisted high-water mark. - ActiveTxRegistry — Mutex<BTreeMap> over in-flight TxId → begin_ts mappings. register(&clock) returns a RAII TxHandle that unregisters on drop. min_active_begin_ts() answers Phase 11.6 GC's "what's still possibly visible" question in O(log N). - TxId newtype + TxTimestampOrId tagged union, defined now so 11.4 can plug in without re-litigating the type shape. WAL format bumps v1 → v2: - Bytes 24..32 of the WAL header (previously reserved-zero) now carry the persisted clock_high_water u64. - v1 WALs open cleanly — those zero bytes read as "clock never advanced" — and the next checkpoint rewrites the header at v2. No offline upgrade step. - New WAL_FORMAT_VERSION_MIN_SUPPORTED = 1 keeps the version-range check explicit. Forward versions (e.g. v3) reject with a clean error rather than misinterpreting bytes. - Wal::clock_high_water() / Wal::set_clock_high_water(value) accessors. The setter rejects regressions with a typed error (same value is a no-op). Not yet wired into the executor — that's 11.3. The clock is standalone today; only the mvcc module's own tests tick it. The plumbing is in place so 11.3 can read Wal::clock_high_water() at open time and feed the in-memory clock into a Database field. 21 new tests: - 8 in mvcc::clock::tests — seed, default, monotonicity, observe, contention (8 threads × 250 ticks unique). - 7 in mvcc::registry::tests — empty-min, register-advances-clock, unique-ids, unregister-arbitrary-order, Send+Sync compile check, concurrent registrations. - 6 in sql::pager::wal::tests — fresh-zero, round-trip-through- truncate, monotonic-persistence-across-truncates, regression-rejected, v1-opens-with-zero-clock, unknown-future-version-rejected. 599/599 workspace tests pass. fmt + clippy clean. cargo doc clean on changed files. Docs: docs/file-format.md WAL header diagram updated for v2 + the v1→v2 compatibility note. docs/roadmap.md marks 11.1 ✅ and 11.2 🚧. docs/design-decisions.md gets a 12b entry on the clock + WAL persistence design. docs/_index.md project-state line picks up the 11.2 summary. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c2ad854 commit 6838bff

9 files changed

Lines changed: 787 additions & 13 deletions

File tree

docs/_index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ As of May 2026, SQLRite has:
5454
- Full-text search + hybrid retrieval (Phase 8 complete): FTS5-style inverted index with BM25 ranking + `fts_match` / `bm25_score` scalar functions + `try_fts_probe` optimizer hook + on-disk persistence with on-demand v4 → v5 file-format bump (8a-8c), a worked hybrid-retrieval example combining BM25 with vector cosine via raw arithmetic (8d), and a `bm25_search` MCP tool symmetric with `vector_search` (8e). See [`docs/fts.md`](fts.md).
5555
- SQL surface + DX follow-ups (Phase 9 complete, v0.2.0 → v0.9.1): DDL completeness — `DEFAULT`, `DROP TABLE` / `DROP INDEX`, `ALTER TABLE` (9a); free-list + manual `VACUUM` (9b) + auto-VACUUM (9c); `IS NULL` / `IS NOT NULL` (9d); `GROUP BY` + aggregates + `DISTINCT` + `LIKE` + `IN` (9e); four flavors of `JOIN` — INNER, LEFT, RIGHT, FULL OUTER (9f); prepared statements + `?` parameter binding with a per-connection LRU plan cache (9g); HNSW probe widened to cosine + dot via `WITH (metric = …)` (9h); `PRAGMA` dispatcher with the `auto_vacuum` knob (9i)
5656
- Benchmarks against SQLite + DuckDB (Phase 10 complete, SQLR-4 / SQLR-16): twelve-workload bench harness with a pluggable `Driver` trait, criterion-driven, pinned-host runs published. See [`docs/benchmarks.md`](benchmarks.md).
57-
- Phase 11 (concurrent writes via MVCC + `BEGIN CONCURRENT`, SQLR-22) is in flight. **11.1 multi-connection foundation: shipped on this branch.** `Connection` is now `Send + Sync` and `Connection::connect()` mints a sibling handle that shares the same backing `Database`. Snapshot-isolation reads + `BEGIN CONCURRENT` writes follow in 11.3 / 11.4. Plan: [`docs/concurrent-writes-plan.md`](concurrent-writes-plan.md).
57+
- Phase 11 (concurrent writes via MVCC + `BEGIN CONCURRENT`, SQLR-22) is in flight. **11.1 multi-connection foundation: shipped.** `Connection` is now `Send + Sync` and `Connection::connect()` mints a sibling handle that shares the same backing `Database`. **11.2 logical clock + active-tx registry: shipped on this branch.** New `sqlrite::mvcc` module exposes `MvccClock` (AtomicU64-backed) and `ActiveTxRegistry` (`min_active_begin_ts` for GC). The WAL header bumps v1 → v2 to persist the clock high-water mark; v1 WALs upgrade transparently. Snapshot-isolation reads + `BEGIN CONCURRENT` writes follow in 11.3 / 11.4. Plan: [`docs/concurrent-writes-plan.md`](concurrent-writes-plan.md).
5858
- A fully-automated release pipeline that ships every product to its registry on every release with one human action — Rust engine + `sqlrite-ask` + `sqlrite-mcp` to crates.io, Python wheels to PyPI (`sqlrite`), Node.js + WASM to npm (`@joaoh82/sqlrite` + `@joaoh82/sqlrite-wasm`), Go module via `sdk/go/v*` git tag, plus C FFI tarballs, MCP binary tarballs, and unsigned desktop installers as GitHub Release assets (Phase 6 complete)
5959

6060
See the [Roadmap](roadmap.md) for the full phase plan.

docs/design-decisions.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,22 @@ Decisions are grouped by the engine layer they concern: parser, storage, concurr
156156

157157
---
158158

159+
### 12b. MVCC logical clock persisted in the WAL header (Phase 11.2)
160+
161+
**Decision.** A new `sqlrite::mvcc` module ships [`MvccClock`](../src/mvcc/clock.rs) (an `AtomicU64`-backed process-wide counter) and [`ActiveTxRegistry`](../src/mvcc/registry.rs) (a `Mutex<BTreeMap>`-backed set of in-flight `TxId → begin_ts` mappings). The clock's high-water mark is persisted in the WAL header — bytes 24..32, previously reserved-zero — and the WAL format version bumps from 1 to 2.
162+
163+
**Why.** MVCC visibility (`begin_ts <= ts < end_ts`) requires that timestamps never repeat — including across reopens. Restarting the engine and resuming the clock from zero would silently break that invariant the moment two transactions on either side of a restart picked the same value. Persisting the high-water mark on each checkpoint means reopen seeds the in-memory clock past the last value the previous run handed out. Putting it in the WAL header (rather than the main file) is right because the WAL is the durability boundary already; commits already fsync the WAL, and the existing checkpoint code rewrites the WAL header on truncate.
164+
165+
**Cost.** A WAL format bump is real but is mitigated by the v1 layout: bytes 24..32 were reserved-zero, so a v1 WAL parses cleanly under v2 rules with `clock_high_water = 0` — exactly what a never-ticked clock would carry. The next checkpoint rewrites the header at v2; no offline upgrade step. The reader still rejects forward versions (e.g. v3) with a typed error rather than silently misinterpreting bytes.
166+
167+
**Why an `AtomicU64`, not a plain `u64` behind a `Mutex`.** Each transaction calls the clock twice (begin + commit). Under contention, a `Mutex` would funnel every BEGIN through one critical section. Atomic CAS is wait-free and cheaper. The `observe()` helper (used at WAL replay to bring the clock up to a persisted value) is a CAS loop rather than a `store` so two racing observers can't move the clock backwards.
168+
169+
**Why `Mutex<BTreeMap>` for the active-tx registry rather than a sharded skip list.** The registry is touched twice per transaction (begin + commit/rollback). Phase 11.6's GC reads `min_active_begin_ts` once per sweep. That's roughly 2N + 1 mutex calls per N transactions — well below contention thresholds for any realistic workload. When the GC profile actually shows the registry on the hot path, a sharded structure is the obvious upgrade; until then, simple wins.
170+
171+
**Plan-doc reference.** [`concurrent-writes-plan.md`](concurrent-writes-plan.md) §4.1 (clock) and §4.2 (version index — the registry is an extracted slice of MvStore that's standalone in 11.2). Phase 11.3 is the first in-tree consumer.
172+
173+
---
174+
159175
## Query execution
160176

161177
### 13. `NULL`-as-false in `WHERE` clauses

docs/file-format.md

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -326,14 +326,27 @@ A second file alongside the `.sqlrite`, named `<stem>.sqlrite-wal`, records page
326326
│ offset │ length │ content │
327327
├────────┼────────┼─────────────────────────────────────────────────┤
328328
│ 0 │ 8 │ magic: "SQLRWAL\0" │
329-
│ 8 │ 4 │ format version (u32 LE) = 1 │
329+
│ 8 │ 4 │ format version (u32 LE) │
330+
│ │ │ 1 = pre-Phase-11 │
331+
│ │ │ 2 = Phase 11.2 — adds clock_high_water │
330332
│ 12 │ 4 │ page size (u32 LE) = 4096 │
331333
│ 16 │ 4 │ salt (u32 LE) — rolled each checkpoint │
332334
│ 20 │ 4 │ checkpoint seq (u32 LE) — increments per ckpt │
333-
│ 24 │ 8 │ reserved / zero │
335+
│ 24 │ 8 │ clock_high_water (u64 LE) — Phase 11.2 MVCC │
336+
│ │ │ logical-clock high-water mark; rewritten on │
337+
│ │ │ each checkpoint. v1 left these bytes as zero │
338+
│ │ │ (read identically as "no timestamps issued"). │
334339
└────────┴────────┴─────────────────────────────────────────────────┘
335340
```
336341

342+
**v1 → v2 compatibility.** The bytes that now hold `clock_high_water`
343+
were reserved-zero in v1, so a pre-Phase-11 WAL opens cleanly: the
344+
parser interprets the zeros as `clock_high_water = 0`, which is
345+
indistinguishable from "fresh checkpoint, clock has never advanced."
346+
The next checkpoint rewrites the header at v2 — there's no offline
347+
upgrade step. Forward versions we don't recognize (e.g. v3) error
348+
out with a clean diagnostic rather than misinterpreting the bytes.
349+
337350
### Frames
338351

339352
Each frame is `FRAME_HEADER_SIZE + PAGE_SIZE` = **4112 bytes**:

docs/roadmap.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -585,13 +585,19 @@ End-to-end SQLR-4 / SQLR-16 bench harness with twelve workloads across three gro
585585

586586
Lift SQLRite past SQLite's single-writer ceiling with multi-version concurrency control and a `BEGIN CONCURRENT` transaction mode, modelled on Turso's experimental MVCC. The plan doc internally numbers sub-phases as "Phase 10.x" (its working title before the roadmap renumbering); they're listed under Phase 11 here because Phase 10 already shipped.
587587

588-
### 🚧 Phase 11.1 — Multi-connection foundation *(in progress, plan-doc "Phase 10.1")*
588+
### Phase 11.1 — Multi-connection foundation *(plan-doc "Phase 10.1")*
589589

590-
`Connection` is now a thin handle backed by `Arc<Mutex<Database>>`. Call [`Connection::connect`] to mint a sibling that shares the same engine state — typically one per worker thread. The headline contract: `Connection` is `Send + Sync`, and the engine no longer requires the caller to wrap the public API in their own `Mutex`. Today every operation still serializes through the per-database mutex (and the pager's existing process-level flock), so the behaviour change is *capability*, not throughput; concurrent throughput arrives with `BEGIN CONCURRENT` in 11.4.
590+
`Connection` is a thin handle backed by `Arc<Mutex<Database>>`. Call [`Connection::connect`] to mint a sibling that shares the same engine state — typically one per worker thread. The headline contract: `Connection` is `Send + Sync`, and the engine no longer requires the caller to wrap the public API in their own `Mutex`. Today every operation still serializes through the per-database mutex (and the pager's existing process-level flock), so the behaviour change is *capability*, not throughput; concurrent throughput arrives with `BEGIN CONCURRENT` in 11.4.
591591

592-
### Phase 11.2 — Logical clock + active-tx registry *(planned)*
592+
### 🚧 Phase 11.2 — Logical clock + active-tx registry *(in progress, plan-doc "Phase 10.2")*
593593

594-
`MvccClock` (`AtomicU64`) hands out begin / commit timestamps; `ActiveTxRegistry` exposes `min_active_begin_ts()` for GC. WAL header bumps from v1 → v2 to persist the high-water mark.
594+
New [`sqlrite::mvcc`](../src/mvcc/) module:
595+
596+
- `MvccClock` — process-wide monotonic `u64` over `AtomicU64`. `tick()` hands out begin- / commit-timestamps; `now()` reads the high-water without advancing it; `observe(value)` advances the clock to `value` if greater (used at WAL replay).
597+
- `ActiveTxRegistry``Mutex<BTreeMap>` over in-flight transactions. `register(&clock)` allocates a `TxId`, snapshots `begin_ts`, and returns a RAII `TxHandle`; `min_active_begin_ts()` answers Phase 11.6 GC's "what's still possibly visible" question.
598+
- `TxId` newtype + `TxTimestampOrId` tagged union — defined now so 11.4 can plug in without re-litigating the type shape.
599+
600+
WAL format bumps **v1 → v2**: bytes 24..32 of the WAL header (previously reserved-zero) now carry the persisted `clock_high_water` `u64`. v1 WALs open cleanly — those zero bytes read as "clock never advanced" — and the next checkpoint rewrites the header at v2. No offline upgrade step. `Wal::set_clock_high_water` / `Wal::clock_high_water` accessors expose the field; the setter rejects regressions with a typed error. The clock isn't wired into the executor yet (that's 11.3); the persistence + restore plumbing is in place so 11.3 just reads the high-water at open and seeds the in-memory clock.
595601

596602
### Phase 11.3 — `MvStore` skeleton + snapshot-isolation reads *(planned)*
597603

src/lib.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,12 @@ extern crate prettytable;
6161
pub mod ask;
6262
pub mod connection;
6363
pub mod error;
64+
// Phase 11.2 — multi-version concurrency control primitives. The
65+
// module is public because Phase 11.3 / 11.4 grow externally-visible
66+
// types (timestamps, busy errors) on top of these foundations; today
67+
// it just exposes the standalone clock + active-tx registry. See
68+
// `docs/concurrent-writes-plan.md`.
69+
pub mod mvcc;
6470
pub mod sql;
6571

6672
// Phase 5a public API.

src/mvcc/clock.rs

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
//! [`MvccClock`] — the logical-clock primitive that hands out
2+
//! begin- and commit-timestamps for MVCC transactions (Phase 11.2).
3+
//!
4+
//! Per [`docs/concurrent-writes-plan.md`](../../../docs/concurrent-writes-plan.md):
5+
//!
6+
//! > A monotonic `u64` counter, per-`Database`. Hands out `begin_ts`
7+
//! > at `BEGIN CONCURRENT` and `commit_ts` at the start of validation.
8+
//! > Wrapped in `AtomicU64`; no contention because each transaction
9+
//! > calls it twice.
10+
//!
11+
//! The clock is persisted to the WAL header on each checkpoint so
12+
//! reopens resume past the highest committed timestamp — see
13+
//! [`crate::sql::pager::wal::WalHeader::clock_high_water`]. Without
14+
//! persistence, two transactions on either side of a reopen could
15+
//! receive the same timestamp and the snapshot-isolation visibility
16+
//! rule (`begin <= ts < end`) would mis-classify one of them.
17+
18+
use std::sync::atomic::{AtomicU64, Ordering};
19+
20+
/// Process-wide logical clock. Cheap to clone — internally an `Arc`
21+
/// over an [`AtomicU64`] in the [`Database`](crate::Database) wiring
22+
/// (added in Phase 11.3). Standalone today.
23+
#[derive(Debug, Default)]
24+
pub struct MvccClock {
25+
counter: AtomicU64,
26+
}
27+
28+
impl MvccClock {
29+
/// Builds a clock seeded at `initial`. The next [`MvccClock::tick`]
30+
/// returns `initial + 1`.
31+
///
32+
/// Use this with the value persisted in the WAL header at open
33+
/// time so the clock resumes past the last-checkpointed
34+
/// high-water mark.
35+
pub fn new(initial: u64) -> Self {
36+
Self {
37+
counter: AtomicU64::new(initial),
38+
}
39+
}
40+
41+
/// Returns the current high-water timestamp without advancing it.
42+
/// Phase 11.6's GC reads this alongside
43+
/// [`super::ActiveTxRegistry::min_active_begin_ts`] to decide
44+
/// which row-version chains are reclaimable.
45+
pub fn now(&self) -> u64 {
46+
self.counter.load(Ordering::Acquire)
47+
}
48+
49+
/// Bumps the clock by one and returns the new value. Strictly
50+
/// monotonic: every call observes a distinct `u64`.
51+
pub fn tick(&self) -> u64 {
52+
// `fetch_add` returns the *previous* value — adjust to "after"
53+
// semantics so callers see "the timestamp this call hands out".
54+
// Wrap-around is impossible in practice (a billion ticks/s for
55+
// 600 years still fits in `u64`), so saturating-add isn't
56+
// needed.
57+
self.counter.fetch_add(1, Ordering::AcqRel) + 1
58+
}
59+
60+
/// Promotes the clock to at least `value`. No-op if `value` is at
61+
/// or below the current high-water mark. Used at WAL replay to
62+
/// bring the in-memory clock up to the persisted high-water
63+
/// without an extra `tick()`.
64+
pub fn observe(&self, value: u64) {
65+
let mut current = self.counter.load(Ordering::Acquire);
66+
while value > current {
67+
// CAS rather than `store` — racing observers shouldn't
68+
// step on each other and shouldn't move the clock
69+
// backwards if a faster `tick` already overtook them.
70+
match self.counter.compare_exchange_weak(
71+
current,
72+
value,
73+
Ordering::AcqRel,
74+
Ordering::Acquire,
75+
) {
76+
Ok(_) => return,
77+
Err(actual) => current = actual,
78+
}
79+
}
80+
}
81+
}
82+
83+
#[cfg(test)]
84+
mod tests {
85+
use super::*;
86+
use std::sync::Arc;
87+
use std::thread;
88+
89+
#[test]
90+
fn new_seeds_the_counter() {
91+
let c = MvccClock::new(42);
92+
assert_eq!(c.now(), 42);
93+
assert_eq!(c.tick(), 43);
94+
assert_eq!(c.now(), 43);
95+
}
96+
97+
#[test]
98+
fn default_starts_at_zero() {
99+
let c = MvccClock::default();
100+
assert_eq!(c.now(), 0);
101+
assert_eq!(c.tick(), 1);
102+
}
103+
104+
#[test]
105+
fn tick_is_strictly_monotonic_within_a_thread() {
106+
let c = MvccClock::new(0);
107+
let mut last = 0;
108+
for _ in 0..1_000 {
109+
let t = c.tick();
110+
assert!(t > last, "tick went backwards: {t} after {last}");
111+
last = t;
112+
}
113+
}
114+
115+
#[test]
116+
fn observe_only_moves_forward() {
117+
let c = MvccClock::new(100);
118+
c.observe(50); // ignored — below current
119+
assert_eq!(c.now(), 100);
120+
c.observe(200);
121+
assert_eq!(c.now(), 200);
122+
c.observe(150); // ignored — below current
123+
assert_eq!(c.now(), 200);
124+
}
125+
126+
/// Concurrent ticks across N threads must hand out N × M distinct
127+
/// values (no duplicates, no skipped values). This is the property
128+
/// MVCC visibility relies on.
129+
#[test]
130+
fn ticks_are_unique_under_contention() {
131+
const THREADS: usize = 8;
132+
const PER_THREAD: usize = 250;
133+
let clock = Arc::new(MvccClock::new(0));
134+
135+
let handles: Vec<_> = (0..THREADS)
136+
.map(|_| {
137+
let c = Arc::clone(&clock);
138+
thread::spawn(move || {
139+
let mut out = Vec::with_capacity(PER_THREAD);
140+
for _ in 0..PER_THREAD {
141+
out.push(c.tick());
142+
}
143+
out
144+
})
145+
})
146+
.collect();
147+
148+
let mut all = Vec::with_capacity(THREADS * PER_THREAD);
149+
for h in handles {
150+
all.extend(h.join().unwrap());
151+
}
152+
all.sort_unstable();
153+
// No duplicates.
154+
for w in all.windows(2) {
155+
assert_ne!(w[0], w[1], "duplicate timestamp {}", w[0]);
156+
}
157+
// Range is contiguous 1..=THREADS*PER_THREAD (clock seeded at 0).
158+
assert_eq!(all.first().copied(), Some(1));
159+
assert_eq!(all.last().copied(), Some((THREADS * PER_THREAD) as u64));
160+
}
161+
162+
/// Concurrent `observe`s must not move the clock backwards.
163+
#[test]
164+
fn observe_under_contention_never_regresses() {
165+
const THREADS: usize = 8;
166+
let clock = Arc::new(MvccClock::new(0));
167+
let handles: Vec<_> = (0..THREADS)
168+
.map(|tid| {
169+
let c = Arc::clone(&clock);
170+
thread::spawn(move || {
171+
// Each thread observes a deterministic distinct
172+
// value; the clock should end at the max.
173+
c.observe((tid as u64 + 1) * 1_000);
174+
})
175+
})
176+
.collect();
177+
for h in handles {
178+
h.join().unwrap();
179+
}
180+
assert_eq!(clock.now(), THREADS as u64 * 1_000);
181+
}
182+
}

src/mvcc/mod.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
//! Multi-version concurrency control primitives (Phase 11).
2+
//!
3+
//! This module is the foundation for SQLRite's `BEGIN CONCURRENT` story
4+
//! — see [`docs/concurrent-writes-plan.md`](../../docs/concurrent-writes-plan.md)
5+
//! for the full sequenced design. As of **Phase 11.2** it carries the
6+
//! standalone primitives that the rest of the work hangs off:
7+
//!
8+
//! - [`MvccClock`] — a process-wide monotonic `u64` counter that hands
9+
//! out begin- and commit-timestamps. Persisted to the WAL header so
10+
//! timestamps don't reuse the same value across reopens.
11+
//! - [`ActiveTxRegistry`] — tracks the begin-timestamps of in-flight
12+
//! transactions. Garbage collection (Phase 11.6) needs
13+
//! [`ActiveTxRegistry::min_active_begin_ts`] to know which versions
14+
//! are still possibly visible to a live reader.
15+
//! - [`TxId`] — opaque newtype around a `u64`, allocated by the clock
16+
//! while a transaction is in flight. After commit the same value is
17+
//! reused as the row version's `begin` timestamp; the discriminator
18+
//! between "in-flight transaction id" and "committed timestamp"
19+
//! lives in [`TxTimestampOrId`].
20+
//!
21+
//! Nothing in the executor reads from these yet — Phase 11.3 wires
22+
//! them into a new `MvStore` in front of the pager. Keeping the
23+
//! plumbing standalone in 11.2 means the Phase 11.4 `BEGIN CONCURRENT`
24+
//! work can pull them in without re-litigating the foundation.
25+
26+
pub mod clock;
27+
pub mod registry;
28+
29+
pub use clock::MvccClock;
30+
pub use registry::{ActiveTxRegistry, TxHandle, TxId, TxTimestampOrId};

0 commit comments

Comments
 (0)