Skip to content

Commit 07eec13

Browse files
committed
docs(sql): capture cached_jobs base-table DDL from prod (PERFDB-4)
Pins the cached_jobs base table — which until now lived ONLY in the prod Supabase DB — into a tracked, idempotent migration, captured verbatim from the live catalog (pg_get_indexdef + pg_attribute + pg_get_expr + pg_get_constraintdef on the jobagent project, 2026-06-22). Closes the PERFDB-4 reproducibility gap and is the safety net for the hybrid-search lean/full switch: "restore to full" and disaster-recovery rebuilds are now reproducible, and a botched embedding drop can't lose the exact index config. The file captures the COMPLETE Tier 1 lean schema: - 19 columns incl. 3 GENERATED ALWAYS AS (...) STORED columns (search_tsv title-A/company-B/description-C; work_mode and employment_type_norm CASE normalizations) transcribed verbatim — not paraphrased; the partial indexes + search RPCs depend on the exact expressions - 9 indexes: the search_tsv GIN (hard prereq for the lexical RPC), two pg_trgm GIN (title/company fuzzy match), four partial filter btrees (work_mode/employment_type/remote/source), the recency btree, the refresh keep-alive btree - unique (source, job_id) the upserts target; PK on id (bigserial) - pg_trgm extension; RLS enabled with no policies (service-role-only) The embedding vector(1536) column + HNSW index deliberately stay in supabase-cached-jobs-pgvector.sql so the column has one source of truth and base.sql doubles as the "rebuild lean on the free tier" definition. Also records the live storage check in the deployment runbook: jobagent prod DB was 504 MB (already over the free-tier 500 MB cap), cached_jobs 485 MB of it, the semantic layer (embedding 83 MB + HNSW 110 MB) = 193 MB. Lean mode -> ~300 MB. Noted that ~100 MB of the heap is refresh dead-tuple bloat, so a standalone VACUUM FULL is a cheaper duck-under- the-cap lever that keeps semantic search. docs/README.md: register the new base SQL file in the migrations index.
1 parent a39804f commit 07eec13

3 files changed

Lines changed: 182 additions & 11 deletions

File tree

docs/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ The `docs/sql/*.sql` files are reference copies of the Supabase migrations appli
4949
| `docs/sql/supabase-subscriptions.sql` | `aijobagent_subscriptions` table for the Lemon Squeezy integration |
5050
| `docs/sql/supabase-run-traces.sql` | `aijobagent_run_traces` cost-attribution table (prompt/completion tokens + USD cost) |
5151
| `docs/sql/supabase-feedback.sql` | `aijobagent_feedback` artifact thumbs-up/down table + RLS |
52+
| `docs/sql/supabase-cached-jobs-base.sql` | **The `cached_jobs` base table itself** — columns, the 3 GENERATED STORED columns (`search_tsv`, `work_mode`, `employment_type_norm`), the GIN/trgm/partial/recency indexes, the `unique (source, job_id)`, RLS-enabled-no-policies. Captured verbatim from the prod catalog (closes PERFDB-4 — the table used to live only in prod). This is the complete Tier 1 *lean* schema; the embedding column + HNSW are the separate pgvector add-on below |
5253
| `docs/sql/supabase-cached-jobs-search.sql` | `search_cached_jobs_ranked` RPC: text-search + filters + sort + `LIMIT`/`OFFSET` pagination over `cached_jobs` (Tier 1 lexical search). **service_role-only** EXECUTE — the REVOKEs are part of the canonical definition |
5354
| `docs/sql/supabase-cached-jobs-pgvector.sql` | Tier 2 semantic-search schema: the `vector` extension, the `cached_jobs.embedding vector(1536)` column, and the HNSW cosine index |
5455
| `docs/sql/supabase-cached-jobs-hybrid.sql` | `search_cached_jobs_hybrid` RPC: Reciprocal Rank Fusion of the Tier 1 lexical ranking and a pgvector semantic ranking (HNSW candidate pools). **service_role-only** EXECUTE |

docs/deployment.md

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -262,19 +262,30 @@ specialist" with no shared keyword).
262262
4. Set `JOB_SEARCH_HYBRID_ENABLED=true`; redeploy api. Embed-on-write
263263
resumes for new rows from the next refresh.
264264

