From 83d940c43aba5bf708c42792f5c7389d6590d1a1 Mon Sep 17 00:00:00 2001 From: Jerred Shepherd Date: Sun, 20 Sep 2026 12:55:56 -0700 Subject: [PATCH 1/3] fix(scout-for-lol): route a match by its own id, not by who still tracks it Why: the V2 match-context resolver raised a non-retryable failure when no currently tracked account played on the match's platform. Every per-match Activity resolves that context, and archiving is the first of them, so the failure landed before anything else could run. A failing child breaks the discovery loop, so the run stopped, the cursor never advanced past that match, and every later match queued behind it. One account deregistering after a match was observed could stall post-match processing indefinitely, and reconciliation restarts hit the same failure every time. It also fired during exactly those restarts, which is where the deliberate roster precondition was designed to stand down. What: the route now comes from the match id's own prefix. The resolver was already deriving that platform in order to search the roster for an account whose region mapped back to it, then handing that region to a fetcher which mapped it forward again through a function that accepts either spelling. The round trip was ceremony, and the failure guarded a step that needs no guard. The fetcher's parameter is widened to accept either spelling, which is convergence rather than invention: the sibling timeline fetcher directly below it already took both, so the match fetcher was the odd one out. The region spelling was also strictly narrower, not merely redundant. ME1 is a platform no Region maps to, so a region-derived route could never express a match played there at any roster size. This is not a weakening of the tracked-account precondition, which is separate, deliberate and untouched: it lives in the observation commit and has its own documented escape for reconciliation restarts. Removing the wedge does not relocate the stall. The cursor advance is the last Activity of the per-match workflow and the report render runs in a fan-out child started after it, so a render failure cannot hold the cursor. This one could, because it ran in the first Activity. Also removes the context's whole-roster field, which no V2 consumer reads. It was verified unread rather than assumed, and a live field of the same name in the v1 post-match path, populated by v1's own callers, is untouched. The match fetch now happens before the roster query, which makes their independence visible rather than merely true. Verification: bunx turbo run build typecheck test lint across all seven Scout filters, forced, with the incremental caches cleared first -- 44 tasks successful, 482 backend test files. Nothing pinned this before: no test named the failure and the module had no test file, for a non-retryable failure in the first Activity of every match. The new test covers both the reported case and the platform no Region can express. Mutation proof: against the previous resolver both cases fail with "No tracked account plays on KR" and "No tracked account plays on ME1" respectively; the file was restored and verified byte-identical afterwards. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013qRdG6fzJr3THzYzAJwTZB --- .../tasks/postmatch/match-data-fetcher.ts | 14 ++- .../v2/match-context.integration.test.ts | 89 +++++++++++++++++++ .../backend/src/temporal/v2/match-context.ts | 58 +++--------- .../v2/match-observation.integration.test.ts | 1 - ...ion-delivery-postmatch.integration.test.ts | 1 - ...ostmatch-mint-audience.integration.test.ts | 1 - .../v2/recovery-conflict.integration.test.ts | 1 - .../temporal/v2/recovery.integration.test.ts | 1 - .../settlement-checkpoint.integration.test.ts | 1 - 9 files changed, 114 insertions(+), 53 deletions(-) create mode 100644 packages/scout-for-lol/packages/backend/src/temporal/v2/match-context.integration.test.ts diff --git a/packages/scout-for-lol/packages/backend/src/league/tasks/postmatch/match-data-fetcher.ts b/packages/scout-for-lol/packages/backend/src/league/tasks/postmatch/match-data-fetcher.ts index 9d771fbc71..8003b2e742 100644 --- a/packages/scout-for-lol/packages/backend/src/league/tasks/postmatch/match-data-fetcher.ts +++ b/packages/scout-for-lol/packages/backend/src/league/tasks/postmatch/match-data-fetcher.ts @@ -28,19 +28,27 @@ const logger = createLogger("match-data-fetcher"); * Fetch match data from Riot API * * Validates the response against our schema to ensure type safety and catch API changes. + * + * Takes either spelling of the route because the only thing it does with the + * value is hand it to `platformToRegionalRoute`, which parses which one it was + * given. A caller holding a match id already knows the platform — it is the + * id's own prefix — and should pass that rather than finding an account whose + * region maps to it. The region spelling is strictly narrower: `ME1` is a + * platform no `Region` maps to, so a region-derived route cannot express a + * match played on it at all. */ export async function fetchMatchData( matchId: MatchId, - playerRegion: Region, + route: PlatformRoute | Region, ): Promise { - const regionalRoute = platformToRegionalRoute(playerRegion); + const regionalRoute = platformToRegionalRoute(route); const match = await callRiotOrUndefined( { source: "match-data", schema: RawMatchSchema, schemaLabel: "match", - context: { matchId, region: playerRegion }, + context: { matchId, region: route }, onValidationFailure: { kind: "save-to-s3", assetType: "match", diff --git a/packages/scout-for-lol/packages/backend/src/temporal/v2/match-context.integration.test.ts b/packages/scout-for-lol/packages/backend/src/temporal/v2/match-context.integration.test.ts new file mode 100644 index 0000000000..2d8079f91d --- /dev/null +++ b/packages/scout-for-lol/packages/backend/src/temporal/v2/match-context.integration.test.ts @@ -0,0 +1,89 @@ +import { expect, test, vi } from "vitest"; +import { RiotMatchIdSchema } from "@scout-for-lol/domain/identity/brands.ts"; +import { RawMatchSchema } from "@scout-for-lol/data"; +import { + createTestDatabase, + testDatabaseModule, +} from "#src/testing/test-database.ts"; + +/** + * Which route a match is fetched through, when no tracked account is on it. + * + * The route belongs to the match, not to the roster: a Riot match id carries + * its platform as its own prefix. The resolver used to derive that platform + * and then search the live accounts for one whose region mapped back to it, + * failing non-retryably when none did. That made every per-match Activity + * depend on the present state of the roster to process a match Scout had + * already observed, and it failed at the FIRST Activity, so the run stopped + * before the cursor could advance and every later match queued behind it. + * + * Both cases below fail under that shape. The first is the reported one: an + * account deregisters and the platform it covered goes with it. The second + * could never work at all, because `ME1` is a platform no `Region` maps to, + * so no roster however complete could have produced a route for it. + */ + +const { prisma } = createTestDatabase("scout-v2-match-context-route"); + +const RIFT_PATH = `${import.meta.dir}/../../../../../testdata/rift.json`; + +const riot = vi.hoisted((): { routes: string[]; response: unknown } => ({ + routes: [], + response: undefined, +})); + +vi.mock("#src/database/index.ts", async () => await testDatabaseModule(prisma)); + +// No guilds, therefore no tracked accounts, which is the condition under test. +vi.mock("#src/discord/utils/guild-membership.ts", () => ({ + getActiveServerIds: () => [], +})); + +vi.mock("#src/league/api/api.ts", () => ({ + riotClient: { + match: { + get: (_matchId: string, regionalRoute: string) => { + riot.routes.push(regionalRoute); + return Promise.resolve(riot.response); + }, + timeline: () => Promise.resolve(undefined), + }, + }, +})); + +const { resolveScoutV2MatchContext } = + await import("#src/temporal/v2/match-context.ts"); + +async function riftPayload(): Promise { + const raw: unknown = JSON.parse(await Bun.file(RIFT_PATH).text()); + return RawMatchSchema.parse(raw); +} + +test("resolves a match on a platform no tracked account plays on", async () => { + riot.routes.length = 0; + riot.response = await riftPayload(); + + const context = await resolveScoutV2MatchContext( + RiotMatchIdSchema.parse("KR_9701"), + ); + + // The platform came from the id, so the fetch is routed without consulting + // anyone's registration. + expect(riot.routes).toEqual(["ASIA"]); + // Nobody tracked is in it, which is a match with no audience rather than an + // error: the pipeline still archives it and still advances its cursor. + expect(context.trackedPlayers).toEqual([]); + expect(context.riotMatchId).toBe("KR_9701"); +}); + +test("resolves a match on a platform no Region can express", async () => { + riot.routes.length = 0; + riot.response = await riftPayload(); + + const context = await resolveScoutV2MatchContext( + RiotMatchIdSchema.parse("ME1_9702"), + ); + + expect(riot.routes).toEqual(["EUROPE"]); + expect(context.trackedPlayers).toEqual([]); +}); diff --git a/packages/scout-for-lol/packages/backend/src/temporal/v2/match-context.ts b/packages/scout-for-lol/packages/backend/src/temporal/v2/match-context.ts index ee149ff0ab..fffc22c699 100644 --- a/packages/scout-for-lol/packages/backend/src/temporal/v2/match-context.ts +++ b/packages/scout-for-lol/packages/backend/src/temporal/v2/match-context.ts @@ -1,11 +1,8 @@ -import { ApplicationFailure } from "@temporalio/common"; import { MatchIdSchema, - regionToPlatformRoute, type MatchId, type PlayerConfigEntry, type RawMatch, - type Region, } from "@scout-for-lol/data"; import type { RiotMatchId } from "@scout-for-lol/domain/identity/brands.ts"; import { getAccountsWithState, prisma } from "#src/database/index.ts"; @@ -33,60 +30,33 @@ export type ScoutV2MatchContext = { readonly matchData: RawMatch; /** Every tracked account that played in this match. */ readonly trackedPlayers: PlayerConfigEntry[]; - /** Every tracked account, which is what the v1 pipeline's steps take. */ - readonly allPlayerConfigs: PlayerConfigEntry[]; }; -/** - * The region to fetch a match through, taken from a tracked account on the - * match's own platform. - * - * Riot routes a MatchV5 read by regional route, and `platformToRegionalRoute` - * derives that from either spelling — but `fetchMatchData` takes Scout's - * `Region` label, and the match id carries the platform. Resolving one from a - * tracked account rather than inverting the mapping here keeps a single source - * of truth for the correspondence, and makes the failure honest: Scout - * archives matches of accounts it tracks, so a match on a platform where it - * tracks none is not a transient fault. - */ -function regionForPlatform( - accounts: readonly PlayerConfigEntry[], - riotMatchId: RiotMatchId, -): Region { - const platform = platformRouteOf(riotMatchId); - const region = accounts - .map((config) => config.league.leagueAccount.region) - .find((candidate) => regionToPlatformRoute(candidate) === platform); - if (region === undefined) { - throw ApplicationFailure.nonRetryable( - `No tracked account plays on ${platform}, so ${riotMatchId} cannot be fetched`, - "MissingDomainRecord", - ); - } - return region; -} - export async function resolveScoutV2MatchContext( riotMatchId: RiotMatchId, ): Promise { const matchId = MatchIdSchema.parse(riotMatchId); - const accounts = await getAccountsWithState(prisma, getActiveServerIds()); - const allPlayerConfigs = accounts.map((account) => account.config); + // The route comes from the match id's own prefix, so fetching does not + // depend on the roster at all. It previously did: the resolver derived this + // same platform, searched the live accounts for one whose region mapped back + // to it, and passed that region to a fetcher that mapped it forward again. + // That round trip raised a non-retryable failure whenever no tracked account + // remained on the match's platform, which stalled the whole per-match + // pipeline at its first Activity for a match Scout had already observed. + // `platformRouteOf` is also the only derivation `MatchObservationRecordSchema` + // accepts, so the id is the source of truth for this by construction. const matchData = requireAuthoritativeMatchData( riotMatchId, - await fetchMatchData( - matchId, - regionForPlatform(allPlayerConfigs, riotMatchId), - ), + await fetchMatchData(matchId, platformRouteOf(riotMatchId)), ); + const accounts = await getAccountsWithState(prisma, getActiveServerIds()); const participants = new Set(matchData.metadata.participants); return { matchId, riotMatchId, matchData, - trackedPlayers: allPlayerConfigs.filter((config) => - participants.has(config.league.leagueAccount.puuid), - ), - allPlayerConfigs, + trackedPlayers: accounts + .map((account) => account.config) + .filter((config) => participants.has(config.league.leagueAccount.puuid)), }; } diff --git a/packages/scout-for-lol/packages/backend/src/temporal/v2/match-observation.integration.test.ts b/packages/scout-for-lol/packages/backend/src/temporal/v2/match-observation.integration.test.ts index ed9b78b61d..4090c9b59f 100644 --- a/packages/scout-for-lol/packages/backend/src/temporal/v2/match-observation.integration.test.ts +++ b/packages/scout-for-lol/packages/backend/src/temporal/v2/match-observation.integration.test.ts @@ -40,7 +40,6 @@ vi.mock("#src/temporal/v2/match-context.ts", () => ({ alias: puuid.slice(0, 4), league: { leagueAccount: { puuid } }, })), - allPlayerConfigs: [], }), })); diff --git a/packages/scout-for-lol/packages/backend/src/temporal/v2/notification-delivery-postmatch.integration.test.ts b/packages/scout-for-lol/packages/backend/src/temporal/v2/notification-delivery-postmatch.integration.test.ts index 1668c94a9b..4401464ea6 100644 --- a/packages/scout-for-lol/packages/backend/src/temporal/v2/notification-delivery-postmatch.integration.test.ts +++ b/packages/scout-for-lol/packages/backend/src/temporal/v2/notification-delivery-postmatch.integration.test.ts @@ -85,7 +85,6 @@ vi.mock("#src/temporal/v2/match-context.ts", () => ({ riotMatchId, matchData: { info: { queueId: 420 } }, trackedPlayers: [], - allPlayerConfigs: [], }); }, })); diff --git a/packages/scout-for-lol/packages/backend/src/temporal/v2/postmatch-mint-audience.integration.test.ts b/packages/scout-for-lol/packages/backend/src/temporal/v2/postmatch-mint-audience.integration.test.ts index 867bb3115d..a5a9ea8dee 100644 --- a/packages/scout-for-lol/packages/backend/src/temporal/v2/postmatch-mint-audience.integration.test.ts +++ b/packages/scout-for-lol/packages/backend/src/temporal/v2/postmatch-mint-audience.integration.test.ts @@ -55,7 +55,6 @@ vi.mock("#src/temporal/v2/match-context.ts", () => ({ league: { leagueAccount: { puuid: "s".repeat(78) } }, }, ], - allPlayerConfigs: [], }), })); diff --git a/packages/scout-for-lol/packages/backend/src/temporal/v2/recovery-conflict.integration.test.ts b/packages/scout-for-lol/packages/backend/src/temporal/v2/recovery-conflict.integration.test.ts index bfef6db106..5364004218 100644 --- a/packages/scout-for-lol/packages/backend/src/temporal/v2/recovery-conflict.integration.test.ts +++ b/packages/scout-for-lol/packages/backend/src/temporal/v2/recovery-conflict.integration.test.ts @@ -45,7 +45,6 @@ vi.mock("#src/temporal/v2/match-context.ts", () => ({ riotMatchId, matchData: { info: { gameCreation: riot.gameCreation } }, trackedPlayers: [], - allPlayerConfigs: [], }), })); diff --git a/packages/scout-for-lol/packages/backend/src/temporal/v2/recovery.integration.test.ts b/packages/scout-for-lol/packages/backend/src/temporal/v2/recovery.integration.test.ts index 37f7cbd96c..6f693c692a 100644 --- a/packages/scout-for-lol/packages/backend/src/temporal/v2/recovery.integration.test.ts +++ b/packages/scout-for-lol/packages/backend/src/temporal/v2/recovery.integration.test.ts @@ -61,7 +61,6 @@ vi.mock("#src/temporal/v2/match-context.ts", () => ({ riotMatchId, matchData: { info: { gameCreation: riot.gameCreation } }, trackedPlayers: [], - allPlayerConfigs: [], }), })); diff --git a/packages/scout-for-lol/packages/backend/src/temporal/v2/settlement-checkpoint.integration.test.ts b/packages/scout-for-lol/packages/backend/src/temporal/v2/settlement-checkpoint.integration.test.ts index 041ed3275b..076155c491 100644 --- a/packages/scout-for-lol/packages/backend/src/temporal/v2/settlement-checkpoint.integration.test.ts +++ b/packages/scout-for-lol/packages/backend/src/temporal/v2/settlement-checkpoint.integration.test.ts @@ -57,7 +57,6 @@ vi.mock("#src/temporal/v2/match-context.ts", () => ({ info: { gameCreation: Date.parse("2026-09-18T09:00:00.000Z") }, }, trackedPlayers: [], - allPlayerConfigs: [], }), })); From 1c50529be652694bd703b9749627a173576c1468 Mon Sep 17 00:00:00 2001 From: Jerred Shepherd Date: Sun, 20 Sep 2026 16:53:51 -0700 Subject: [PATCH 2/3] test(scout-for-lol): disconnect the match-context test database client Why: the new suite created a dedicated test database client and never released it. It runs inside the large backend suite, so a pool left open for the worker's lifetime draws down a shared connection budget, and the exhaustion that eventually causes would surface in some unrelated suite rather than here. What: release it in an afterAll hook, as the other V2 database integration suites do. Thirteen of the fourteen that create a client already did; this was the only exception. Verification: the suite passes with the hook in place, 2 of 2. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013qRdG6fzJr3THzYzAJwTZB --- .../src/temporal/v2/match-context.integration.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/scout-for-lol/packages/backend/src/temporal/v2/match-context.integration.test.ts b/packages/scout-for-lol/packages/backend/src/temporal/v2/match-context.integration.test.ts index 2d8079f91d..3069381b78 100644 --- a/packages/scout-for-lol/packages/backend/src/temporal/v2/match-context.integration.test.ts +++ b/packages/scout-for-lol/packages/backend/src/temporal/v2/match-context.integration.test.ts @@ -1,4 +1,4 @@ -import { expect, test, vi } from "vitest"; +import { afterAll, expect, test, vi } from "vitest"; import { RiotMatchIdSchema } from "@scout-for-lol/domain/identity/brands.ts"; import { RawMatchSchema } from "@scout-for-lol/data"; import { @@ -87,3 +87,7 @@ test("resolves a match on a platform no Region can express", async () => { expect(riot.routes).toEqual(["EUROPE"]); expect(context.trackedPlayers).toEqual([]); }); + +afterAll(async () => { + await prisma.$disconnect(); +}); From 216bdca56f29e3971e56e741f3488a5a52c62050 Mon Sep 17 00:00:00 2001 From: Jerred Shepherd Date: Mon, 21 Sep 2026 17:16:26 -0700 Subject: [PATCH 3/3] fix(scout-for-lol): read the post-commit roster from the observation Why: routing a match by its own id removed a non-retryable failure that stalled the per-match pipeline at its first Activity. That made previously unreachable code reachable, and the review gate found the consequence. The gate named a mechanism that cannot produce it. It reported that a resumed match whose last participant deregistered mints an owed intent and then fails to render. Deregistration hard-deletes the account row, and the mint's audience comes from an account-by-puuid join, so after a full deregistration the minter mints nothing and no render is ever owed. The real path is fan-out, which plans notification children from STANDING drivable intent rows: an intent minted before the deregistration and left pending by an outage is re-driven later, and that render has nobody to render for. The gate was right about the outcome and wrong about the mechanism, and the difference matters, because a fix built to its stated mechanism would have been built for a case that cannot occur. The cause is not that the roster changed. The live roster is narrowed by a read of the Discord gateway's guild cache, which returns no filter at all in a process that owns no gateway. The mint runs on the realtime queue and the render on background, so they are different worker pools and can hold different cache state. The roster therefore depends on WHICH PROCESS ASKS, not only on when. That helper's own comment says its fallback means "more work, never the wrong work". True for the polling filter it was written for, where widening only costs effort. Exactly false here, where the filter NARROWS a match's roster, so a gateway-owning worker does less work and the wrong work. A justification that holds for the use it was written for, applied to a use it does not cover, is how the comment became the part everyone trusted. The general rule: a fallback that fails open is safe where the set decides how much work to do, and unsafe where the set defines who a durable fact is about. An audience, a settlement roster and an attested count are all the second kind. Failing open in one process and closed in another is not a performance trade, it is two answers to one question. This is an instance of a class. The same filter narrows sets at prematch-reads.ts:155, at prematch-context.ts:142 -- where the shared-derivation comment argues against precisely the drift its own process-dependent derivation permits -- and at temporal-match-ingestion.ts:30. Those are recorded separately and are NOT fixed here. A reader who fixes this one and believes the class is closed is worse off than one who knows three more exist. What: two resolvers, split on the observation commit rather than two fields on one type, because the distinction is temporal and a separately named function tells the reader which side of the commit they are on. The existing resolver keeps the present roster for the three consumers that run BEFORE the observation commits: the snapshot's own producer, the discovery-source precondition, and the archive's alias metadata, which runs before any snapshot exists and is receipt-gated on resume. A new observed resolver serves those that run after: settlement, progression, the render, and the mint. It carries what the observation recorded and the configs those identities hydrate to, which are two facts rather than two rosters: the second is shorter exactly when a registration has been hard-deleted since. Hydration is keyed by identity with no guild filter. A match with no observation fails loudly, because that is a caller reaching for a snapshot before it is written. An observation that recorded nobody returns empty, because the route fix made that case reachable. Those two must not collapse. Progression is the worst of the three consumers: its attested account count is written into a receipt, so a narrowed roster became durable evidence stating a wrong number that no later reader could distinguish from the truth. The mint was already correct and is the precedent; its comment argued this case in full, and the render now agrees with reasoning that was already written down. Not fixed here, deliberately: an intent minted before a deregistration and left pending is re-driven by fan-out and still fails, because the account row is gone and no observation-time config is reconstructible from what remains. Preserving one would mean keeping a Discord identity for a user who asked to be removed, which is not an acceptable fix. Retiring an intent whose audience no longer exists is the right answer and is a notification-lifecycle change with its own evidence. Verification: bunx turbo run build typecheck test lint over the seven Scout workspaces, run five times by the implementing lane and once more independently. The backend package, the only one this change touches, passed 4562 of 4562 in every run without exception, and both new tests passed in every run. Three runs were otherwise green; two carried a single 5000ms test timeout apiece, in the temporal package and the root package, neither of which this diff touches. Those two suites were re-run in isolation on an uncontended machine and both passed, 18 tasks of 18. The flaky pair share one signature, a bare 5000ms timeout in a suite that builds a heavy fixture, where the backend package sets 20000; that is recorded separately as a tooling defect. One pre-existing failure is unrelated and unfixed: the data package's generate step reports that the committed Dare paraphrase schema no longer matches its generator. It is a drift check rather than a build error, it executed and exited non-zero on its own rather than being skipped, it masked no sibling task, and it fails identically on a tree this branch does not touch. Duplication ratchet passed with the baseline unmodified and no new clones. Dependency check clean. Layers verified: source and focused local checks only. CI, published artifacts, ArgoCD reconciliation and runtime are unverified. Four mutations, each broken, observed failing, restored and re-asserted green: the observed resolver rebuilt from the live roster; a missing observation returning empty instead of failing loudly, where the other cases still passed so the two empty cases are provably distinguished; an observation recording nobody made to throw; and the mint using hydrated configs instead of the recorded identities. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013qRdG6fzJr3THzYzAJwTZB --- .../packages/backend/src/database/index.ts | 43 +++++ .../v2/match-context.integration.test.ts | 126 ++++++++++++++- .../backend/src/temporal/v2/match-context.ts | 149 ++++++++++++++++-- .../backend/src/temporal/v2/match-effects.ts | 32 ++-- ...notification-delivery-announcement.test.ts | 4 +- .../v2/notification-delivery-boundary.test.ts | 8 +- ...ion-delivery-postmatch.integration.test.ts | 2 +- .../notification-render.integration.test.ts | 2 +- .../postmatch-notification.test.ts | 6 +- .../v2/notification/postmatch-notification.ts | 15 +- ...ostmatch-mint-audience.integration.test.ts | 53 ++++--- .../settlement-checkpoint.integration.test.ts | 2 +- 12 files changed, 377 insertions(+), 65 deletions(-) diff --git a/packages/scout-for-lol/packages/backend/src/database/index.ts b/packages/scout-for-lol/packages/backend/src/database/index.ts index d10fe253cc..8bea090596 100644 --- a/packages/scout-for-lol/packages/backend/src/database/index.ts +++ b/packages/scout-for-lol/packages/backend/src/database/index.ts @@ -183,6 +183,49 @@ export async function getChannelsSubscribedToPlayers( } } +/** + * The configs behind a FIXED set of PUUIDs, with no live-guild filter. + * + * `getAccountsWithState` narrows by `getActiveServerIds()`, which is a read of + * the Discord gateway's guild cache and therefore answers differently in + * different processes. Its own doc calls the unfiltered fallback "more work, + * never the wrong work", and for the polling filter it was written for that is + * true: widening the set only costs effort. It is exactly false wherever a + * match's already-recorded roster is being rebuilt, because there the filter + * NARROWS, so a gateway-owning worker does less work and the wrong work. + * + * This lookup takes the PUUIDs as given and answers the same way in every + * process. One entry per `Account` row, as `getAccountsWithState` also returns, + * so a PUUID registered in several guilds yields several configs; ordered so + * two runs over the same rows agree. + */ +export async function getAccountConfigsByPuuids( + puuids: readonly LeaguePuuid[], + prismaClient: Pick = prisma, +): Promise { + if (puuids.length === 0) return []; + const accounts = await prismaClient.account.findMany({ + where: { puuid: { in: [...puuids] } }, + include: { player: true }, + orderBy: [{ puuid: "asc" }, { id: "asc" }], + }); + return accounts.map((account) => ({ + alias: account.player.alias, + league: { + leagueAccount: LeagueAccountSchema.parse({ + puuid: account.puuid, + region: account.region, + }), + }, + discordAccount: { + id: + account.player.discordId === null + ? undefined + : DiscordAccountIdSchema.parse(account.player.discordId), + }, + })); +} + /** * Get all player accounts with their runtime state for polling. * Includes lastMatchTime and lastCheckedAt to determine polling intervals. diff --git a/packages/scout-for-lol/packages/backend/src/temporal/v2/match-context.integration.test.ts b/packages/scout-for-lol/packages/backend/src/temporal/v2/match-context.integration.test.ts index 3069381b78..b83ad372e1 100644 --- a/packages/scout-for-lol/packages/backend/src/temporal/v2/match-context.integration.test.ts +++ b/packages/scout-for-lol/packages/backend/src/temporal/v2/match-context.integration.test.ts @@ -51,14 +51,52 @@ vi.mock("#src/league/api/api.ts", () => ({ }, })); -const { resolveScoutV2MatchContext } = +const { resolveScoutV2MatchContext, resolveScoutV2ObservedMatchContext } = await import("#src/temporal/v2/match-context.ts"); +const { observeMatch } = + await import("#src/database/durable/observation-repository.ts"); +const { recordTrackedAccounts } = + await import("#src/database/durable/tracked-account-repository.ts"); +const { IsoInstantSchema } = + await import("@scout-for-lol/domain/identity/brands.ts"); +const { LeaguePuuidSchema } = + await import("@scout-for-lol/domain/identity/league-account.ts"); +const { platformRouteOf } = + await import("#src/durable/match/match-identity.ts"); +const { DiscordAccountIdSchema, DiscordGuildIdSchema } = + await import("@scout-for-lol/domain/identity/discord.ts"); async function riftPayload(): Promise { const raw: unknown = JSON.parse(await Bun.file(RIFT_PATH).text()); return RawMatchSchema.parse(raw); } +/** An observation for `matchId`, tracking `puuids`. */ +async function observe(matchId: string, puuids: string[]): Promise { + const parsed = RiotMatchIdSchema.parse(matchId); + await observeMatch(prisma, { + matchId: parsed, + platformRoute: platformRouteOf(parsed), + policy: "FULL", + deliveryMode: "live", + owner: { kind: "temporal-v2" }, + promotion: null, + gameCreatedAt: IsoInstantSchema.parse("2026-09-18T09:00:00.000Z"), + observedAt: IsoInstantSchema.parse("2026-09-18T09:40:00.000Z"), + artifacts: { match: null, timeline: null }, + }); + await recordTrackedAccounts( + prisma, + puuids.map((puuid) => ({ + matchId: parsed, + puuid: LeaguePuuidSchema.parse(puuid), + playerId: null, + accountId: null, + cursorAdvancedAt: null, + })), + ); +} + test("resolves a match on a platform no tracked account plays on", async () => { riot.routes.length = 0; riot.response = await riftPayload(); @@ -88,6 +126,92 @@ test("resolves a match on a platform no Region can express", async () => { expect(context.trackedPlayers).toEqual([]); }); +/** + * The roster the observation recorded, against a live roster that hides it. + * + * `getActiveServerIds` is mocked empty above, which is exactly what a worker + * whose Discord guild cache does not hold this guild sees — the `Account` rows + * are all still there, and the live resolver still answers with nobody. The + * observed resolver must answer with the recorded account regardless, because + * the same filter narrows differently in different processes and a report that + * was owed must not depend on which worker picked the Activity up. + */ +test("observed roster survives a live roster narrowed to nothing", async () => { + riot.response = await riftPayload(); + const puuid = LeaguePuuidSchema.parse("a".repeat(78)); + const player = await prisma.player.create({ + data: { + alias: "observed-player", + discordId: DiscordAccountIdSchema.parse("1".repeat(18)), + // A guild this worker's gateway cache does not hold. + serverId: DiscordGuildIdSchema.parse("3".repeat(18)), + creatorDiscordId: DiscordAccountIdSchema.parse("2".repeat(18)), + createdTime: new Date(), + updatedTime: new Date(), + }, + }); + await prisma.account.create({ + data: { + alias: "observed-account", + puuid, + region: "AMERICA_NORTH", + playerId: player.id, + serverId: player.serverId, + creatorDiscordId: player.creatorDiscordId, + createdTime: new Date(), + updatedTime: new Date(), + }, + }); + await observe("NA1_9801", [puuid]); + + const live = await resolveScoutV2MatchContext( + RiotMatchIdSchema.parse("NA1_9801"), + ); + const observed = await resolveScoutV2ObservedMatchContext( + RiotMatchIdSchema.parse("NA1_9801"), + ); + + // The two genuinely disagree, which is the whole defect. + expect(live.trackedPlayers).toEqual([]); + expect(observed.observedPuuids).toEqual([puuid]); + expect(observed.trackedPlayers.map((config) => config.alias)).toEqual([ + "observed-player", + ]); + expect( + observed.trackedPlayers.map((config) => config.league.leagueAccount.region), + ).toEqual(["AMERICA_NORTH"]); +}); + +/** + * An observation that recorded NOBODY is legitimate and must return empty. + * Routing a match by its own id made exactly this reachable, so collapsing it + * into the failure below would reintroduce the stall that change removed. + */ +test("observed roster is empty for a match observed for nobody", async () => { + riot.response = await riftPayload(); + await observe("NA1_9802", []); + + const observed = await resolveScoutV2ObservedMatchContext( + RiotMatchIdSchema.parse("NA1_9802"), + ); + + expect(observed.observedPuuids).toEqual([]); + expect(observed.trackedPlayers).toEqual([]); +}); + +/** + * No observation at all is a caller reaching for the snapshot before it is + * written — an ordering error no retry fixes — and must not be answered with + * the same empty roster a match observed for nobody gets. + */ +test("observed roster fails loudly when the match has no observation", async () => { + riot.response = await riftPayload(); + + await expect( + resolveScoutV2ObservedMatchContext(RiotMatchIdSchema.parse("NA1_9803")), + ).rejects.toThrow(/has no observation/u); +}); + afterAll(async () => { await prisma.$disconnect(); }); diff --git a/packages/scout-for-lol/packages/backend/src/temporal/v2/match-context.ts b/packages/scout-for-lol/packages/backend/src/temporal/v2/match-context.ts index fffc22c699..6e8713f4b1 100644 --- a/packages/scout-for-lol/packages/backend/src/temporal/v2/match-context.ts +++ b/packages/scout-for-lol/packages/backend/src/temporal/v2/match-context.ts @@ -1,3 +1,4 @@ +import { ApplicationFailure } from "@temporalio/common"; import { MatchIdSchema, type MatchId, @@ -5,7 +6,14 @@ import { type RawMatch, } from "@scout-for-lol/data"; import type { RiotMatchId } from "@scout-for-lol/domain/identity/brands.ts"; -import { getAccountsWithState, prisma } from "#src/database/index.ts"; +import type { LeaguePuuid } from "@scout-for-lol/domain/identity/league-account.ts"; +import { + getAccountConfigsByPuuids, + getAccountsWithState, + prisma, +} from "#src/database/index.ts"; +import { getObservation } from "#src/database/durable/observation-repository.ts"; +import { listTrackedAccounts } from "#src/database/durable/tracked-account-repository.ts"; import { getActiveServerIds } from "#src/discord/utils/guild-membership.ts"; import { platformRouteOf } from "#src/durable/match/match-identity.ts"; import { fetchMatchData } from "#src/league/tasks/postmatch/match-data-fetcher.ts"; @@ -22,33 +30,81 @@ import { requireAuthoritativeMatchData } from "#src/league/tasks/postmatch/tempo * worker after a crash, which is the only situation the V2 core exists to * survive. The alternative — carrying the payload through the Workflow — is * forbidden outright: a MatchV5 payload in a Workflow history is kept forever. + * + * ## Which roster, and why there are two resolvers + * + * `resolveScoutV2MatchContext` answers with the roster as it stands NOW. + * {@link resolveScoutV2ObservedMatchContext} answers with the roster the + * match's own observation RECORDED. The split is temporal, which is why it is + * two functions rather than two fields: the present-roster resolver serves the + * Activities that run BEFORE the observation commits, and the observed one + * serves those that run after. A field would let the next reader pick whichever + * name read better at the call site; a separately named function makes the + * choice deliberate every time, and names which side of the commit it is on. */ export type ScoutV2MatchContext = { /** The loose v1 match id the task services take. */ readonly matchId: MatchId; readonly riotMatchId: RiotMatchId; readonly matchData: RawMatch; - /** Every tracked account that played in this match. */ + /** + * The tracked accounts in this match, as the roster stands NOW. + * + * Only correct for a caller that runs before the observation commits. Every + * later consumer wants {@link ScoutV2ObservedMatchContext.trackedPlayers}. + */ readonly trackedPlayers: PlayerConfigEntry[]; }; -export async function resolveScoutV2MatchContext( +/** + * The same payload, over the roster the observation recorded for this match. + * + * `observedPuuids` and `trackedPlayers` are two facts rather than two rosters: + * the first is what the observation recorded, the second is those same PUUIDs' + * configs as they can still be read. The second is SHORTER exactly when a + * registration has been deleted since — deregistration hard-deletes the + * `Account` row, and a config's alias, region and Discord identity live only + * there, so such a PUUID has no config to rebuild from anything the database + * still holds. + */ +export type ScoutV2ObservedMatchContext = { + readonly matchId: MatchId; + readonly riotMatchId: RiotMatchId; + readonly matchData: RawMatch; + /** Every PUUID the observation recorded as tracked in this match. */ + readonly observedPuuids: LeaguePuuid[]; + /** Those PUUIDs' configs, for the ones still registered. */ + readonly trackedPlayers: PlayerConfigEntry[]; +}; + +/** + * The match payload, routed by the match id's own prefix. + * + * The route does not depend on the roster at all. It previously did: the + * resolver derived this same platform, searched the live accounts for one whose + * region mapped back to it, and passed that region to a fetcher that mapped it + * forward again. That round trip raised a non-retryable failure whenever no + * tracked account remained on the match's platform, which stalled the whole + * per-match pipeline at its first Activity for a match Scout had already + * observed. `platformRouteOf` is also the only derivation + * `MatchObservationRecordSchema` accepts, so the id is the source of truth for + * this by construction. + */ +async function authoritativeMatchData( + matchId: MatchId, riotMatchId: RiotMatchId, -): Promise { - const matchId = MatchIdSchema.parse(riotMatchId); - // The route comes from the match id's own prefix, so fetching does not - // depend on the roster at all. It previously did: the resolver derived this - // same platform, searched the live accounts for one whose region mapped back - // to it, and passed that region to a fetcher that mapped it forward again. - // That round trip raised a non-retryable failure whenever no tracked account - // remained on the match's platform, which stalled the whole per-match - // pipeline at its first Activity for a match Scout had already observed. - // `platformRouteOf` is also the only derivation `MatchObservationRecordSchema` - // accepts, so the id is the source of truth for this by construction. - const matchData = requireAuthoritativeMatchData( +): Promise { + return requireAuthoritativeMatchData( riotMatchId, await fetchMatchData(matchId, platformRouteOf(riotMatchId)), ); +} + +export async function resolveScoutV2MatchContext( + riotMatchId: RiotMatchId, +): Promise { + const matchId = MatchIdSchema.parse(riotMatchId); + const matchData = await authoritativeMatchData(matchId, riotMatchId); const accounts = await getAccountsWithState(prisma, getActiveServerIds()); const participants = new Set(matchData.metadata.participants); return { @@ -60,3 +116,66 @@ export async function resolveScoutV2MatchContext( .filter((config) => participants.has(config.league.leagueAccount.puuid)), }; } + +/** + * The PUUIDs this match's observation recorded as tracked in it. + * + * The two empty answers are different facts and must not collapse. NO + * OBSERVATION is a caller reaching for the snapshot before it is written — + * a programming error about ordering, which no retry fixes — so it fails + * loudly. An observation that recorded NOBODY is legitimate and returns empty: + * routing a match by its own id made a match with no tracked participant + * reachable, and such a match is archived and cursored like any other. + */ +export async function observedTrackedPuuids( + riotMatchId: RiotMatchId, +): Promise { + const observation = await getObservation(prisma, { matchId: riotMatchId }); + if (observation === null) { + throw ApplicationFailure.nonRetryable( + `${riotMatchId} has no observation, so the roster it was observed for cannot be read; this caller runs before the observation commits`, + "MissingDomainRecord", + ); + } + const tracked = await listTrackedAccounts(prisma, { matchId: riotMatchId }); + return tracked.map((account) => account.puuid); +} + +/** + * The context for every Activity that runs AFTER the observation commits. + * + * The roster is read from the durable snapshot rather than rebuilt from who is + * tracked at the moment this Activity happens to run. Rebuilding asks a + * different question, and the two questions diverge for a reason that has + * nothing to do with the match: the live roster is narrowed by + * `getActiveServerIds()`, a read of the Discord gateway's guild cache, which + * answers `undefined` — no filter at all — in a process that owns no gateway. + * Settlement, progression and the mint run on the `realtime` queue and the + * render on `background`, so they are different worker pools and can hold + * different cache state. The live roster therefore depends on WHICH PROCESS + * ASKS, not only on when. + * + * The cost of that was a report silently dropped. The mint reads the snapshot, + * so it mints the intents a match is owed and the Workflow advances the cursor; + * the render rebuilt the roster, got an empty one, and failed with nothing left + * to rediscover the match. Settlement and progression had the same exposure and + * were quieter about it — progression writes the count it saw into its receipt, + * so a shrunken roster becomes durable evidence attesting the wrong number. + */ +export async function resolveScoutV2ObservedMatchContext( + riotMatchId: RiotMatchId, +): Promise { + const matchId = MatchIdSchema.parse(riotMatchId); + const matchData = await authoritativeMatchData(matchId, riotMatchId); + const observedPuuids = await observedTrackedPuuids(riotMatchId); + return { + matchId, + riotMatchId, + matchData, + observedPuuids, + // The client is passed rather than defaulted, as every other read here + // does: a default parameter closes over the database module's own `prisma` + // binding, which an integration test's module mock cannot redirect. + trackedPlayers: await getAccountConfigsByPuuids(observedPuuids, prisma), + }; +} diff --git a/packages/scout-for-lol/packages/backend/src/temporal/v2/match-effects.ts b/packages/scout-for-lol/packages/backend/src/temporal/v2/match-effects.ts index 70ddc57a7f..64b87eae4e 100644 --- a/packages/scout-for-lol/packages/backend/src/temporal/v2/match-effects.ts +++ b/packages/scout-for-lol/packages/backend/src/temporal/v2/match-effects.ts @@ -24,12 +24,11 @@ import { } from "#src/betting/notify/announcement-sink.ts"; import { runGuardedEffectV2 } from "#src/temporal/v2/effect-fence.ts"; import { durableCommitV2 } from "#src/temporal/v2/match-commits.ts"; -import { listTrackedAccounts } from "#src/database/durable/tracked-account-repository.ts"; import { readMatchReceiptEvidenceV2, recordMatchReceiptV2, } from "#src/temporal/v2/match-commits.ts"; -import { resolveScoutV2MatchContext } from "#src/temporal/v2/match-context.ts"; +import { resolveScoutV2ObservedMatchContext } from "#src/temporal/v2/match-context.ts"; import { matchMayAnnounce, mintDareSummaryIntentsV2, @@ -295,8 +294,12 @@ export async function settleMatchMarketsV2(input: { }, apply: async (fence) => { // Resolved inside the guard so a replay whose claim is already complete - // costs no Riot read at all. - const context = await resolveScoutV2MatchContext(input.riotMatchId); + // costs no Riot read at all. The OBSERVED roster, because settlement runs + // after the observation commits: a live rebuild would settle only for the + // accounts whose guild this worker's gateway cache happens to hold. + const context = await resolveScoutV2ObservedMatchContext( + input.riotMatchId, + ); // Settlement is ENTERED even when checkpoints already stand. // // A standing checkpoint used to short-circuit this, on the reasoning @@ -439,7 +442,14 @@ export async function applyMatchProgressionV2(input: { }; }, apply: async (fence) => { - const context = await resolveScoutV2MatchContext(input.riotMatchId); + // The OBSERVED roster. This stage is the one where a live rebuild does + // lasting damage rather than transient: `trackedAccountCount` below is + // written into the receipt, so a roster narrowed by this worker's gateway + // cache would become durable evidence attesting the wrong number, and a + // later reader has no way to tell it from the truth. + const context = await resolveScoutV2ObservedMatchContext( + input.riotMatchId, + ); const evidence = { participantCount: context.matchData.metadata.participants.length, trackedAccountCount: context.trackedPlayers.length, @@ -486,7 +496,6 @@ export async function applyMatchProgressionV2(input: { export async function mintPostmatchNotificationIntentsV2(input: { riotMatchId: RiotMatchId; }): Promise { - const context = await resolveScoutV2MatchContext(input.riotMatchId); // The audience is read from the SNAPSHOT the observation recorded, not // rebuilt from who is tracked now. // @@ -502,12 +511,15 @@ export async function mintPostmatchNotificationIntentsV2(input: { // it much later than the observation; `MatchTrackedAccount` exists to make // the answer durable rather than time-dependent, which is why the // observation writes it in the same call that commits. - const tracked = await listTrackedAccounts(prisma, { - matchId: input.riotMatchId, - }); + // + // This reasoning was always right and was for a long time written only here. + // The resolver now carries it, so the render — which had rebuilt the roster + // and failed on the empty result — agrees with it instead of contradicting + // it, and the PUUID set is derived once rather than beside the payload read. + const context = await resolveScoutV2ObservedMatchContext(input.riotMatchId); const summary = await mintPostmatchIntentsV2(prisma, { matchId: input.riotMatchId, - puuids: tracked.map((account) => account.puuid), + puuids: context.observedPuuids, queue: { queueId: context.matchData.info.queueId, gameMode: context.matchData.info.gameMode, diff --git a/packages/scout-for-lol/packages/backend/src/temporal/v2/notification-delivery-announcement.test.ts b/packages/scout-for-lol/packages/backend/src/temporal/v2/notification-delivery-announcement.test.ts index a19f20c62a..1f914c1cb2 100644 --- a/packages/scout-for-lol/packages/backend/src/temporal/v2/notification-delivery-announcement.test.ts +++ b/packages/scout-for-lol/packages/backend/src/temporal/v2/notification-delivery-announcement.test.ts @@ -29,7 +29,7 @@ const stubs = vi.hoisted(() => ({ afterDareSummaryDeliveredV2: vi.fn(), readAttestedReportArtifactV2: vi.fn(), readAttestedPrematchArtifactV2: vi.fn(), - resolveScoutV2MatchContext: vi.fn(), + resolveScoutV2ObservedMatchContext: vi.fn(), generateMatchReport: vi.fn(), fetchChannelForDelivery: vi.fn(), send: vi.fn(), @@ -66,7 +66,7 @@ vi.mock("#src/temporal/v2/notification/notification-artifact.ts", async () => { }; }); vi.mock("#src/temporal/v2/match-context.ts", () => ({ - resolveScoutV2MatchContext: stubs.resolveScoutV2MatchContext, + resolveScoutV2ObservedMatchContext: stubs.resolveScoutV2ObservedMatchContext, })); vi.mock("#src/league/tasks/postmatch/match-report-generator.ts", () => ({ generateMatchReport: stubs.generateMatchReport, diff --git a/packages/scout-for-lol/packages/backend/src/temporal/v2/notification-delivery-boundary.test.ts b/packages/scout-for-lol/packages/backend/src/temporal/v2/notification-delivery-boundary.test.ts index 40b6e39a2d..297b4a3eea 100644 --- a/packages/scout-for-lol/packages/backend/src/temporal/v2/notification-delivery-boundary.test.ts +++ b/packages/scout-for-lol/packages/backend/src/temporal/v2/notification-delivery-boundary.test.ts @@ -37,7 +37,7 @@ import { const stubs = vi.hoisted(() => ({ requireIntentRecordV2: vi.fn(), - resolveScoutV2MatchContext: vi.fn(), + resolveScoutV2ObservedMatchContext: vi.fn(), generateMatchReport: vi.fn(), generateAiReviewIfEnabled: vi.fn(), readAttestedReportArtifactV2: vi.fn(), @@ -53,7 +53,7 @@ vi.mock("#src/temporal/v2/notification-reads.ts", () => ({ requireIntentRecordV2: stubs.requireIntentRecordV2, })); vi.mock("#src/temporal/v2/match-context.ts", () => ({ - resolveScoutV2MatchContext: stubs.resolveScoutV2MatchContext, + resolveScoutV2ObservedMatchContext: stubs.resolveScoutV2ObservedMatchContext, })); vi.mock("#src/league/tasks/postmatch/match-report-generator.ts", () => ({ generateMatchReport: stubs.generateMatchReport, @@ -438,7 +438,7 @@ describe("the prematch-shaped path", () => { ); expect(stubs.readAttestedReportArtifactV2).not.toHaveBeenCalled(); expect(stubs.generateMatchReport).not.toHaveBeenCalled(); - expect(stubs.resolveScoutV2MatchContext).not.toHaveBeenCalled(); + expect(stubs.resolveScoutV2ObservedMatchContext).not.toHaveBeenCalled(); const sent = SentMessageSchema.parse(stubs.send.mock.calls[0]?.[0]); expect(sent.content).toBe("someone started a game"); }); @@ -471,7 +471,7 @@ describe("what the send assembles", () => { await deliverNotificationV2(attemptRef()); expect(stubs.generateMatchReport).not.toHaveBeenCalled(); - expect(stubs.resolveScoutV2MatchContext).not.toHaveBeenCalled(); + expect(stubs.resolveScoutV2ObservedMatchContext).not.toHaveBeenCalled(); expect(stubs.generateAiReviewIfEnabled).not.toHaveBeenCalled(); }); diff --git a/packages/scout-for-lol/packages/backend/src/temporal/v2/notification-delivery-postmatch.integration.test.ts b/packages/scout-for-lol/packages/backend/src/temporal/v2/notification-delivery-postmatch.integration.test.ts index 4401464ea6..92145afad4 100644 --- a/packages/scout-for-lol/packages/backend/src/temporal/v2/notification-delivery-postmatch.integration.test.ts +++ b/packages/scout-for-lol/packages/backend/src/temporal/v2/notification-delivery-postmatch.integration.test.ts @@ -78,7 +78,7 @@ const stubs = vi.hoisted(() => ({ // read it did is not available here, and its absence would fail the test for // the wrong reason. The fixed delivery never calls it, which is asserted. vi.mock("#src/temporal/v2/match-context.ts", () => ({ - resolveScoutV2MatchContext: (riotMatchId: string) => { + resolveScoutV2ObservedMatchContext: (riotMatchId: string) => { stubs.matchContextReads += 1; return Promise.resolve({ matchId: riotMatchId, diff --git a/packages/scout-for-lol/packages/backend/src/temporal/v2/notification-render.integration.test.ts b/packages/scout-for-lol/packages/backend/src/temporal/v2/notification-render.integration.test.ts index 4e2f16f1a6..c5d255bb76 100644 --- a/packages/scout-for-lol/packages/backend/src/temporal/v2/notification-render.integration.test.ts +++ b/packages/scout-for-lol/packages/backend/src/temporal/v2/notification-render.integration.test.ts @@ -49,7 +49,7 @@ const world = vi.hoisted(() => ({ })); vi.mock("#src/temporal/v2/match-context.ts", () => ({ - resolveScoutV2MatchContext: (riotMatchId: string) => + resolveScoutV2ObservedMatchContext: (riotMatchId: string) => Promise.resolve({ matchId: riotMatchId, riotMatchId, diff --git a/packages/scout-for-lol/packages/backend/src/temporal/v2/notification/postmatch-notification.test.ts b/packages/scout-for-lol/packages/backend/src/temporal/v2/notification/postmatch-notification.test.ts index 99112c657f..e0088ca7a8 100644 --- a/packages/scout-for-lol/packages/backend/src/temporal/v2/notification/postmatch-notification.test.ts +++ b/packages/scout-for-lol/packages/backend/src/temporal/v2/notification/postmatch-notification.test.ts @@ -26,13 +26,13 @@ import { MatchIdSchema } from "@scout-for-lol/data"; */ const stubs = vi.hoisted(() => ({ - resolveScoutV2MatchContext: vi.fn(), + resolveScoutV2ObservedMatchContext: vi.fn(), resolvePostmatchDeliveryChannels: vi.fn(), generateMatchReport: vi.fn(), })); vi.mock("#src/temporal/v2/match-context.ts", () => ({ - resolveScoutV2MatchContext: stubs.resolveScoutV2MatchContext, + resolveScoutV2ObservedMatchContext: stubs.resolveScoutV2ObservedMatchContext, })); vi.mock("#src/league/tasks/notification-filters.ts", () => ({ resolvePostmatchDeliveryChannels: stubs.resolvePostmatchDeliveryChannels, @@ -90,7 +90,7 @@ const GuildIdsSchema = z.object({ targetGuildIds: z.array(z.string()) }); beforeEach(() => { vi.clearAllMocks(); - stubs.resolveScoutV2MatchContext.mockResolvedValue({ + stubs.resolveScoutV2ObservedMatchContext.mockResolvedValue({ matchId: MATCH, riotMatchId: RIOT_MATCH, matchData: { diff --git a/packages/scout-for-lol/packages/backend/src/temporal/v2/notification/postmatch-notification.ts b/packages/scout-for-lol/packages/backend/src/temporal/v2/notification/postmatch-notification.ts index adcdc8824e..968c8be8ec 100644 --- a/packages/scout-for-lol/packages/backend/src/temporal/v2/notification/postmatch-notification.ts +++ b/packages/scout-for-lol/packages/backend/src/temporal/v2/notification/postmatch-notification.ts @@ -17,7 +17,7 @@ import { reportImageAttachmentName, } from "#src/league/tasks/postmatch/match-report-image.ts"; import { resolvePostmatchDeliveryChannels } from "#src/league/tasks/notification-filters.ts"; -import { resolveScoutV2MatchContext } from "#src/temporal/v2/match-context.ts"; +import { resolveScoutV2ObservedMatchContext } from "#src/temporal/v2/match-context.ts"; import type { ScoutV2AttestedReportArtifact } from "#src/temporal/v2/notification/notification-artifact.ts"; import type { ScoutV2ReportComponentsSchema } from "#src/temporal/v2/notification-receipts.ts"; import type { z } from "zod"; @@ -182,7 +182,18 @@ function disassembleReport( export async function renderPostmatchNotificationV2( riotMatchId: RiotMatchId, ): Promise { - const context = await resolveScoutV2MatchContext(riotMatchId); + // The OBSERVED roster, which is the one the minter used to decide this + // report was owed. Rebuilding it here asked a different question and could + // answer it differently for reasons outside the match: the live roster is + // narrowed by the Discord gateway's guild cache, and this Activity runs on + // the `background` queue while the mint runs on `realtime`, so the two are + // different worker pools. An empty rebuild produced no report at all, after + // the cursor had already advanced past the match. + // + // The subscription lookup below still asks who subscribes NOW, which is + // right — a channel that unsubscribed should not receive this. It is only + // the PUUIDs it is keyed by that belong to the past. + const context = await resolveScoutV2ObservedMatchContext(riotMatchId); const audience = await resolvePostmatchDeliveryChannels({ puuids: context.trackedPlayers.map( (player) => player.league.leagueAccount.puuid, diff --git a/packages/scout-for-lol/packages/backend/src/temporal/v2/postmatch-mint-audience.integration.test.ts b/packages/scout-for-lol/packages/backend/src/temporal/v2/postmatch-mint-audience.integration.test.ts index a5a9ea8dee..5b4a91d4d5 100644 --- a/packages/scout-for-lol/packages/backend/src/temporal/v2/postmatch-mint-audience.integration.test.ts +++ b/packages/scout-for-lol/packages/backend/src/temporal/v2/postmatch-mint-audience.integration.test.ts @@ -19,8 +19,14 @@ import { * account deregistered in between answers it by vanishing — the mint succeeds * with fewer channels and the run looks complete. * - * So the match context is made to disagree with the durable snapshot on - * purpose: the context knows one account, the snapshot recorded two. + * The resolver is REAL here and reads this file's own database. It used to be + * mocked, with the snapshot read living in the Activity beside it, so the two + * could be made to disagree from the test. The snapshot read now lives inside + * the resolver, and mocking it would have left this asserting only that the + * Activity passes through whatever the mock said. So the disagreement is built + * where it actually occurs: the snapshot records two accounts and NEITHER has + * an `Account` row, which is what a deregistration leaves behind, and the mint + * must still name both. */ const { prisma } = createTestDatabase("scout-v2-postmatch-mint-audience"); @@ -29,33 +35,26 @@ const MATCH_ID = RiotMatchIdSchema.parse("NA1_9601"); const STILL_TRACKED = LeaguePuuidSchema.parse("s".repeat(78)); const DEREGISTERED = LeaguePuuidSchema.parse("d".repeat(78)); +const RIFT_PATH = `${import.meta.dir}/../../../../../testdata/rift.json`; + const minted = vi.hoisted((): { puuids: string[][] } => ({ puuids: [] })); +const riot = vi.hoisted((): { response: unknown } => ({ response: undefined })); vi.mock("#src/database/index.ts", async () => await testDatabaseModule(prisma)); -vi.mock("#src/temporal/v2/match-context.ts", () => ({ - // Only the account that is STILL tracked; the other has deregistered since - // the match was observed. - resolveScoutV2MatchContext: (riotMatchId: string) => - Promise.resolve({ - matchId: riotMatchId, - riotMatchId, - matchData: { - metadata: { participants: [] }, - info: { - gameCreation: Date.parse("2026-09-18T09:00:00.000Z"), - queueId: 420, - gameMode: "CLASSIC", - gameType: "MATCHED_GAME", - }, - }, - trackedPlayers: [ - { - alias: "still", - league: { leagueAccount: { puuid: "s".repeat(78) } }, - }, - ], - }), +// The resolver imports this module; the observed path never calls it, and +// stubbing it keeps the Discord client out of the test. +vi.mock("#src/discord/utils/guild-membership.ts", () => ({ + getActiveServerIds: () => [], +})); + +vi.mock("#src/league/api/api.ts", () => ({ + riotClient: { + match: { + get: () => Promise.resolve(riot.response), + timeline: () => Promise.resolve(undefined), + }, + }, })); vi.mock("#src/temporal/v2/notification/match-intents.ts", async () => { @@ -90,6 +89,7 @@ const { recordTrackedAccounts } = await import("#src/database/durable/tracked-account-repository.ts"); const { observeMatch } = await import("#src/database/durable/observation-repository.ts"); +const { RawMatchSchema } = await import("@scout-for-lol/data"); afterAll(async () => { await prisma.$disconnect(); @@ -97,6 +97,9 @@ afterAll(async () => { beforeEach(async () => { minted.puuids.length = 0; + riot.response = RawMatchSchema.parse( + JSON.parse(await Bun.file(RIFT_PATH).text()), + ); await prisma.matchTrackedAccount.deleteMany({}); await prisma.matchObservation.deleteMany({}); }); diff --git a/packages/scout-for-lol/packages/backend/src/temporal/v2/settlement-checkpoint.integration.test.ts b/packages/scout-for-lol/packages/backend/src/temporal/v2/settlement-checkpoint.integration.test.ts index 076155c491..3d04d6b61e 100644 --- a/packages/scout-for-lol/packages/backend/src/temporal/v2/settlement-checkpoint.integration.test.ts +++ b/packages/scout-for-lol/packages/backend/src/temporal/v2/settlement-checkpoint.integration.test.ts @@ -49,7 +49,7 @@ const settlement = vi.hoisted( vi.mock("#src/database/index.ts", async () => await testDatabaseModule(prisma)); vi.mock("#src/temporal/v2/match-context.ts", () => ({ - resolveScoutV2MatchContext: (riotMatchId: string) => + resolveScoutV2ObservedMatchContext: (riotMatchId: string) => Promise.resolve({ matchId: riotMatchId, riotMatchId,