Skip to main content

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}