265-
**Prerequisite — do this BEFORE the first downgrade:** the full
265+
**Safety net (DONE — PERFDB-4 resolved 2026-06-22):** the full
266266
`cached_jobs` base-table DDL (the table, the `search_tsv` generated
267-
tsvector, the GIN index, the `unique (source, job_id)`, the
267+
tsvector, the GIN/trgm indexes, the `unique (source, job_id)`, the
268268
`work_mode`/`employment_type_norm` generated columns + partial indexes,
269-
the recency btree) currently lives ONLY in the prod DB — it is NOT in a
270-
tracked migration (the parked **PERFDB-4** finding in `report.md`).
271-
Capture it first with `pg_dump --schema-only -t cached_jobs` into a
272-
tracked `docs/sql/supabase-cached-jobs-base.sql`, so "restore to full"
273-
is reproducible and a botched drop can't lose the exact index config.
274-
The lean-mode script only ever drops the *semantic* add-ons (embedding
275-
column + HNSW); it never touches the base table or the lexical indexes —
276-
but pinning the base DDL is the safety net that makes the whole switch
277-
safe to operate.
269+
the recency btree) is now captured verbatim from the prod catalog into
270+
the tracked `docs/sql/supabase-cached-jobs-base.sql`, so "restore to
271+
full" — or a full disaster-recovery rebuild — is reproducible and a
272+
botched drop can't lose the exact index config. The lean-mode script
273+
only ever drops the *semantic* add-ons (embedding column + HNSW); it
274+
never touches the base table or the lexical indexes.
275+
276+
**Live storage check (jobagent prod, 2026-06-22):** the DB was **504
277+
MB — already over the free-tier 500 MB cap.** `cached_jobs` is 485 MB of
278+
that (14,085 rows, all embedded): heap+TOAST 335 MB, indexes 150 MB.
279+
The semantic layer alone = the `embedding` column (83 MB live) + the
280+
HNSW index (110 MB) = **193 MB**. Lean mode therefore takes the DB to
281+
**~300 MB**, comfortably under the cap. Note the heap+TOAST (335 MB) far
282+
exceeds the live column data (~220 MB) — the 4-hourly refresh churn
283+
leaves ~100 MB of dead-tuple bloat, so a plain `VACUUM FULL
284+
public.cached_jobs` *without* dropping embeddings is a cheaper immediate
285+
lever that may by itself bring the DB back under 500 MB while keeping
286+
semantic search. Use lean mode when you need the headroom long-term on
287+
the free tier; use a standalone VACUUM FULL when you just need to duck
288+
back under the cap for a while.
278289

