mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-26 21:27:48 -05:00
Compact
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
135
app/features/scanner/store/compacted-matches.browser.test.ts
Normal file
135
app/features/scanner/store/compacted-matches.browser.test.ts
Normal 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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
122
app/features/scanner/store/compacted-matches.ts
Normal file
122
app/features/scanner/store/compacted-matches.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -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[]>,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user