rushdb_storage/commit.rs
1//! Command admission, ordered publication, and transaction commit.
2//! The store owns the health fence and publication locks across these phases.
3
4use super::{
5 log_key, mpsc, resources, wal, Arc, BTreeMap, BTreeSet, CommitBatch, CommitOutcome,
6 CommitPhase, DependencySet, DeserializeOwned, Op, Ordering, OutcomeKind, OutcomeStatus,
7 OverlayView, PendingCommit, PreparedCommand, RefCell, SequencerState, Serialize, StorageError,
8 Store, TenantId, TxAccumulator, Txn, TxnBase, Uuid, LAST_APPLIED_KEY, SERIALIZABLE_CONFLICT,
9};
10
11impl Store {
12 /// Data-map guard for a transaction base. `None` when the transaction
13 /// carries a pinned snapshot (the snapshot owns the read, and taking this
14 /// guard too would self-deadlock re-reading through the memory snapshot
15 /// while holding `data.read()`, since std `RwLock` is not reentrant);
16 /// otherwise the live committed data guard.
17 fn txn_data(
18 &self,
19 snapshot: bool,
20 ) -> Result<Option<std::sync::RwLockReadGuard<'_, BTreeMap<Vec<u8>, Vec<u8>>>>, StorageError>
21 {
22 if snapshot {
23 Ok(None)
24 } else {
25 Ok(Some(self.health.guard(self.data.read())?))
26 }
27 }
28
29 // Shared by every commit path (autocommit, serializable, explicit tx): reject an expanded
30 // op-set (plus command) over `max_expanded_commit_bytes` before a slot is reserved, as a
31 // retryable resource error rather than the store-fencing a durability failure would cause.
32 fn reject_oversized_commit(
33 &self,
34 ops: &[Op],
35 command_payload_len: usize,
36 ) -> Result<(), StorageError> {
37 let expanded_bytes = wal::op_set_encoded_len(ops).saturating_add(command_payload_len);
38 if expanded_bytes > self.resource_config.max_expanded_commit_bytes {
39 return Err(StorageError::ResourceExhausted(format!(
40 "expanded mutation batch exceeds {} bytes (got {expanded_bytes})",
41 self.resource_config.max_expanded_commit_bytes
42 )));
43 }
44 Ok(())
45 }
46
47 /// Deterministic expansion + commit bookkeeping for `batch` at commit index
48 /// `ci`, against whatever base `txn` is positioned at (live committed
49 /// state, a pinned snapshot, or a sequencer overlay). Returns the drained
50 /// op-set exactly as the forward publish path persists it. This is the one
51 /// home for expansion: the live commit path and the sequencer overlay path
52 /// funnel through it, so their op-sets cannot drift.
53 ///
54 /// # Errors
55 /// Returns [`StorageError::CorruptLog`] when no expander is installed and
56 /// [`StorageError::Builder`] when expansion fails.
57 fn expand_commit_ops(
58 &self,
59 ci: u64,
60 batch: &CommitBatch,
61 payload_bytes: &[u8],
62 mut txn: Txn<'_>,
63 ) -> Result<Vec<Op>, StorageError> {
64 let expander = self.health.guard(self.expander.read())?.clone();
65 let Some(expand) = expander.as_ref() else {
66 return Err(StorageError::CorruptLog);
67 };
68 expand(ci, batch, &mut txn).map_err(StorageError::Builder)?;
69 // A durable store derives the journal from the WAL instead;
70 // an in-memory store has no WAL, so it keeps this row (test-mode only).
71 if !self.is_durable() {
72 let command_payload = rushdb_types::command_codec::encode(batch);
73 txn.put(log_key(ci), wal::encode_wal_record(0, ci, &command_payload));
74 }
75 self.put_receipt(
76 &mut txn,
77 batch.commit_key,
78 OutcomeStatus::Committed,
79 ci,
80 Some(payload_bytes),
81 );
82 txn.set(LAST_APPLIED_KEY.to_vec(), wal::encode_last_applied(0, ci));
83 Ok(txn.drain())
84 }
85
86 /// Sequencer expansion primitive: the deterministic ordered op-set for
87 /// `batch` at commit index `ci` computed against `overlay` — a committed
88 /// base plus lower-index *pending* ops — instead of the live committed
89 /// state. Deterministic and side-effect free: takes no admission, shard
90 /// or publish lock, and publishes nothing.
91 ///
92 /// When `overlay` carries the expanded output of commits `< ci`, the
93 /// result is byte-identical to the serialized forward path publishing
94 /// those commits first (`sequencer_tests` asserts the equivalence). The
95 /// sequencer calls this to expand without holding the shard lock
96 /// across durability.
97 ///
98 /// # Errors
99 /// Returns the same errors as forward expansion: a base read error, or
100 /// [`StorageError::CorruptLog`]/[`StorageError::Builder`] when no
101 /// expander is installed or expansion fails.
102 pub fn expand_against_overlay<P>(
103 &self,
104 ci: u64,
105 batch: &CommitBatch,
106 payload: &P,
107 overlay: Arc<OverlayView>,
108 ) -> Result<Vec<Op>, StorageError>
109 where
110 P: Serialize,
111 {
112 self.check_health()?;
113 let payload_bytes =
114 serde_json::to_vec(payload).map_err(|_| StorageError::OutcomePayload)?;
115 let txn = Txn {
116 base: TxnBase::Overlay(overlay),
117 buffer: BTreeMap::new(),
118 purge_keys: BTreeSet::default(),
119 set_keys: BTreeSet::default(),
120 watermark_keys: BTreeSet::default(),
121 ids: &self.ids,
122 deps: None,
123 };
124 self.expand_commit_ops(ci, batch, &payload_bytes, txn)
125 }
126
127 /// Autocommit path: admission builds and validates a canonical
128 /// `CommitBatch` inside `build`, storage publishes everything atomically.
129 /// Retrying with the same commit key discovers the durable outcome.
130 ///
131 /// # Errors
132 /// Returns [`StorageError`] if the publisher has failed, a precondition is
133 /// violated, expansion fails, or durable publication fails.
134 pub fn commit<P, F>(&self, commit_key: Uuid, build: F) -> Result<CommitOutcome<P>, StorageError>
135 where
136 P: Serialize + DeserializeOwned,
137 F: FnOnce(u64, &mut Txn) -> Result<(CommitBatch, P), String>,
138 {
139 self.check_health()?;
140 self.commit_sharded(0, None, commit_key, None, build)
141 }
142
143 /// Tenant-aware autocommit. Outer shards preserve per-tenant replication
144 /// ordering; the local logical writer serializes planning and expansion.
145 ///
146 /// # Errors
147 /// Returns [`StorageError`] if the publisher has failed, a precondition is
148 /// violated, expansion fails, or durable publication fails.
149 pub fn commit_for_tenant<P, F>(
150 &self,
151 tenant: &TenantId,
152 commit_key: Uuid,
153 build: F,
154 ) -> Result<CommitOutcome<P>, StorageError>
155 where
156 P: Serialize + DeserializeOwned,
157 F: FnOnce(u64, &mut Txn) -> Result<(CommitBatch, P), String>,
158 {
159 self.check_health()?;
160 let shard = Store::shard_for(Some(tenant));
161 self.commit_sharded(shard, Some(tenant), commit_key, None, build)
162 }
163
164 /// Tenant-aware autocommit of a batch planned at an earlier snapshot: it
165 /// commits only if no commit after `snapshot_ci` wrote a key `deps`
166 /// covers (published or still in flight); otherwise it is
167 /// [`CommitOutcome::Conflicted`] with a `serializable_conflict` reason.
168 ///
169 /// # Errors
170 /// As [`Store::commit_for_tenant`], plus
171 /// [`StorageError::ConflictHistoryUnavailable`] when the conflict window
172 /// no longer holds a commit the check needs (retryable).
173 pub fn commit_for_tenant_since<P, F>(
174 &self,
175 tenant: &TenantId,
176 commit_key: Uuid,
177 snapshot_ci: u64,
178 deps: &DependencySet,
179 build: F,
180 ) -> Result<CommitOutcome<P>, StorageError>
181 where
182 P: Serialize + DeserializeOwned,
183 F: FnOnce(u64, &mut Txn) -> Result<(CommitBatch, P), String>,
184 {
185 self.check_health()?;
186 let shard = Store::shard_for(Some(tenant));
187 self.commit_sharded(
188 shard,
189 Some(tenant),
190 commit_key,
191 Some((snapshot_ci, deps)),
192 build,
193 )
194 }
195
196 /// The first commit after `snapshot_ci` that wrote a key `deps` covers:
197 /// published commits from the conflict window, in-flight ones from the
198 /// admission buffer. The caller holds admission (so no commit is
199 /// assigned meanwhile) and has drained publication.
200 fn first_conflict_since(
201 &self,
202 admission: &SequencerState,
203 snapshot_ci: u64,
204 deps: &DependencySet,
205 ) -> Result<Option<u64>, StorageError> {
206 let published = self.published.load(Ordering::SeqCst);
207 let window = self.health.guard(self.conflicts.lock())?;
208 for probe in (snapshot_ci + 1)..=published {
209 let key_set = window
210 .keys(probe)
211 .ok_or(StorageError::ConflictHistoryUnavailable {
212 commit_index: probe,
213 })?;
214 if key_set.iter().any(|key| deps.intersects(key)) {
215 return Ok(Some(probe));
216 }
217 }
218 drop(window);
219 Ok(admission
220 .buffer
221 .iter()
222 .filter(|(key, (ci, _))| *ci > snapshot_ci && deps.intersects(key))
223 .map(|(_, (ci, _))| *ci)
224 .min())
225 }
226
227 #[expect(
228 clippy::too_many_lines,
229 reason = "the commit state machine keeps admission, expansion, slot reservation, and publication together so the ordering contract is auditable in one place"
230 )]
231 pub(super) fn commit_sharded<P, F>(
232 &self,
233 shard: usize,
234 routed_tenant: Option<&TenantId>,
235 commit_key: Uuid,
236 since: Option<(u64, &DependencySet)>,
237 build: F,
238 ) -> Result<CommitOutcome<P>, StorageError>
239 where
240 P: Serialize + DeserializeOwned,
241 F: FnOnce(u64, &mut Txn) -> Result<(CommitBatch, P), String>,
242 {
243 self.check_health()?;
244 let shard_guard = self.health.guard(self.shards[shard].lock())?;
245 // Replication re-enters through apply_replicated_batch; do not hold
246 // this mutex across the replication call. Local writes hold it through
247 // planning + expansion + enqueue, then release it before the durability
248 // wait so concurrent commits can coalesce; every expansion reads
249 // the preceding logical state from the pending overlay instead.
250 let replicator = self.replicator();
251 let admission_started = std::time::Instant::now();
252 let mut admission = if replicator.is_none() {
253 Some(self.acquire_admission()?)
254 } else {
255 None
256 };
257 self.observe_phase(CommitPhase::AdmissionWait, admission_started);
258 self.check_publisher()?;
259 if let Some((status, prev_ci)) = self.lookup_outcome_checked(commit_key)? {
260 return match status {
261 OutcomeStatus::Aborted => Ok(CommitOutcome::Conflicted(
262 "previously aborted under this commit key".to_string(),
263 )),
264 OutcomeStatus::Committed => Ok(CommitOutcome::Committed {
265 commit_index: prev_ci,
266 payload: self.committed_payload(commit_key)?,
267 }),
268 };
269 }
270
271 // Admission builds + validates against a stable read view. Aborts do
272 // NOT consume a commit-index slot, keeping the log dense and replay
273 // position-indexed. `ci` is only reserved after the build succeeds.
274 // Refresh the pending overlay, then plan and expand against it so the
275 // command sees every lower-index commit whether or not it is published
276 // yet (the property the shard lock used to guarantee). Phase A holds
277 // admission across publication, so the overlay is empty here and this
278 // is behavior-preserving; Phase C releases it before the ack wait.
279 if let Some(state) = admission.as_mut() {
280 self.trim_sequencer(state);
281 }
282 let sequencer_overlay = match admission.as_ref() {
283 Some(state) => Some(self.sequencer_overlay(state)?),
284 None => None,
285 };
286 let exists_in_view = |key: &[u8]| -> Result<bool, StorageError> {
287 match &sequencer_overlay {
288 Some(overlay) => Ok(overlay.get(key)?.is_some()),
289 None => Ok(self.try_get(key)?.is_some()),
290 }
291 };
292 let provisional = self.next_commit_index.load(Ordering::SeqCst);
293 let build_deps = RefCell::new(DependencySet::default());
294 let (mut batch, payload) = {
295 // When an overlay is present it is authoritative, so the base reads
296 // go through it and never touch the live map. Acquiring
297 // `data.read()` here and then reading through the overlay would
298 // re-enter the same in-memory RwLock while a publisher waits on the
299 // write side — a self-deadlock.
300 let guard = if sequencer_overlay.is_none() {
301 Some(self.health.guard(self.data.read())?)
302 } else {
303 None
304 };
305 let native = if sequencer_overlay.is_none() {
306 self.native_transaction_view()?
307 } else {
308 None
309 };
310 let base = TxnBase::resolve(sequencer_overlay.clone(), None, native, guard.as_deref());
311 let mut txn = Txn {
312 base,
313 buffer: BTreeMap::new(),
314 purge_keys: BTreeSet::default(),
315 set_keys: BTreeSet::default(),
316 watermark_keys: BTreeSet::default(),
317 ids: &self.ids,
318 deps: Some(&build_deps),
319 };
320 let build_started = std::time::Instant::now();
321 let build_result = build(provisional, &mut txn);
322 self.observe_phase(CommitPhase::Build, build_started);
323 match build_result {
324 Ok(outcome) => outcome,
325 Err(reason) => {
326 drop(txn);
327 if admission.is_some() {
328 self.record_aborted_outcome_admitted(commit_key)?;
329 } else {
330 self.record_aborted_outcome(commit_key)?;
331 }
332 return Ok(CommitOutcome::Conflicted(reason));
333 }
334 }
335 };
336 // The storage boundary owns idempotency. Planners cannot accidentally
337 // emit a nil or mismatched key into the replicated command log.
338 batch.commit_key = commit_key;
339 if routed_tenant.is_some_and(|tenant| batch.tenant != *tenant) {
340 return Err(StorageError::Builder(
341 "commit batch tenant does not match the routed tenant".into(),
342 ));
343 }
344
345 // When a replication seam is installed, admission still validates
346 // locally (leader planning), then the batch is routed through the
347 // leader. `apply_replicated_batch` on this node publishes the outcome
348 // durably, and the returned commit index + payload reproduce the
349 // caller's response. Aborts stay local and durable.
350 if let Some(replicator) = replicator {
351 for pc in &batch.preconditions {
352 let exists = self.try_get(&pc.key)?.is_some();
353 let violated =
354 exists != matches!(pc.kind, rushdb_types::command::PreconditionKind::Exists);
355 if violated {
356 self.record_aborted_outcome(commit_key)?;
357 return Err(StorageError::Precondition {
358 path: rushdb_types::keys::describe_key(&pc.key),
359 });
360 }
361 }
362 let payload_bytes =
363 serde_json::to_vec(&payload).map_err(|_| StorageError::OutcomePayload)?;
364 replicator
365 .replicate(&batch, &payload_bytes)
366 .map_err(StorageError::Builder)?;
367 return match self.lookup_outcome_checked(commit_key)? {
368 Some((OutcomeStatus::Committed, ci)) => Ok(CommitOutcome::Committed {
369 commit_index: ci,
370 payload,
371 }),
372 _ => Err(StorageError::CorruptLog),
373 };
374 }
375
376 if let (Some((snapshot_ci, deps)), Some(state)) = (since, admission.as_ref()) {
377 {
378 let _publish = self.health.guard(self.publish_lock.lock())?;
379 self.drain_and_publish();
380 }
381 if let Some(probe) = self.first_conflict_since(state, snapshot_ci, deps)? {
382 self.record_aborted_outcome_admitted(commit_key)?;
383 return Ok(CommitOutcome::Conflicted(format!(
384 "{SERIALIZABLE_CONFLICT} on write at ci {probe}"
385 )));
386 }
387 }
388
389 for pc in &batch.preconditions {
390 let exists = exists_in_view(&pc.key)?;
391 let violated =
392 exists != matches!(pc.kind, rushdb_types::command::PreconditionKind::Exists);
393 if violated {
394 // Terminal and durable: a retry under the same commit key
395 // must discover `aborted`, never run again or report success.
396 self.record_aborted_outcome_admitted(commit_key)?;
397 return Err(StorageError::Precondition {
398 path: rushdb_types::keys::describe_key(&pc.key),
399 });
400 }
401 }
402
403 let provisional = self.next_commit_index.load(Ordering::SeqCst);
404 batch.start_index = provisional.saturating_sub(1);
405
406 // Deterministic expansion + log/outcome bookkeeping. The expansion
407 // runs BEFORE the slot is reserved so an expansion failure does not
408 // consume a commit index (the log stays dense). The reservation is a
409 // CAS from the expansion's `provisional`; if another admission
410 // reserved in between, the expansion is retried at the current value.
411 let payload_bytes =
412 serde_json::to_vec(&payload).map_err(|_| StorageError::OutcomePayload)?;
413 let expand_at = |ci: u64| -> Result<(Vec<Op>, DependencySet), StorageError> {
414 let expand_deps = RefCell::new(DependencySet::default());
415 // As in the build block: with an overlay the live map is never
416 // read, so do not take its read guard (which the overlay's base
417 // read would re-enter).
418 let guard = if sequencer_overlay.is_none() {
419 Some(self.health.guard(self.data.read())?)
420 } else {
421 None
422 };
423 let native = if sequencer_overlay.is_none() {
424 self.native_transaction_view()?
425 } else {
426 None
427 };
428 let base = TxnBase::resolve(sequencer_overlay.clone(), None, native, guard.as_deref());
429 let wtxn = Txn {
430 base,
431 buffer: BTreeMap::new(),
432 purge_keys: BTreeSet::default(),
433 set_keys: BTreeSet::default(),
434 watermark_keys: BTreeSet::default(),
435 ids: &self.ids,
436 // Record expansion reads so `prepared.reads` is non-empty and
437 // admission is held through publication. Otherwise a
438 // read-dependent expander (e.g. a counter) releases admission
439 // early and concurrent commits overwrite each other.
440 deps: Some(&expand_deps),
441 };
442 let ops = self.expand_commit_ops(ci, &batch, &payload_bytes, wtxn)?;
443 Ok((ops, expand_deps.into_inner()))
444 };
445
446 // Computed once, up front, so the size check and the WAL/outcome writers never re-serialize it.
447 let command_payload = rushdb_types::command_codec::encode(&batch);
448
449 // Reserve the slot with a CAS loop: expansion runs *before* reservation so a failed
450 // expansion consumes no commit index (the log stays dense); if another admission
451 // reserved in between, re-read and re-expand at the new value — never `fetch_add` a
452 // slot before expansion, which would leak a hole on a failed retry. The size check
453 // runs inside the loop, before reservation.
454 let expand_started = std::time::Instant::now();
455 let (ci, ops, expand_deps) = loop {
456 let ci = self.next_commit_index.load(Ordering::SeqCst);
457 let (ops, deps) = expand_at(ci)?;
458 self.reject_oversized_commit(&ops, command_payload.len())?;
459 if self
460 .next_commit_index
461 .compare_exchange(ci, ci + 1, Ordering::SeqCst, Ordering::SeqCst)
462 .is_ok()
463 {
464 break (ci, ops, deps);
465 }
466 };
467 self.observe_phase(CommitPhase::Expand, expand_started);
468
469 let shard =
470 resources::physical_shard_for(Some(&batch.tenant), self.resource_config.shard_count);
471 let mut prepared = PreparedCommand::from_ops(ci, &ops);
472 let mut build_deps = build_deps.into_inner();
473 build_deps.extend(expand_deps);
474 prepared.reads = build_deps;
475 // Planned and expanded against the sequencer overlay under admission
476 // (the replicated path returned above), so every predecessor's writes
477 // were visible to this command's reads.
478 prepared.sequenced = sequencer_overlay.is_some();
479 self.enqueue_and_publish_prepared(
480 ci,
481 shard,
482 ops,
483 command_payload,
484 Some(prepared),
485 admission,
486 Some(shard_guard),
487 )?;
488 Ok(CommitOutcome::Committed {
489 commit_index: ci,
490 payload,
491 })
492 }
493
494 /// Enqueue a fully-admitted op-set and wait until it is durably
495 /// persisted + applied. The committing thread attempts to become the
496 /// group-commit publisher; if another thread already holds the publish
497 /// lock, that publisher drains everything currently pending (including
498 /// this entry) and acks it.
499 pub(super) fn enqueue_and_publish(
500 &self,
501 ci: u64,
502 shard: usize,
503 ops: Vec<Op>,
504 command_payload: Vec<u8>,
505 ) -> Result<(), StorageError> {
506 self.check_health()?;
507 self.enqueue_and_publish_prepared(ci, shard, ops, command_payload, None, None, None)
508 }
509
510 #[expect(
511 clippy::too_many_arguments,
512 reason = "one ordered enqueue carries the reserved index, shard, ops, originating command, prepared metadata, and the two serialization guards it releases before the durability wait"
513 )]
514 pub(super) fn enqueue_and_publish_prepared(
515 &self,
516 ci: u64,
517 shard: usize,
518 ops: Vec<Op>,
519 command_payload: Vec<u8>,
520 prepared: Option<PreparedCommand>,
521 mut admission: Option<std::sync::MutexGuard<'_, SequencerState>>,
522 shard_guard: Option<std::sync::MutexGuard<'_, ()>>,
523 ) -> Result<(), StorageError> {
524 let started = std::time::Instant::now();
525 self.check_health()?;
526 if self.maintenance_aborted() {
527 return Err(StorageError::Durability(
528 "store is shutting down; commit was not enqueued".into(),
529 ));
530 }
531 let (ack_tx, ack_rx) = mpsc::channel();
532 {
533 let mut pending = self.health.guard(self.pending.lock())?;
534 self.check_publisher()?;
535 // Record the ordered commit in the pending overlay *before* it can
536 // be published, so the very next writer to acquire admission sees
537 // it. Recording under `pending` means an aborted enqueue cannot
538 // leave a phantom overlay entry.
539 if let Some(state) = admission.as_mut() {
540 state.record(ci, &ops);
541 }
542 pending.insert(
543 ci,
544 PendingCommit {
545 ci,
546 shard,
547 prepared: prepared.unwrap_or_else(|| PreparedCommand::from_ops(ci, &ops)),
548 ops,
549 command_payload,
550 ack: ack_tx,
551 },
552 );
553 }
554 // The ordered entry is now visible in `pending`, so the next writer may
555 // sequence. Release the serialization locks *before* waiting on
556 // durability: concurrent commits then accumulate in `pending` and
557 // `drain_and_publish` coalesces them into one fsync. Their expansions
558 // already read this commit's ops from the pending overlay, so releasing
559 // here cannot expose them to stale state.
560 drop(admission);
561 drop(shard_guard);
562 self.pending_cv.notify_all();
563
564 loop {
565 // Try to become the publisher — only until this commit is
566 // published; later commits belong to their own committers.
567 if let Ok(guard) = self.publish_lock.try_lock() {
568 self.drain_and_publish_until(Some(ci));
569 drop(guard);
570 }
571 match ack_rx.try_recv() {
572 Ok(outcome) => {
573 if outcome.is_ok() {
574 self.observe_phase(CommitPhase::Durable, started);
575 }
576 return outcome;
577 }
578 Err(mpsc::TryRecvError::Disconnected) => return Err(StorageError::CorruptLog),
579 Err(mpsc::TryRecvError::Empty) => {}
580 }
581 // Wait for publish progress (a lower ci may still be admitted).
582 let mut pending = self.health.guard(self.pending.lock())?;
583 match ack_rx.try_recv() {
584 Ok(outcome) => {
585 if outcome.is_ok() {
586 self.observe_phase(CommitPhase::Durable, started);
587 }
588 return outcome;
589 }
590 Err(mpsc::TryRecvError::Disconnected) => return Err(StorageError::CorruptLog),
591 Err(mpsc::TryRecvError::Empty) => {}
592 }
593 self.check_publisher()?;
594 // A shutdown must not leave a committer waiting forever for a
595 // publisher that will never run. Atomically dequeue our own entry
596 // under `pending`: if it is still queued, no publisher can have
597 // published it, so a terminal error is accurate. If a publisher
598 // already took it, the commit is in flight — keep waiting for its
599 // ack (or the dropped-sender `Disconnected` error) so we report the
600 // true outcome instead of a spurious failure.
601 if self.maintenance_aborted() && pending.remove(&ci).is_some() {
602 return Err(StorageError::Durability(
603 "store is shutting down before the commit was published".into(),
604 ));
605 }
606 // A read-view/maintenance operation may hold publish_lock without
607 // becoming a publisher. Retry after a bounded wait in that case;
608 // a notification must not be the only route to terminal progress.
609 pending = self
610 .health
611 .guard(
612 self.pending_cv
613 .wait_timeout(pending, std::time::Duration::from_millis(10)),
614 )?
615 .0;
616 drop(pending);
617 }
618 }
619
620 /// Serializable-optimistic entry point (document 07): `deps` records
621 /// point/range reads performed at `snapshot_ci`; validation rejects if
622 /// any committed mutation between then and now writes into a recorded
623 /// range. Runs while the publish lock is held so validation and
624 /// publication are atomic; a loser records its terminal-aborted outcome.
625 ///
626 /// # Errors
627 /// Returns [`StorageError`] if the publisher has failed, a precondition is
628 /// violated, a conflict summary is missing, the snapshot is in the future,
629 /// or publication fails. A poisoned admission, publish, or data lock
630 /// permanently fences the store and returns [`StorageError::Unhealthy`].
631 ///
632 /// # Panics
633 /// Panics only if `batch` fails to serialize (never, for a well-formed
634 /// `CommitBatch`).
635 pub fn commit_serializable<P, F>(
636 &self,
637 commit_key: Uuid,
638 snapshot_ci: u64,
639 deps: DependencySet,
640 build: F,
641 ) -> Result<CommitOutcome<P>, StorageError>
642 where
643 P: Serialize + DeserializeOwned,
644 F: FnOnce(&mut Txn) -> Result<(CommitBatch, P), String>,
645 {
646 self.check_health()?;
647 let mut admission = self.acquire_admission()?;
648 self.check_publisher()?;
649 if let Some((status, prev_ci)) = self.lookup_outcome_checked(commit_key)? {
650 return match status {
651 OutcomeStatus::Aborted => Ok(CommitOutcome::Conflicted(
652 "previously aborted under this commit key".to_string(),
653 )),
654 OutcomeStatus::Committed => Ok(CommitOutcome::Committed {
655 commit_index: prev_ci,
656 payload: self.committed_payload(commit_key)?,
657 }),
658 };
659 }
660
661 if snapshot_ci > self.committed_version() {
662 return Err(StorageError::FutureSnapshot {
663 requested: snapshot_ci,
664 visible: self.committed_version(),
665 });
666 }
667 // Refresh the pending overlay and plan/expand against it so this commit
668 // sees every lower-index commit, published or not. In-flight writes are
669 // additionally checked against `deps` below because they are not yet in
670 // the conflict window.
671 self.trim_sequencer(&mut admission);
672 let sequencer_overlay = self.sequencer_overlay(&admission)?;
673 // This entry point preserves the existing staging-at-commit contract;
674 // supplied dependencies describe the caller's earlier reads. It does
675 // not itself implement begin-time transactional reads.
676 let dep_recorder = RefCell::new(deps);
677 let (mut batch, payload) = {
678 // The sequencer overlay is authoritative, so base reads go through
679 // it and never touch the live map; taking its read guard would let
680 // the overlay's base read re-enter the same RwLock (self-deadlock).
681 let mut txn = Txn {
682 base: TxnBase::Overlay(Arc::clone(&sequencer_overlay)),
683 buffer: BTreeMap::new(),
684 purge_keys: BTreeSet::default(),
685 set_keys: BTreeSet::default(),
686 watermark_keys: BTreeSet::default(),
687 ids: &self.ids,
688 deps: Some(&dep_recorder),
689 };
690 match build(&mut txn) {
691 Ok(outcome) => outcome,
692 Err(reason) => {
693 drop(txn);
694 self.record_aborted_outcome_admitted(commit_key)?;
695 return Ok(CommitOutcome::Conflicted(reason));
696 }
697 }
698 };
699 let deps = dep_recorder.into_inner();
700 batch.commit_key = commit_key;
701
702 // Admission excludes concurrent logical writers through publication.
703 // Select a tentative CI; reserve it only after successful expansion.
704 let ci = {
705 let _guard = self.health.guard(self.publish_lock.lock())?;
706 // Publish any pending commits so the committed log is complete.
707 self.drain_and_publish();
708
709 // Never replay an old command against today's state to infer what
710 // it once wrote. Missing legacy summaries are an explicit retry,
711 // not evidence of the absence of a conflict.
712 // In-flight commits (assigned but not yet published) are not in
713 // the conflict window; `first_conflict_since` consults the pending
714 // overlay too, so a dependency cannot be missed against an
715 // unpublished writer.
716 if let Some(probe) = self.first_conflict_since(&admission, snapshot_ci, &deps)? {
717 // An aborted tx consumes no commit index; the terminal aborted
718 // outcome is keyed by the commit key, so the contiguous
719 // publisher never has to fill a hole for it.
720 self.record_outcome_locked(commit_key, OutcomeStatus::Aborted, 0)?;
721 return Ok(CommitOutcome::Conflicted(format!(
722 "{SERIALIZABLE_CONFLICT} on write at ci {probe}"
723 )));
724 }
725
726 for pc in &batch.preconditions {
727 let exists = sequencer_overlay.get(&pc.key)?.is_some();
728 let violated =
729 exists != matches!(pc.kind, rushdb_types::command::PreconditionKind::Exists);
730 if violated {
731 self.record_outcome_locked(commit_key, OutcomeStatus::Aborted, 0)?;
732 return Err(StorageError::Precondition {
733 path: rushdb_types::keys::describe_key(&pc.key),
734 });
735 }
736 }
737
738 // The candidate slot is derived from the published watermark; the
739 // actual reservation happens after expansion succeeds so an
740 // expansion failure never consumes a commit index.
741 self.published.load(Ordering::SeqCst) + 1
742 };
743
744 // Feeds the WAL's embedded command on a durable store, or
745 // the in-memory command-journal row otherwise (no WAL to derive it from).
746 let command_payload = rushdb_types::command_codec::encode(&batch);
747 let payload_bytes =
748 serde_json::to_vec(&payload).map_err(|_| StorageError::OutcomePayload)?;
749 let expand_at = |ci: u64| -> Result<Vec<Op>, StorageError> {
750 // The overlay is authoritative; skip the live map's read guard.
751 let wtxn = Txn {
752 base: TxnBase::Overlay(Arc::clone(&sequencer_overlay)),
753 buffer: BTreeMap::new(),
754 purge_keys: BTreeSet::default(),
755 set_keys: BTreeSet::default(),
756 watermark_keys: BTreeSet::default(),
757 ids: &self.ids,
758 deps: None,
759 };
760 self.expand_commit_ops(ci, &batch, &payload_bytes, wtxn)
761 };
762
763 // Admission is held for the whole protocol, so the prospective `ci`
764 // cannot move. Expand first (a failure must not consume a slot), then
765 // reserve with a plain store; the previous CAS/`fetch_add` fallback
766 // could leak a reserved index when the re-expansion failed.
767 let ops = expand_at(ci)?;
768 self.reject_oversized_commit(&ops, command_payload.len())?;
769 admission.record(ci, &ops);
770 self.next_commit_index.store(ci + 1, Ordering::SeqCst);
771 let shard =
772 resources::physical_shard_for(Some(&batch.tenant), self.resource_config.shard_count);
773 self.enqueue_and_publish(ci, shard, ops, command_payload)?;
774 Ok(CommitOutcome::Committed {
775 commit_index: ci,
776 payload,
777 })
778 }
779
780 /// Run one statement's planning + expansion against a transaction's
781 /// accumulated overlay (read-your-writes), appending the produced writes to
782 /// the transaction. Nothing is committed or visible outside the tx.
783 ///
784 /// # Errors
785 /// Returns [`StorageError`] if the publisher has failed, no expander is
786 /// installed, or the supplied `build`/expansion closure fails.
787 ///
788 /// Lock poisoning fences the store until reopen.
789 pub fn tx_apply<P, F>(
790 &self,
791 tx: &mut TxAccumulator,
792 commit_key: Uuid,
793 build: F,
794 ) -> Result<P, StorageError>
795 where
796 P: Serialize + DeserializeOwned,
797 F: FnOnce(u64, &mut Txn) -> Result<(CommitBatch, P), String>,
798 {
799 self.check_health()?;
800 self.check_publisher()?;
801 let ci = self.next_commit_index.load(Ordering::SeqCst);
802 let payload = {
803 // Statement planning reads the transaction's pinned snapshot, not
804 // current committed state, so read-your-writes is stable and
805 // later commits cannot leak into the plan.
806 let snapshot = tx.base_view.is_some();
807 let guard = self.txn_data(snapshot)?;
808 let native = if snapshot {
809 None
810 } else {
811 self.native_transaction_view()?
812 };
813 let base = TxnBase::resolve(None, tx.base_view.clone(), native, guard.as_deref());
814 // Both statement planning and deterministic expansion can branch
815 // on committed state. Preserve those reads in the accumulator so
816 // tx_commit can reject a stale read-dependent transaction even
817 // when the branch did not emit the key it inspected.
818 let dep_recorder = RefCell::new(tx.deps.clone());
819 let mut txn = Txn {
820 base,
821 buffer: tx.buffer.clone(),
822 purge_keys: BTreeSet::default(),
823 set_keys: BTreeSet::default(),
824 watermark_keys: BTreeSet::default(),
825 ids: &self.ids,
826 deps: Some(&dep_recorder),
827 };
828 let (batch, payload) = build(ci, &mut txn).map_err(StorageError::Builder)?;
829 let mut batch = batch;
830 batch.commit_key = commit_key;
831 let expander = self.health.guard(self.expander.read())?.clone();
832 let Some(expand) = expander.as_ref() else {
833 return Err(StorageError::CorruptLog);
834 };
835 expand(ci, &batch, &mut txn).map_err(StorageError::Builder)?;
836 tx.buffer = txn.buffer;
837 tx.watermarks.extend(txn.watermark_keys);
838 tx.mutations.append(&mut batch.mutations);
839 tx.preconditions.append(&mut batch.preconditions);
840 tx.deps = dep_recorder.into_inner();
841 payload
842 };
843 Ok(payload)
844 }
845
846 /// Run a read inside a transaction: the closure sees committed state merged
847 /// with the transaction's overlay (read-your-writes) and records its reads
848 /// into the transaction's dependency set. Use `get_tracked`/range-tracked
849 /// reads so the conflict check at commit covers what this read observed.
850 ///
851 /// # Errors
852 /// Returns [`StorageError`] if a backend read fails or the closure returns
853 /// an error.
854 ///
855 /// Lock poisoning fences the store until reopen.
856 pub fn tx_read<P, F>(&self, tx: &mut TxAccumulator, f: F) -> Result<P, StorageError>
857 where
858 F: FnOnce(&mut Txn) -> Result<P, String>,
859 {
860 self.check_health()?;
861 // Reads resolve at the transaction's pinned snapshot.
862 let snapshot = tx.base_view.is_some();
863 let guard = self.txn_data(snapshot)?;
864 let native = if snapshot {
865 None
866 } else {
867 self.native_transaction_view()?
868 };
869 let base = TxnBase::resolve(None, tx.base_view.clone(), native, guard.as_deref());
870 let dep_recorder = RefCell::new(tx.deps.clone());
871 let mut txn = Txn {
872 base,
873 buffer: tx.buffer.clone(),
874 purge_keys: BTreeSet::default(),
875 set_keys: BTreeSet::default(),
876 watermark_keys: BTreeSet::default(),
877 ids: &self.ids,
878 deps: Some(&dep_recorder),
879 };
880 let result = f(&mut txn).map_err(StorageError::Builder);
881 tx.deps = dep_recorder.into_inner();
882 result
883 }
884
885 /// Stage a pre-built commit batch (e.g. a statement-language mutation delta)
886 /// into a transaction: the batch's writes expand against the transaction's
887 /// overlay and append to its accumulated mutations. Nothing is committed or
888 /// visible outside the tx.
889 ///
890 /// # Errors
891 /// Returns [`StorageError`] if the publisher has failed, no expander is
892 /// installed, or expansion fails.
893 ///
894 /// Lock poisoning fences the store until reopen.
895 pub fn tx_apply_batch(
896 &self,
897 tx: &mut TxAccumulator,
898 commit_key: Uuid,
899 batch: &CommitBatch,
900 ) -> Result<(), StorageError> {
901 self.check_health()?;
902 self.check_publisher()?;
903 let mut batch = batch.clone();
904 batch.commit_key = commit_key;
905 let ci = self.next_commit_index.load(Ordering::SeqCst);
906 // Batch expansion reads the transaction's pinned snapshot.
907 let snapshot = tx.base_view.is_some();
908 let guard = self.txn_data(snapshot)?;
909 let native = if snapshot {
910 None
911 } else {
912 self.native_transaction_view()?
913 };
914 let base = TxnBase::resolve(None, tx.base_view.clone(), native, guard.as_deref());
915 // A batch expander may read a key to decide which derived rows to
916 // produce. Track that dependency just like tx_apply tracks planner
917 // and expansion reads; otherwise stale read-dependent commits can
918 // pass conflict validation when their write-set is disjoint.
919 let dep_recorder = RefCell::new(tx.deps.clone());
920 let mut txn = Txn {
921 base,
922 buffer: tx.buffer.clone(),
923 purge_keys: BTreeSet::default(),
924 set_keys: BTreeSet::default(),
925 watermark_keys: BTreeSet::default(),
926 ids: &self.ids,
927 deps: Some(&dep_recorder),
928 };
929 let expander = self.health.guard(self.expander.read())?.clone();
930 let Some(expand) = expander.as_ref() else {
931 return Err(StorageError::CorruptLog);
932 };
933 expand(ci, &batch, &mut txn).map_err(StorageError::Builder)?;
934 tx.buffer = txn.buffer;
935 tx.watermarks.extend(txn.watermark_keys);
936 tx.mutations.append(&mut batch.mutations);
937 tx.preconditions.append(&mut batch.preconditions);
938 tx.deps = dep_recorder.into_inner();
939 Ok(())
940 }
941
942 /// Atomically commit a transaction's accumulated writes. Conflict rule: any
943 /// committed mutation since `base_ci` that wrote a key the transaction read
944 /// (public read-set) or writes (write-set) aborts it. Monotone counters
945 /// (`Txn::set_watermark`) are exempt: ids come from the store's
946 /// [`crate::IdAllocator`], so concurrent transactions never share an
947 /// ordinal, and each commit only raises the counter. Success publishes the writes as one
948 /// atomic commit; conflict aborts the transaction, which may retry or roll back.
949 ///
950 /// # Errors
951 /// Returns [`StorageError`] if the publisher has failed, the base snapshot
952 /// is in the future, a precondition fails, a conflict is detected, or
953 /// publication fails.
954 pub fn tx_commit(&self, commit_key: Uuid, tx: &TxAccumulator) -> Result<u64, StorageError> {
955 self.check_health()?;
956 self.tx_commit_impl(commit_key, tx, None)
957 }
958
959 /// Commit a cross-request transaction with an optional revalidator that runs inside
960 /// the publish lock (atomic with conflict detection), reading keys via the injected
961 /// reader — never by opening a new store view, which would nest view locks under the
962 /// publish lock — and returning `Err` to abort the commit and record a terminal outcome.
963 ///
964 /// # Errors
965 /// Returns [`StorageError`] if the publisher has failed, the base snapshot is in the future,
966 /// a precondition fails, the revalidator rejects, a conflict is detected, or publication fails.
967 pub fn tx_commit_revalidated(
968 &self,
969 commit_key: Uuid,
970 tx: &TxAccumulator,
971 revalidate: impl FnOnce(&dyn Fn(&[u8]) -> Result<Option<Vec<u8>>, StorageError>) -> Result<(), String>
972 + 'static,
973 ) -> Result<u64, StorageError> {
974 self.check_health()?;
975 let revalidate: Box<
976 dyn FnOnce(
977 &dyn Fn(&[u8]) -> Result<Option<Vec<u8>>, StorageError>,
978 ) -> Result<(), String>,
979 > = Box::new(revalidate);
980 self.tx_commit_impl(commit_key, tx, Some(revalidate))
981 }
982
983 #[expect(
984 clippy::too_many_lines,
985 reason = "transaction commit keeps conflict probing, revalidation, index reservation, and publication in one linear protocol for auditability"
986 )]
987 pub(super) fn tx_commit_impl(
988 &self,
989 commit_key: Uuid,
990 tx: &TxAccumulator,
991 revalidate: Option<
992 Box<
993 dyn FnOnce(
994 &dyn Fn(&[u8]) -> Result<Option<Vec<u8>>, StorageError>,
995 ) -> Result<(), String>,
996 >,
997 >,
998 ) -> Result<u64, StorageError> {
999 self.check_health()?;
1000 // A replicated commit is assigned and published by the replication
1001 // path. Reserving a local slot first would advance
1002 // `next_commit_index`; a loopback replicator would then apply at the
1003 // following index and leave the reserved slot permanently missing.
1004 // Keep the selected replicator for the call below so the admission
1005 // decision and the publication path agree.
1006 let replicator = self.replicator();
1007 // One writer-admission protocol for the whole commit: discovery,
1008 // validation, expansion, CI reservation, publication and ack. Without
1009 // it, two transactions can validate against the same published
1010 // watermark and both reserve, missing interleaved write/read conflicts
1011 // (a lost update). A replicator owns ordering, so admission is skipped
1012 // there exactly as in the autocommit path (holding it across the
1013 // synchronous replication call would deadlock the state-machine apply).
1014 let mut admission = if replicator.is_none() {
1015 Some(self.acquire_admission()?)
1016 } else {
1017 None
1018 };
1019 self.check_publisher()?;
1020 if let Some((OutcomeStatus::Committed, ci)) = self.lookup_outcome_checked(commit_key)? {
1021 return Ok(ci);
1022 }
1023 if matches!(
1024 self.try_outcome_for(commit_key)?,
1025 Some(OutcomeKind::Aborted)
1026 ) {
1027 return Err(StorageError::Builder(
1028 "commit key has a terminal aborted outcome".into(),
1029 ));
1030 }
1031 if tx.base_ci > self.committed_version() {
1032 return Err(StorageError::FutureSnapshot {
1033 requested: tx.base_ci,
1034 visible: self.committed_version(),
1035 });
1036 }
1037 // This path expands against the transaction's pinned snapshot, not the
1038 // live overlay: a cross-request transaction must not observe commits
1039 // after its snapshot. `drain_and_publish` below publishes every
1040 // assigned-but-unpublished commit before validation, so the durable
1041 // conflict summaries already cover them; the pending buffer is trimmed
1042 // here and consulted below as a defensive second source.
1043 if let Some(state) = admission.as_mut() {
1044 self.trim_sequencer(state);
1045 }
1046
1047 // Conflict check + CI selection under the publish lock. The guard is
1048 // scoped to this block: `enqueue_and_publish` must be able to become
1049 // the publisher afterwards.
1050 let ci = {
1051 let _guard = self.health.guard(self.publish_lock.lock())?;
1052 self.drain_and_publish();
1053 // The slot is reserved only after every abort check passes. An
1054 // aborted transaction must NOT consume a commit index: it records
1055 // a terminal aborted outcome keyed by its commit key, and the
1056 // contiguous publisher never has to fill a hole for it.
1057 let published = self.published.load(Ordering::SeqCst);
1058
1059 // Authorization revalidation runs before the generic read/write
1060 // conflict scan so a rotated or malformed policy surfaces as an
1061 // explicit `authorization_changed`/`invalid authorization policy`
1062 // abort rather than being masked by the policy key's read-write
1063 // conflict. It still runs under the publish lock, atomic with
1064 // conflict detection and publication.
1065 if let Some(revalidate) = revalidate {
1066 let read =
1067 |key: &[u8]| -> Result<Option<Vec<u8>>, StorageError> { self.try_get(key) };
1068 if let Err(reason) = revalidate(&read) {
1069 self.record_outcome_locked(commit_key, OutcomeStatus::Aborted, 0)?;
1070 return Err(StorageError::Builder(reason));
1071 }
1072 }
1073
1074 // Read-set (public reads) + write-set (buffer, incl. counters) check.
1075 // Only published commits since the tx's snapshot can conflict; an
1076 // in-flight reserved slot is not visible yet and is serialized by
1077 // the publisher before this commit.
1078 let mut conflicted: Option<u64> = None;
1079 let window = self.health.guard(self.conflicts.lock())?;
1080 for probe in (tx.base_ci + 1)..=published {
1081 let key_set =
1082 window
1083 .keys(probe)
1084 .ok_or(StorageError::ConflictHistoryUnavailable {
1085 commit_index: probe,
1086 })?;
1087 for k in key_set.iter() {
1088 if tx.deps.intersects_key(k) || writes_conflicting(tx, k) {
1089 conflicted = Some(probe);
1090 break;
1091 }
1092 }
1093 if conflicted.is_some() {
1094 break;
1095 }
1096 }
1097 drop(window);
1098 // Defensive: consult the pending overlay for any commit that
1099 // `drain_and_publish` did not publish (for example a mid-assignment
1100 // gap), so an unpublished writer cannot be missed.
1101 if let Some(state) = admission.as_ref() {
1102 for (key, (ci, _)) in &state.buffer {
1103 if *ci > tx.base_ci
1104 && (tx.deps.intersects_key(key) || writes_conflicting(tx, key))
1105 {
1106 conflicted = Some(*ci);
1107 break;
1108 }
1109 }
1110 }
1111 if let Some(probe) = conflicted {
1112 self.record_outcome_locked(commit_key, OutcomeStatus::Aborted, 0)?;
1113 return Err(StorageError::Builder(format!(
1114 "transaction_conflict on write at ci {probe}"
1115 )));
1116 }
1117 for pc in &tx.preconditions {
1118 // Preconditions from statements in one transaction must see
1119 // the transaction's own write overlay. Relationship batches,
1120 // for example, require endpoints created earlier in this same
1121 // transaction to exist before the final commit is published.
1122 let exists = match pc.kind {
1123 // An `Absent` guard belongs to the create operation that
1124 // also stages this key. Check committed state so that the
1125 // operation does not conflict with its own overlay.
1126 rushdb_types::command::PreconditionKind::Absent => {
1127 self.try_get(&pc.key)?.is_some()
1128 }
1129 // An `Exists` guard holds when the key is committed or
1130 // this transaction wrote it: relationship batches need
1131 // endpoints created earlier in the same transaction, and
1132 // a delete guards a key its own write removes.
1133 rushdb_types::command::PreconditionKind::Exists => {
1134 matches!(tx.buffer.get(&pc.key), Some(Some(_)))
1135 || self.try_get(&pc.key)?.is_some()
1136 }
1137 };
1138 let violated =
1139 exists != matches!(pc.kind, rushdb_types::command::PreconditionKind::Exists);
1140 if violated {
1141 self.record_outcome_locked(commit_key, OutcomeStatus::Aborted, 0)?;
1142 return Err(StorageError::Precondition {
1143 path: rushdb_types::keys::describe_key(&pc.key),
1144 });
1145 }
1146 }
1147 // Prospective slot only. The slot is reserved after expansion
1148 // succeeds so a fallible expansion can never consume (and strand) a
1149 // commit index. Admission is held for the local path, so no other
1150 // local writer can change the counter in between; a replicator owns
1151 // index assignment and must observe the current slot unchanged.
1152 self.next_commit_index.load(Ordering::SeqCst)
1153 };
1154
1155 let batch = CommitBatch {
1156 command_version: rushdb_types::command::COMMAND_VERSION,
1157 tenant: tx.tenant,
1158 transaction_id: String::new(),
1159 commit_key,
1160 start_index: ci.saturating_sub(1),
1161 isolation: rushdb_types::command::IsolationMode::Snapshot,
1162 preconditions: tx.preconditions.clone(),
1163 mutations: tx.mutations.clone(),
1164 };
1165
1166 // When a replication seam is installed, the tx commits by replicating
1167 // the batch (like every other commit) instead of publishing locally.
1168 if let Some(replicator) = replicator {
1169 let payload_bytes: Vec<u8> = b"{}".to_vec();
1170 replicator
1171 .replicate(&batch, &payload_bytes)
1172 .map_err(StorageError::Builder)?;
1173 return match self.lookup_outcome_checked(commit_key)? {
1174 Some((OutcomeStatus::Committed, replicated_ci)) => Ok(replicated_ci),
1175 _ => Err(StorageError::CorruptLog),
1176 };
1177 }
1178
1179 // Feeds the WAL's embedded command on a durable store, or
1180 // the in-memory command-journal row otherwise (no WAL to derive it from).
1181 let command_payload = rushdb_types::command_codec::encode(&batch);
1182 let ops = {
1183 // Expand against current state (committed plus pending), as an
1184 // autocommit and a replay of this command do. The conflict check
1185 // above aborted the transaction if any key it read or wrote
1186 // changed since its snapshot, so for those keys this is the state
1187 // it observed. The exception is monotone counters
1188 // (`Txn::set_watermark`), which concurrent commits may have
1189 // advanced: reading them here keeps them from moving back.
1190 let overlay = match admission.as_ref() {
1191 Some(state) => Some(self.sequencer_overlay(state)?),
1192 None => None,
1193 };
1194 // The overlay is authoritative; taking the live map's read guard
1195 // as well would re-enter the RwLock a publisher may be waiting on.
1196 let guard = if overlay.is_none() {
1197 Some(self.health.guard(self.data.read())?)
1198 } else {
1199 None
1200 };
1201 let native = if overlay.is_none() {
1202 self.native_transaction_view()?
1203 } else {
1204 None
1205 };
1206 let base = TxnBase::resolve(overlay, None, native, guard.as_deref());
1207 let mut wtxn = Txn {
1208 base,
1209 buffer: BTreeMap::new(),
1210 purge_keys: BTreeSet::default(),
1211 set_keys: BTreeSet::default(),
1212 watermark_keys: BTreeSet::default(),
1213 ids: &self.ids,
1214 deps: None,
1215 };
1216 let expander = self.health.guard(self.expander.read())?.clone();
1217 let Some(expand) = expander.as_ref() else {
1218 return Err(StorageError::CorruptLog);
1219 };
1220 expand(ci, &batch, &mut wtxn).map_err(StorageError::Builder)?;
1221 if !self.is_durable() {
1222 wtxn.put(log_key(ci), wal::encode_wal_record(0, ci, &command_payload));
1223 }
1224 // Interactive transactions return no stored response body.
1225 self.put_receipt(&mut wtxn, commit_key, OutcomeStatus::Committed, ci, None);
1226 wtxn.set(LAST_APPLIED_KEY.to_vec(), wal::encode_last_applied(0, ci));
1227 wtxn.drain()
1228 };
1229
1230 self.reject_oversized_commit(&ops, command_payload.len())?;
1231 // Expansion succeeded: reserve the slot. Admission is held until this
1232 // function returns, so the prospective `ci` is still current.
1233 self.next_commit_index.store(ci + 1, Ordering::SeqCst);
1234 let shard =
1235 resources::physical_shard_for(Some(&batch.tenant), self.resource_config.shard_count);
1236 self.enqueue_and_publish(ci, shard, ops, command_payload)?;
1237 Ok(ci)
1238 }
1239}
1240
1241/// Whether a key written by a later commit collides with the transaction's
1242/// own writes. A monotone counter both advance is not a collision.
1243fn writes_conflicting(tx: &TxAccumulator, key: &[u8]) -> bool {
1244 tx.buffer.contains_key(key) && !tx.watermarks.contains(key)
1245}