Merge remote-tracking branch 'origin/staging' into codex/chat-background-run-notice-20261106
Independent Staging Quality Gate / validate-python (push) Failing after 6m53s
Independent Staging Quality Gate / validate-frontend (push) Failing after 7m6s
Independent Staging Quality Gate / publish (push) Skipped

This commit is contained in:
2026-11-06 00:15:45 +08:00
3 changed files with 40 additions and 32 deletions
@@ -173,6 +173,7 @@ export interface PersonalReportJobDataClient {
rpc(
functionName:
| "claim_personal_report_job"
| "claim_personal_report_job_for_types"
| "heartbeat_personal_report_job"
| "complete_personal_report_job"
| "fail_personal_report_job"
@@ -679,12 +680,17 @@ export function createPersonalReportJobService(
const workerId = requireWorkerId(input.workerId);
const leaseSeconds = requireInteger(input.leaseSeconds ?? 60, "leaseSeconds", 5, 3600);
const jobId = optionalUuid(input.jobId, "jobId");
const reportTypes = input.reportTypes && input.reportTypes.length > 0 ? [...input.reportTypes] : null;
const { data, error } = await client.rpc("claim_personal_report_job", {
// A lane limited to some report types (report queue, 2026-11-05) uses its own
// function, so the original claim signature is never overloaded.
const reportTypes = input.reportTypes && input.reportTypes.length > 0 && jobId === null ? [...input.reportTypes] : null;
const { data, error } = reportTypes ? await client.rpc("claim_personal_report_job_for_types", {
p_worker_id: workerId,
p_lease_seconds: leaseSeconds,
p_report_types: reportTypes,
}) : await client.rpc("claim_personal_report_job", {
p_worker_id: workerId,
p_lease_seconds: leaseSeconds,
p_job_id: jobId,
p_report_types: reportTypes,
});
if (error) throw storageError(error);
const row = firstRpcRow(data);
@@ -7,10 +7,12 @@ begin;
-- annual_full) already goes through personal_report_jobs, one job at a time
-- per server. The worker now runs a few lanes at once; a lane may take only
-- some report types, so a short annual or synastry report is never stuck
-- behind long personal reports. claim_personal_report_job gains an optional
-- p_report_types filter (null = any type, the old behaviour). The old
-- three-argument signature is dropped so positional calls with three
-- arguments resolve to the new function and keep working.
-- behind long personal reports. Such a lane claims with
-- claim_personal_report_job_for_types, a separate function with a report-type
-- filter; claim_personal_report_job is left exactly as it was. (A first version
-- overloaded claim_personal_report_job with a fourth, defaulted argument; a
-- database that also replays 20260814040000 then held both signatures and a
-- three-argument call was ambiguous. A new name cannot collide.)
--
-- personal_report_queue_view gives one job's place in the queue: the types of
-- the jobs that will be claimed before it, the jobs running now with how long
@@ -19,13 +21,10 @@ begin;
-- (auth.uid()) and returns report types and times, never another user's
-- identifiers. The report route calls it with the user's own client.
drop function if exists public.claim_personal_report_job(text, integer, uuid);
create or replace function public.claim_personal_report_job(
create or replace function public.claim_personal_report_job_for_types(
p_worker_id text,
p_lease_seconds integer default 60,
p_job_id uuid default null,
p_report_types text[] default null
p_lease_seconds integer,
p_report_types text[]
)
returns setof public.personal_report_jobs
language plpgsql
@@ -40,23 +39,22 @@ begin
if p_lease_seconds not between 5 and 3600 then
raise exception using errcode = '22023', message = 'personal_report_job_lease_seconds_invalid';
end if;
if p_report_types is null or cardinality(p_report_types) = 0 then
raise exception using errcode = '22023', message = 'personal_report_job_report_types_invalid';
end if;
select job.id
into v_job_id
from public.personal_report_jobs as job
where (p_job_id is null or job.id = p_job_id)
and job.status in ('queued', 'retrying')
where job.status in ('queued', 'retrying')
and (job.next_attempt_at is null or job.next_attempt_at <= clock_timestamp())
and job.attempt_count < job.max_attempts
and (
p_report_types is null
or exists (
select 1
from public.personal_reports as report
where report.user_id = job.user_id
and report.request_id = job.request_id
and report.report_type = any(p_report_types)
)
and exists (
select 1
from public.personal_reports as report
where report.user_id = job.user_id
and report.request_id = job.request_id
and report.report_type = any(p_report_types)
)
order by job.next_attempt_at nulls first, job.created_at, job.id
for update skip locked
@@ -86,8 +84,8 @@ begin
end;
$$;
revoke all on function public.claim_personal_report_job(text, integer, uuid, text[]) from public, anon, authenticated;
grant execute on function public.claim_personal_report_job(text, integer, uuid, text[]) to service_role;
revoke all on function public.claim_personal_report_job_for_types(text, integer, text[]) from public, anon, authenticated;
grant execute on function public.claim_personal_report_job_for_types(text, integer, text[]) to service_role;
create or replace function public.personal_report_queue_view(p_job_id uuid)
returns jsonb
+10 -6
View File
@@ -99,17 +99,21 @@ test("a lane claims only its report types", async () => {
const service = createSupabasePersonalReportJobService(client as never);
assert.equal(await service.claimLease({ workerId: "w:lane-2", reportTypes: ["annual_full", "synastry_full"] }), null);
assert.equal(await service.claimLease({ workerId: "w:lane-1" }), null);
assert.deepEqual(calls.map((call) => call.p_report_types), [["annual_full", "synastry_full"], null]);
assert.deepEqual(calls.map((call) => [call.name, call.p_report_types ?? null]), [
["claim_personal_report_job_for_types", ["annual_full", "synastry_full"]],
["claim_personal_report_job", null],
]);
});
test("migration: the claim filter, the dropped three-argument claim, and an owner-only queue view", () => {
test("migration: a separate typed claim (the original claim is untouched) and an owner-only queue view", () => {
const sql = read("../supabase/migrations/20261105010000_personal_report_job_lanes.sql");
assert.match(sql, /drop function if exists public\.claim_personal_report_job\(text, integer, uuid\);/);
assert.match(sql, /p_report_types text\[\] default null/);
// Overloading claim_personal_report_job made three-argument calls ambiguous in the staging DB gate.
assert.doesNotMatch(sql, /function public\.claim_personal_report_job\(/);
assert.match(sql, /create or replace function public\.claim_personal_report_job_for_types\(\s*p_worker_id text,\s*p_lease_seconds integer,\s*p_report_types text\[\]\s*\)/);
assert.match(sql, /report\.report_type = any\(p_report_types\)/);
assert.match(sql, /for update skip locked/);
assert.match(sql, /revoke all on function public\.claim_personal_report_job\(text, integer, uuid, text\[\]\) from public, anon, authenticated/);
assert.match(sql, /grant execute on function public\.claim_personal_report_job\(text, integer, uuid, text\[\]\) to service_role/);
assert.match(sql, /revoke all on function public\.claim_personal_report_job_for_types\(text, integer, text\[\]\) from public, anon, authenticated/);
assert.match(sql, /grant execute on function public\.claim_personal_report_job_for_types\(text, integer, text\[\]\) to service_role/);
// The view answers only the job's own user; it can count others' jobs (security definer).
assert.match(sql, /owner uuid := \(select auth\.uid\(\)\);/);
assert.match(sql, /where job\.id = p_job_id and job\.user_id = owner/);