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 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 20:43:08 +01:00
co-authored by Claude Opus 5.5
parent b2e6e1948b
commit 6efb97eb51
2 changed files with 66 additions and 29 deletions
+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
* 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(
db: Db,
@@ -43,38 +47,48 @@ export async function ingestListens(
records: SourceRecord[],
actor: Actor = `plugin:${source}:ingest`,
): Promise<{ inserted: number }> {
const listens = records.filter((r): r is Extract<SourceRecord, { kind: "listen" }> => r.kind === "listen");
let inserted = 0;
for (const r of records) {
if (r.kind !== "listen") continue;
const exists = await db.k
for (let i = 0; i < listens.length; i += BATCH) {
const batch = new Map<string, ListenFields>();
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")
.select("id")
.select("source_key")
.where("user_id", "=", userId)
.where("source", "=", source)
.where("source_key", "=", r.key)
.executeTakeFirst();
if (exists) continue;
const l = r.listen;
const recording = l.mbids?.recording;
const workId = recording ? await trackWork(db, recording, l, actor) : null;
const res = await db.k
.insertInto("listens")
.values({
id: newId(),
user_id: userId,
listened_at: l.listenedAt,
artist: l.artist,
track: l.track,
album: l.album ?? null,
duration_ms: l.durationMs ?? null,
mbids: toJson(l.mbids ?? {}),
work_id: workId,
source,
source_key: r.key,
created_at: nowIso(),
})
.onConflict((oc) => oc.columns(["user_id", "source", "source_key"]).doNothing())
.executeTakeFirst();
.where("source_key", "in", [...batch.keys()])
.execute();
for (const e of existing) batch.delete(e.source_key);
if (batch.size === 0) continue;
const works = new Map<string, string>();
for (const l of batch.values()) {
const rec = l.mbids?.recording;
if (rec && !works.has(rec)) works.set(rec, await trackWork(db, rec, l, actor));
}
const now = nowIso();
const rows = [...batch].map(([key, l]) => ({
id: newId(),
user_id: userId,
listened_at: l.listenedAt,
artist: l.artist,
track: l.track,
album: l.album ?? null,
duration_ms: l.durationMs ?? null,
mbids: toJson(l.mbids ?? {}),
work_id: l.mbids?.recording ? works.get(l.mbids.recording)! : null,
source,
source_key: key,
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);
}
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 });
});
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 () => {
const db = getDb();
const userId = await makeUser(db);