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:
+163
-1
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user