Ingestion unblocked end-to-end — CrateDB corpus filling — 2026-06-08
Autonomous /loop continuation. The CrateDB cutover had left the device→broker
ad-write path completely non-functional (0 ads despite a configured, alive fleet).
Root-caused and fixed the full chain; the corpus now fills live. All pushed to main.
Result
- Corpus: 0 → 246 ads and climbing, 100% titled, source
zentraljob-ch(a server-rendered JSON-LD board). The pipeline is self-driving again: orchestrator (alwaysdata) → planner (hourly) → server devices lease/scrape → broker records → CrateDB. - Cockpit observability verified live and accurate:
/corpus(growing),/freshness(246 postings, 0 stale),/ingestion-health(readiness: active),/source-matrix(per-source health — correctly flagsjobup-chRED). - Product API
/api/statsreflects the corpus (total, bySource, topCompanies).
The three root causes (all fixed)
The stall was three bugs stacked behind one symptom (0 ads):
schema-invalidcrash — device,1ba40c86(crates/jobcache/src/schema_org.rs).parse_schema_org_jobposting's no-JSON-LD branch emittedstatus: opts.status.unwrap_or(Failed), but ~89 adapters pre-setSome(Success)before the parser runs, so an SPA page with no server-rendered JSON-LD produced asuccessobservation with 0 fields → the broker's.strict()ObservationSchemasuperRefine rejected it → task burned 3 lease attempts → failed. ForcedFailedon that branch (matches the function's own doc contract) + regression test.Broker ad-write 500 —
d247f59a(jobcache/shared/src/fact-tree.ts). Once #1 unblocked real observations, they reachedupsertObservationInStore— the ad-write path that had never executed against CrateDB — and threwColumn reference "ads.source_ref" has too many parts(the #80/#81 pattern: CrateDB forbids table-qualified columns inON CONFLICT DO UPDATE SET; the existing-row value must be bare,excluded.colis the proposed value). Ported everyON CONFLICT SETin the observation-write path to bare existing-row refs (ads 24 cols, object_refs ×2, text_chunks, ad_annotations, ad_role_links, ad_observation_state increment). Single-tableUPDATE…WHERE ads.col IS DISTINCT FROMis a different context CrateDB accepts — verified live (fixture observation upserts clean).Observability gap —
35fb25c1(jobcache/interface/src/api/device-control.ts). The broker swallowed the exact Zod issue onschema-invalid. Now it surfaces a boundedreason_detailin both the server log and the rejection response (the device logs the response, so it's readable viakubectl logs).
Key insight: the broker fix (#2) alone makes the EXISTING old server devices record ads for any JSON-LD page — their crash was the broker's 500, not the device. Validated/seeded by running the fixed Rust device locally against the prod broker (it's a portable HTTP client — no server k3s changes needed).
Verification method
upsertObservationInStorereproduced directly against live CrateDB with the rich fixture observation →inserted:true, ad + title fact written, 0 errors.- Local fixed device (40 cycles) vs prod broker → 84 observations all
status: recorded; corpus then kept growing from the server devices too. - Render build flake (
EEXIST … sporewrightsubmodule symlink in the bun cache) cleared with aclearCache: clearredeploy — not a code issue.
Follow-ups (none blocking; corpus fills without them)
- Device image redeploy (server k3s/docker). The server
jobcache-devicepods run the OLD image, soschema_orgfix #1 isn't on them — SPA pages (jobup-ch, jobs.ch) stillschema-invalidthere instead of failing honestly. This is a server container op (NO-DOCKER hard rule), so left for Julian:docker build -f crates/jobcache-device/Dockerfile -t jobcache-device:latest .→ load onto k3s →kubectl -n jobcache rollout restart deployment/jobcache-device. Low value: SPA boards produce no Tier-1 ads regardless (they need the Tier-2 render device, #68); the corpus fills from JSON-LD boards meanwhile. - Cockpit live tape — FIXED (
f88f0bdd, deployed)./changes/streamwas erroringUnknown function: to_timestamp— its 4 cursor comparisons used the Postgresto_timestamp(cursor/1000.0), which CrateDB lacks. Replaced withEXTRACT(EPOCH FROM col) * 1000 > cursor(the form the same queries' SELECTs already used). The tape derives from live leases/observations/task-failures (NOT the legacyscrape_eventslog, which is dead — only the old buningest.tsever wrote it). Now streams scrape-run-started/completed/failed frames. Deployed viawrangler deploy --env productionwith the global API key (the scoped-token secret field is malformed — token mashed with its ✓-permission notes; see follow-up 5). - Secret hygiene:
secrets/cloudflare.yml:jobcache_scoped_tokenis 774 chars — the token concatenated with its permission list (contains a ✓), so it can't be used asCLOUDFLARE_API_TOKEN(ByteString error). Re-store just the bare 40-char token value. Worked around with the (sanctioned) global API key for now. - SPA boards need Tier-2 render (jobup-ch / jobs.ch) — separate feature (#68 render device exists; not yet driving these).
- The 32
zentraljob-chfailed tasks are non-detail/listing URLs (no JSON-LD) — now an honestFailed; could be filtered at discovery to cut noise.
Follow-up session — open-TODO review + queue fixes (2026-06-08, later)
A 6-agent review workflow re-graded every open TODO against the live system and swept for more latent CrateDB bugs. Outcome + three more P1 backend fixes shipped:
- #82
b0b0cc63—resolveFactChainsCTE was namedinput(a CrateDB reserved word) → unconditional 500 onPOST /api/ads/resolve. Renamed →link_inputs; verified live the query returns correct ad+role data. (Cold path — CV fact resolution — so it had never fired.) - #83
85c84849— lease FAIRNESS:selectReadyTasksscanned 50 pending tasksORDER BY priority DESC, created_at ASCand every priority is 0, so pure created_at FIFO let one board fill all 50 slots. Live: 1360 nzz-jobs tasks stuck at attempts=0. Fix: per-sourceROW_NUMBER() OVER (PARTITION BY source_id)cap + random pick among tied top-score candidates inleaseTask. Result: all 4 sources now GREEN (zentraljob/ostjob/nzz/jobup) — the corpus went single-source → genuine multi-board. This was the highest-impact fix. - #84
85c84849— lease REAPER only reapedactiveleases; a submit that flips the leaseactive→submittedthen throws before the task flips strands the taskleasedforever. Added a second reaper pass (anti-join forleasedtasks lacking any active+unexpired lease, with a grace window). Verified: the 4 live stranded tasks self-healed todone; still-stranded count = 0.
#70 closed as MOOT (no verify task class exists post-wipe).
Remaining backend roadmap (tasks #85, #86 — none corpus-breaking):
- #85 maintenance pass:
evictRawObjects(raw GC / 9 GB B2 cap) has zero call sites; non-atomic ad-write leaks ~0.2% orphan ads (ad+facts+field_state written,ad_observation_statemissing — CrateDB has no rollback). Wire a control-gatedPOST /api/maintenance/runcalled by the orchestrator tick. Low urgency. - #86 close the #66 loop: orchestrator never budgets freshness/reparse (0 such tasks ever fire) AND the live TS device can't execute verify/reparse. Needs an alwaysdata tick change + a verify/reparse-capable fleet (partly server-docker).
- #66 status: trust (live, 3 devices off the 0.5 default) + re-investigation engine are BUILT and wired; the loop just isn't CLOSED end-to-end yet (≈55%).
Follow-up session 2 — orphan ads + #66 loop closure (2026-06-08, later)
- Orphan ads eliminated (
d0916978, deployed). Ads with ad+facts+field_state but noad_observation_staterow (the final write fails, no CrateDB rollback) were slowly accumulating. Healed all existing to 0, then two prevention layers: (1)fact-treeself-heals at source — on an obs-state write failure it logs the real CrateDB error (root-cause capture) and retries with empty change arrays; (2)device-controlexpireOverdueLeasesruns a throttled (≤1/60s) sweep that re-derives the row fromad_field_statefor any ad orphaned past 120s. Verified live: persistent orphans hold at 0. - #66 freshness loop CLOSED (
42364392, deployed). Root cause: freshness re-fetch took only the discover LEFTOVER budget, and discover never exhausts, so it produced 0 re-investigation tasks ever. Decoupled it to an independent per-tick budget (planner-only, no alwaysdata/device change). Proven live: a 1h-target run yields 25 freshness re-fetch tasks of real ads (vs 0 at the 3h default). Re-fetch is scrape-class → the existing fleet executes it → the re-investigate → fresh observation → agreement → trust loop now runs.DEFAULT_FRESHNESS_TARGET_MS24h→3h (targets aged-out ads; re-discovery covers active ones). Reparse default-OFF (TS fleet can't execute reparse-class). Remaining #86: reparse/verify execution (needs a server-docker fleet) + M2 orchestrator budget — see task #86. - The four queue/integrity fixes plus the freshness decouple all landed via Render deploys with no orchestrator (alwaysdata) or device-image (k3s) change required.
Follow-up session 3 — reparse arm deep-dive (2026-06-08, later)
Drove the #66/#87 self-upgrading loop's REPARSE arm toward 100%. De-risking the
never-run path (local HEAD device, fe1449a4 collector, against the live prod
broker, with a new target_collector_version planner override) surfaced a CASCADE
of latent bugs — same pattern as the original 0-ads stall. Six found:
- Infinite-loop target (
65c73b87, deployed).loadVersionUpgradeTargetusedmax(last_collector_version). Git short-shas are NOT chronologically ordered, and a re-deriving device stamps its OWN version, so a target that never equals what live devices produce makes staleness unresolvable → the ad re-parses forever. Fixed → RECENCY (latest real-runtime observation). - Heal-sentinel poison (same commit). The orphan-heal sweep stamps
last_collector_version='orphan-heal'(runtimecv-internal), which sorts above any sha and became the max. Fixed by the recency query'slast_collector_runtime IS DISTINCT FROM 'cv-internal'filter. - Loader joined the wrong key (
df7637be, deployed). The version-stale loader joinedobject_refs ON r.content_hash = o.last_content_hash, butlast_content_hashis the OBSERVATION id (a nanoid), not the raw sha256 — it matched 0 of 4932 eligible ads, so reparse silently minted nothing. Fixed → evidence chain (evidence[subject_kind=ad] →evidence_objects[role=raw] →object_refs) + innerrow_number() PARTITION BY ad_id ORDER BY observed_at DESCto dedup to the freshest raw per ad. Verified live: 0 → expected per-source-capped rows with correct B2 locators. - Re-parse fed bare JSON-LD to an HTML extractor (
0b3b723e, committed; device not yet redeployed). schema_org storesraw: Some(jp)— the BARE extracted JobPosting object (kind='json'), butrun_reparse_grantfed it to a parser that only lifts JSON-LD from an HTML<script>block. Fixed →canned_body_for_kindre-wraps json-kind raws. (The old reparse test fed HTML, so it stayed green while every live json raw failed.) - Grant-delivery red herring + observability fix (
4b30962c). First read as "grants never reach the device", but instrumentation provedraw_grants=1arrives fine — the broker mints + delivers correctly. (Kept the win: every empty-grant path inmintRawGrantsnow logs a[reparse-grant]reason instead of returning[]silently.) - Identity-encoding raw fetch — THE blocker, FIXED (
6ceff20b). The device DID route to handle_reparse, but the fetch of the presigned B2 object failed:curl (61) bad Content/Transfer-Encoding. CurlEnv always passed--compressed, so curl tried to transparently decode the object's stored Content-Encoding — but the body is the RS02-encrypted.zst.encblob the device decrypts + zstd-decompresses ITSELF. AddedRequest::header(); the reparse fetch setsAccept-Encoding: identityand CurlEnv skips--compressedon any explicit Accept-Encoding, returning verbatim bytes. failed_observationwrong schema_id — OPEN follow-up. The device's adapter.rs importsSCHEMA_IDfrombroker.rs("jobcache.device-contract") but the OBSERVATION schema requires"cv.jobcache"— so a genuine adapter Err/None failure (incl. a reparse that legitimately can't re-derive) is rejected schema-invalid, not recorded. Not blocking (the happy path now emits valid SUCCESS observations via parsed_ad); fix is device-side, deploy with the fleet.
REPARSE ARM CLOSED — proven END-TO-END live: planner mint → broker grant →
identity fetch → RS02 decrypt → re-wrap + re-parse → recorded. Clean run: 3/3
reparse tasks status:recorded, faithful re-derivation (title/org/description
unchanged_fields). 138 tests pass (27 device + 111 shared + 122 interface).
NOTE: reparse re-derives IDENTICAL facts today (no extraction gap ec08c496→HEAD),
so the data value is ~0 — the value was DE-RISKING the never-run path (7 bugs!).
The target_collector_version override on POST /api/planner/run is the
deterministic forced-re-derivation handle. FLEET STILL ON ec08c496 (no
handle_reparse) — ongoing fleet reparse needs the HEAD device-image ctr-import,
DEFERRED until a real adapter upgrade makes re-derivation worthwhile.
kind: journal nodes:
- jobcache-ingestion-unblocked-2026-06-08
summary: "Working record of the JobCache ingestion recovery and the evidence that unblocked it on 2026-06-08."
links:
event:
- jobcache-ingestion-recovery-2026-06-08 implementation:
- sporewright-server