fix(core): batch listen ingestion per 500

One existing-key lookup, one resolve per distinct recording, and one
multi-row insert-or-ignore per batch instead of per-listen queries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-30 20:43:08 +01:00
1 parent b2e6e1948b
commit 6efb97eb51
2 files changed
+66 -29

No files matched your search

+43 -29
View File
@@ -31,10 +31,14 @@ async function trackWork(db: Db, recording: string, l: ListenFields, actor: Acto
} }
} }
/** Listens per query/transaction. 500 rows × 12 columns stays well under both dialects' parameter limits. */
const BATCH = 500;
/** /**
* Append listens (spec §6.4): insert-or-ignore on (user, source, source_key), where `source_key` is the * Append listens (spec §6.4): insert-or-ignore on (user, source, source_key), where `source_key` is the
* record key. A work is linked only through `mbids.recording`. Non-listen records are ignored. * record key. A work is linked only through `mbids.recording`. Non-listen records are ignored.
* No change events are written. * No change events are written. Per batch: one lookup of existing keys, each distinct recording resolved
* once (outside the transaction; `createWork` has its own), then one multi-row insert.
*/ */
export async function ingestListens( export async function ingestListens(
db: Db, db: Db,
@@ -43,38 +47,48 @@ export async function ingestListens(
records: SourceRecord[], records: SourceRecord[],
actor: Actor = `plugin:${source}:ingest`, actor: Actor = `plugin:${source}:ingest`,
): Promise<{ inserted: number }> { ): Promise<{ inserted: number }> {
const listens = records.filter((r): r is Extract<SourceRecord, { kind: "listen" }> => r.kind === "listen");
let inserted = 0; let inserted = 0;
for (const r of records) { for (let i = 0; i < listens.length; i += BATCH) {
if (r.kind !== "listen") continue; const batch = new Map<string, ListenFields>();
const exists = await db.k for (const r of listens.slice(i, i + BATCH)) if (!batch.has(r.key)) batch.set(r.key, r.listen);
const existing = await db.k
.selectFrom("listens") .selectFrom("listens")
.select("id") .select("source_key")
.where("user_id", "=", userId) .where("user_id", "=", userId)
.where("source", "=", source) .where("source", "=", source)
.where("source_key", "=", r.key) .where("source_key", "in", [...batch.keys()])
.executeTakeFirst(); .execute();
if (exists) continue; for (const e of existing) batch.delete(e.source_key);
const l = r.listen; if (batch.size === 0) continue;
const recording = l.mbids?.recording;
const workId = recording ? await trackWork(db, recording, l, actor) : null; const works = new Map<string, string>();
const res = await db.k for (const l of batch.values()) {
.insertInto("listens") const rec = l.mbids?.recording;
.values({ if (rec && !works.has(rec)) works.set(rec, await trackWork(db, rec, l, actor));
id: newId(), }
user_id: userId, const now = nowIso();
listened_at: l.listenedAt, const rows = [...batch].map(([key, l]) => ({
artist: l.artist, id: newId(),
track: l.track, user_id: userId,
album: l.album ?? null, listened_at: l.listenedAt,
duration_ms: l.durationMs ?? null, artist: l.artist,
mbids: toJson(l.mbids ?? {}), track: l.track,
work_id: workId, album: l.album ?? null,
source, duration_ms: l.durationMs ?? null,
source_key: r.key, mbids: toJson(l.mbids ?? {}),
created_at: nowIso(), work_id: l.mbids?.recording ? works.get(l.mbids.recording)! : null,
}) source,
.onConflict((oc) => oc.columns(["user_id", "source", "source_key"]).doNothing()) source_key: key,
.executeTakeFirst(); created_at: now,
}));
const res = await db.k.transaction().execute((tx) =>
tx
.insertInto("listens")
.values(rows)
.onConflict((oc) => oc.columns(["user_id", "source", "source_key"]).doNothing())
.executeTakeFirst(),
);
inserted += Number(res.numInsertedOrUpdatedRows ?? 0n); inserted += Number(res.numInsertedOrUpdatedRows ?? 0n);
} }
return { inserted }; return { inserted };
+23
View File
@@ -45,6 +45,29 @@ forEachDialect("listens", (getDb) => {
expect(await ingestListens(db, bob, "listenbrainz", records.slice(0, 1))).toEqual({ inserted: 1 }); expect(await ingestListens(db, bob, "listenbrainz", records.slice(0, 1))).toEqual({ inserted: 1 });
}); });
test("1200 listens ingest correctly and idempotently", async () => {
const db = getDb();
const userId = await makeUser(db);
const records = Array.from({ length: 1200 }, (_, i) =>
listenRec(`k${i}`, {
listenedAt: `2024-01-01T00:00:${String(i % 60).padStart(2, "0")}Z`,
artist: "A",
track: `T${i % 7}`,
...(i % 3 === 0 ? { mbids: { recording: `rec-${i % 7}` } } : {}),
}),
);
// A duplicate key inside the same call is inserted once.
records.push(records[5]!);
expect(await ingestListens(db, userId, "lb", records)).toEqual({ inserted: 1200 });
expect(await count(db, "listens")).toBe(1200);
expect(await count(db, "works")).toBe(7);
const linked = await db.k.selectFrom("listens").select("work_id").where("work_id", "is not", null).execute();
expect(linked).toHaveLength(400);
expect(await ingestListens(db, userId, "lb", records)).toEqual({ inserted: 0 });
expect(await count(db, "listens")).toBe(1200);
expect(await count(db, "works")).toBe(7);
});
test("runSync routes listens to ingestListens and counts them", async () => { test("runSync routes listens to ingestListens and counts them", async () => {
const db = getDb(); const db = getDb();
const userId = await makeUser(db); const userId = await makeUser(db);