-
Notifications
You must be signed in to change notification settings - Fork 10
fix(research): one capture survives two simultaneous identical requests (chat#1912 row 7) #801
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
9e33b52
8b433f9
77ad599
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| import { describe, it, expect } from "vitest"; | ||
| import { pickCanonicalSnapshot } from "@/lib/research/playcounts/pickCanonicalSnapshot"; | ||
| import type { Tables } from "@/types/database.types"; | ||
|
|
||
| const row = (over: Partial<Tables<"playcount_snapshots">>) => | ||
| ({ | ||
| id: "b", | ||
| created_at: "2026-07-30T12:00:00.000Z", | ||
| ...over, | ||
| }) as Tables<"playcount_snapshots">; | ||
|
|
||
| describe("pickCanonicalSnapshot", () => { | ||
| // chat#1912 row 7. Two simultaneous identical requests both insert before | ||
| // either can see the other, so the pre-check cannot help. Both then re-read | ||
| // and must independently agree on the same winner, or they both scrape. | ||
| it("picks the earliest claim", () => { | ||
| const winner = row({ id: "late", created_at: "2026-07-30T12:00:05.000Z" }); | ||
| const early = row({ id: "early", created_at: "2026-07-30T12:00:01.000Z" }); | ||
|
|
||
| expect(pickCanonicalSnapshot([winner, early])?.id).toBe("early"); | ||
| }); | ||
|
|
||
| // Same-millisecond inserts are the whole point of this function, so the | ||
| // tie-break must be total and identical for every caller. | ||
| it("breaks an exact timestamp tie by id, deterministically", () => { | ||
| const a = row({ id: "aaa", created_at: "2026-07-30T12:00:00.000Z" }); | ||
| const b = row({ id: "bbb", created_at: "2026-07-30T12:00:00.000Z" }); | ||
|
|
||
| expect(pickCanonicalSnapshot([a, b])?.id).toBe("aaa"); | ||
| // Order of the input must not change the answer, or the two racers disagree. | ||
| expect(pickCanonicalSnapshot([b, a])?.id).toBe("aaa"); | ||
| }); | ||
|
|
||
| it("returns the only claim when there is no race", () => { | ||
| expect(pickCanonicalSnapshot([row({ id: "solo" })])?.id).toBe("solo"); | ||
| }); | ||
|
|
||
| it("returns null for no claims", () => { | ||
| expect(pickCanonicalSnapshot([])).toBeNull(); | ||
| }); | ||
|
|
||
| it("ignores claims with no timestamp rather than crowning them", () => { | ||
| const dated = row({ id: "dated", created_at: "2026-07-30T12:00:09.000Z" }); | ||
| const undatedRow = row({ id: "aaa", created_at: null }); | ||
|
|
||
| expect(pickCanonicalSnapshot([undatedRow, dated])?.id).toBe("dated"); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,6 +5,10 @@ import { insertPlaycountSnapshot } from "@/lib/supabase/playcount_snapshots/inse | |
| import { playcountSnapshotWorkflow } from "@/app/workflows/playcountSnapshotWorkflow"; | ||
| import { findReusableSnapshot } from "@/lib/research/playcounts/findReusableSnapshot"; | ||
| import { buildReusedSnapshotResult } from "@/lib/research/playcounts/buildReusedSnapshotResult"; | ||
| import { pickCanonicalSnapshot } from "@/lib/research/playcounts/pickCanonicalSnapshot"; | ||
| import { sameScope } from "@/lib/research/playcounts/sameScope"; | ||
| import { isReusableSnapshotState } from "@/lib/research/playcounts/isReusableSnapshotState"; | ||
| import { deletePlaycountSnapshot } from "@/lib/supabase/playcount_snapshots/deletePlaycountSnapshot"; | ||
| import { getMonthlySpendUsd } from "@/lib/research/playcounts/getMonthlySpendUsd"; | ||
| import { CreateSnapshotBody } from "@/lib/research/playcounts/validateCreateSnapshotRequest"; | ||
|
|
||
|
|
@@ -87,6 +91,43 @@ export async function createSnapshot(params: { | |
| estimated_cost_usd: estimatedCostUsd, | ||
| }); | ||
|
|
||
| // The insert is a claim, not yet a scrape. Two simultaneous identical | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Custom agent: Enforce Clear Code Style and Maintainability Practices This file has grown to roughly 142 lines, exceeding the 100-line limit set by Rule 3. The new post-insert reconciliation logic filters DB claims, picks the canonical winner, deletes the caller's row if it lost, and re-reads survivors in case the winner withdrew. This is a separable sub-concern that can be extracted into a helper like Prompt for AI agents |
||
| // requests both get here before either can see the other, so re-read and | ||
| // let the earliest claim win — the loser withdraws its row and hands back | ||
| // the winner's rather than starting a second capture (chat#1912 row 7). | ||
| const claims = await selectPlaycountSnapshots({ | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: A transient read failure is treated as proof that no competing claim exists, so concurrent callers can both pass reconciliation and start duplicate captures. The post-insert reconciliation should fail closed or retry when the claims query cannot be completed. Prompt for AI agents |
||
| account: params.accountId, | ||
| createdAfter: reuseCutoff.toISOString(), | ||
| }); | ||
| const canonical = pickCanonicalSnapshot( | ||
| claims.filter( | ||
| candidate => | ||
| candidate.id === row.id || | ||
| (sameScope(candidate, albumIds, params.body.platforms, params.body.schedule) && | ||
| isReusableSnapshotState(candidate.state)), | ||
| ), | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| ); | ||
| if (canonical && canonical.id !== row.id) { | ||
| await deletePlaycountSnapshot(row.id); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: If withdrawing the loser fails, this await propagates a 500 while the queued row remains in the database. Because queued rows are reusable, subsequent requests can be handed an id whose workflow never started; deletion failure should retry or mark the loser non-reusable before the request is completed. Prompt for AI agents |
||
|
|
||
| // The claim that looked canonical from here may itself have been a loser | ||
| // that withdrew, which would hand this caller a snapshot id that no longer | ||
| // exists. Re-read after withdrawing so the id returned is one still | ||
| // standing (observed with 8 concurrent requests during verification). | ||
| const remaining = await selectPlaycountSnapshots({ | ||
| account: params.accountId, | ||
| createdAfter: reuseCutoff.toISOString(), | ||
| }); | ||
| const survivor = pickCanonicalSnapshot( | ||
| remaining.filter( | ||
| candidate => | ||
| sameScope(candidate, albumIds, params.body.platforms, params.body.schedule) && | ||
| isReusableSnapshotState(candidate.state), | ||
| ), | ||
| ); | ||
| return buildReusedSnapshotResult(survivor ?? canonical); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: A loser can still receive a snapshot id that is no longer usable: Prompt for AI agents |
||
| } | ||
|
|
||
| await start(playcountSnapshotWorkflow, [row.id]); | ||
|
|
||
| return { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| /** A capture that failed has nothing to hand back; every other live state does. */ | ||
| const REUSABLE_STATES = new Set(["queued", "running", "done"]); | ||
|
|
||
| /** | ||
| * Whether a capture in this state can satisfy a new request for the same scope. | ||
| * | ||
| * Shared by the reuse pre-check and the post-insert race reconcile. They must | ||
| * agree: if the reconcile recognised only `queued`, a winner that advanced to | ||
| * `running` between the two reads would be invisible and both requests would | ||
| * scrape — the exact race this is meant to close (chat#1912 rows 4 and 7). | ||
| */ | ||
| export function isReusableSnapshotState(state: string | null): boolean { | ||
| return REUSABLE_STATES.has(state ?? ""); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| import type { Tables } from "@/types/database.types"; | ||
|
|
||
| /** | ||
| * Chooses which of several competing claims on the same scope is the real one. | ||
| * | ||
| * Two simultaneous identical requests both insert before either can see the | ||
| * other, so the reuse pre-check cannot help (chat#1912 row 7). Both then re-read | ||
| * and call this, and because the ordering is total and independent of input | ||
| * order they reach the same answer without coordinating: earliest claim wins, | ||
| * ties broken by id. | ||
| * | ||
| * Rows with no timestamp cannot be ordered and are never crowned. | ||
| */ | ||
| export function pickCanonicalSnapshot( | ||
| claims: Tables<"playcount_snapshots">[], | ||
| ): Tables<"playcount_snapshots"> | null { | ||
| const dated = claims.filter(row => !!row.created_at); | ||
| if (dated.length === 0) return null; | ||
|
|
||
| return dated.reduce((winner, row) => { | ||
| const a = new Date(row.created_at!).getTime(); | ||
| const b = new Date(winner.created_at!).getTime(); | ||
| if (a !== b) return a < b ? row : winner; | ||
| return row.id < winner.id ? row : winner; | ||
| }); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| import type { Tables } from "@/types/database.types"; | ||
|
|
||
| const sameSet = (a: string[], b: string[]) => | ||
| a.length === b.length && [...a].sort().join(" ") === [...b].sort().join(" "); | ||
|
|
||
| /** | ||
| * Whether a snapshot row asks the same question as a request: same album set | ||
| * (order-insensitive), same platforms, same schedule. | ||
| * | ||
| * Shared by the reuse pre-check and the post-insert race reconcile so both | ||
| * agree on what "identical" means (chat#1912 rows 4 and 7). | ||
| */ | ||
| export function sameScope( | ||
| row: Tables<"playcount_snapshots">, | ||
| albumIds: string[], | ||
| platforms: string[], | ||
| schedule: string, | ||
| ): boolean { | ||
| if (row.schedule !== schedule) return false; | ||
| if (!sameSet(row.album_ids ?? [], albumIds)) return false; | ||
| return sameSet(row.platforms ?? [], platforms); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| import supabase from "../serverClient"; | ||
|
|
||
| /** | ||
| * Delete a snapshot job row. | ||
| * | ||
| * Used to withdraw a losing claim when two simultaneous identical requests | ||
| * both inserted before either could see the other, so one capture is left | ||
| * rather than two (chat#1912 row 7). | ||
| * | ||
| * @param id - The snapshot id | ||
| * @throws Error if the delete fails | ||
| */ | ||
| export async function deletePlaycountSnapshot(id: string): Promise<void> { | ||
| const { error } = await supabase.from("playcount_snapshots").delete().eq("id", id); | ||
|
|
||
| if (error) { | ||
| throw new Error(`Failed to delete playcount snapshot: ${error.message}`); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: Missing assertions in the "never hands back a claim that has itself withdrawn" test: the test should verify that
startwas NOT called (line 357) and that the result includesreused: true(line 358). The simpler loser test at ~line 221 covers both of these — this test should follow the same pattern to fully validate the loser-withdraw behavior.Prompt for AI agents