fix(zset): mark a rebuild chunk's push as a step so a panicking push is counted (improvise-bw4p.5)

Also renames the MemTrace clone-panic test to what it asserts.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
This commit is contained in:
Claude (zset-rollback)
2026-10-10 18:28:18 -07:00
committed by Edward Langley
parent b2445fed97
commit 8b0b715213
7 changed files with 66 additions and 4 deletions
+3 -2
View File
@@ -227,7 +227,7 @@ clause's wording stands; this is how the crate reads it).
sweeps a panic over every key clone in a step). A step that dies half way has therefore
logged exactly the keys it applied. A custom `Trace` must do the same: a panicking `apply`
leaves the trace as it was. `MemTrace` does, since it makes every clone before it touches
its map (`c2_memtrace_value_clone_panic_leaves_an_empty_group`).
its map (`a_panicking_value_clone_leaves_a_memtrace_unchanged`).
5. **The edge's two copies.** An integrate stream's piece is also its output, so a second copy
(the whole-tick value) only exists while a tick is split. `Slot::whole` is `None` otherwise
and `current()` reads the piece. `piece()` lends a borrow rather than a clone
@@ -241,7 +241,8 @@ clause's wording stands; this is how the crate reads it).
ahead of the circuit. The clock cannot tell: `rollback` rewinds `ticks`, which can make
a dead job's recorded tick current again. So the circuit also counts the rollbacks that
undid a step or piece (`undone`, never rewound), and a job records the count when it first
runs, before its first chunk can panic. A later count makes `interrupted` true for good,
runs, before its first chunk can panic. The chunk's `push` is marked as a step too, since it
can overflow the pending delta before any step runs (`p1_a_chunk_whose_push_panics_in_a_txn_is_counted`). A later count makes `interrupted` true for good,
finished or not (`rollback_rebuild.rs`: begun before the transaction and continued inside,
finished inside, a first chunk that panicked, and a dead job revived by a later step).
- An `Output::take` made inside the transaction: a rollback subtracts the step's output
+6
View File
@@ -215,3 +215,9 @@ A job whose first chunk failed inside the transaction resumed without that chunk
### f3_begin_on_a_circuit_poisoned_outside_a_txn_is_poisoned
`begin` is `ZS_ERR_POISONED`; `commit` and `rollback` are `ZS_ERR_TRANSACTION`.
### f5_a_chunk_whose_push_overflows_in_a_txn_then_rollback_is_interrupted
The same path through the ABI: `zs_rebuild_step` returns `ZS_ERR_OVERFLOW` from the chunk's
push, `zs_circuit_rollback` succeeds, and the next `zs_rebuild_step` was a silent success; it is
now `ZS_ERR_INTERRUPTED`. Rival: the mark before the push removed fails `[f5]`.
+8 -1
View File
@@ -566,7 +566,7 @@ key is only visible when it is touched again (a rival that mutated `DistinctStat
cloning its entry survived the first version). Rivals, each failing it: `add_logged`,
`DistinctState::step` and `Undoable::apply` each mutating before cloning the entry.
### c2_memtrace_value_clone_panic_leaves_an_empty_group
### a_panicking_value_clone_leaves_a_memtrace_unchanged
`MemTrace::apply` inserted an empty group before cloning the value. Rival: that order restored
fails `[c2]`.
@@ -589,3 +589,10 @@ split the tick inside the transaction and never reached the save).
Pins the limit `zset.md` states: a first continued piece in a transaction clones the edge's
whole value, growing with the state.
### p1_a_chunk_whose_push_panics_in_a_txn_is_counted
Delta review of #355 at b2445fed. `Rebuild::step` takes the chunk from its iterator and pushes
it before stepping; a push that overflows the pending delta panicked with no step run, so
`undone` was not bumped and the resumed job silently dropped the chunk (key 1's `+1`). The step
is now marked before the push. Rival: that mark removed fails `[p1]`.
+24
View File
@@ -167,3 +167,27 @@ fn f4_a_failed_rebuild_step_in_a_txn_then_rollback_then_resume() {
"[f4] header: a rebuild stepped in a rolled-back txn reports ZS_ERR_INTERRUPTED; got rc={rc} done={done}"
);
}
#[test]
fn f5_a_chunk_whose_push_overflows_in_a_txn_then_rollback_is_interrupted() {
let (c, i, _s) = rig();
let pending = zset(&[(b"a", i64::MAX)]);
assert_eq!(unsafe { zs_circuit_push(c, i, pending) }, OK);
let snap = zset(&[(b"a", 1), (b"b", 1)]);
let mut job = null_mut();
assert_eq!(unsafe { zs_rebuild_new(c, i, snap, 1, &mut job) }, OK);
assert_eq!(unsafe { zs_circuit_begin(c) }, OK);
let mut done = 0;
assert_eq!(
unsafe { zs_rebuild_step(job, c, None, null_mut(), &mut done) },
Code::Overflow as i32,
"the chunk's push overflows the pending delta"
);
assert_eq!(unsafe { zs_circuit_rollback(c) }, OK);
let rc = unsafe { zs_rebuild_step(job, c, None, null_mut(), &mut done) };
unsafe { zs_rebuild_free(job) };
unsafe { zs_zset_free(snap) };
unsafe { zs_zset_free(pending) };
unsafe { zs_circuit_free(c) };
assert_eq!(rc, Code::Interrupted as i32, "[f5] got rc={rc} done={done}");
}
+1
View File
@@ -789,6 +789,7 @@ impl<K: Ord> Rebuild<K> {
if chunk.is_zero() && self.began_at.is_some() {
return Progress::Done;
}
circuit.stepped |= circuit.open.is_some();
self.input.push(chunk);
if self.began_at.is_some() {
circuit.step_continue();
+1 -1
View File
@@ -172,7 +172,7 @@ fn c1_a_clone_panic_anywhere_in_a_step_is_rolled_back() {
}
#[test]
fn c2_memtrace_value_clone_panic_leaves_an_empty_group() {
fn a_panicking_value_clone_leaves_a_memtrace_unchanged() {
quiet();
let mut t: MemTrace<u8, Boom> = MemTrace::new();
CLONE_TRIP.with(|c| c.set(Some(0)));
+23
View File
@@ -142,3 +142,26 @@ fn b1_a_closure_that_reads_a_stream_during_eval_does_not_panic() {
c.rollback();
c.step();
}
#[test]
fn p1_a_chunk_whose_push_panics_in_a_txn_is_counted() {
let mut c = Circuit::new();
let (input, s) = c.input::<u8>();
let i = c.integrate(&s);
input.push(z(&[(1, i64::MAX)]));
let mut job = Rebuild::new(input.clone(), z(&[(1, 1), (2, 1)]), 1);
c.begin();
let r = catch_unwind(AssertUnwindSafe(|| job.step(&mut c, || false)));
assert!(r.is_err(), "the push overflows");
c.rollback();
let interrupted = job.interrupted(&c);
let mut got = None;
if !interrupted {
while job.step(&mut c, || false) == Progress::Pending {}
got = Some(i.current());
}
assert!(
interrupted,
"[p1] the chunk left the job unstepped; finishing it gives {got:?}"
);
}