Skip to content

Commit 0d31404

Browse files
authored
Merge pull request #23 from spacedock-dev/codex/fix-spider2-dbt-preflight-sources
[codex] fix spider2 dbt source preflight repairs
2 parents 12f539c + 45bf5f3 commit 0d31404

4 files changed

Lines changed: 343 additions & 69 deletions

File tree

src/razorback/benchmarks/spider2_dbt/harbor_view.py

Lines changed: 97 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
from razorback.benchmarks.spider2_dbt import eval_spec as _eval_spec_mod
1111
from razorback.benchmarks.spider2_dbt import verify as _verify_mod
1212
from razorback.benchmarks.spider2_dbt.preflight import (
13+
_read_dbt_source_tables,
14+
_read_duckdb_tables,
1315
preflight_script_text,
1416
resolve_spider2_db_name,
1517
)
@@ -40,9 +42,7 @@
4042
# structural divergence from ade-bench's `project/`.
4143
_DBT_PROJECT_DIRNAME = "dbt_project"
4244

43-
_BUILD_CONTEXT_MARKER = (
44-
"# Razorback: land spider2-dbt project + source DuckDB at /app before agent runtime."
45-
)
45+
_BUILD_CONTEXT_MARKER = "# Razorback: land spider2-dbt project + source DuckDB at /app before agent runtime."
4646
_DBT_DEPS_LAYER_MARKER = (
4747
"# Razorback: install declared dbt packages before agent runtime."
4848
)
@@ -88,6 +88,9 @@ def materialize_spider2_harbor_task_view(
8888
exclude_globs=SPIDER2_DBT_DENY_GLOBS,
8989
view_mode=view_mode,
9090
)
91+
_repair_missing_spider2_source_tables(
92+
view, source_task_dir=Path(source_task_dir), task_slug=task_slug
93+
)
9194
# RIDER (Codex finding 2): stage dbt_project/ (incl. the source .duckdb)
9295
# into the build context and COPY it to /app BEFORE the preflight RUN, so
9396
# the preflight `--workspace /app` can never fail on a missing project.
@@ -114,6 +117,92 @@ def _copy_into_view(src: Path, dst: Path) -> None:
114117
shutil.copy2(src, dst)
115118

116119

