Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .impeccable/hook.cache.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"version":1,"sessions":{"70c96a73-49a6-4422-a310-d0294a45dc49":{"updatedAt":1782977742215,"files":{"C:\\Users\\joshs\\.copilot\\repos\\copilot-worktrees\\Database\\bigsimmo-bookish-barnacle\\supabase\\functions\\indexing-v3-agent\\index.ts":{"editCount":1,"findings":[]}}}}}
30 changes: 30 additions & 0 deletions supabase/functions/indexing-v3-agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,8 @@ type ChunkSectionSource = {
content: string;
};

type AgentJobStatus = "pending" | "completed" | "failed" | "needs_enrichment_artifacts";

const GENERATED_BY = "indexing-v3-agent";
const AGENT_SECRET = Deno.env.get("INDEXING_V3_AGENT_SECRET") ?? Deno.env.get("CRON_SECRET") ?? "";
const EXPECTED_EMBED_DIM = 1536;
Expand Down Expand Up @@ -1874,6 +1876,26 @@ async function needsVisualArtifacts(job: ClaimedJob): Promise<boolean> {
return shouldRunVisualArtifacts(row);
}

async function updateAgentJobStatus(
job: ClaimedJob,
status: AgentJobStatus,
error: string | null = null,
nextRunAt: string | null = null,
): Promise<void> {
const rows = await sql<Array<{ ok: boolean }>>`
select *
from public.update_indexing_v3_agent_job_status(
Comment thread
BigSimmo marked this conversation as resolved.
${job.document_id}::uuid,
${status}::text,
${error}::text,
${nextRunAt}::timestamptz
)
`;
if (!rows[0]?.ok) {
throw new Error(`Failed to update indexing_v3_agent_jobs status to ${status} for document ${job.document_id}`);
}
}

