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