120+
def _repair_missing_spider2_source_tables(
121+
view_dir: Path, *, source_task_dir: Path, task_slug: str
122+
) -> None:
123+
"""Hydrate missing referenced source tables from Spider2's gold DuckDB.
124+
125+
Some upstream Spider2 DBT examples ship a source DuckDB that lacks raw
126+
source tables declared and referenced by project models, while the per-task
127+
gold DuckDB still contains those raw relations alongside expected outputs.
128+
Copy only the referenced dbt source relations into the agent-facing source
129+
DB; never copy arbitrary gold tables or scorer condition tables.
130+
"""
131+
project_dir = _dbt_project_dir(view_dir)
132+
if project_dir is None:
133+
return
134+
135+
db_name = resolve_spider2_db_name(project_dir, task_slug=task_slug)
136+
source_db = project_dir / f"{db_name}.duckdb"
137+
if not source_db.is_file():
138+
return
139+
140+
required_tables = _read_dbt_source_tables(project_dir)
141+
if not required_tables:
142+
return
143+
144+
observed_tables = _read_duckdb_tables(source_db)
145+
missing_tables = required_tables - observed_tables
146+
if not missing_tables:
147+
return
148+
149+
gold_db = _source_gold_db(source_task_dir)
150+
if gold_db is None:
151+
return
152+
153+
gold_tables = _read_duckdb_tables(gold_db)
154+
repairable = sorted(missing_tables & gold_tables)
155+
if not repairable:
156+
return
157+
158+
import duckdb
159+
160+
if source_db.is_symlink():
161+
source_db_target = source_db.resolve(strict=True)
162+
source_db.unlink()
163+
shutil.copy2(source_db_target, source_db)
164+
165+
conn = duckdb.connect(str(source_db))
166+
try:
167+
conn.execute(
168+
f"ATTACH {_sql_string(str(gold_db))} AS razorback_gold (READ_ONLY)"
169+
)
170+
for schema, table in repairable:
171+
conn.execute(f"CREATE SCHEMA IF NOT EXISTS {_quote_ident(schema)}")
172+
conn.execute(
173+
"CREATE TABLE "
174+
f"{_quote_ident(schema)}.{_quote_ident(table)} AS "
175+
"SELECT * FROM "
176+
f"razorback_gold.{_quote_ident(schema)}.{_quote_ident(table)}"
177+
)
178+
finally:
179+
conn.close()
180+
181+
182+
def _source_gold_db(source_task_dir: Path) -> Path | None:
183+
source_gold = Path(source_task_dir) / "tests" / "gold"
184+
source_spec = source_gold / "spider2_eval.jsonl"
185+
if not source_spec.is_file():
186+
return None
187+
188+
try:
189+
spec = _eval_spec_mod.load_eval_spec(source_spec)
190+
except Exception:
191+
return None
192+
193+
gold_basename = spec.gold or "gold.duckdb"
194+
source_gold_db = source_gold / gold_basename
195+
return source_gold_db if source_gold_db.is_file() else None
196+
197+
198+
def _quote_ident(value: str) -> str:
199+
return '"' + value.replace('"', '""') + '"'
200+
201+
202+
def _sql_string(value: str) -> str:
203+
return "'" + value.replace("'", "''") + "'"
204+
205+
117206
def _ensure_verifier_assets(
118207
view_dir: Path, *, source_task_dir: Path, task_slug: str
119208
) -> None:
@@ -193,9 +282,7 @@ def _ensure_verifier_assets(
193282
gold_db=shlex.quote(f"/tests/{gold_basename}"),
194283
)
195284
)
196-
test_sh.chmod(
197-
test_sh.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
198-
)
285+
test_sh.chmod(test_sh.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
199286

200287
# The deny-glob reflection strips gold FILES but leaves the now-empty
201288
# `gold/` directory behind. Prune empty `gold/`-named dirs so no `gold/`
@@ -233,12 +320,9 @@ def _dbt_project_dir(view_dir: Path) -> Path | None:
233320

234321

235322
def _has_dbt_packages_manifest(view_dir: Path) -> bool:
236-
return (
237-
(view_dir / _DBT_PROJECT_DIRNAME / "packages.yml").is_file()
238-
or (
239-
view_dir / "environment" / _DBT_PROJECT_DIRNAME / "packages.yml"
240-
).is_file()
241-
)
323+
return (view_dir / _DBT_PROJECT_DIRNAME / "packages.yml").is_file() or (
324+
view_dir / "environment" / _DBT_PROJECT_DIRNAME / "packages.yml"
325+
).is_file()
242326

243327

244328
def _ensure_spider2_build_context_layer(view_dir: Path) -> None:
@@ -313,9 +397,7 @@ def _ensure_dbt_deps_image_layer(view_dir: Path) -> None:
313397
dockerfile.write_text(_insert_before_final_cmd(text, block))
314398

315399

316-
def _ensure_workspace_preflight_image_layer(
317-
view_dir: Path, *, task_slug: str
318-
) -> None:
400+
def _ensure_workspace_preflight_image_layer(view_dir: Path, *, task_slug: str) -> None:
319401
"""Validate the source DuckDB at build time, before the agent runs.
320402
321403
Gated on `_has_dbt_project`: spider2-dbt has no task families, so the

src/razorback/benchmarks/spider2_dbt/preflight.py

Lines changed: 53 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import argparse
44
import json
5+
import re
56
import sys
67
from pathlib import Path
78
from typing import Any
@@ -172,9 +173,7 @@ def _duckdb_path(output: dict[str, Any]) -> str | None:
172173
for profile in _iter_dicts(list(_as_dict(document).values())):
173174
outputs = _as_dict(profile.get("outputs"))
174175
output_dicts = {
175-
name: out
176-
for name, out in outputs.items()
177-
if isinstance(out, dict)
176+
name: out for name, out in outputs.items() if isinstance(out, dict)
178177
}
179178
if not output_dicts:
180179
continue
@@ -241,20 +240,33 @@ def _resolve_db_path(
241240
return None
242241

243242

243+
_SOURCE_CALL_RE = re.compile(
244+
r"""\bsource\s*\(\s*(['"])(?P<source>[^'"]+)\1\s*,\s*(['"])(?P<table>[^'"]+)\3\s*\)"""
245+
)
246+
247+
244248
def _read_dbt_source_tables(workspace: Path) -> set[tuple[str, str]]:
245249
"""Read dbt `sources:` as `(schema, table)` relations.
246250
247251
The relation schema is resolved with dbt's precedence: a table-level
248252
`schema` overrides a source-level `schema`, which in turn defaults to the
249253
source `name` (dbt's documented default when a source omits `schema`). The
250-
table identifier follows the same `identifier`-over-`name` precedence. Both
251-
parts are lowercased to match `_read_duckdb_tables`.
254+
table identifier follows the same `identifier`-over-`name` precedence.
255+
256+
When the project contains static `source('source_name', 'table_name')`
257+
references, only those referenced declarations are required. Some upstream
258+
Spider2 exports carry stale or duplicate source metadata for tables that no
259+
model reads; failing the image build on those unused declarations rejects an
260+
otherwise runnable task. If no static references are found, fall back to
261+
enforcing every declared source table. Both relation parts are lowercased to
262+
match `_read_duckdb_tables`.
252263
"""
253264
try:
254265
import yaml
255266
except Exception:
256267
return set()
257268

269+
referenced_sources = _read_referenced_source_names(workspace)
258270
relations: set[tuple[str, str]] = set()
259271
for yaml_path in _iter_candidate_dbt_yaml_files(workspace):
260272
try:
@@ -265,18 +277,49 @@ def _read_dbt_source_tables(workspace: Path) -> set[tuple[str, str]]:
265277
source_name = source.get("name")
266278
source_schema = source.get("schema") or source_name
267279
for table in _iter_dicts(_as_list(source.get("tables"))):
268-
name = table.get("identifier") or table.get("name")
269-
if not (isinstance(name, str) and name.strip()):
280+
table_name = table.get("name")
281+
if not (
282+
isinstance(source_name, str)
283+
and source_name.strip()
284+
and isinstance(table_name, str)
285+
and table_name.strip()
286+
):
270287
continue
288+
source_key = (source_name.strip().lower(), table_name.strip().lower())
289+
if referenced_sources and source_key not in referenced_sources:
290+
continue
291+
name = table.get("identifier") or table_name
271292
schema = table.get("schema") or source_schema
272293
if not (isinstance(schema, str) and schema.strip()):
273294
continue
274-
relations.add(
275-
(schema.strip().lower(), name.strip().lower())
276-
)
295+
relations.add((schema.strip().lower(), name.strip().lower()))
277296
return relations
278297

279298

299+
def _read_referenced_source_names(workspace: Path) -> set[tuple[str, str]]:
300+
"""Return static dbt source references as `(source_name, table_name)` pairs."""
301+
if not workspace.is_dir():
302+
return set()
303+
304+
referenced: set[tuple[str, str]] = set()
305+
excluded_parts = {".git", ".venv", "dbt_packages", "logs", "target"}
306+
for sql_path in sorted(workspace.rglob("*.sql")):
307+
if excluded_parts & set(sql_path.relative_to(workspace).parts):
308+
continue
309+
try:
310+
text = sql_path.read_text()
311+
except (OSError, UnicodeDecodeError):
312+
continue
313+
for match in _SOURCE_CALL_RE.finditer(text):
314+
referenced.add(
315+
(
316+
match.group("source").strip().lower(),
317+
match.group("table").strip().lower(),
318+
)
319+
)
320+
return referenced
321+
322+
280323
def _format_relations(relations: set[tuple[str, str]]) -> list[str]:
281324
return [f"{schema}.{table}" for schema, table in relations]
282325

0 commit comments

Comments
 (0)