function logCompletionGate(job: ClaimedJob, gate: CompletionGate): void {
console.log(
JSON.stringify({
Expand Down Expand Up @@ -1921,6 +1943,12 @@ async function deferJob(job: ClaimedJob, gate: CompletionGate): Promise<void> {
updated_at = now()
where id = ${job.document_id}::uuid
`;
await updateAgentJobStatus(
job,
decision.status === "needs_enrichment_artifacts" ? "needs_enrichment_artifacts" : "pending",
null,
decision.status === "needs_enrichment_artifacts" ? null : decision.next_run_at,
);
}

async function completeJob(job: ClaimedJob): Promise<void> {
Expand All @@ -1943,6 +1971,7 @@ async function completeJob(job: ClaimedJob): Promise<void> {
})}`,
);
}
await updateAgentJobStatus(job, "completed");
}

async function markJobFailure(job: ClaimedJob, message: string): Promise<boolean> {
Expand Down Expand Up @@ -1971,6 +2000,7 @@ async function markJobFailure(job: ClaimedJob, message: string): Promise<boolean
updated_at = now()
where id = ${job.document_id}::uuid
`;
await updateAgentJobStatus(job, shouldRetry ? "pending" : "failed", message, nextRunAt);
return shouldRetry;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-- Fix #12: Add explanatory comment to claim_ingestion_jobs explaining the
-- dual-lock (FOR UPDATE OF j, d SKIP LOCKED) pattern. No behaviour change.

comment on function public.claim_ingestion_jobs(text, integer, integer) is
'Claims up to p_limit pending/failed ingestion jobs for the given worker_id.
Uses "FOR UPDATE OF j, d SKIP LOCKED" to lock both the ingestion_job row (j)
and the parent document row (d) in a single CTE scan. Locking the document
prevents two concurrent workers from racing on the same document even when
separate ingestion jobs reference it (e.g. a retry and a re-queue arriving
simultaneously). SKIP LOCKED ensures a busy document is silently bypassed
rather than causing a block, giving other workers fair access.';
27 changes: 27 additions & 0 deletions supabase/migrations/20260702110000_drop_redundant_indexes.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
-- Fix #9: Drop redundant indexes.
--
-- 1. documents_owner_hash_idx (owner_id, content_hash) is a plain non-unique
-- index whose column set is a strict subset of the UNIQUE partial index
-- documents_owner_content_hash_unique_idx (owner_id, content_hash WHERE
-- content_hash IS NOT NULL). The unique index is used for duplicate detection
-- (ON CONFLICT) and also satisfies all equality lookups on (owner_id,
-- content_hash). The plain index adds write overhead with no read benefit.
--
-- NOTE: DROP INDEX CONCURRENTLY cannot run inside a transaction block.
-- Supabase migrations are wrapped in a transaction by default, which means
-- CONCURRENTLY is not available here. We use a plain DROP INDEX instead;
-- the table is small relative to write load and this is a one-time maintenance
-- operation. If you prefer zero-impact removal, run this statement manually in
-- the Supabase SQL editor outside a transaction:
-- DROP INDEX CONCURRENTLY IF EXISTS public.documents_owner_hash_idx;

drop index if exists public.documents_owner_hash_idx;

-- 2. ingestion_jobs_claim_idx covers (status, next_run_at, created_at) WHERE
-- status IN ('pending','processing'). The superset index
-- ingestion_jobs_status_next_run_idx covers the same columns with WHERE
-- status IN ('pending','processing','failed'). PostgreSQL can use the
-- superset index for any query the subset index would satisfy, so the subset
-- index is fully redundant once the superset exists.

drop index if exists public.ingestion_jobs_claim_idx;
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
-- Fix #6: Add retention policy for rag_retrieval_logs and document audit_logs intent.
--
-- rag_queries already has a purge cron (20260629100000). rag_retrieval_logs has
-- no TTL. We add a matching cron to purge rows older than 90 days.
-- audit_logs is intentionally kept indefinitely (compliance requirement); add a
-- comment so this is self-documenting and not mistaken for an oversight.

comment on table public.audit_logs is
'Append-only audit trail. Rows are retained indefinitely for compliance.
Writes are best-effort (fire-and-forget) from the application layer; a write
failure is swallowed and does not affect the calling request.
Do NOT add an automatic purge to this table without a compliance review.';

comment on table public.rag_retrieval_logs is
'Per-request retrieval telemetry. Rows older than 90 days are purged nightly
by the pg_cron job "purge-rag-retrieval-logs". Adjust the retention window
by changing the interval in that cron job definition.';

-- Register the nightly purge cron job.
-- cron.schedule is idempotent on the job name; re-running this migration is safe.
select cron.schedule(
'purge-rag-retrieval-logs',
'0 3 * * *', -- 03:00 UTC daily
$$
delete from public.rag_retrieval_logs
where created_at < now() - interval '90 days';
$$
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
-- Fix #3: Add foreign key from storage_cleanup_jobs.document_id to documents(id).
--
-- The column was declared as "uuid" with no referential constraint, meaning
-- orphaned rows (pointing to deleted documents) would accumulate silently.
-- We first delete any orphaned rows, then add ON DELETE SET NULL so future
-- document deletions leave the cleanup job record in place (the worker should
-- still attempt storage cleanup for any paths recorded before deletion).

-- Step 1: remove orphans (document_id is non-null but no matching document exists).
delete from public.storage_cleanup_jobs
where document_id is not null
and not exists (
select 1 from public.documents d where d.id = storage_cleanup_jobs.document_id
);
Comment thread
BigSimmo marked this conversation as resolved.

-- Step 2: add the FK constraint.
alter table public.storage_cleanup_jobs
add constraint storage_cleanup_jobs_document_id_fkey
foreign key (document_id)
references public.documents(id)
Comment thread
BigSimmo marked this conversation as resolved.
on delete set null;
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
-- Fix #2: Remove duplicate/incomplete reset_document_index definition.
--
-- schema.sql contained two definitions of reset_document_index. The first
-- (lines 1075-1091) did NOT delete from document_index_units. The second
-- (later in the file) does delete from document_index_units and is the
-- authoritative live version. Because CREATE OR REPLACE is last-write-wins,
-- the second definition is what the database actually runs. This migration
-- is a no-op CREATE OR REPLACE of the correct complete definition, making
-- migration history authoritative and preventing any future schema replay
-- from accidentally deploying the incomplete version first.

create or replace function public.reset_document_index(p_document_id uuid)
returns void
language plpgsql
set search_path = public, extensions, pg_temp
as $$
begin
perform set_config('statement_timeout', '180000', true);
-- document_index_units must be deleted first (references document_chunks via FK).
delete from public.document_index_units where document_id = p_document_id;
delete from public.document_memory_cards where document_id = p_document_id;
delete from public.document_sections where document_id = p_document_id;
delete from public.document_table_facts where document_id = p_document_id;
delete from public.document_embedding_fields where document_id = p_document_id;
delete from public.document_index_quality where document_id = p_document_id;
delete from public.document_chunks where document_id = p_document_id;
delete from public.document_images where document_id = p_document_id;
delete from public.document_pages where document_id = p_document_id;
end;
$$;

-- Validation: confirm the function exists with the correct signature.
do $$
begin
if to_regprocedure('public.reset_document_index(uuid)') is null then
raise exception 'reset_document_index(uuid) not found after migration';
end if;
end;
$$;
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
-- Fix #5: Add covering index for RLS correlated subquery on documents.
--
-- Several tables (document_chunks, document_sections, document_memory_cards,
-- document_index_units, etc.) have RLS policies of the form:
--
-- EXISTS (SELECT 1 FROM documents WHERE id = document_id AND owner_id = auth.uid())
--
-- The existing documents_owner_idx covers (owner_id) only, so PostgreSQL must
-- re-fetch the heap to confirm id = document_id. A composite index on
-- (owner_id, id) allows an index-only scan, eliminating the heap fetch.
-- The index is small (two uuid columns) and benefits every authenticated read
-- on the child tables.
--
-- NOTE: CONCURRENTLY cannot run inside a transaction.
-- If you need a zero-lock creation on a loaded system, run this statement
-- manually outside a transaction:
-- CREATE INDEX CONCURRENTLY IF NOT EXISTS documents_owner_id_covering_idx
-- ON public.documents(owner_id, id);

create index if not exists documents_owner_id_covering_idx
on public.documents(owner_id, id);
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
-- Fix #8: Replace hardcoded project URL in invoke_indexing_v3_agent with a
-- GUC-based setting, so the function works across all environments (staging,
-- production, local) without a code change.
--
-- We store the base URL in a database-level GUC (app.indexing_v3_agent_base_url).
-- current_setting('app.indexing_v3_agent_base_url', true) returns NULL if the
-- GUC is not set, so the function retains the current project URL as its fallback,
-- meaning this change is fully backwards-compatible.

-- Set the default base URL for the current (production) project.
-- This value must be changed for staging/dev environments via:
-- ALTER DATABASE postgres SET app.indexing_v3_agent_base_url = '...';
alter database postgres
set app.indexing_v3_agent_base_url = 'https://sjrfecxgysukkwxsowpy.supabase.co';

create or replace function public.invoke_indexing_v3_agent(p_limit integer default 1)
returns bigint
language plpgsql
security definer
set search_path = public, extensions, vault, pg_temp
as $$
declare
v_request_id bigint;
v_secret text;
v_base_url text;
begin
select decrypted_secret
into v_secret
from vault.decrypted_secrets
where name = 'indexing_v3_agent_secret'
limit 1;

if nullif(v_secret, '') is null then
raise exception 'indexing_v3_agent_secret is missing from Supabase Vault';
end if;

-- Prefer the GUC; fall back to the hardcoded production URL so that
-- existing deployments that have not yet set the GUC continue to work.
v_base_url := coalesce(
nullif(current_setting('app.indexing_v3_agent_base_url', true), ''),
'https://sjrfecxgysukkwxsowpy.supabase.co'
);

select net.http_post(
url := v_base_url || '/functions/v1/indexing-v3-agent?limit='
|| greatest(1, least(coalesce(p_limit, 1), 10))::text,
headers := jsonb_build_object(
'Content-Type', 'application/json',
'x-indexing-agent-secret', v_secret
),
body := jsonb_build_object('source', 'pg_cron', 'worker', 'v3-indexing-worker', 'ts', now()),
timeout_milliseconds := 60000
) into v_request_id;

return v_request_id;
end;
$$;
Loading
Loading