Skip to content

Commit 0ea5a6b

Browse files
fix: batch database queries in getPlaylistsForDJ to eliminate N+1
Replaced sequential per-show queries with batched IN queries, reducing ~4N queries to ~5 constant queries. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 108d1bf commit 0ea5a6b

2 files changed

Lines changed: 217 additions & 38 deletions

File tree

apps/backend/services/djs.service.ts

Lines changed: 51 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
specialty_shows,
1515
user,
1616
} from '@wxyc/database';
17-
import { and, eq, isNull } from 'drizzle-orm';
17+
import { and, eq, inArray, isNull } from 'drizzle-orm';
1818

1919
export const addToBin = async (bin_entry: NewBinEntry): Promise<BinEntry> => {
2020
const added_bin_entry = await db.insert(bins).values(bin_entry).returning();
@@ -63,47 +63,60 @@ type ShowPeek = {
6363

6464
// ERRORS IN SERVICES ARE 500 ERRORS
6565
export const getPlaylistsForDJ = async (dj_id: string) => {
66-
// gets a 'preview set' of 4 artists/albums and the show id for each show the dj has been in
6766
const this_djs_shows = await db.select().from(show_djs).where(eq(show_djs.dj_id, dj_id));
6867

69-
const show_previews = [];
70-
for (let i = 0; i < this_djs_shows.length; i++) {
71-
const show = await db.select().from(shows).where(eq(shows.id, this_djs_shows[i].show_id));
72-
73-
const djs_involved = await db
74-
.select({ dj_id: show_djs.dj_id, dj_name: user.djName })
75-
.from(show_djs)
76-
.innerJoin(user, and(eq(show_djs.show_id, show[0].id), eq(show_djs.dj_id, user.id)));
77-
78-
const peek_object: ShowPeek = {
79-
show: show[0].id,
80-
show_name: show[0].show_name ?? '',
81-
date: show[0].start_time,
82-
djs: djs_involved,
83-
specialty_show: '',
84-
preview: [],
85-
};
86-
87-
if (show[0].specialty_id != null) {
88-
const specialty_show = await db
89-
.select()
90-
.from(specialty_shows)
91-
.where(eq(specialty_shows.id, show[0].specialty_id));
92-
peek_object.specialty_show = specialty_show[0].specialty_name;
93-
}
94-
95-
//get 4 track entries to display in preview
96-
const entries: FSEntry[] = await db
97-
.select()
98-
.from(flowsheet)
99-
.limit(4)
100-
.where(and(eq(flowsheet.show_id, show[0].id), isNull(flowsheet.message)));
101-
102-
peek_object.preview = entries;
103-
show_previews.push(peek_object);
68+
if (this_djs_shows.length === 0) return [];
69+
70+
const showIds = this_djs_shows.map((s) => s.show_id);
71+
72+
const allShows = await db.select().from(shows).where(inArray(shows.id, showIds));
73+
74+
const allDjs = await db
75+
.select({ dj_id: show_djs.dj_id, dj_name: user.djName, show_id: show_djs.show_id })
76+
.from(show_djs)
77+
.innerJoin(user, eq(show_djs.dj_id, user.id))
78+
.where(inArray(show_djs.show_id, showIds));
79+
80+
const specialtyIds = allShows
81+
.filter((s) => s.specialty_id != null)
82+
.map((s) => s.specialty_id!);
83+
84+
const allSpecialties =
85+
specialtyIds.length > 0
86+
? await db.select().from(specialty_shows).where(inArray(specialty_shows.id, specialtyIds))
87+
: [];
88+
89+
const allEntries: FSEntry[] = await db
90+
.select()
91+
.from(flowsheet)
92+
.where(and(inArray(flowsheet.show_id, showIds), isNull(flowsheet.message)));
93+
94+
const specialtyMap = new Map(allSpecialties.map((s) => [s.id, s.specialty_name]));
95+
const djsByShow = new Map<number, { dj_id: string; dj_name: string | null }[]>();
96+
for (const dj of allDjs) {
97+
const list = djsByShow.get(dj.show_id) ?? [];
98+
list.push({ dj_id: dj.dj_id, dj_name: dj.dj_name });
99+
djsByShow.set(dj.show_id, list);
100+
}
101+
const entriesByShow = new Map<number, FSEntry[]>();
102+
for (const entry of allEntries) {
103+
if (entry.show_id == null) continue;
104+
const list = entriesByShow.get(entry.show_id) ?? [];
105+
list.push(entry);
106+
entriesByShow.set(entry.show_id, list);
104107
}
105108

106-
return show_previews;
109+
return allShows.map((show) => {
110+
const preview = (entriesByShow.get(show.id) ?? []).slice(0, 4);
111+
return {
112+
show: show.id,
113+
show_name: show.show_name ?? '',
114+
date: show.start_time,
115+
djs: djsByShow.get(show.id) ?? [],
116+
specialty_show: specialtyMap.get(show.specialty_id!) ?? '',
117+
preview,
118+
} satisfies ShowPeek;
119+
});
107120
};
108121

109122
export const getPlaylist = async (show_id: number) => {
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
let selectCallCount = 0;
2+
const selectSpy = jest.fn();
3+
const queryResults: unknown[][] = [];
4+
5+
function createChain(resolveIndex: number) {
6+
const resolver = () => queryResults[resolveIndex] ?? [];
7+
const chain: Record<string, unknown> = {};
8+
for (const m of ['select', 'from', 'where', 'innerJoin', 'leftJoin', 'limit', 'orderBy']) {
9+
chain[m] = jest.fn(() => chain);
10+
}
11+
chain.then = (onFulfill: (v: unknown) => unknown, onReject?: (e: unknown) => unknown) =>
12+
Promise.resolve(resolver()).then(onFulfill, onReject);
13+
return chain;
14+
}
15+
16+
jest.mock('@wxyc/database', () => ({
17+
db: {
18+
select: (...args: unknown[]) => {
19+
selectSpy(...args);
20+
return createChain(selectCallCount++);
21+
},
22+
},
23+
show_djs: { dj_id: 'dj_id', show_id: 'show_id' },
24+
shows: { id: 'id', specialty_id: 'specialty_id' },
25+
specialty_shows: { id: 'id', specialty_name: 'specialty_name' },
26+
flowsheet: { show_id: 'show_id', message: 'message' },
27+
user: { id: 'id', djName: 'djName' },
28+
bins: {},
29+
library: {},
30+
artists: {},
31+
format: {},
32+
genres: {},
33+
}));
34+
35+
jest.mock('drizzle-orm', () => ({
36+
eq: jest.fn((a, b) => ({ eq: [a, b] })),
37+
and: jest.fn((...args: unknown[]) => ({ and: args })),
38+
isNull: jest.fn((col) => ({ isNull: col })),
39+
inArray: jest.fn((col, vals) => ({ inArray: [col, vals] })),
40+
}));
41+
42+
import { getPlaylistsForDJ } from '../../../apps/backend/services/djs.service';
43+
44+
const DJ_ID = 'dj-1';
45+
46+
function makeShowDjRows(count: number) {
47+
return Array.from({ length: count }, (_, i) => ({
48+
show_id: i + 1,
49+
dj_id: DJ_ID,
50+
active: true,
51+
}));
52+
}
53+
54+
function makeShowRows(count: number) {
55+
return Array.from({ length: count }, (_, i) => ({
56+
id: i + 1,
57+
primary_dj_id: DJ_ID,
58+
specialty_id: i === 0 ? 10 : null,
59+
show_name: `Show ${i + 1}`,
60+
start_time: new Date(`2024-01-${String(i + 1).padStart(2, '0')}T20:00:00Z`),
61+
end_time: null,
62+
}));
63+
}
64+
65+
function makeDjsForShows(count: number) {
66+
return Array.from({ length: count }, (_, i) => ({
67+
dj_id: DJ_ID,
68+
dj_name: 'DJ One',
69+
show_id: i + 1,
70+
}));
71+
}
72+
73+
function makeFlowsheetRows(showCount: number) {
74+
const entries = [];
75+
for (let s = 0; s < showCount; s++) {
76+
for (let e = 0; e < 4; e++) {
77+
entries.push({
78+
id: s * 4 + e + 1,
79+
show_id: s + 1,
80+
album_id: null,
81+
rotation_id: null,
82+
entry_type: 'track',
83+
track_title: `Track ${e + 1}`,
84+
album_title: `Album ${e + 1}`,
85+
artist_name: `Artist ${e + 1}`,
86+
record_label: null,
87+
play_order: e + 1,
88+
request_flag: false,
89+
message: null,
90+
add_time: new Date(),
91+
});
92+
}
93+
}
94+
return entries;
95+
}
96+
97+
describe('djs.service - getPlaylistsForDJ', () => {
98+
beforeEach(() => {
99+
selectCallCount = 0;
100+
selectSpy.mockClear();
101+
queryResults.length = 0;
102+
});
103+
104+
const NUM_SHOWS = 5;
105+
const MAX_ALLOWED_SELECTS = 6;
106+
107+
it(`should make at most ${MAX_ALLOWED_SELECTS} select() calls, not O(N) for ${NUM_SHOWS} shows`, async () => {
108+
const showDjRows = makeShowDjRows(NUM_SHOWS);
109+
const showRows = makeShowRows(NUM_SHOWS);
110+
const djRows = makeDjsForShows(NUM_SHOWS);
111+
const specialtyRows = [{ id: 10, specialty_name: 'Jazz After Hours' }];
112+
const flowsheetRows = makeFlowsheetRows(NUM_SHOWS);
113+
114+
// Provide enough results for both the batched (5 queries) and N+1 (17+ queries) paths.
115+
// Batched order: [showDjs, shows, allDjs, specialties, flowsheet]
116+
// N+1 order: [showDjs, show1, djs1, specialty1, fs1, show2, djs2, fs2, ...]
117+
// We fill enough slots so either code path can run without crashing.
118+
queryResults.push(showDjRows); // 0: show_djs for DJ
119+
queryResults.push(showRows); // 1: batched shows / N+1 show[0]
120+
queryResults.push(djRows); // 2: batched djs / N+1 djs[0]
121+
queryResults.push(specialtyRows); // 3: batched specialty / N+1 specialty[0]
122+
queryResults.push(flowsheetRows); // 4: batched flowsheet / N+1 flowsheet[0]
123+
// Extra slots for N+1 loop iterations (shows 2-5)
124+
for (let i = 1; i < NUM_SHOWS; i++) {
125+
queryResults.push([showRows[i]]); // show
126+
queryResults.push([{ dj_id: DJ_ID, dj_name: 'DJ One' }]); // djs
127+
if (showRows[i].specialty_id != null) {
128+
queryResults.push(specialtyRows);
129+
}
130+
queryResults.push(flowsheetRows.filter((e) => e.show_id === i + 1)); // flowsheet
131+
}
132+
133+
await getPlaylistsForDJ(DJ_ID);
134+
135+
const totalSelectCalls = selectSpy.mock.calls.length;
136+
expect(totalSelectCalls).toBeLessThanOrEqual(MAX_ALLOWED_SELECTS);
137+
});
138+
139+
it('returns correct ShowPeek structures', async () => {
140+
const showDjRows = makeShowDjRows(2);
141+
const showRows = makeShowRows(2);
142+
const djRows = makeDjsForShows(2);
143+
const specialtyRows = [{ id: 10, specialty_name: 'Jazz After Hours' }];
144+
const flowsheetRows = makeFlowsheetRows(2);
145+
146+
queryResults.push(showDjRows);
147+
queryResults.push(showRows);
148+
queryResults.push(djRows);
149+
queryResults.push(specialtyRows);
150+
queryResults.push(flowsheetRows);
151+
// N+1 fallback slots
152+
queryResults.push([showRows[1]]);
153+
queryResults.push([{ dj_id: DJ_ID, dj_name: 'DJ One' }]);
154+
queryResults.push(flowsheetRows.filter((e) => e.show_id === 2));
155+
156+
const result = await getPlaylistsForDJ(DJ_ID);
157+
158+
expect(result).toHaveLength(2);
159+
expect(result[0]).toHaveProperty('show');
160+
expect(result[0]).toHaveProperty('show_name');
161+
expect(result[0]).toHaveProperty('date');
162+
expect(result[0]).toHaveProperty('djs');
163+
expect(result[0]).toHaveProperty('specialty_show');
164+
expect(result[0]).toHaveProperty('preview');
165+
});
166+
});

0 commit comments

Comments
 (0)