Skip to content

Commit 04e98cb

Browse files
authored
fix: let Recall's final delivery rows use the remaining shutdown budget (#1325)
* fix: let Recall's final delivery rows use the remaining shutdown budget The delivery summary rows written when a session closes were capped at 500 ms even when most of shutdown_timeout remained, so one slow hosted store response marked an otherwise fully delivered transcript incomplete. They now get the rest of the shutdown budget, and at least 500 ms; close still returns by the shutdown deadline. * test: pin the deadline Recall gives its final delivery rows * docs: note why Recall's final-row floor may outlast close
1 parent cd38ff9 commit 04e98cb

5 files changed

Lines changed: 136 additions & 10 deletions

File tree

‎crates/sqlbuild-recall/src/writer/_helpers/delivery.rs‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ fn retry_limit(shared: &Shared, started: Instant) -> Instant {
6464
.unwrap_or_else(|| started + Duration::from_millis(RUNNING_RETRY_LIMIT_MS))
6565
}
6666

67-
/// Each SQLite busy wait ends by `give_up`, so no write outlasts the shutdown budget.
67+
/// Each SQLite busy wait ends by `give_up`: the shutdown deadline, or 500 ms on for the final rows.
6868
fn bound_busy_wait(store: &SqliteStore, give_up: Instant) {
6969
let remaining: Duration = give_up
7070
.saturating_duration_since(Instant::now())
@@ -396,8 +396,8 @@ pub(crate) fn deliver(shared: &Arc<Shared>, config: &SessionConfig) {
396396
state.rejecting = true;
397397
let rows = final_rows(config, &state);
398398
drop(state);
399-
let give_up = (Instant::now() + Duration::from_millis(FINAL_ROWS_BUDGET_MS))
400-
.min(retry_limit(shared, Instant::now()));
399+
let give_up = retry_limit(shared, Instant::now())
400+
.max(Instant::now() + Duration::from_millis(FINAL_ROWS_BUDGET_MS));
401401
if let Err(error) = write_batch(store.as_ref(), &rows, give_up) {
402402
locked(&shared.state).degraded.get_or_insert(error.message);
403403
}

‎crates/sqlbuild-recall/src/writer/constants.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ pub const RUNNING_RETRY_LIMIT_MS: u64 = 30_000;
1919
pub const SETUP_RETRY_MS: u64 = 50;
2020
/// Longest wait between wall-clock checks, so a resumed process beats promptly.
2121
pub const CLOCK_CHECK_MS: u64 = 1_000;
22-
/// Budget for the final delivery rows, inside the shutdown budget.
22+
/// Least time the final delivery rows get; rows landing after close then leave the CLI under-reporting, not the store.
2323
pub const FINAL_ROWS_BUDGET_MS: u64 = 500;
2424
/// Maintenance is skipped when less than this remains of the shutdown budget.
2525
pub const MINIMUM_MAINTENANCE_MS: u64 = 200;

‎crates/sqlbuild-recall/src/writer/tests/helpers.rs‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ use crate::writer::models::{
1919
};
2020
use crate::writer::tests::test_types::{
2121
CopyingReplacement, CreatingStore, OutputBytesStore, PartialStore, PrefixReplacement,
22-
RecordedSession, RecordingReplacement,
22+
RecordedSession, RecordingReplacement, SlowFinalRowsStore,
2323
};
2424

2525
/// The envelope of invocation `invocation_id`.
@@ -715,3 +715,31 @@ pub(crate) fn left_out_evidence(outcome: &str) -> Vec<RecallValue> {
715715
RecallValue::Null,
716716
]
717717
}
718+
719+
/// Seconds `close` took, whether delivery reported complete, and how long after close began the final rows' deadline fell.
720+
pub(crate) fn close_with_slow_final_rows(shutdown: Duration, delay: Duration) -> (f64, bool, f64) {
721+
let store = SlowFinalRowsStore {
722+
delay,
723+
final_give_up: Arc::default(),
724+
};
725+
let session = start_recall_session(SessionConfig {
726+
store: SessionStore::Hosted(Arc::new(store.clone())),
727+
shutdown_timeout: shutdown,
728+
maintenance: false,
729+
..config(PathBuf::from("unused"), "slow-final-rows")
730+
});
731+
session.append_output(OutputStream::Stdout, b"printed\n");
732+
let started = std::time::Instant::now();
733+
let summary = session.close();
734+
let elapsed = started.elapsed().as_secs_f64();
735+
let give_up = store
736+
.final_give_up
737+
.lock()
738+
.unwrap_or_else(std::sync::PoisonError::into_inner)
739+
.expect("the final rows reached the store");
740+
(
741+
elapsed,
742+
summary.flush_complete,
743+
give_up.saturating_duration_since(started).as_secs_f64(),
744+
)
745+
}

‎crates/sqlbuild-recall/src/writer/tests/test_session.rs‎

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,19 @@ use crate::writer::models::{Priority, SetupOutcome};
88
#[cfg(unix)]
99
use crate::writer::tests::helpers::unwritable_session;
1010
use crate::writer::tests::helpers::{
11-
artifact_counts, close_under_held_lock, creation_notices, event_counts, insert_heartbeat,
12-
invocation_during_discovery, output_stored_while_running, partially_inserted,
13-
pending_event_counts, record_run, recorded_session, replaced_session, replacement_order,
14-
sent_before_the_window, session_on_corrupt_store, written_to_core_release_store,
11+
artifact_counts, close_under_held_lock, close_with_slow_final_rows, creation_notices,
12+
event_counts, insert_heartbeat, invocation_during_discovery, output_stored_while_running,
13+
partially_inserted, pending_event_counts, record_run, recorded_session, replaced_session,
14+
replacement_order, sent_before_the_window, session_on_corrupt_store,
15+
written_to_core_release_store,
1516
};
1617
#[cfg(unix)]
1718
use crate::writer::tests::test_types::UnwritableTestCase;
1819
use crate::writer::tests::test_types::{
1920
ArtifactSizeTestCase, CoreReleaseStoreTestCase, CorruptStoreTestCase, CreationNoticeTestCase,
2021
DiscoveryTestCase, EarlyBatchTestCase, EventBuildTestCase, HeldLockTestCase,
2122
PartialInsertTestCase, PendingEventTestCase, RecordedSession, ReplacementOrderTestCase,
22-
ReplacementTestCase, SessionTestCase, StoredWhileRunningTestCase,
23+
ReplacementTestCase, SessionTestCase, SlowFinalRowsTestCase, StoredWhileRunningTestCase,
2324
};
2425

2526
const CLOSE_SLACK_SECONDS: f64 = 0.3;
@@ -108,6 +109,44 @@ fn given_a_held_write_lock_when_closed_then_the_shutdown_budget_caps_the_delay()
108109
}
109110
}
110111

