fix(app): hold input until the schema-cache refresh lands (#39)

A simple-mode Form-B insert (`insert into T values (…)`) submitted faster
than the post-DDL schema-cache refresh was validated against the stale
cache and wrongly rejected as "trying to write SQL?". The cache is
refreshed asynchronously after the worker applies a command, posting
`SchemaCacheRefreshed` on the same FIFO channel as key events; under
faster-than-human input the next Enter is processed before the refresh.

Add a submission gate: `dispatch_dsl` arms `awaiting_schema_refresh` on
every `ExecuteDsl` dispatch, holds further DSL submissions in a
`held_submissions` FIFO while armed, and the `SchemaCacheRefreshed`
handler drains them against the fresh cache in submission order.
App-lifecycle commands bypass the gate. Arm-on-every-dispatch keeps
exactly one DSL command in flight, so the boolean is provably correct.
Interactive-only: the replay/batch path already re-snapshots the schema
synchronously per line. No interactive-user impact; held input is never
lost (a refresh always follows a dispatch).

Tests (both verified red→green): a Tier-1 deterministic ordering test and
a Tier-4 PTY back-to-back regression (the unpaced inverse of flow 3).
Records the design as ADR-0022 Amendment 8.
This commit is contained in:
claude@clouddev1
2026-06-22 16:59:40 +00:00
parent 010dbf8e9e
commit 07575da983
5 changed files with 254 additions and 2 deletions
+163 -1
View File
@@ -377,6 +377,31 @@ pub struct App {
/// by default; refreshed by the runtime on project load
/// and after successful DDL.
pub schema_cache: crate::completion::SchemaCache,
/// Issue #39: `true` while a dispatched DSL command's async
/// schema-cache refresh is still in flight. The runtime applies a
/// command on the worker thread and only *afterwards* sends back a
/// `SchemaCacheRefreshed` event; until that lands, `schema_cache` is
/// stale w.r.t. the command just dispatched. Validating a follow-up
/// submission against that stale cache is the issue #39 bug (a Form-B
/// insert after `add column` sees the pre-DDL columns and is wrongly
/// rejected as "trying to write SQL?"). While this flag is set, new
/// `dispatch_dsl` submissions are *held* in `held_submissions` rather
/// than validated, then drained when the refresh arrives. Armed on
/// every `ExecuteDsl` dispatch, so at most one *DSL* command is ever in
/// flight — refreshes are then strictly one-per-dispatch and in order,
/// keeping the gate a simple, provably-correct boolean. (App-lifecycle
/// commands — `load` / `undo` / `rebuild` — also refresh the cache but
/// bypass this gate; they are modal/picker-gated and so cannot overlap a
/// rapid DSL paste, the only thing this guards.) Cleared in the
/// `SchemaCacheRefreshed` handler.
awaiting_schema_refresh: bool,
/// Issue #39: submissions deferred while `awaiting_schema_refresh` is
/// set, in submission order. Each is the canonical input line plus the
/// effective mode it was submitted under; drained through
/// `dispatch_dsl` (re-validated against the now-fresh cache) when the
/// pending refresh lands. `push_history` already ran for these at
/// `submit` time, so draining re-enters at `dispatch_dsl`, not `submit`.
held_submissions: std::collections::VecDeque<(String, EffectiveMode)>,
/// Whether the undo/snapshot machinery is active this session
/// (ADR-0006 Amendment 1). `false` under the `--no-undo` CLI
/// flag; the `undo` / `redo` commands then report undo is off
@@ -596,6 +621,10 @@ impl App {
modal: None,
last_completion: None,
schema_cache: crate::completion::SchemaCache::default(),
// Issue #39: no command is in flight at construction; the
// schema-refresh gate starts open with an empty hold queue.
awaiting_schema_refresh: false,
held_submissions: std::collections::VecDeque::new(),
// Undo is on by default; the runtime flips this off for
// a `--no-undo` session (ADR-0006 Amendment 1).
undo_enabled: true,
@@ -912,7 +941,24 @@ impl App {
"schema cache refreshed",
);
self.schema_cache = cache;
Vec::new()
// Issue #39: the in-flight command's refresh has landed, so
// the gate opens. Drain any submissions held while it was in
// flight, re-validating each against the now-fresh cache.
// Stop as soon as a drained command dispatches (re-arming the
// gate via its own `ExecuteDsl`): the remaining held commands
// then wait for *its* refresh, preserving submission order. A
// held command that does not dispatch (parse error / pre-flight
// rejection) leaves the gate open, so the loop continues to the
// next held submission.
self.awaiting_schema_refresh = false;
let mut actions = Vec::new();
while !self.awaiting_schema_refresh {
let Some((input, submission_mode)) = self.held_submissions.pop_front() else {
break;
};
actions.extend(self.dispatch_dsl(&input, submission_mode));
}
actions
}
AppEvent::RelationshipsRefreshed(relationships) => {
trace!(count = relationships.len(), "relationships refreshed");
@@ -1981,6 +2027,20 @@ impl App {
}
fn dispatch_dsl(&mut self, input: &str, submission_mode: EffectiveMode) -> Vec<Action> {
// Issue #39: if a previously-dispatched command's schema-cache
// refresh is still in flight, `schema_cache` is stale w.r.t. that
// command. Validating this submission now would race it (the bug:
// a Form-B insert after `add column` rejected against the pre-DDL
// schema). Hold it in submission order; the `SchemaCacheRefreshed`
// handler drains the queue once the fresh schema lands. App-level
// commands (`quit`, `help`, `load`, …) route through
// `dispatch_app_command` *before* here, so they are never held.
if self.awaiting_schema_refresh {
debug!(input, "holding submission until schema cache refresh lands");
self.held_submissions
.push_back((input.to_string(), submission_mode));
return Vec::new();
}
// The two-way mode the walker + the `[mode]` render tag read; the
// three-way `submission_mode` (ADR-0037) rides on `ExecuteDsl` for
// the runtime's echo gate (ADR-0038).
@@ -2069,6 +2129,14 @@ impl App {
}];
}
self.push_output(OutputLine::echo(input, mode));
// Issue #39: a command is now in flight; its schema-cache
// refresh will land asynchronously. Arm the gate so any
// follow-up submission waits for the fresh schema rather
// than racing the stale cache. Armed on every dispatch (not
// just DDL) so only one command is ever in flight, which
// keeps the gate a simple boolean — refreshes are then
// strictly one-per-dispatch and in order.
self.awaiting_schema_refresh = true;
vec![Action::ExecuteDsl {
command: cmd,
source: input.to_string(),
@@ -4614,6 +4682,100 @@ mod tests {
);
}
#[test]
fn form_b_insert_after_ddl_is_held_until_refresh_then_dispatched() {
// Issue #39: a simple-mode Form-B insert (`insert into T values
// (…)`, no column list) submitted faster than the post-DDL
// schema-cache refresh must NOT be validated against the *stale*
// cache — doing so wrongly rejects it as "trying to write SQL?".
//
// This reproduces, deterministically, the event ordering the async
// runtime produces under fast input: a schema-mutating command is
// dispatched (arming the gate while its cache refresh is in flight),
// a follow-up insert is submitted before the refresh lands, and only
// then does the `SchemaCacheRefreshed` event arrive. Pre-fix the
// insert is validated against the pre-DDL schema and rejected; with
// the gate it is held and dispatched against the fresh schema.
use crate::completion::{SchemaCache, TableColumn};
use crate::dsl::types::Type;
// Pre-DDL schema: `Customers` has only the auto `id` (serial). This
// is the stale cache a racing Form-B insert would otherwise see —
// zero user-fillable columns, so `values ('Alice')` can't match.
let mut app = App::new();
let mut stale = SchemaCache::default();
stale.tables.push("Customers".to_string());
stale.columns.push("id".to_string());
stale.table_columns.insert(
"Customers".to_string(),
vec![TableColumn {
name: "id".to_string(),
user_type: Type::Serial,
not_null: true,
has_default: false,
}],
);
app.schema_cache = stale;
// 1. Submit the DDL. It dispatches and arms the gate: a schema
// refresh is now (conceptually) in flight.
type_str(&mut app, "add column to Customers: Name (text)");
let ddl_actions = submit(&mut app);
assert!(
matches!(ddl_actions.as_slice(), [Action::ExecuteDsl { .. }]),
"the DDL should dispatch; got {ddl_actions:?}",
);
// 2. Submit the Form-B insert *before* the refresh lands. It must be
// held — not dispatched, and crucially not rejected against the
// stale cache (no error note such as "trying to write SQL?").
type_str(&mut app, "insert into Customers values ('Alice')");
let held_actions = submit(&mut app);
assert!(
held_actions.is_empty(),
"the insert must be held while the refresh is in flight, \
not dispatched or rejected; got {held_actions:?}",
);
assert!(
!app.output.iter().any(|l| l.kind == OutputKind::Error),
"a held insert must produce no error note (e.g. the \
'trying to write SQL?' pointer); errors:\n{}",
error_lines(&app),
);
// 3. The DDL's schema refresh lands carrying the fresh schema
// (`Customers` now has `id` + `Name`). The held insert drains and
// dispatches, validated against the up-to-date cache.
let mut fresh = SchemaCache::default();
fresh.tables.push("Customers".to_string());
fresh.columns.push("id".to_string());
fresh.columns.push("Name".to_string());
fresh.table_columns.insert(
"Customers".to_string(),
vec![
TableColumn {
name: "id".to_string(),
user_type: Type::Serial,
not_null: true,
has_default: false,
},
TableColumn::new("Name", Type::Text),
],
);
let drained = app.update(AppEvent::SchemaCacheRefreshed(fresh));
assert!(
matches!(
drained.as_slice(),
[Action::ExecuteDsl {
command: Command::Insert { .. },
..
}]
),
"the held insert must dispatch once the fresh schema lands; \
got {drained:?}",
);
}
#[test]
fn simple_mode_submit_of_pure_dsl_error_has_no_advanced_pointer() {
// A DSL error that is *not* valid SQL either (unknown command)