This commit is contained in:
Kalle
2026-09-26 16:15:01 +03:00
parent d8b7a9ee8b
commit 6783967c19
16 changed files with 797 additions and 171 deletions

View File

@@ -48,7 +48,21 @@ opens it, for anyone, through the same handoff Inspect uses.
so events from different page loads share one timeline; the ring buffer
stamps footage the same way.
- **Retention** (`store/events.ts`, on a throttled pass at every save):
whole sessions older than 30 days or beyond the newest 20 go. Full-res
whole sessions older than 30 days, beyond the newest 20 or past the
`MAX_STORED_EVENTS` budget (~140 SZ games; the newest session is never
cut) go — never part of one, so a kept session's cards always expand to
their full timeline and scoreboards. The feed and live sends re-read only
the newest session (`refreshFeed`/`sendLive` take a session key to read
from), so the store's size costs nothing per saved event.
- **Compaction** (`events-feed.ts` on refresh, `store/compacted-matches.ts`):
72 h after a session ends its games are frozen as built, one record each,
keeping every source event except the per-second Objective/PlayerStatus/
StripWeapons reads (`compactSources`; ~85% of a game's events, ~6× its
bytes), and its raw events are deleted in the same transaction. Cards,
uploads (`sendCompacted`) and the debug tab read a compacted session like
a raw one, but it no longer picks up match builder fixes and its
`Raw detections` CSV option is disabled. Compacted sessions share the
30-day / 20-session retention with the raw ones. Full-res
frames (and the thumbnails made from them) are only captured in debug
mode — the worker skips the PNG encode otherwise (`attachFrames`) — and
are bounded by 72 h and `MAX_FRAMES`, the event staying with

View File