112+
#[test]
113+
fn given_slow_final_delivery_rows_when_closed_then_the_shutdown_budget_bounds_them() {
114+
let test_cases = [
115+
SlowFinalRowsTestCase {
116+
description: "final rows slower than half a second but inside the budget record complete",
117+
shutdown: Duration::from_secs(5),
118+
final_rows_delay: Duration::from_millis(1_500),
119+
expected_flush_complete: true,
120+
},
121+
SlowFinalRowsTestCase {
122+
description: "final rows slower than the shutdown budget still exit on time",
123+
shutdown: Duration::from_millis(1_000),
124+
final_rows_delay: Duration::from_secs(10),
125+
expected_flush_complete: false,
126+
},
127+
];
128+
129+
for test_case in test_cases {
130+
let (elapsed, flush_complete, final_rows_deadline) =
131+
close_with_slow_final_rows(test_case.shutdown, test_case.final_rows_delay);
132+
assert!(
133+
final_rows_deadline <= test_case.shutdown.as_secs_f64() + CLOSE_SLACK_SECONDS,
134+
"{}: the final rows may wait {final_rows_deadline:.3}s",
135+
test_case.description
136+
);
137+
assert!(
138+
elapsed <= test_case.shutdown.as_secs_f64() + CLOSE_SLACK_SECONDS,
139+
"{}: {elapsed:.3}s",
140+
test_case.description
141+
);
142+
assert_eq!(
143+
flush_complete, test_case.expected_flush_complete,
144+
"{}",
145+
test_case.description
146+
);
147+
}
148+
}
149+
111150
#[test]
112151
fn given_a_file_that_is_not_a_database_when_started_then_it_is_moved_aside_once() {
113152
let test_cases = [CorruptStoreTestCase {

‎crates/sqlbuild-recall/src/writer/tests/test_types.rs‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,3 +361,62 @@ pub(crate) struct FailedStatementTestCase {
361361
pub(crate) description: &'static str,
362362
pub(crate) expected_rows: Vec<Vec<RecallValue>>,
363363
}
364+
365+
pub(crate) struct SlowFinalRowsTestCase {
366+
pub(crate) description: &'static str,
367+
pub(crate) shutdown: Duration,
368+
/// How long the store takes to accept the final delivery rows.
369+
pub(crate) final_rows_delay: Duration,
370+
pub(crate) expected_flush_complete: bool,
371+
}
372+
373+
/// A hosted store whose final delivery rows take `delay`, giving up at the deadline like HTTP.
374+
#[derive(Clone, Debug)]
375+
pub(crate) struct SlowFinalRowsStore {
376+
pub(crate) delay: Duration,
377+
/// The deadline the store was given for the final delivery rows.
378+
pub(crate) final_give_up: Arc<Mutex<Option<Instant>>>,
379+
}
380+
381+
impl RecallStore for SlowFinalRowsStore {
382+
fn insert(&self, rows: &[RecallRow], give_up: Instant) -> Result<InsertOutcome, StoreError> {
383+
if !rows.iter().any(|row| row.table == Table::Delivery) {
384+
return Ok(InsertOutcome::default());
385+
}
386+
*self
387+
.final_give_up
388+
.lock()
389+
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(give_up);
390+
let answered: Instant = Instant::now() + self.delay;
391+
std::thread::sleep(
392+
answered
393+
.min(give_up)
394+
.saturating_duration_since(Instant::now()),
395+
);
396+
if answered > give_up {
397+
return Err(StoreError::new(
398+
StoreErrorKind::Busy,
399+
"the deadline expired",
400+
));
401+
}
402+
Ok(InsertOutcome::default())
403+
}
404+
}
405+
406+
impl RecallStoreOpener for SlowFinalRowsStore {
407+
fn open(
408+
&self,
409+
_envelope: &RowEnvelope,
410+
_give_up: Instant,
411+
) -> Result<Box<dyn RecallStore>, StoreError> {
412+
Ok(Box::new(self.clone()))
413+
}
414+
415+
fn batch_rows(&self) -> usize {
416+
1_000
417+
}
418+
419+
fn batch_window(&self) -> Duration {
420+
Duration::from_millis(10)
421+
}
422+
}

0 commit comments

Comments
 (0)