@@ -35,6 +35,15 @@ enum SourceBindInvalidation {
3535 AllSources,
3636}
3737
38+/// One `run [ids] query` acknowledgment frame: the `&`-id list and the sources
39+/// actually read fully while the query was compiled.
40+struct RunAckFrame {
41+ acked: Vec<String>,
42+ read_ids: HashSet<String>,
43+ span: ast::Span,
44+ fn_name: String,
45+}
46+
3847pub struct Codegen<M: cranelift_module::Module = ObjectModule> {
3948 module: M,
4049 ctx: Context,
@@ -253,6 +262,12 @@ pub struct Codegen<M: cranelift_module::Module = ObjectModule> {
253262 /// trace back to where the relation is declared).
254263 source_decl_spans: HashMap<String, ast::Span>,
255264
265+ /// The stack of `run [ids] query` acknowledgment frames (nested runs nest).
266+ /// While a frame is on top, full in-memory reads are checked against its
267+ /// `&`-id list: an acked read is suppressed; after the query is compiled,
268+ /// an acked id that was never read fully is a build error.
269+ run_ack_stack: Vec<RunAckFrame>,
270+
256271 // Resolved monad types for desugared do-blocks (from type inference)
257272 monad_info: MonadInfo,
258273
@@ -1467,6 +1482,7 @@ impl<M: cranelift_module::Module> Codegen<M> {
14671482 relational_do_spans: HashSet::new(),
14681483 in_memory_source_reads: HashMap::new(),
14691484 source_decl_spans: HashMap::new(),
1485+ run_ack_stack: Vec::new(),
14701486 monad_info: HashMap::new(),
14711487 nullable_ctors: HashMap::new(),
14721488 io_functions: HashSet::new(),
@@ -5054,18 +5070,18 @@ impl<M: cranelift_module::Module> Codegen<M> {
50545070 // A bare `*rel` read that reaches here was not consumed by any
50555071 // SQL-pushdown matcher — it loads the whole relation into
50565072 // memory via `knot_source_read`. Track it for the build-time
5057− // INFO diagnostic naming full in-memory reads.
5058− self.in_memory_source_reads
5059− .insert(name.clone(), (expr.span, "a direct read".to_string()));
5060− let schema = self.source_schemas.get(name).cloned().unwrap_or_default();
5061− let (name_ptr, name_len) = self.string_ptr(builder, name);
5073+ // full-read warning (checked against a `run [ids]` ack frame).
5074+ let name = name.clone();
5075+ self.record_full_read(&name, expr.span, "a direct read".to_string());
5076+ let schema = self.source_schemas.get(&name).cloned().unwrap_or_default();
5077+ let (name_ptr, name_len) = self.string_ptr(builder, &name);
50625078 let (schema_ptr, schema_len) = self.string_ptr(builder, &schema);
50635079 let rel = self.call_rt(
50645080 builder,
50655081 "knot_source_read",
50665082 &[db, name_ptr, name_len, schema_ptr, schema_len],
50675083 );
5068− if self.scalar_sources.contains(name) || self.single_value_sources.contains(name) {
5084+ if self.scalar_sources.contains(&name) || self.single_value_sources.contains(&name) {
50695085 // Single-value source: unwrap the one row's `_value` field
50705086 // back to the bare value (or a default if never written).
50715087 self.call_rt(builder, "knot_scalar_source_unwrap", &[rel])
@@ -7239,42 +7255,37 @@ impl<M: cranelift_module::Module> Codegen<M> {
72397255 // query→data boundary. The first argument is a Vec of `&`-ids
72407256 // acknowledging which sources this run reads in full. A source read
72417257 // fully but not in the list warns; an id in the list but not read fully
7242− // is a build error.
7258+ // is a build error. The frame covers the whole query — a bare source
7259+ // AND a computed query (a fn boundary, a comprehension) that full-reads.
72437260 if matches!(Self::query_form_name(func_expr), Some("run") | Some("run1"))
72447261 && args.len() == 2
72457262 && !user_shadows_special
7246− && let Some(source_name) = self.resolve_source(args[1])
7247− && let Some(schema) = self.source_schemas.get(&source_name).cloned()
72487263 {
7249− let fn_name = Self::query_form_name(func_expr).unwrap();
7250− // The acknowledged ids (the `&`-paths in the list).
7251− let acked = self.nameof_ids_in(args[0]);
7252− let my_id = self.nameof_path(args[1]);
7253− // An acknowledged id that isn't the source this run reads — the id
7254− // claims a full read that doesn't happen. Build error.
7255− for id in &acked {
7256− if id != &my_id {
7264+ let fn_name = Self::query_form_name(func_expr).unwrap().to_string();
7265+ // Push the ack frame: the acknowledged ids; the sources read fully
7266+ // while the query compiles are recorded into it.
7267+ self.run_ack_stack.push(RunAckFrame {
7268+ acked: self.nameof_ids_in(args[0]),
7269+ read_ids: HashSet::new(),
7270+ span: expr.span,
7271+ fn_name: fn_name.clone(),
7272+ });
7273+ let rel = self.compile_expr(builder, args[1], env, db);
7274+ let frame = self.run_ack_stack.pop().unwrap();
7275+ // An acked id that was never read fully — the id claims a full read
7276+ // that doesn't happen. Build error.
7277+ for id in &frame.acked {
7278+ if !frame.read_ids.contains(id) {
72577279 self.push_codegen_error(
72587280 builder,
7259− expr.span,
7281+ frame.span,
72607282 format!(
7261− "the `{fn_name}` list acknowledges `{id}`, but this `{fn_name}` reads `*{source_name}` — `{id}` is not read fully here"
7283+ "the `{}` list acknowledges `{}`, but this `{}` never reads it fully — remove it, or the query does not read that source in full",
7284+ frame.fn_name, id, frame.fn_name
72627285 ),
72637286 );
72647287 }
72657288 }
7266− if !acked.contains(&my_id) {
7267− self.in_memory_source_reads
7268− .insert(source_name.clone(), (expr.span, fn_name.to_string()));
7269− }
7270− self.emit_stm_track_read(builder, &source_name);
7271− let (name_ptr, name_len) = self.string_ptr(builder, &source_name);
7272− let (schema_ptr, schema_len) = self.string_ptr(builder, &schema);
7273− let rel = self.call_rt(
7274− builder,
7275− "knot_source_read",
7276− &[db, name_ptr, name_len, schema_ptr, schema_len],
7277− );
72787289 // run → IO (Vec a); run1 → IO a (the single row).
72797290 let rt = if fn_name == "run1" { "knot_relation_run1" } else { "knot_relation_run_io" };
72807291 return self.call_rt(builder, rt, &[rel]);
@@ -16451,6 +16462,28 @@ impl<M: cranelift_module::Module> Codegen<M> {
1645116462 rel
1645216463 }
1645316464
16465+ /// Record a full in-memory read of `source_name` (at `read_span`, via
16466+ /// `how`). If a `run [ids]` frame is on top, the read counts toward it: an
16467+ /// acked read is suppressed; an unacked one still warns. With no frame, the
16468+ /// read always warns.
16469+ fn record_full_read(&mut self, source_name: &str, read_span: ast::Span, how: String) {
16470+ let decl_span = self
16471+ .source_decl_spans
16472+ .get(source_name)
16473+ .copied()
16474+ .unwrap_or(read_span);
16475+ let (line, col) = knot::diagnostic::line_col(&self.source_text, decl_span.start);
16476+ let id = format!("{}:{}:{}:{}", self.source_name, line, col, source_name);
16477+ if let Some(frame) = self.run_ack_stack.last_mut() {
16478+ frame.read_ids.insert(id.clone());
16479+ if frame.acked.contains(&id) {
16480+ return;
16481+ }
16482+ }
16483+ self.in_memory_source_reads
16484+ .insert(source_name.to_string(), (read_span, how));
16485+ }
16486+
1645416487 /// Extract the `&`-ids from a Vec literal of NameOf expressions (the run
1645516488 /// acknowledgment list). Each element's qualified path.
1645616489 fn nameof_ids_in(&self, expr: &ast::Expr) -> Vec<String> {