@@ -1,7 +1,8 @@
/**
* `⇩ CSV` in a session header: `Matches` (one row per game, the rows the
* cards render) or `Raw detections` (one row per event), for this session
* or file. Settings has the same two shapes over everything.
* or file. A compacted session no longer has its raw detections, so that
* option is disabled rather than exporting the few events compaction kept.
*/
import { Download, FileText, ListTree } from "lucide-react";
import { SendouButton } from "~/components/elements/Button";
@@ -9,9 +10,12 @@ import { SendouMenu, SendouMenuItem } from "~/components/elements/Menu";
import { eventsToCsv } from "../core/csv/events";
import { type MatchCsvSource, matchesToCsv } from "../core/csv/matches";
import type { BuiltMatch } from "../core/match-builder";
import { SESSION_COMPACT_AFTER_MS } from "../core/sessions";
import { downloadCsv } from "./download";
import type { ScanEvent } from "./session-data";
const RAW_KEPT_DAYS = SESSION_COMPACT_AFTER_MS / (24 * 60 * 60 * 1000);
export function ExportMenu({
built,
events,
@@ -21,7 +25,8 @@ export function ExportMenu({
}: {
/** chronological */
built: readonly BuiltMatch<ScanEvent>[];
events: readonly ScanEvent[];
/** null once the session is compacted */
events: readonly ScanEvent[] | null;
source: MatchCsvSource;
clipCounts: readonly number[];
/** `scanner-matches-2026-09-16` / `sws26-finals` — the download's stem */
@@ -34,7 +39,7 @@ export function ExportMenu({
variant="minimal"
size="small"
icon={<Download />}
isDisabled={events.length === 0}
isDisabled={built.length === 0 && !events?.length}
>
CSV
</SendouButton>
@@ -58,17 +63,21 @@ export function ExportMenu({
</SendouMenuItem>
<SendouMenuItem
icon={<ListTree />}
onAction={() =>
isDisabled={events === null}
onAction={() => {
if (!events) return;
downloadCsv(
`${fileBase}-events.csv`,
eventsToCsv(
events.toSorted((a, b) => a.t - b.t),
source.originT,
),
)
}
);
}}
>
Raw detections
{events === null
? `Raw detections (removed ${RAW_KEPT_DAYS} days after the session)`
: "Raw detections"}
</SendouMenuItem>
</SendouMenu>
);

View File

@@ -14,7 +14,7 @@ import { loadEventFrame } from "../store/events";
import { useClips } from "./clips-feed";
import { EventFeed } from "./EventFeed";
import { ExportMenu } from "./ExportMenu";
import { currentSession, useFeed } from "./events-feed";
import { currentSession, newestSessionKey, useFeed } from "./events-feed";
import styles from "./LiveView.module.css";
import {
saveCurrentFrameAsFixture,
@@ -87,6 +87,7 @@ export function LiveView() {
return (
<SessionView
kind="live"
built={session?.built ?? []}
events={events}
originT={session?.originT ?? 0}
clips={sessionClips}
@@ -95,7 +96,9 @@ export function LiveView() {
canUpload={Boolean(user)}
onUpload={(built) => {
const id = built.sources[0]?.id;
if (id !== undefined) void sendLive(matchContaining(id));
if (id !== undefined) {
void sendLive(matchContaining(id), newestSessionKey());
}
}}
getFrame={frameLoader}
emptyText="Play a game — it shows up here once its results screen is read."

View File

@@ -9,6 +9,7 @@ import {
useSearchParamsTyped,
} from "~/modules/search-params/hooks";
import { scannerSearchParams } from "../scanner-search-params";
import { deleteCompactedSessions } from "../store/compacted-matches";
import { deleteEvents, loadEventFrame } from "../store/events";
import { useClips } from "./clips-feed";
import { ExportMenu } from "./ExportMenu";
@@ -20,7 +21,7 @@ import { SessionView } from "./SessionView";
import { sessionLabel } from "./SettingsPopover";
import { matchContaining } from "./sendou-ingest";
import type { ScanEvent } from "./session-data";
import { sendLive } from "./upload";
import { sendCompacted, sendLive } from "./upload";
export function PastSessionView() {
const [id] = useSearchParam(scannerSearchParams, "id");
@@ -43,18 +44,23 @@ export function PastSessionView() {
);
const remove = async () => {
await deleteEvents(
session.events
.map((event) => event.id)
.filter((eventId): eventId is number => eventId !== undefined),
);
refreshFeed();
if (session.compacted) {
await deleteCompactedSessions([session.key]);
} else {
await deleteEvents(
session.events
.map((event) => event.id)
.filter((eventId): eventId is number => eventId !== undefined),
);
}
refreshFeed(session.key);
setParams({ view: "home" });
};
return (
<SessionView
kind="session"
built={session.built}
events={session.events}
originT={session.originT}
clips={sessionClips}
@@ -63,7 +69,11 @@ export function PastSessionView() {
canUpload={Boolean(user)}
onUpload={(built) => {
const eventId = built.sources[0]?.id;
if (eventId !== undefined) void sendLive(matchContaining(eventId));
if (eventId === undefined) return;
void (session.compacted ? sendCompacted : sendLive)(
matchContaining(eventId),
session.key,
);
}}
getFrame={frameLoader}
emptyText="No games were read in this session."
@@ -73,7 +83,7 @@ export function PastSessionView() {
<>
<ExportMenu
built={info.built}
events={session.events}
events={session.compacted ? null : session.events}
source={{
label: sessionLabel(session.startedAt),
originT: session.originT,

View File

@@ -18,11 +18,7 @@ import {
} from "~/components/elements/Tabs";
import { MAP_START_EVENT_TYPE } from "../core/detectors/map-start";
import type { IngestSkipReason } from "../core/match-builder";
import {
type BuiltMatch,
buildScannerMatches,
ingestSkipReasons,
} from "../core/match-builder";
import { type BuiltMatch, ingestSkipReasons } from "../core/match-builder";
import { assignMatchSets } from "../core/match-sets";
import type { ScannerMatch } from "../core/scanner-match";
import { kdRatio, type SessionSummary, sessionSummary } from "../core/sessions";
@@ -40,13 +36,6 @@ import { useDebug } from "./use-debug";
const NO_KEYS: ReadonlySet<React.Key> = new Set();
/**
* Builds keyed by the events array: views re-render for reasons other than new
* events (clips, upload state), and reusing the same `BuiltMatch` objects lets
* the unchanged cards skip rendering.
*/
const builtCache = new WeakMap<readonly ScanEvent[], BuiltMatch<ScanEvent>[]>();
type LobbyGroup = "private" | "x" | "other";
const LOBBY_GROUPS: LobbyGroup[] = ["private", "x", "other"];
@@ -70,6 +59,7 @@ export interface SessionInfo {
export function SessionView({
kind,
built,
events,
originT,
clips,
@@ -83,6 +73,11 @@ export function SessionView({
children,
}: {
kind: SessionKind;
/**
* `events` built into matches, chronological. Reusing the same `BuiltMatch`
* objects across renders lets the unchanged cards skip rendering.
*/
built: BuiltMatch<ScanEvent>[];
/** chronological */
events: readonly ScanEvent[];
/** stream/file second positions count from */
@@ -104,7 +99,6 @@ export function SessionView({
const debug = useDebug();
const [playing, setPlaying] = useState<ScannerClip | null>(null);
const built = cachedBuild(events);
const skipReasons = ingestSkipReasons(built);
const clipsByMatch = built.map((b, index) =>
clipsOf(b.match, built[index + 1]?.match, clips),
@@ -244,14 +238,6 @@ function MatchList({
);
}
function cachedBuild(events: readonly ScanEvent[]): BuiltMatch<ScanEvent>[] {
const cached = builtCache.get(events);
if (cached) return cached;
const built = buildScannerMatches(events);
builtCache.set(events, built);
return built;
}
function lobbyGroup(lobby: ScannerLobby | null): LobbyGroup {
if (lobby === "PRIVATE") return "private";
if (lobby === "X") return "x";

View File

@@ -18,6 +18,7 @@ import {
} from "~/modules/search-params/hooks";
import type { ScanTelemetry } from "../core/detectors/telemetry";
import { formatTime } from "../core/format";
import { type BuiltMatch, buildScannerMatches } from "../core/match-builder";
import { scannerSearchParams } from "../scanner-search-params";
import { deleteVodClips } from "../store/clips";
import {
@@ -49,6 +50,13 @@ import {
} from "./vod-scan";
import { refreshVods } from "./vods-feed";
/**
* Builds keyed by the events array: views re-render for reasons other than new
* events (clips, upload state), and reusing the same `BuiltMatch` objects lets
* the unchanged cards skip rendering.
*/
const builtCache = new WeakMap<readonly ScanEvent[], BuiltMatch<ScanEvent>[]>();
export function VodView() {
const [name] = useSearchParam(scannerSearchParams, "name");
const scan = useVodScan();
@@ -236,6 +244,7 @@ function VodSessionView({
return (
<SessionView
kind="vod"
built={cachedBuild(events)}
events={events}
originT={0}
clips={vodClips}
@@ -364,3 +373,11 @@ function TelemetryPanel({ telemetry }: { telemetry: ScanTelemetry }) {
</details>
);
}
function cachedBuild(events: readonly ScanEvent[]): BuiltMatch<ScanEvent>[] {
const cached = builtCache.get(events);
if (cached) return cached;
const built = buildScannerMatches(events);
builtCache.set(events, built);
return built;
}

View File

@@ -3,26 +3,43 @@
* sessions (core/sessions.ts) with each session's matches built once per
* refresh rather than per render. Every saved event asks for a refresh, ~2-3
* a second during a match; requests landing while one runs coalesce into a
* single trailing pass.
* single trailing pass. A refresh re-reads only the newest session unless
* told otherwise: the store holds weeks of sessions, and only a send status
* write or a delete changes an older one. Refreshes also compact the sessions
* that ended `SESSION_COMPACT_AFTER_MS` ago (store/compacted-matches.ts) and
* apply retention to the compacted ones.
*/
import { useSyncExternalStore } from "react";
import * as R from "remeda";
import {
type BuiltMatch,
buildScannerMatches,
invalidObjectiveEvents,
} from "../core/match-builder";
import {
compactSources,
expiredCompactedSessionKeys,
SESSION_COMPACT_AFTER_MS,
SESSION_GAP_MS,
type SessionSummary,
sessionKey,
sessionSummary,
splitSessions,
} from "../core/sessions";
import {
type CompactedMatch,
compactedBuilt,
compactSessions,
deleteCompactedSessions,
listCompactedMatches,
} from "../store/compacted-matches";
import { deleteEvents, listEvents, type StoredEvent } from "../store/events";
export interface LiveSession {
/** the first event's detection time — the URL id */
key: number;
/** its games are frozen as built and `events` holds only what compaction kept */
compacted: boolean;
/** oldest first */
events: StoredEvent[];
/** chronological */
@@ -44,39 +61,73 @@ const EMPTY: FeedSnapshot = { loaded: false, sessions: [] };
let snapshot: FeedSnapshot = EMPTY;
const listeners = new Set<() => void>();
const state = { running: false, queued: false };
let running = false;
/** the earliest `since` requested while a refresh was running */
let pendingSince: number | null = null;
/** an older session's events don't change, so its build is kept */
const buildCache = new Map<number, { signature: string; built: LiveSession }>();
/** the raw events the snapshot's not yet compacted sessions were built from */
let rawEvents: StoredEvent[] = [];
/** the compacted sessions, by key */
const compactedSessions = new Map<number, LiveSession>();
export function refreshFeed(): void {
if (state.running) {
state.queued = true;
return;
}
state.running = true;
/**
* Re-reads the events detected at or after `since` (a session's key, 0 for
* everything) and keeps the older sessions as they are. Defaults to the newest
* session, the one a capture adds to — everything before the feed first loads.
*/
export function refreshFeed(since = newestSessionKey()): void {
pendingSince = Math.min(pendingSince ?? since, since);
if (running) return;
running = true;
void (async () => {
try {
do {
state.queued = false;
const events = await listEvents();
snapshot = { loaded: true, sessions: await toSessions(events) };
while (pendingSince !== null) {
const from = pendingSince;
pendingSince = null;
const [loaded, loadedCompacted] = await Promise.all([
listEvents(from),
listCompactedMatches(from),
]);
const loadedIds = new Set(loaded.map((event) => event.id));
rawEvents = [
...rawEvents.filter(
(event) => event.detectedAt < from && !loadedIds.has(event.id),
),
...loaded,
];
for (const key of compactedSessions.keys()) {
if (key >= from) compactedSessions.delete(key);
}
addCompactedSessions(loadedCompacted);
snapshot = { loaded: true, sessions: await toSessions() };
for (const listener of listeners) listener();
} while (state.queued);
}
} catch {
pendingSince = null;
snapshot = { loaded: true, sessions: snapshot.sessions };
for (const listener of listeners) listener();
} finally {
state.running = false;
running = false;
}
})();
}
/**
* The newest session's key, 0 before the feed loads. Reading from here always
* covers the session a capture is adding to, even one its latest event just
* started.
*/
export function newestSessionKey(): number {
return snapshot.loaded ? (snapshot.sessions.at(-1)?.key ?? 0) : 0;
}
export function useFeed(): FeedSnapshot {
return useSyncExternalStore(subscribe, getFeed, () => EMPTY);
}
export function getFeed(): FeedSnapshot {
if (!snapshot.loaded && !state.running) refreshFeed();
if (!snapshot.loaded && !running) refreshFeed();
return snapshot;
}
@@ -104,7 +155,82 @@ export function findSession(
return feed.sessions.find((session) => session.key === key) ?? null;
}
async function toSessions(events: StoredEvent[]): Promise<LiveSession[]> {
/**
* Every session oldest first: the raw ones built from `rawEvents`, less those
* this pass compacts, then the compacted ones retention keeps.
*/
async function toSessions(): Promise<LiveSession[]> {
const now = Date.now();
const raw = await rawSessions(rawEvents);
const ripe = raw.filter(
(session) => now - session.endedAt > SESSION_COMPACT_AFTER_MS,
);
if (ripe.length > 0) await compact(ripe);
const kept = raw.filter((session) => !ripe.includes(session));
const expired = expiredCompactedSessionKeys(
[...compactedSessions.values()],
kept.length,
now,
);
if (expired.length > 0) {
await deleteCompactedSessions(expired);
for (const key of expired) compactedSessions.delete(key);
}
return [...compactedSessions.values(), ...kept].sort((a, b) => a.key - b.key);
}
async function compact(sessions: readonly LiveSession[]): Promise<void> {
const matches: CompactedMatch[] = sessions.flatMap((session) =>
session.built.map((built, index) => {
const sources = compactSources(built.sources).map((event) => ({
...event,
hasFrame: false,
}));
return {
id: sources[0]!.id!,
session: {
key: session.key,
endedAt: session.endedAt,
originT: session.originT,
},
index,
match: built.match,
sources,
};
}),
);
const eventIds = new Set(
sessions.flatMap((session) => session.events.map((event) => event.id!)),
);
await compactSessions(matches, [...eventIds]);
rawEvents = rawEvents.filter((event) => !eventIds.has(event.id!));
addCompactedSessions(matches);
}
function addCompactedSessions(matches: readonly CompactedMatch[]): void {
for (const games of Object.values(
R.groupBy(matches, (match) => match.session.key),
)) {
const { session } = games[0];
const built = games
.toSorted((a, b) => a.index - b.index)
.map(compactedBuilt);
compactedSessions.set(session.key, {
key: session.key,
compacted: true,
events: built.flatMap((b) => b.sources).toSorted((a, b) => a.t - b.t),
built,
summary: sessionSummary(built.map((b) => b.match)),
startedAt: session.key,
endedAt: session.endedAt,
originT: session.originT,
});
}
}
async function rawSessions(events: StoredEvent[]): Promise<LiveSession[]> {
const sessions: LiveSession[] = [];
const seen = new Set<number>();
for (const sessionEvents of splitSessions(events)) {
@@ -134,6 +260,7 @@ async function toSessions(events: StoredEvent[]): Promise<LiveSession[]> {
}
const session: LiveSession = {
key,
compacted: false,
events: sorted,
built,
summary: sessionSummary(built.map((b) => b.match)),

View File

@@ -49,6 +49,7 @@ import { describeError } from "./errors";
import {
currentSession,
getFeed,
newestSessionKey,
refreshFeed,
subscribeFeed,
} from "./events-feed";
@@ -278,13 +279,18 @@ export async function startCapture({
void sendLive(
(built) =>
retryableUnlinkedMatches(built) || unsentClosedMatches(built),
newestSessionKey(),
);
}
}, UNLINKED_RETRY_TICK_MS);
clipTimer = setInterval(clipTick, CLIP_TICK_MS);
audioTimer = setInterval(audioCheck, AUDIO_CHECK_MS);
unsubscribeFeed = subscribeFeed(clipTick);
void trimEvents().catch(() => {});
// a session is only worth coming back to if the browser keeps it
requestPersistentStorage();
void trimEvents()
.then(() => refreshFeed(0))
.catch(() => {});
set({
status: "running",
since: Date.now(),
@@ -309,11 +315,13 @@ export function stopCapture(): void {
set({ ...IDLE });
// the scan ending is the last match boundary — flush what's unsent
// (partials are safe: the server merges them into fuller resends)
if (uploadEnabled()) void sendLive(unsentMatches);
if (uploadEnabled()) void sendLive(unsentMatches, newestSessionKey());
void rollSessionClipsIntoHistory()
.then(() => refreshClips())
.catch(() => {});
void trimEvents().catch(() => {});
void trimEvents()
.then(() => refreshFeed(0))
.catch(() => {});
}
/** Debug: the current capture frame as a PNG download. */
@@ -421,6 +429,7 @@ async function persist(
refreshFeed();
await sendLive(
(built) => matchContaining(id)(built) && unsentMatches(built),
newestSessionKey(),
);
}
} catch (error) {

View File

@@ -15,10 +15,9 @@ import type { IngestResponse } from "~/features/scanner-ingest/scanner-ingest-sc
import { SCOREBOARD_EVENT_TYPES } from "../core/detectors/registry";
import type { DetectedEvent } from "../core/detectors/types";
import type { BuiltMatch } from "../core/match-builder";
import { buildScannerMatches, ingestSkipReasons } from "../core/match-builder";
import { ingestSkipReasons } from "../core/match-builder";
import type { ScannerMatch } from "../core/scanner-match";
import { EVENTS_STORE } from "../store/db";
import { type SendStatus, updateEventsSend } from "../store/events";
import type { SendStatus } from "../store/events";
import type { ScanEvent } from "./session-data";
const INGEST_URL = "/ingest";
@@ -39,9 +38,9 @@ export interface SendResult {
}
/**
* Builds the stored events into matches, POSTs the ingestable ones `include`
* selects, and records the outcome on every source event's `send` status
* (calling `onStatus` after each request's store writes).
* POSTs the ingestable `matches` (a whole session or file, which
* the skip rules look across) that `include` selects, and records the outcome
* through `writeSend` (calling `onStatus` after each request's store writes).
*
* Matches go out in as few requests as the server cap allows: sendou.ink
* resolves a whole request at once, so several matches anchor on their
@@ -51,30 +50,26 @@ export interface SendResult {
* "unlinked"; the retry carries only those, which then resolve on their own.
*/
export async function sendMatches({
events,
matches,
include,
onStatus,
store = EVENTS_STORE,
writeSend,
}: {
events: readonly ScanEvent[];
/** chronological */
matches: readonly BuiltMatch<ScanEvent>[];
include: (built: BuiltMatch<ScanEvent>) => boolean;
onStatus: () => void;
/** the IndexedDB store the events' send statuses are written to */
store?: string;
/** stores a send status on the given matches */
writeSend: (
matches: readonly BuiltMatch<ScanEvent>[],
send: SendStatus,
) => Promise<void>;
}): Promise<SendResult> {
const allBuilt = ingestableBuilt(
buildScannerMatches(events.filter((e) => e.id !== undefined)),
);
const selected = allBuilt.filter(include);
const selected = ingestableBuilt(matches).filter(include);
const result: SendResult = { sentMatches: 0, failedMatches: 0 };
for (const request of R.chunk(selected, MAX_MATCHES_PER_REQUEST)) {
const idsPerMatch = request.map((built) => built.sources.map((e) => e.id!));
await updateEventsSend(
idsPerMatch.flat(),
{ state: "sending", at: Date.now() },
store,
);
await writeSend(request, { state: "sending", at: Date.now() });
onStatus();
try {
const response = await postIngestMatches(
@@ -88,33 +83,25 @@ export async function sendMatches({
// match: the game is just not reported yet, so a later resend can still
// land it. Without a context there is nothing to wait for.
const unlinked = !link && response.contextResolved;
await updateEventsSend(
idsPerMatch[matchIndex]!,
{
state: unlinked ? "unlinked" : "sent",
at: Date.now(),
...(link ? { link } : null),
...(unlinked
? {
attempts:
(aggregateSendStatus(built.sources)?.attempts ?? 0) + 1,
}
: null),
},
store,
);
await writeSend([built], {
state: unlinked ? "unlinked" : "sent",
at: Date.now(),
...(link ? { link } : null),
...(unlinked
? {
attempts:
(aggregateSendStatus(built.sources)?.attempts ?? 0) + 1,
}
: null),
});
}
result.sentMatches += request.length;
} catch (err) {
await updateEventsSend(
idsPerMatch.flat(),
{
state: "failed",
at: Date.now(),
error: err instanceof Error ? err.message : String(err),
},
store,
);
await writeSend(request, {
state: "failed",
at: Date.now(),
error: err instanceof Error ? err.message : String(err),
});
result.failedMatches += request.length;
}
onStatus();
@@ -180,7 +167,7 @@ export function unsentClosedMatches(built: BuiltMatch<ScanEvent>): boolean {
}
function ingestableBuilt<E extends DetectedEvent>(
built: BuiltMatch<E>[],
built: readonly BuiltMatch<E>[],
): BuiltMatch<E>[] {
const skipped = ingestSkipReasons(built);
return built.filter((match) => !skipped.has(match));

View File

@@ -4,9 +4,18 @@
* never overlap (a send requested mid-flight runs right after), and one
* place that knows whether uploading is on at all (the setting, and a login).
*/
import type { BuiltMatch } from "../core/match-builder";
import { EVENTS_STORE, VOD_EVENTS_STORE } from "../store/db";
import { listEvents } from "../store/events";
import { type BuiltMatch, buildScannerMatches } from "../core/match-builder";
import {
compactedBuilt,
listCompactedMatches,
updateCompactedMatchesSend,
} from "../store/compacted-matches";
import {
COMPACTED_MATCHES_STORE,
EVENTS_STORE,
VOD_EVENTS_STORE,
} from "../store/db";
import { listEvents, type SendStatus, updateEventsSend } from "../store/events";
import { loadVodEvents } from "../store/vods";
import { refreshFeed } from "./events-feed";
import { type SendResult, sendMatches } from "./sendou-ingest";
@@ -15,9 +24,24 @@ import { readSettings } from "./settings";
export type MatchSelector = (built: BuiltMatch<ScanEvent>) => boolean;
interface SendRequest {
include: MatchSelector;
/** live: the session key built matches are loaded from; VoD: unused */
since: number;
}
interface SendTarget {
load: (since: number) => Promise<BuiltMatch<ScanEvent>[]>;
writeSend: (
matches: readonly BuiltMatch<ScanEvent>[],
send: SendStatus,
) => Promise<void>;
onStatus: (since: number) => void;
}
interface Sender {
sending: boolean;
pending: MatchSelector[];
pending: SendRequest[];
}
let user: { id: number } | null = null;
@@ -37,9 +61,45 @@ export function uploadEnabled(): boolean {
return user !== null && readSettings().upload;
}
/** Sends the live matches `include` selects; the feed refreshes as statuses change. */
export function sendLive(include: MatchSelector): Promise<SendResult | null> {
return send(EVENTS_STORE, listEvents, include, refreshFeed);
/**
* Sends the matches `include` selects among the live events detected since
* `since` (the key of the session they belong to); the feed refreshes from
* there as statuses change.
*/
export function sendLive(
include: MatchSelector,
since: number,
): Promise<SendResult | null> {
return send(
EVENTS_STORE,
{ include, since },
{
load: async (from) => buildScannerMatches(await listEvents(from)),
writeSend: eventsSendWriter(EVENTS_STORE),
onStatus: refreshFeed,
},
);
}
/** Sends the matches `include` selects among the compacted games of the session keyed `sessionKey`. */
export function sendCompacted(
include: MatchSelector,
sessionKey: number,
): Promise<SendResult | null> {
return send(
COMPACTED_MATCHES_STORE,
{ include, since: sessionKey },
{
load: async (from) =>
(await listCompactedMatches(from)).map(compactedBuilt),
writeSend: (matches, sendStatus) =>
updateCompactedMatchesSend(
matches.map((built) => built.sources[0]!.id!),
sendStatus,
),
onStatus: refreshFeed,
},
);
}
/** Sends the VoD's matches `include` selects; `onStatus` runs after each status write. */
@@ -50,19 +110,19 @@ export function sendVod(
): Promise<SendResult | null> {
return send(
`${VOD_EVENTS_STORE}:${name}`,
() => loadVodEvents(name),
include,
onStatus,
VOD_EVENTS_STORE,
{ include, since: 0 },
{
load: async () => buildScannerMatches(await loadVodEvents(name)),
writeSend: eventsSendWriter(VOD_EVENTS_STORE),
onStatus,
},
);
}
async function send(
key: string,
loadEvents: () => Promise<ScanEvent[]>,
include: MatchSelector,
onStatus: () => void,
store: string = EVENTS_STORE,
request: SendRequest,
target: SendTarget,
): Promise<SendResult | null> {
if (!isLoggedIn()) return null;
let sender = senders.get(key);
@@ -71,20 +131,22 @@ async function send(
senders.set(key, sender);
}
if (sender.sending) {
sender.pending.push(include);
sender.pending.push(request);
return null;
}
sender.sending = true;
const result: SendResult = { sentMatches: 0, failedMatches: 0 };
let sentSince = request.since;
try {
let next: MatchSelector | undefined = include;
let next: SendRequest | undefined = request;
while (next) {
const events = await loadEvents();
const { since } = next;
sentSince = Math.min(sentSince, since);
const pass = await sendMatches({
events,
include: next,
onStatus,
store,
matches: await target.load(since),
include: next.include,
onStatus: () => target.onStatus(since),
writeSend: target.writeSend,
});
result.sentMatches += pass.sentMatches;
result.failedMatches += pass.failedMatches;
@@ -92,12 +154,24 @@ async function send(
sender.pending = [];
next =
pending.length > 0
? (built) => pending.some((fn) => fn(built))
? {
include: (built) => pending.some((p) => p.include(built)),
since: Math.min(...pending.map((p) => p.since)),
}
: undefined;
}
} finally {
sender.sending = false;
onStatus();
target.onStatus(sentSince);
}
return result;
}
function eventsSendWriter(store: string): SendTarget["writeSend"] {
return (matches, sendStatus) =>
updateEventsSend(
matches.flatMap((built) => built.sources.map((event) => event.id!)),
sendStatus,
store,
);
}

View File

@@ -2,13 +2,41 @@
* Sessions are client-only and derived at render: live detections ordered by
* wall-clock time, split wherever two consecutive detections lie ≥ 2 h apart.
* A session is keyed by its first event's `detectedAt` (stable across reloads,
* usable in a URL). Retention evicts whole sessions on the same split.
* usable in a URL). Retention evicts whole sessions on the same split, so a
* kept session's games always rebuild with their full details. A session
* `SESSION_COMPACT_AFTER_MS` past its end is compacted: its games are frozen
* as built and the per-second reads behind them dropped (`compactSources`).
*/
import { OBJECTIVE_EVENT_TYPE } from "./detectors/objective/index";
import { PLAYER_STATUS_EVENT_TYPE } from "./detectors/objective/player-status";
import { STRIP_WEAPONS_EVENT_TYPE } from "./detectors/objective/strip-weapons";
import type { ScannerMatch } from "./scanner-match";
export const SESSION_GAP_MS = 2 * 60 * 60 * 1000;
export const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
export const MAX_SESSIONS = 20;
/**
* Stored events across sessions. A Splat Zones game keeps ~350 (the counter
* and status reads behind its timeline), so this holds ~140 games; past it the
* oldest whole sessions go.
*/
export const MAX_STORED_EVENTS = 50_000;
/**
* Until then a session keeps its raw reads: debugging a misread and rebuilding
* after a match builder fix both need them, and frames are kept as long.
*/
export const SESSION_COMPACT_AFTER_MS = 72 * 60 * 60 * 1000;
/**
* The per-second reads a compacted match drops: its frozen `objective`,
* `playerStatus` and team weapons already hold what they were read for, and
* they are ~85% of a game's events.
*/
const COMPACTED_AWAY_TYPES = [
OBJECTIVE_EVENT_TYPE,
PLAYER_STATUS_EVENT_TYPE,
STRIP_WEAPONS_EVENT_TYPE,
];
interface Stamped {
/** wall-clock ms of detection */
@@ -84,20 +112,62 @@ export function kdRatio(summary: SessionSummary): number | null {
/**
* Ids of the events retention evicts: every event of a session older than
* `SESSION_MAX_AGE_MS` or beyond the newest `MAX_SESSIONS`. A session's age is
* its last event's.
* `SESSION_MAX_AGE_MS`, beyond the newest `MAX_SESSIONS` or past the
* `MAX_STORED_EVENTS` budget, oldest first. A session's age is its last
* event's. The newest session is never cut for the budget: a session missing
* its first events would rebuild its games without their intro and timeline.
*/
export function expiredSessionEventIds<E extends Stamped & { id: number }>(
events: readonly E[],
now: number,
): number[] {
const sessions = splitSessions(events);
const kept = sessions.slice(-MAX_SESSIONS);
let keptCount = 0;
let keptEvents = 0;
for (const session of sessions.toReversed()) {
const tooOld = now - session.at(-1)!.detectedAt > SESSION_MAX_AGE_MS;
const overBudget =
keptCount > 0 && keptEvents + session.length > MAX_STORED_EVENTS;
if (tooOld || overBudget || keptCount === MAX_SESSIONS) break;
keptCount++;
keptEvents += session.length;
}
return sessions
.filter(
(session) =>
!kept.includes(session) ||
now - session.at(-1)!.detectedAt > SESSION_MAX_AGE_MS,
)
.slice(0, sessions.length - keptCount)
.flatMap((session) => session.map((event) => event.id));
}
/**
* The source events a compacted match keeps: all but the per-second reads,
* so its card still lists deaths, shows its scan time and upload state, and
* the debug view its detections. A match read off nothing else keeps its first
* source, which the card and uploads identify it by.
*/
export function compactSources<E extends { type: string }>(
sources: readonly E[],
): E[] {
const kept = sources.filter(
(event) => !COMPACTED_AWAY_TYPES.includes(event.type),
);
return kept.length > 0 ? kept : sources.slice(0, 1);
}
/**
* Keys of the compacted sessions retention evicts: older than
* `SESSION_MAX_AGE_MS`, or beyond the newest `MAX_SESSIONS` once the
* `rawSessionCount` sessions not yet compacted (always the newest) are counted.
*/
export function expiredCompactedSessionKeys(
compacted: readonly { key: number; endedAt: number }[],
rawSessionCount: number,
now: number,
): number[] {
const room = Math.max(0, MAX_SESSIONS - rawSessionCount);
return compacted
.toSorted((a, b) => b.key - a.key)
.filter(
(session, index) =>
index >= room || now - session.endedAt > SESSION_MAX_AGE_MS,
)
.map((session) => session.key);
}

View File

@@ -0,0 +1,135 @@
import { beforeEach, describe, expect, test } from "vitest";
import type { ScannerMatch } from "../core/scanner-match";
import {
type CompactedMatch,
compactSessions,
deleteCompactedSessions,
listCompactedMatches,
updateCompactedMatchesSend,
} from "./compacted-matches";
import { deleteEvents, listEvents, saveEvent } from "./events";
const MATCH = { mode: "SZ", stage: 0 } as ScannerMatch;
function compacted(
id: number,
sessionKey: number,
index: number,
): CompactedMatch {
return {
id,
session: { key: sessionKey, endedAt: sessionKey + 1_000, originT: 0 },
index,
match: MATCH,
sources: [
{
id,
type: "MapStart",
t: 0,
detectedAt: sessionKey,
confidence: 1,
data: null,
},
{
id: id + 1,
type: "Death",
t: 1,
detectedAt: sessionKey,
confidence: 1,
data: null,
},
],
};
}
async function clearAll() {
const matches = await listCompactedMatches();
await deleteCompactedSessions(matches.map((match) => match.session.key));
await deleteEvents((await listEvents()).map((event) => event.id!));
}
describe("compactSessions()", () => {
beforeEach(clearAll);
test("stores the games and deletes the raw events they replace", async () => {
const id = await saveEvent({
type: "Objective",
t: 0,
confidence: 1,
data: null,
});
await compactSessions([compacted(10, 1_000, 0)], [id]);
expect(await listEvents()).toEqual([]);
expect((await listCompactedMatches()).map((match) => match.id)).toEqual([
10,
]);
});
});
describe("listCompactedMatches()", () => {
beforeEach(clearAll);
test("lists the sessions keyed from `since` on, each session's games in order", async () => {
await compactSessions(
[
compacted(30, 2_000, 1),
compacted(10, 1_000, 0),
compacted(20, 2_000, 0),
],
[],
);
expect((await listCompactedMatches()).map((match) => match.id)).toEqual([
10, 20, 30,
]);
expect(
(await listCompactedMatches(2_000)).map((match) => match.id),
).toEqual([20, 30]);
});
});
describe("updateCompactedMatchesSend()", () => {
beforeEach(clearAll);
test("sets the status on every kept source of the given games", async () => {
await compactSessions(
[compacted(10, 1_000, 0), compacted(20, 1_000, 1)],
[],
);
await updateCompactedMatchesSend([10], { state: "sent", at: 5 });
const [first, second] = await listCompactedMatches();
expect(first!.sources.map((source) => source.send?.state)).toEqual([
"sent",
"sent",
]);
expect(second!.sources.map((source) => source.send)).toEqual([
undefined,
undefined,
]);
});
});
describe("deleteCompactedSessions()", () => {
beforeEach(clearAll);
test("deletes every game of the given sessions only", async () => {
await compactSessions(
[
compacted(10, 1_000, 0),
compacted(20, 1_000, 1),
compacted(30, 2_000, 0),
],
[],
);
await deleteCompactedSessions([1_000]);
expect((await listCompactedMatches()).map((match) => match.id)).toEqual([
30,
]);
});
});

View File

@@ -0,0 +1,122 @@
/**
* Live sessions past `SESSION_COMPACT_AFTER_MS`, one record per game: the
* match frozen as it was built plus the source events `compactSources` keeps.
* A session's raw events (and frames) leave the `events` store in the same
* transaction its games arrive here, so it is always in exactly one of them.
*/
import type { BuiltMatch } from "../core/match-builder";
import type { ScannerMatch } from "../core/scanner-match";
import {
COMPACTED_MATCHES_STORE,
EVENTS_STORE,
FRAMES_STORE,
readwrite,
tx,
} from "./db";
import type { SendStatus, StoredEvent } from "./events";
export interface CompactedMatch {
/** the first kept source's id — the card key and upload selectors' id */
id: number;
/** the session the game was played in, as its raw events split it */
session: {
/** the session key — its URL id and its clips' `sessionKey` */
key: number;
endedAt: number;
/** stream second the session's positions count from */
originT: number;
};
/** the game's position in its session, 0-based */
index: number;
match: ScannerMatch;
/** chronological; each carries the match's send status */
sources: StoredEvent[];
}
/** A compacted game as the views and uploads take a built one. */
export function compactedBuilt(
compacted: CompactedMatch,
): BuiltMatch<StoredEvent> {
return { match: compacted.match, sources: compacted.sources };
}
/** Stores the games and deletes the raw events (and frames) they were built from, in one transaction. */
export function compactSessions(
matches: readonly CompactedMatch[],
eventIds: readonly number[],
): Promise<void> {
return readwrite(
[COMPACTED_MATCHES_STORE, EVENTS_STORE, FRAMES_STORE],
(transaction) => {
const compacted = transaction.objectStore(COMPACTED_MATCHES_STORE);
for (const match of matches) {
compacted.put(match);
}
const events = transaction.objectStore(EVENTS_STORE);
const frames = transaction.objectStore(FRAMES_STORE);
for (const id of eventIds) {
events.delete(id);
frames.delete(id);
}
},
);
}
/** Games of the sessions keyed at or after `since` (every compacted game by default), chronological. */
export async function listCompactedMatches(
since = 0,
): Promise<CompactedMatch[]> {
const matches = await tx(
COMPACTED_MATCHES_STORE,
"readonly",
(store) =>
store
.index("sessionKey")
.getAll(IDBKeyRange.lowerBound(since)) as IDBRequest<CompactedMatch[]>,
);
return matches.sort(
(a, b) => a.session.key - b.session.key || a.index - b.index,
);
}
/** Sets (or clears) the send status of the given games, on every source they kept. */
export function updateCompactedMatchesSend(
ids: readonly number[],
send: SendStatus | undefined,
): Promise<void> {
return readwrite([COMPACTED_MATCHES_STORE], (transaction) => {
const store = transaction.objectStore(COMPACTED_MATCHES_STORE);
for (const id of ids) {
const get = store.get(id) as IDBRequest<CompactedMatch | undefined>;
get.onsuccess = () => {
const record = get.result;
if (!record) return; // deleted meanwhile
for (const source of record.sources) {
if (send) source.send = send;
else delete source.send;
}
store.put(record);
};
}
});
}
/** Deletes every game of the given sessions. */
export function deleteCompactedSessions(
keys: readonly number[],
): Promise<void> {
return readwrite([COMPACTED_MATCHES_STORE], (transaction) => {
const index = transaction
.objectStore(COMPACTED_MATCHES_STORE)
.index("sessionKey");
for (const key of keys) {
const req = index.openCursor(IDBKeyRange.only(key));
req.onsuccess = () => {
const cursor = req.result;
if (!cursor) return;
cursor.delete();
cursor.continue();
};
}
});
}

View File

@@ -2,6 +2,7 @@
* Shared IndexedDB handle. Stores:
* - `events`: live detections, keyed by auto id (events.ts)
* - `frames`: their full-res analyzed PNGs by event id, kept apart so listing the feed never deserializes them
* - `compacted-matches`: older live sessions' games, frozen as built, indexed by session (compacted-matches.ts)
* - `vods`: one summary per scanned VoD, keyed by file name (vods.ts)
* - `vod-events`: each saved VoD's detections, indexed by VoD name
* - `vod-frames`: their PNGs, keyed by vod-event id
@@ -10,10 +11,11 @@
* - `inspect-frames`: one-shot Inspect handoffs into a new debug tab (inspect.ts)
*/
const DB_NAME = "scanner";
const DB_VERSION = 2;
const DB_VERSION = 3;
export const EVENTS_STORE = "events";
export const FRAMES_STORE = "frames";
export const COMPACTED_MATCHES_STORE = "compacted-matches";
export const VODS_STORE = "vods";
export const VOD_EVENTS_STORE = "vod-events";
export const VOD_FRAMES_STORE = "vod-frames";
@@ -25,7 +27,8 @@ export const INSPECT_FRAMES_STORE = "inspect-frames";
* Adds the stores a DB_VERSION bump introduced, keeping the existing ones and
* their data. v2 added the clip stores and moved live event times onto the
* wall clock, so a v1 database's live events (stamped on the page clock)
* are dropped. Changing an existing store's shape needs a real migration here.
* are dropped; v3 added the compacted matches. Changing an existing store's
* shape needs a real migration here.
*/
function upgrade(database: IDBDatabase, oldVersion: number): void {
const has = (name: string) => database.objectStoreNames.contains(name);
@@ -44,6 +47,13 @@ function upgrade(database: IDBDatabase, oldVersion: number): void {
events.createIndex("detectedAt", "detectedAt");
}
if (!has(COMPACTED_MATCHES_STORE)) {
const compacted = database.createObjectStore(COMPACTED_MATCHES_STORE, {
keyPath: "id",
});
compacted.createIndex("sessionKey", "session.key");
}
if (!has(VODS_STORE)) {
database.createObjectStore(VODS_STORE, { keyPath: "name" });
}

View File

@@ -4,22 +4,14 @@
* analyzed PNG lives in the separate `frames` store under the same id
* (loadEventFrame) so listing the feed never deserializes megabytes of
* blobs. Retention runs on save (throttled): whole sessions past
* `core/sessions.ts`'s age/count limits go, frames go after `FRAME_MAX_AGE_MS`
* or past `MAX_FRAMES` (the events stay, marked frameless), and `MAX_EVENTS`
* remains a hard floor.
* `core/sessions.ts`'s age/count/event limits go, and frames go after
* `FRAME_MAX_AGE_MS` or past `MAX_FRAMES` (the events stay, marked frameless).
*/
import type { IngestedMatchLink } from "~/features/scanner-ingest/scanner-ingest-schemas";
import type { DetectedEvent } from "../core/detectors/types";
import { expiredSessionEventIds } from "../core/sessions";
import { EVENTS_STORE, FRAMES_STORE, readwrite, tx } from "./db";
/**
* Counter/status reads land ~2.2 events a second of match time, so the cap must
* hold a whole session: at 1000 the store rolled over in ~8 minutes and evicted
* matches before they were sent (2026-08-23: Mahi-Mahi reached sendou.ink with no data).
*/
const MAX_EVENTS = 10_000;
/**
* Full-res frame PNGs (~1-2MB each) are what makes a misread reportable; only
* debug-mode captures save them, bounded by age and count, whichever bites first.
@@ -91,7 +83,6 @@ export async function saveEvent(
id = add.result;
if (frame) frames.put(frame, id);
else if (reuseId !== undefined) frames.delete(id);
evictBeyondCap(events, frames);
const now = Date.now();
if (now - lastRetentionAt >= RETENTION_INTERVAL_MS) {
lastRetentionAt = now;
@@ -114,24 +105,6 @@ export function trimEvents(): Promise<void> {
);
}
/** Delete records (and frames) beyond MAX_EVENTS, oldest ids first. */
function evictBeyondCap(events: IDBObjectStore, frames: IDBObjectStore): void {
const count = events.count();
count.onsuccess = () => {
let excess = count.result - MAX_EVENTS;
if (excess <= 0) return;
const cursor = events.openKeyCursor(); // ascending id = oldest first
cursor.onsuccess = () => {
const c = cursor.result;
if (!c || excess <= 0) return;
frames.delete(c.primaryKey);
events.delete(c.primaryKey);
excess--;
if (excess > 0) c.continue();
};
};
}
/**
* Session and frame retention over key cursors only (no record is
* deserialized): the `detectedAt` index yields every event's id and time,
@@ -231,11 +204,15 @@ export async function deleteEvents(ids: number[]): Promise<void> {
});
}
export function listEvents(): Promise<StoredEvent[]> {
/** Events detected at or after `since` (wall-clock ms), every event by default. */
export function listEvents(since = 0): Promise<StoredEvent[]> {
return tx(
EVENTS_STORE,
"readonly",
(store) => store.getAll() as IDBRequest<StoredEvent[]>,
(store) =>
store
.index("detectedAt")
.getAll(IDBKeyRange.lowerBound(since)) as IDBRequest<StoredEvent[]>,
);
}

View File

@@ -6,9 +6,12 @@
import assert from "node:assert/strict";
import type { ScannerMatch } from "../../core/scanner-match";
import {
compactSources,
expiredCompactedSessionKeys,
expiredSessionEventIds,
kdRatio,
MAX_SESSIONS,
MAX_STORED_EVENTS,
SESSION_GAP_MS,
SESSION_MAX_AGE_MS,
sessionKey,
@@ -20,6 +23,14 @@ import { test } from "../node-test-compat";
const HOUR = 60 * 60 * 1000;
const DAY = 24 * HOUR;
/** `count` events a second apart from `start`, ids continuing from `firstId` */
function eventRun(start: number, count: number, firstId: number) {
return Array.from({ length: count }, (_, i) => ({
id: firstId + i,
detectedAt: start + i * 1000,
}));
}
function stamped(...detectedAts: number[]) {
return detectedAts.map((detectedAt, id) => ({ id, detectedAt }));
}
@@ -126,3 +137,68 @@ test("retention keeps the newest sessions only", () => {
}));
assert.deepEqual(expiredSessionEventIds(events, now), [0, 1]);
});
test("retention drops the oldest whole sessions past the event budget", () => {
const now = 100 * DAY;
const half = MAX_STORED_EVENTS / 2;
const oldest = eventRun(now - 3 * DAY, half, 0);
const middle = eventRun(now - 2 * DAY, half, half);
const newest = eventRun(now - DAY, half, 2 * half);
const ids = expiredSessionEventIds([...oldest, ...middle, ...newest], now);
assert.deepEqual(
ids,
oldest.map((e) => e.id),
);
});
test("retention keeps the newest session whole even past the event budget", () => {
const now = 100 * DAY;
const older = eventRun(now - 2 * DAY, 10, 0);
const newest = eventRun(now - DAY, MAX_STORED_EVENTS + 1, 10);
const ids = expiredSessionEventIds([...older, ...newest], now);
assert.deepEqual(
ids,
older.map((e) => e.id),
);
});
test("compaction keeps every source but the per-second reads", () => {
const sources = [
{ type: "Objective" },
{ type: "MapStart" },
{ type: "PlayerStatus" },
{ type: "StripWeapons" },
{ type: "Death" },
{ type: "Scoreboard" },
];
assert.deepEqual(
compactSources(sources).map((event) => event.type),
["MapStart", "Death", "Scoreboard"],
);
});
test("a match read only off per-second reads keeps its first source", () => {
const sources = [{ type: "Objective" }, { type: "PlayerStatus" }];
assert.deepEqual(compactSources(sources), [sources[0]]);
});
test("compacted retention counts the raw sessions against the session cap", () => {
const now = 100 * DAY;
const compacted = Array.from({ length: MAX_SESSIONS }, (_, i) => ({
key: now - (MAX_SESSIONS - i) * DAY,
endedAt: now - (MAX_SESSIONS - i) * DAY + HOUR,
}));
assert.deepEqual(expiredCompactedSessionKeys(compacted, 2, now), [
compacted[1]!.key,
compacted[0]!.key,
]);
});
test("compacted retention drops sessions older than 30 days", () => {
const now = 100 * DAY;
const old = { key: now - 40 * DAY, endedAt: now - 40 * DAY + HOUR };
const recent = { key: now - 5 * DAY, endedAt: now - 5 * DAY + HOUR };
assert.deepEqual(expiredCompactedSessionKeys([old, recent], 0, now), [
old.key,
]);
});