279290
## Operational gotchas (the runbook entries that cost real time)
280291

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
-- ---------------------------------------------------------------------------
2+
-- supabase-cached-jobs-base — the cached_jobs base table (Tier 1 schema)
3+
-- ---------------------------------------------------------------------------
4+
-- THE TRACKED, REPRODUCIBLE DEFINITION OF `cached_jobs`. Closes the PERFDB-4
5+
-- finding: until this file existed, the base table — the generated columns,
6+
-- the GIN/trgm/partial indexes, the unique key — lived ONLY in the prod
7+
-- Supabase DB and could not be rebuilt from version control. A
8+
-- disaster-recovery rebuild, a fresh staging/preview env, or a project
9+
-- migration that recreated `cached_jobs` WITHOUT (say) the `search_tsv` GIN
10+
-- index would silently turn every `search_cached_jobs_ranked` call into a
11+
-- seq-scan + ts_rank sort over ~14k rows, reproducing the documented
12+
-- statement-timeout collapse. This file is the source of truth; it was
13+
-- captured from the live prod catalog (pg_get_indexdef + pg_attribute +
14+
-- pg_get_expr) on 2026-06-22, not hand-written.
15+
--
16+
-- THIS IS THE COMPLETE *LEAN* (Tier 1, lexical-only) SCHEMA. It includes
17+
-- everything EXCEPT the Tier 2 semantic add-ons:
18+
-- * the `embedding vector(1536)` column -> docs/sql/supabase-cached-jobs-pgvector.sql
19+
-- * the HNSW cosine index -> same file
20+
-- Keeping the embedding column in pgvector.sql (not here) gives it ONE
21+
-- source of truth and makes this file double as the "rebuild from scratch on
22+
-- the Supabase free tier" definition — see the "Hybrid-search lean/full
23+
-- switch" runbook in docs/deployment.md.
24+
--
25+
-- REBUILD ORDER (fresh DB):
26+
-- Lean (free tier): apply THIS file -> apply supabase-cached-jobs-search.sql
27+
-- (the lexical RPC) -> the 4-hourly refresh fills rows.
28+
-- Full (paid plan): the above, THEN supabase-cached-jobs-pgvector.sql ->
29+
-- run scripts/backfill_job_embeddings.py ->
30+
-- supabase-cached-jobs-hybrid.sql -> flip
31+
-- JOB_SEARCH_HYBRID_ENABLED=true.
32+
--
33+
-- IDEMPOTENT: CREATE TABLE/INDEX IF NOT EXISTS + guarded constraint adds, so
34+
-- re-applying against the live DB is a safe no-op (it will NOT alter the
35+
-- existing table). Use it to rebuild, or to diff against prod.
36+
--
37+
-- SECURITY: RLS is enabled with NO policies (verified: relrowsecurity=true,
38+
-- 0 policies). The table is reached only by the service-role client, which
39+
-- bypasses RLS; enabling RLS with no policies is defence-in-depth so a
40+
-- mis-scoped anon/authenticated client can never read it. Do NOT add policies
41+
-- — that would imply non-service-role access this table is not designed for.
42+
-- ---------------------------------------------------------------------------
43+
44+
-- pg_trgm backs the title/company trigram GIN indexes (fuzzy company/title
45+
-- matching in lexical search). The `vector` extension is NOT enabled here —
46+
-- it belongs with the embedding column in pgvector.sql.
47+
CREATE EXTENSION IF NOT EXISTS pg_trgm;
48+
49+
-- ---- table -----------------------------------------------------------------
50+
-- `bigserial` reproduces the prod `id bigint DEFAULT nextval('cached_jobs_id_seq')`
51+
-- (bigserial auto-creates the same-named sequence + default). The three
52+
-- GENERATED ALWAYS AS (...) STORED columns are transcribed verbatim from the
53+
-- prod generation expressions — do NOT paraphrase the CASE arms; the partial
54+
-- indexes and the search RPCs depend on these exact normalizations.
55+
CREATE TABLE IF NOT EXISTS public.cached_jobs (
56+
id bigserial PRIMARY KEY,
57+
source text NOT NULL,
58+
job_id text NOT NULL,
59+
title text NOT NULL DEFAULT ''::text,
60+
company text NOT NULL DEFAULT ''::text,
61+
location text NOT NULL DEFAULT ''::text,
62+
employment_type text NOT NULL DEFAULT ''::text,
63+
url text NOT NULL DEFAULT ''::text,
64+
summary text NOT NULL DEFAULT ''::text,
65+
description text NOT NULL DEFAULT ''::text,
66+
remote boolean NOT NULL DEFAULT false,
67+
posted_at timestamptz,
68+
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
69+
first_seen_at timestamptz NOT NULL DEFAULT timezone('utc'::text, now()),
70+
last_seen_at timestamptz NOT NULL DEFAULT timezone('utc'::text, now()),
71+
removed_at timestamptz,
72+
73+
-- Lexical search vector: title weighted A, company B, description C.
74+
search_tsv tsvector GENERATED ALWAYS AS (
75+
(
76+
setweight(to_tsvector('english'::regconfig, COALESCE(title, ''::text)), 'A')
77+
|| setweight(to_tsvector('english'::regconfig, COALESCE(company, ''::text)), 'B')
78+
)
79+
|| setweight(to_tsvector('english'::regconfig, COALESCE(description, ''::text)), 'C')
80+
) STORED,
81+
82+
-- Work-mode dropdown filter, derived from remote flag + metadata + location.
83+
work_mode text GENERATED ALWAYS AS (
84+
CASE
85+
WHEN (remote IS TRUE) THEN 'remote'::text
86+
WHEN ((metadata ->> 'is_remote'::text) = 'true'::text) THEN 'remote'::text
87+
WHEN ((metadata ->> 'workplace_type'::text) ~~* 'remote%'::text) THEN 'remote'::text
88+
WHEN ((metadata ->> 'workplace_type'::text) ~~* 'hybrid%'::text) THEN 'hybrid'::text
89+
WHEN ((metadata ->> 'workplace_type'::text) ~~* 'in%office%'::text) THEN 'onsite'::text
90+
WHEN ((metadata ->> 'workplace_type'::text) ~~* 'in%person%'::text) THEN 'onsite'::text
91+
WHEN (location ~~* '%remote%'::text) THEN 'remote'::text
92+
WHEN (location ~~* '%hybrid%'::text) THEN 'hybrid'::text
93+
WHEN (location ~~* '%anywhere%'::text) THEN 'remote'::text
94+
WHEN (location <> ''::text) THEN 'onsite'::text
95+
ELSE ''::text
96+
END
97+
) STORED,
98+
99+
-- Employment-type dropdown filter, normalized from title + raw employment_type.
100+
employment_type_norm text GENERATED ALWAYS AS (
101+
CASE
102+
WHEN (title ~* '\mintern(s|ship|ships)?\M'::text) THEN 'internship'::text
103+
WHEN (employment_type ~* '\mintern(s|ship|ships)?\M'::text) THEN 'internship'::text
104+
WHEN (employment_type ~~* '%contract%'::text) THEN 'contract'::text
105+
WHEN (employment_type ~~* '%contractor%'::text) THEN 'contract'::text
106+
WHEN ((employment_type ~~* '%temporary%'::text) OR (employment_type ~~* '%temp '::text)) THEN 'temporary'::text
107+
WHEN ((employment_type ~~* '%part%time%'::text) OR (employment_type ~~* '%parttime%'::text)) THEN 'parttime'::text
108+
WHEN ((employment_type ~~* '%full%time%'::text) OR (employment_type ~~* '%fulltime%'::text)) THEN 'fulltime'::text
109+
WHEN (employment_type = 'FullTime'::text) THEN 'fulltime'::text
110+
WHEN (employment_type = 'PartTime'::text) THEN 'parttime'::text
111+
ELSE ''::text
112+
END
113+
) STORED
114+
);
115+
116+
-- ---- unique key (the upserts depend on this) -------------------------------
117+
-- on_conflict="source,job_id" in CachedJobsStore.upsert_postings targets this.
118+
DO $$
119+
BEGIN
120+
IF NOT EXISTS (
121+
SELECT 1 FROM pg_constraint WHERE conname = 'cached_jobs_source_job_id_unique'
122+
) THEN
123+
ALTER TABLE public.cached_jobs
124+
ADD CONSTRAINT cached_jobs_source_job_id_unique UNIQUE (source, job_id);
125+
END IF;
126+
END$$;
127+
128+
-- ---- indexes (verbatim from pg_get_indexdef; HNSW lives in pgvector.sql) ----
129+
-- Lexical: the GIN on search_tsv is the hard prerequisite for the search RPC.
130+
CREATE INDEX IF NOT EXISTS cached_jobs_search_idx
131+
ON public.cached_jobs USING gin (search_tsv);
132+
-- Fuzzy title/company matching (pg_trgm).
133+
CREATE INDEX IF NOT EXISTS cached_jobs_title_trgm_idx
134+
ON public.cached_jobs USING gin (title gin_trgm_ops);
135+
CREATE INDEX IF NOT EXISTS cached_jobs_company_trgm_idx
136+
ON public.cached_jobs USING gin (company gin_trgm_ops);
137+
-- Filter dropdowns (partial — only live, non-empty rows).
138+
CREATE INDEX IF NOT EXISTS cached_jobs_work_mode_idx
139+
ON public.cached_jobs USING btree (work_mode)
140+
WHERE ((removed_at IS NULL) AND (work_mode <> ''::text));
141+
CREATE INDEX IF NOT EXISTS cached_jobs_employment_type_idx
142+
ON public.cached_jobs USING btree (employment_type_norm)
143+
WHERE ((removed_at IS NULL) AND (employment_type_norm <> ''::text));
144+
CREATE INDEX IF NOT EXISTS cached_jobs_remote_idx
145+
ON public.cached_jobs USING btree (remote)
146+
WHERE ((removed_at IS NULL) AND (remote = true));
147+
CREATE INDEX IF NOT EXISTS cached_jobs_source_idx
148+
ON public.cached_jobs USING btree (source)
149+
WHERE (removed_at IS NULL);
150+
-- Recency sort.
151+
CREATE INDEX IF NOT EXISTS cached_jobs_posted_at_idx
152+
ON public.cached_jobs USING btree (posted_at DESC NULLS LAST)
153+
WHERE (removed_at IS NULL);
154+
-- Refresh worker keep-alive scan (source, last_seen_at).
155+
CREATE INDEX IF NOT EXISTS cached_jobs_refresh_idx
156+
ON public.cached_jobs USING btree (source, last_seen_at);
157+
158+
-- ---- RLS (enabled, no policies — service-role-only) ------------------------
159+
ALTER TABLE public.cached_jobs ENABLE ROW LEVEL SECURITY;

0 commit comments

Comments
 (0)