Skip to content

Commit d66fe33

Browse files
MichaReisercarljm
andauthored
allow reuse of cached provisional memos within the same cycle iteration during maybe_changed_after (#954)
* Allow `validate_same_iteration` slow path for `maybe_changed_after` * Add logging to cycle_panic test and enable output capture for debugging * Add RUST_LOG=trace to CI for detailed tracing output * Possible fix * Discard changes to .github/workflows/test.yml * Delete logs/.eb4cbc62113c2d27a93f1a2e33d842313ed0aa05-audit.json * Delete logs/mcp-puppeteer-2025-08-02.log * docs * Add regression test * Discard changes to tests/parallel/cycle_panic.rs * Discard changes to Cargo.toml * Fix comment * Discard changes to Cargo.toml * Update src/function/maybe_changed_after.rs Co-authored-by: Carl Meyer <[email protected]> --------- Co-authored-by: Carl Meyer <[email protected]>
1 parent 86ca4a9 commit d66fe33

File tree

7 files changed

+84
-15
lines changed

7 files changed

+84
-15
lines changed

src/function.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,7 @@ where
311311
fn wait_for<'me>(&'me self, zalsa: &'me Zalsa, key_index: Id) -> WaitForResult<'me> {
312312
match self.sync_table.try_claim(zalsa, key_index) {
313313
ClaimResult::Running(blocked_on) => WaitForResult::Running(blocked_on),
314-
ClaimResult::Cycle { same_thread } => WaitForResult::Cycle { same_thread },
314+
ClaimResult::Cycle => WaitForResult::Cycle,
315315
ClaimResult::Claimed(_) => WaitForResult::Available,
316316
}
317317
}

src/function/maybe_changed_after.rs

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,7 @@ where
267267
/// cycle heads have all been finalized.
268268
/// * provisional memos that have been created in the same revision and iteration and are part of the same cycle.
269269
#[inline]
270-
pub(super) fn validate_may_be_provisional(
270+
fn validate_may_be_provisional(
271271
&self,
272272
zalsa: &Zalsa,
273273
zalsa_local: &ZalsaLocal,
@@ -342,7 +342,7 @@ where
342342
/// If this is a provisional memo, validate that it was cached in the same iteration of the
343343
/// same cycle(s) that we are still executing. If so, it is valid for reuse. This avoids
344344
/// runaway re-execution of the same queries within a fixpoint iteration.
345-
pub(super) fn validate_same_iteration(
345+
fn validate_same_iteration(
346346
&self,
347347
zalsa: &Zalsa,
348348
zalsa_local: &ZalsaLocal,
@@ -369,15 +369,42 @@ where
369369
.find(|query| query.database_key_index == cycle_head.database_key_index)
370370
.map(|query| query.iteration_count())
371371
.or_else(|| {
372-
// If this is a cycle head is owned by another thread that is blocked by this ingredient,
373-
// check if it has the same iteration count.
372+
// If the cycle head isn't on our stack because:
373+
//
374+
// * another thread holds the lock on the cycle head (but it waits for the current query to complete)
375+
// * we're in `maybe_changed_after` because `maybe_changed_after` doesn't modify the cycle stack
376+
//
377+
// check if the latest memo has the same iteration count.
378+
379+
// However, we've to be careful to skip over fixpoint initial values:
380+
// If the head is the memo we're trying to validate, always return `None`
381+
// to force a re-execution of the query. This is necessary because the query
382+
// has obviously not completed its iteration yet.
383+
//
384+
// This should be rare but the `cycle_panic` test fails on some platforms (mainly GitHub actions)
385+
// without this check. What happens there is that:
386+
//
387+
// * query a blocks on query b
388+
// * query b tries to claim a, fails to do so and inserts the fixpoint initial value
389+
// * query b completes and has `a` as head. It returns its query result Salsa blocks query b from
390+
// exiting inside `block_on` (or the thread would complete before the cycle iteration is complete)
391+
// * query a resumes but panics because of the fixpoint iteration function
392+
// * query b resumes. It rexecutes its own query which then tries to fetch a (which depends on itself because it's a fixpoint initial value).
393+
// Without this check, `validate_same_iteration` would return `true` because the latest memo for `a` is the fixpoint initial value.
394+
// But it should return `false` so that query b's thread re-executes `a` (which then also causes the panic).
395+
//
396+
// That's why we always return `None` if the cycle head is the same as the current database key index.
397+
if cycle_head.database_key_index == database_key_index {
398+
return None;
399+
}
400+
374401
let ingredient = zalsa.lookup_ingredient(
375402
cycle_head.database_key_index.ingredient_index(),
376403
);
377404
let wait_result = ingredient
378405
.wait_for(zalsa, cycle_head.database_key_index.key_index());
379406

380-
if !wait_result.is_cycle_with_other_thread() {
407+
if !wait_result.is_cycle() {
381408
return None;
382409
}
383410

src/function/sync.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ pub(crate) enum ClaimResult<'a> {
2020
/// Can't claim the query because it is running on an other thread.
2121
Running(Running<'a>),
2222
/// Claiming the query results in a cycle.
23-
Cycle { same_thread: bool },
23+
Cycle,
2424
/// Successfully claimed the query.
2525
Claimed(ClaimGuard<'a>),
2626
}
@@ -62,7 +62,7 @@ impl SyncTable {
6262
write,
6363
) {
6464
BlockResult::Running(blocked_on) => ClaimResult::Running(blocked_on),
65-
BlockResult::Cycle { same_thread } => ClaimResult::Cycle { same_thread },
65+
BlockResult::Cycle => ClaimResult::Cycle,
6666
}
6767
}
6868
std::collections::hash_map::Entry::Vacant(vacant_entry) => {

src/ingredient.rs

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -250,12 +250,11 @@ pub(crate) fn fmt_index(debug_name: &str, id: Id, fmt: &mut fmt::Formatter<'_>)
250250
pub enum WaitForResult<'me> {
251251
Running(Running<'me>),
252252
Available,
253-
Cycle { same_thread: bool },
253+
Cycle,
254254
}
255255

256256
impl WaitForResult<'_> {
257-
/// Returns `true` if waiting for this input results in a cycle with another thread.
258-
pub const fn is_cycle_with_other_thread(&self) -> bool {
259-
matches!(self, WaitForResult::Cycle { same_thread: false })
257+
pub const fn is_cycle(&self) -> bool {
258+
matches!(self, WaitForResult::Cycle)
260259
}
261260
}

src/runtime.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ pub(crate) enum BlockResult<'me> {
5151
///
5252
/// The lock is hold by the current thread or there's another thread that is waiting on the current thread,
5353
/// and blocking this thread on the other thread would result in a deadlock/cycle.
54-
Cycle { same_thread: bool },
54+
Cycle,
5555
}
5656

5757
pub struct Running<'me>(Box<BlockedOnInner<'me>>);
@@ -230,14 +230,14 @@ impl Runtime {
230230
let thread_id = thread::current().id();
231231
// Cycle in the same thread.
232232
if thread_id == other_id {
233-
return BlockResult::Cycle { same_thread: true };
233+
return BlockResult::Cycle;
234234
}
235235

236236
let dg = self.dependency_graph.lock();
237237

238238
if dg.depends_on(other_id, thread_id) {
239239
crate::tracing::debug!("block_on: cycle detected for {database_key:?} in thread {thread_id:?} on {other_id:?}");
240-
return BlockResult::Cycle { same_thread: false };
240+
return BlockResult::Cycle;
241241
}
242242

243243
BlockResult::Running(Running(Box::new(BlockedOnInner {

tests/common/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ pub trait LogDatabase: HasLogger + Database {
3434
self.logger().logs.lock().unwrap().push(string);
3535
}
3636

37+
fn clear_logs(&self) {
38+
std::mem::take(&mut *self.logger().logs.lock().unwrap());
39+
}
40+
3741
/// Asserts what the (formatted) logs should look like,
3842
/// clearing the logged events. This takes `&mut self` because
3943
/// it is meant to be run from outside any tracked functions.

tests/cycle.rs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1025,3 +1025,42 @@ fn repeat_provisional_query() {
10251025
"salsa_event(WillExecute { database_key: min_panic(Id(2)) })",
10261026
]"#]]);
10271027
}
1028+
1029+
#[test]
1030+
fn repeat_provisional_query_incremental() {
1031+
let mut db = ExecuteValidateLoggerDatabase::default();
1032+
let a_in = Inputs::new(&db, vec![]);
1033+
let b_in = Inputs::new(&db, vec![]);
1034+
let c_in = Inputs::new(&db, vec![]);
1035+
let a = Input::MinIterate(a_in);
1036+
let b = Input::MinPanic(b_in);
1037+
let c = Input::MinPanic(c_in);
1038+
a_in.set_inputs(&mut db).to(vec![value(59), b.clone()]);
1039+
b_in.set_inputs(&mut db)
1040+
.to(vec![value(60), c.clone(), c.clone(), c]);
1041+
c_in.set_inputs(&mut db).to(vec![a.clone()]);
1042+
1043+
a.assert_value(&db, 59);
1044+
1045+
db.clear_logs();
1046+
1047+
c_in.set_inputs(&mut db).to(vec![a.clone()]);
1048+
1049+
a.assert_value(&db, 59);
1050+
1051+
// `min_panic(Id(2)) should only twice:
1052+
// * Once before iterating
1053+
// * Once as part of iterating
1054+
//
1055+
// If it runs more than once before iterating, than this suggests that
1056+
// `validate_same_iteration` incorrectly returns `false`.
1057+
db.assert_logs(expect![[r#"
1058+
[
1059+
"salsa_event(WillExecute { database_key: min_panic(Id(2)) })",
1060+
"salsa_event(WillExecute { database_key: min_panic(Id(1)) })",
1061+
"salsa_event(WillExecute { database_key: min_iterate(Id(0)) })",
1062+
"salsa_event(WillIterateCycle { database_key: min_iterate(Id(0)), iteration_count: IterationCount(1), fell_back: false })",
1063+
"salsa_event(WillExecute { database_key: min_panic(Id(1)) })",
1064+
"salsa_event(WillExecute { database_key: min_panic(Id(2)) })",
1065+
]"#]]);
1066+
}

0 commit comments

Comments
 (0)