Skip to content

Commit 70bdda2

Browse files
db: cache objects with ttl (#1997)
* db: cache objects with ttl * api: Make sure cache objects are not shared * api/cache: Allow custom ttl for cache Also remove maxKeys which is bad (it throws an error, no LRU etc). * api/playback: Add cache to playback * api: Make cache gets built-in on db * api/playback: Cache object store for assets * api/playback: Fetch streams before assets We got huge traffic now, lets just switch their order. Later we can think of optimizing this better idk * api: Fix GetOptions default values useReplica was defaulting to false when the cache: true option was set. * api: Fix tests * api: make the cache into a class * api: Make sure to cache objectstore queries * api/db: Improve caching logic - Simplify getOrSet on read - Always write to cache even if not reading from cache - Update cache on writes (helps tests more than prod) - Make copies of objects when reading and writing to cache * api: Remove unnecessary cache flushes on tests * api/acl: Make sure all async flows have cache * api: Remove unused import * api/cache: Make cache TTL configurable --------- Co-authored-by: Victor Elias <[email protected]>
1 parent 033cc2c commit 70bdda2

14 files changed

+195
-57
lines changed

packages/api/src/app-router.ts

+5
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { setupTus, setupTestTus } from "./controllers/asset";
2727
import * as fcl from "@onflow/fcl";
2828
import createFrontend from "@livepeer.studio/www";
2929
import { NotFoundError } from "./store/errors";
30+
import { cache } from "./store/cache";
3031

3132
enum OrchestratorSource {
3233
hardcoded = "hardcoded",
@@ -57,6 +58,7 @@ export default async function makeApp(params: CliArgs) {
5758
httpPrefix,
5859
postgresUrl,
5960
postgresReplicaUrl,
61+
defaultCacheTtl,
6062
frontendDomain,
6163
supportAddr,
6264
sendgridTemplateId,
@@ -98,6 +100,9 @@ export default async function makeApp(params: CliArgs) {
98100
postgresReplicaUrl,
99101
appName: ownRegion ? `${ownRegion}-api` : "api",
100102
});
103+
if (defaultCacheTtl > 0) {
104+
cache.init({ stdTTL: defaultCacheTtl });
105+
}
101106

102107
// RabbitMQ
103108
const queue: Queue = amqpUrl

packages/api/src/controllers/access-control.test.ts

+1
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ describe("controllers/signing-key", () => {
195195
});
196196
expect(res.status).toBe(204);
197197
await db.user.update(gatedAsset.userId, { suspended: true });
198+
198199
const res2 = await client.post("/access-control/gate", {
199200
stream: `video+${gatedAsset.playbackId}`,
200201
type: "jwt",

packages/api/src/controllers/access-control.ts

+27-14
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,9 @@ import { fetchWithTimeoutAndRedirects } from "../util";
2020
import fetch from "node-fetch";
2121
import { WithID } from "../store/types";
2222
import { DBStream } from "../store/stream-table";
23-
import { getViewers } from "./usage";
2423
import { HACKER_DISABLE_CUTOFF_DATE } from "./utils/notification";
2524
import { isFreeTierUser } from "./helpers";
25+
import { cache } from "../store/cache";
2626

2727
const WEBHOOK_TIMEOUT = 30 * 1000;
2828
const MAX_ALLOWED_VIEWERS_FOR_FREE_TIER = 5;
@@ -102,9 +102,16 @@ app.post(
102102
validatePost("access-control-gate-payload"),
103103
async (req, res) => {
104104
const playbackId = req.body.stream.replace(/^\w+\+/, "");
105-
const content =
106-
(await db.stream.getByPlaybackId(playbackId)) ||
107-
(await db.asset.getByPlaybackId(playbackId));
105+
106+
let content = await cache.getOrSet(
107+
`acl-content-${playbackId}`,
108+
async () => {
109+
return (
110+
(await db.stream.getByPlaybackId(playbackId)) ||
111+
(await db.asset.getByPlaybackId(playbackId))
112+
);
113+
}
114+
);
108115

109116
res.set("Cache-Control", "max-age=120,stale-while-revalidate=600");
110117

@@ -116,7 +123,7 @@ app.post(
116123
throw new NotFoundError("Content not found");
117124
}
118125

119-
const user = await db.user.get(content.userId);
126+
let user = await db.user.get(content.userId, { useCache: true });
120127

121128
if (user.suspended || ("suspended" in content && content.suspended)) {
122129
const contentLog = JSON.stringify(JSON.stringify(content));
@@ -129,7 +136,7 @@ app.post(
129136
const playbackPolicyType = content.playbackPolicy?.type ?? "public";
130137

131138
if (user.createdAt < HACKER_DISABLE_CUTOFF_DATE) {
132-
let limitReached = await freeTierLimitReached(content, user, req);
139+
let limitReached = freeTierLimitReached(content, user, req);
133140
if (limitReached) {
134141
throw new ForbiddenError("Free tier user reached viewership limit");
135142
}
@@ -159,11 +166,15 @@ app.post(
159166
);
160167
}
161168

162-
const query = [];
163-
query.push(sql`signing_key.data->>'publicKey' = ${req.body.pub}`);
164-
const [signingKeyOutput] = await db.signingKey.find(query, {
165-
limit: 2,
166-
});
169+
const [signingKeyOutput] = await cache.getOrSet(
170+
`acl-signing-key-pub-${req.body.pub}`,
171+
() => {
172+
const query = [
173+
sql`signing_key.data->>'publicKey' = ${req.body.pub}`,
174+
];
175+
return db.signingKey.find(query, { limit: 2 });
176+
}
177+
);
167178

168179
if (signingKeyOutput.length == 0) {
169180
console.log(`
@@ -210,7 +221,9 @@ app.post(
210221
"Content is gated and requires an access key"
211222
);
212223
}
213-
const webhook = await db.webhook.get(content.playbackPolicy.webhookId);
224+
const webhook = await db.webhook.get(content.playbackPolicy.webhookId, {
225+
useCache: true,
226+
});
214227
if (!webhook) {
215228
console.log(`
216229
access-control: gate: content with playbackId=${playbackId} is gated but corresponding webhook not found for webhookId=${content.playbackPolicy.webhookId}, disallowing playback
@@ -280,11 +293,11 @@ app.get("/public-key", async (req, res) => {
280293
});
281294
});
282295

283-
async function freeTierLimitReached(
296+
function freeTierLimitReached(
284297
content: DBStream | WithID<Asset>,
285298
user: User,
286299
req: Request
287-
): Promise<boolean> {
300+
): boolean {
288301
if (!isFreeTierUser(user)) {
289302
return false;
290303
}

packages/api/src/controllers/asset.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,7 @@ async function validateAssetPlaybackPolicy(
292292
}
293293

294294
async function getActiveObjectStore(id: string) {
295-
const os = await db.objectStore.get(id);
295+
const os = await db.objectStore.get(id, { useCache: true });
296296
if (!os || os.deleted || os.disabled) {
297297
throw new Error("Object store not found or disabled");
298298
}

packages/api/src/controllers/playback.test.ts

+2
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { WithID } from "../store/types";
55
import { db } from "../store";
66
import { DBStream } from "../store/stream-table";
77
import { DBSession } from "../store/session-table";
8+
import { cache } from "../store/cache";
89

910
const EXPECTED_CROSS_USER_ASSETS_CUTOFF_DATE = Date.parse(
1011
"2023-06-06T00:00:00.000Z"
@@ -525,6 +526,7 @@ describe("controllers/playback", () => {
525526
await db.asset.update(asset2.id, {
526527
createdAt: EXPECTED_CROSS_USER_ASSETS_CUTOFF_DATE - 1000,
527528
});
529+
cache.flush();
528530
});
529531

530532
it("should return playback URL asset of user from CID", async () => {

packages/api/src/controllers/playback.ts

+43-30
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { NotFoundError, UnprocessableEntityError } from "../store/errors";
2121
import { isExperimentSubject } from "../store/experiment-table";
2222
import logger from "../logger";
2323
import { getRunningRecording } from "./session";
24+
import { cache } from "../store/cache";
2425

2526
/**
2627
* CROSS_USER_ASSETS_CUTOFF_DATE represents the cut-off date for cross-account
@@ -115,7 +116,7 @@ const getAssetPlaybackInfo = async (
115116
ingest: string,
116117
asset: WithID<Asset>
117118
) => {
118-
const os = await db.objectStore.get(asset.objectStoreId);
119+
const os = await db.objectStore.get(asset.objectStoreId, { useCache: true });
119120
if (!os || os.deleted || os.disabled) {
120121
return null;
121122
}
@@ -136,32 +137,17 @@ const getAssetPlaybackInfo = async (
136137
);
137138
};
138139

140+
type PlaybackResource = {
141+
stream?: DBStream;
142+
session?: DBSession;
143+
asset?: WithID<Asset>;
144+
};
145+
139146
export async function getResourceByPlaybackId(
140147
id: string,
141148
user?: User,
142-
cutoffDate?: number,
143-
origin?: string
144-
): Promise<{ stream?: DBStream; session?: DBSession; asset?: WithID<Asset> }> {
145-
let asset =
146-
(await db.asset.getByPlaybackId(id)) ??
147-
(await db.asset.getByIpfsCid(id, user, cutoffDate)) ??
148-
(await db.asset.getBySourceURL("ipfs://" + id, user, cutoffDate)) ??
149-
(await db.asset.getBySourceURL("ar://" + id, user, cutoffDate));
150-
151-
if (asset && !asset.deleted) {
152-
if (asset.status.phase !== "ready" && !asset.sourcePlaybackReady) {
153-
throw new UnprocessableEntityError("asset is not ready for playback");
154-
}
155-
if (asset.userId !== user?.id && cutoffDate) {
156-
console.log(
157-
`Returning cross-user asset for playback. ` +
158-
`userId=${user?.id} userEmail=${user?.email} origin=${origin} ` +
159-
`assetId=${asset.id} assetUserId=${asset.userId} playbackId=${asset.playbackId}`
160-
);
161-
}
162-
return { asset };
163-
}
164-
149+
cutoffDate?: number
150+
): Promise<PlaybackResource> {
165151
let stream = await db.stream.getByPlaybackId(id);
166152
if (!stream) {
167153
const streamById = await db.stream.get(id);
@@ -174,13 +160,24 @@ export async function getResourceByPlaybackId(
174160
return { stream };
175161
}
176162

163+
const asset =
164+
(await db.asset.getByPlaybackId(id)) ??
165+
(await db.asset.getByIpfsCid(id, user, cutoffDate)) ??
166+
(await db.asset.getBySourceURL("ipfs://" + id, user, cutoffDate)) ??
167+
(await db.asset.getBySourceURL("ar://" + id, user, cutoffDate));
168+
169+
if (asset && !asset.deleted) {
170+
return { asset };
171+
}
172+
177173
const session = await db.session.get(id);
178174
if (session && !session.deleted) {
179175
return { session };
180176
}
181177

182178
return {};
183179
}
180+
184181
async function getAttestationPlaybackInfo(
185182
config: CliArgs,
186183
ingest: string,
@@ -224,14 +221,30 @@ async function getPlaybackInfo(
224221
withRecordings?: boolean
225222
): Promise<PlaybackInfo> {
226223
const cutoffDate = isCrossUserQuery ? null : CROSS_USER_ASSETS_CUTOFF_DATE;
227-
let { stream, asset, session } = await getResourceByPlaybackId(
228-
id,
229-
req.user,
230-
cutoffDate,
231-
origin
232-
);
224+
const cacheKey = `playbackInfo-${id}-user-${req.user?.id}-cutoff-${cutoffDate}`;
225+
let resource = cache.get<PlaybackResource>(cacheKey);
226+
if (!resource) {
227+
resource = await getResourceByPlaybackId(id, req.user, cutoffDate);
228+
229+
const ttl =
230+
resource.asset && resource.asset.status.phase !== "ready" ? 5 : 120;
231+
cache.set(cacheKey, resource, ttl);
232+
}
233+
234+
let { stream, asset, session } = resource;
233235

234236
if (asset) {
237+
if (asset.status.phase !== "ready" && !asset.sourcePlaybackReady) {
238+
throw new UnprocessableEntityError("asset is not ready for playback");
239+
}
240+
if (asset.userId !== req.user?.id && cutoffDate) {
241+
console.log(
242+
`Returning cross-user asset for playback. ` +
243+
`userId=${req.user?.id} userEmail=${req.user?.email} origin=${origin} ` +
244+
`assetId=${asset.id} assetUserId=${asset.userId} playbackId=${asset.playbackId}`
245+
);
246+
}
247+
235248
return await getAssetPlaybackInfo(req.config, ingest, asset);
236249
}
237250

packages/api/src/controllers/session.ts

+6-2
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,9 @@ export async function buildRecordingUrl(
246246
recordCatalystObjectStoreId: string,
247247
secondaryRecordObjectStoreId: string
248248
) {
249-
const os = await db.objectStore.get(recordCatalystObjectStoreId);
249+
const os = await db.objectStore.get(recordCatalystObjectStoreId, {
250+
useCache: true,
251+
});
250252

251253
let urlPrefix = pathJoin(os.publicUrl, session.playbackId, session.id);
252254
let manifestUrl = pathJoin(urlPrefix, "output.m3u8");
@@ -265,7 +267,9 @@ export async function buildRecordingUrl(
265267
};
266268
}
267269

268-
const secondaryOs = await db.objectStore.get(secondaryRecordObjectStoreId);
270+
const secondaryOs = await db.objectStore.get(secondaryRecordObjectStoreId, {
271+
useCache: true,
272+
});
269273
urlPrefix = pathJoin(secondaryOs.publicUrl, session.playbackId, session.id);
270274
manifestUrl = pathJoin(urlPrefix, "output.m3u8");
271275

packages/api/src/controllers/stream.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -407,7 +407,7 @@ export async function getRecordingPlaybackUrl(
407407
return null;
408408
}
409409

410-
const os = await db.objectStore.get(objectStoreId);
410+
const os = await db.objectStore.get(objectStoreId, { useCache: true });
411411
url = pathJoin(os.publicUrl, session.playbackId, session.id, "output.m3u8");
412412
} catch (e) {
413413
console.log(`

packages/api/src/middleware/auth.test.ts

+5-3
Original file line numberDiff line numberDiff line change
@@ -136,8 +136,10 @@ describe("auth middleware", () => {
136136
let nonAdminApiKey: string;
137137
let client: TestClient;
138138

139-
const setAccess = (token: string, rules?: ApiToken["access"]["rules"]) =>
140-
db.apiToken.update(token, { access: { rules } });
139+
const setAccess = async (
140+
token: string,
141+
rules?: ApiToken["access"]["rules"]
142+
) => db.apiToken.update(token, { access: { rules } });
141143

142144
const fetchStatus = async (method: string, path: string) => {
143145
const res = await client.fetch(path, { method });
@@ -375,7 +377,7 @@ describe("auth middleware", () => {
375377
} = await setupUsers(server, mockAdminUserInput, mockNonAdminUserInput));
376378
});
377379

378-
const setAccess = (token: string, access?: ApiToken["access"]) =>
380+
const setAccess = async (token: string, access?: ApiToken["access"]) =>
379381
db.apiToken.update(token, { access });
380382

381383
const expectResponse = (res: Response) =>

packages/api/src/middleware/auth.ts

+6-3
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ function authenticator(): RequestHandler {
8383
if (!tokenId) {
8484
throw new UnauthorizedError(`no authorization token provided`);
8585
}
86-
tokenObject = await db.apiToken.get(tokenId);
86+
tokenObject = await db.apiToken.get(tokenId, { useCache: true });
8787
const matchesBasicUser = tokenObject?.userId === basicUser?.name;
8888
if (!tokenObject || (isBasic && !matchesBasicUser)) {
8989
throw new UnauthorizedError(`no token ${tokenId} found`);
@@ -108,7 +108,8 @@ function authenticator(): RequestHandler {
108108
);
109109
}
110110

111-
user = await db.user.get(userId);
111+
user = await db.user.get(userId, { useCache: true });
112+
112113
if (!user) {
113114
throw new UnauthorizedError(
114115
`no user found from authorization header: ${authHeader}`
@@ -118,10 +119,12 @@ function authenticator(): RequestHandler {
118119
throw new ForbiddenError(`user is suspended`);
119120
}
120121

122+
req.token = tokenObject;
121123
req.user = user;
124+
122125
// UI admins must have a JWT
123126
req.isUIAdmin = user.admin && authScheme === "jwt";
124-
req.token = tokenObject;
127+
125128
return next();
126129
};
127130
}

packages/api/src/parse-cli.ts

+5
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,11 @@ export default function parseCli(argv?: string | readonly string[]) {
142142
describe: "url of a postgres read replica database",
143143
type: "string",
144144
},
145+
"default-cache-ttl": {
146+
describe: "default TTL for entries cached in memory, in seconds",
147+
type: "number",
148+
default: 120,
149+
},
145150
"amqp-url": {
146151
describe: "the RabbitMQ Url",
147152
type: "string",

0 commit comments

Comments
 (0)