clockworklabs/SpacetimeDB · error · io::Error
invalid transaction offset
Error message
invalid transaction offset {}, expected {} What it means
segment::Writer::commit enforces contiguity inside a commit batch: each transaction's offset must equal the batch's min_tx_offset plus its index within the batch. On a gap, duplicate, or out-of-order offset the pending commit buffer is reset and io::ErrorKind::InvalidInput is returned naming the bad and expected offsets.
Solutions
- Query the commitlog for the last committed transaction range and start assigning offsets from its end
- Filter and renumber transactions to a contiguous range before calling commit
- On retry of a partially applied batch, resume from the next uncommitted offset instead of replaying from the start
- Assert offsets in tests with contiguous generators (0..n) rather than hand-written literals
Example fix
// before
writer.commit([tx(3), tx(4), tx(6)])?; // gap at 5
// after
let next = log.last_tx_offset()? + 1;
let txs = [tx(3), tx(4), tx(6)]
.into_iter()
.enumerate()
.map(|(i, mut tx)| { tx.offset = next + i as u64; tx });
writer.commit(txs)?; Defensive patterns
Strategy: validation
Validate before calling
fn contiguous_from(next: u64, txs: Vec<Tx>) -> io::Result<Vec<Tx>> {
for (i, tx) in txs.iter().enumerate() {
let expected = next + i as u64;
if tx.offset != expected {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("gap before batch: have {} expected {expected}", tx.offset),
));
}
}
Ok(txs)
} Type guard
fn is_tx_offset_mismatch(err: &io::Error) -> bool {
err.kind() == io::ErrorKind::InvalidInput && err.to_string().starts_with("invalid transaction offset")
} Try / catch
match writer.commit(batch) {
Ok(committed) => Ok(committed),
Err(e) if is_tx_offset_mismatch(&e) => {
// writer reset its batch; rebase offsets onto the log's next offset and retry once
let next = last_committed_offset()?;
writer.commit(renumber_from(next, batch))
}
Err(e) => Err(e),
} Prevention
- Assign offsets from the commitlog's last committed range end, never from caller input
- Make retries resumable: remember the next uncommitted offset, do not replay from batch start
- Add a unit assertion that batch offsets are strictly consecutive before calling commit
When it happens
Trigger: Passing transactions whose offsets skip values (e.g. 1,2,4); retrying a batch whose first transaction already committed (duplicate offset); building the batch from a source that filtered some transactions out, leaving holes; off-by-one when assigning offsets.
Common situations: Generating offsets from user-supplied sequence numbers without normalizing; retry logic that replays a partially committed batch; consuming from an upstream system with its own gaps.
Related errors
- expected commit offset
- invalid seek to a negative or overflowing position
- out-of-order offset: expected=
- all-zeroes commit header
- allocation size overflow
AI-assisted analysis of clockworklabs/SpacetimeDB@3653d2ed49 (2026-08-20).
Data as JSON: /api/errors/6ef2bd96aa87dcb6.
Report an issue: GitHub.
Appendix: source
Thrown at crates/commitlog/src/segment.rs:127
pub(crate) min_tx_offset: u64,
pub(crate) bytes_written: u64,
pub(crate) offset_index_head: Option<OffsetIndexWriter>,
}
impl<W: io::Write> Writer<W> {
pub fn commit<T: Into<Transaction<U>>, U: Encode>(
&mut self,
transactions: impl IntoIterator<Item = T>,
) -> io::Result<Option<Committed>> {
for tx in transactions {
let tx = tx.into();
let expected_offset = self.commit.min_tx_offset + self.commit.n as u64;
if tx.offset != expected_offset {
self.commit.n = 0;
self.commit.records.clear();
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid transaction offset {}, expected {}", tx.offset, expected_offset),
));
}
assert!(
self.commit.n < u16::MAX,
"maximum number of transactions in a single commit exceeded"
);
self.commit.n += 1;
tx.txdata.encode_record(&mut self.commit.records);
}
if self.commit.n == 0 {
return Ok(None);
}
let checksum = self
.commitView on GitHub (pinned to 3653d2ed49)