Skip to content

Commit 490508e

Browse files
authored
Merge pull request #946 from constructive-io/devin/1775023557-bucket-cache
feat: add LRU bucket metadata cache to presigned-url-plugin
2 parents d6c68ac + c015745 commit 490508e

3 files changed

Lines changed: 113 additions & 16 deletions

File tree

graphile/graphile-presigned-url-plugin/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
export { PresignedUrlPlugin, createPresignedUrlPlugin } from './plugin';
3131
export { createDownloadUrlPlugin } from './download-url-field';
3232
export { PresignedUrlPreset } from './preset';
33-
export { getStorageModuleConfig, clearStorageModuleCache } from './storage-module-cache';
33+
export { getStorageModuleConfig, getBucketConfig, clearStorageModuleCache, clearBucketCache } from './storage-module-cache';
3434
export { generatePresignedPutUrl, generatePresignedGetUrl, headObject } from './s3-signer';
3535
export type {
3636
BucketConfig,

graphile/graphile-presigned-url-plugin/src/plugin.ts

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import { extendSchema, gql } from 'graphile-utils';
2323
import { Logger } from '@pgpmjs/logger';
2424

2525
import type { PresignedUrlPluginOptions } from './types';
26-
import { getStorageModuleConfig } from './storage-module-cache';
26+
import { getStorageModuleConfig, getBucketConfig } from './storage-module-cache';
2727
import { generatePresignedPutUrl, headObject } from './s3-signer';
2828

2929
const log = new Logger('graphile-presigned-url:plugin');
@@ -188,21 +188,12 @@ export function createPresignedUrlPlugin(
188188
}
189189
}
190190

191-
// --- Look up the bucket (RLS enforced) ---
192-
const bucketResult = await pgClient.query(
193-
`SELECT id, type, is_public, owner_id, allowed_mime_types, max_file_size
194-
FROM ${storageConfig.bucketsQualifiedName}
195-
WHERE key = $1
196-
LIMIT 1`,
197-
[bucketKey],
198-
);
199-
200-
if (bucketResult.rows.length === 0) {
191+
// --- Look up the bucket (cached; first miss queries via RLS) ---
192+
const bucket = await getBucketConfig(pgClient, storageConfig, databaseId, bucketKey);
193+
if (!bucket) {
201194
throw new Error('BUCKET_NOT_FOUND');
202195
}
203196

204-
const bucket = bucketResult.rows[0];
205-
206197
// --- Validate content type against bucket's allowed_mime_types ---
207198
if (bucket.allowed_mime_types && bucket.allowed_mime_types.length > 0) {
208199
const allowed = bucket.allowed_mime_types as string[];

graphile/graphile-presigned-url-plugin/src/storage-module-cache.ts

Lines changed: 108 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Logger } from '@pgpmjs/logger';
22
import { LRUCache } from 'lru-cache';
3-
import type { StorageModuleConfig } from './types';
3+
import type { StorageModuleConfig, BucketConfig } from './types';
44

55
const log = new Logger('graphile-presigned-url:cache');
66

@@ -125,9 +125,115 @@ export async function getStorageModuleConfig(
125125
return config;
126126
}
127127

128+
// --- Bucket metadata cache ---
129+
130+
/**
131+
* LRU cache for per-database bucket metadata.
132+
*
133+
* Buckets are essentially static config — created once and rarely changed.
134+
* Caching avoids a DB query on every requestUploadUrl call. The bucket
135+
* lookup in the plugin runs under RLS, but since AuthzEntityMembership
136+
* grants all org members access to all org buckets, and the cached data
137+
* is just config (mime types, size limits), bypassing RLS on cache hits
138+
* is safe. The important RLS is on the files table (INSERT/UPDATE),
139+
* which is never cached.
140+
*
141+
* Keys: `bucket:${databaseId}:${bucketKey}`
142+
* TTL: same as storage module cache (5min dev / 1hr prod)
143+
*/
144+
const bucketCache = new LRUCache<string, BucketConfig>({
145+
max: 500, // many buckets across many databases
146+
ttl: process.env.NODE_ENV === 'development' ? FIVE_MINUTES_MS : ONE_HOUR_MS,
147+
updateAgeOnGet: true,
148+
});
149+
150+
/**
151+
* Resolve bucket metadata for a given database + bucket key, using the LRU cache.
152+
*
153+
* On cache miss, queries the bucket table (RLS-enforced via pgSettings on
154+
* the pgClient). On cache hit, returns the cached metadata directly.
155+
*
156+
* @param pgClient - A pg client from the Graphile context
157+
* @param storageConfig - The resolved StorageModuleConfig for this database
158+
* @param databaseId - The metaschema database UUID (used as cache key prefix)
159+
* @param bucketKey - The bucket key (e.g., "public", "private")
160+
* @returns BucketConfig or null if the bucket doesn't exist / isn't accessible
161+
*/
162+
export async function getBucketConfig(
163+
pgClient: { query: (sql: string, params: unknown[]) => Promise<{ rows: unknown[] }> },
164+
storageConfig: StorageModuleConfig,
165+
databaseId: string,
166+
bucketKey: string,
167+
): Promise<BucketConfig | null> {
168+
const cacheKey = `bucket:${databaseId}:${bucketKey}`;
169+
const cached = bucketCache.get(cacheKey);
170+
if (cached) {
171+
return cached;
172+
}
173+
174+
log.debug(`Bucket cache miss for ${databaseId}:${bucketKey}, querying DB...`);
175+
176+
const result = await pgClient.query(
177+
`SELECT id, key, type, is_public, owner_id, allowed_mime_types, max_file_size
178+
FROM ${storageConfig.bucketsQualifiedName}
179+
WHERE key = $1
180+
LIMIT 1`,
181+
[bucketKey],
182+
);
183+
184+
if (result.rows.length === 0) {
185+
return null;
186+
}
187+
188+
const row = result.rows[0] as {
189+
id: string;
190+
key: string;
191+
type: string;
192+
is_public: boolean;
193+
owner_id: string;
194+
allowed_mime_types: string[] | null;
195+
max_file_size: number | null;
196+
};
197+
198+
const config: BucketConfig = {
199+
id: row.id,
200+
key: row.key,
201+
type: row.type as BucketConfig['type'],
202+
is_public: row.is_public,
203+
owner_id: row.owner_id,
204+
allowed_mime_types: row.allowed_mime_types,
205+
max_file_size: row.max_file_size,
206+
};
207+
208+
bucketCache.set(cacheKey, config);
209+
log.debug(`Cached bucket config for ${databaseId}:${bucketKey} (id=${config.id})`);
210+
211+
return config;
212+
}
213+
128214
/**
129-
* Clear the storage module cache (useful for testing or schema changes).
215+
* Clear the storage module cache AND bucket cache.
216+
* Useful for testing or schema changes.
130217
*/
131218
export function clearStorageModuleCache(): void {
132219
storageModuleCache.clear();
220+
bucketCache.clear();
221+
}
222+
223+
/**
224+
* Clear cached bucket entries for a specific database.
225+
* Useful when bucket config changes are detected.
226+
*/
227+
export function clearBucketCache(databaseId?: string): void {
228+
if (!databaseId) {
229+
bucketCache.clear();
230+
return;
231+
}
232+
// Evict all entries for this database
233+
const prefix = `bucket:${databaseId}:`;
234+
for (const key of bucketCache.keys()) {
235+
if (key.startsWith(prefix)) {
236+
bucketCache.delete(key);
237+
}
238+
}
133239
}

0 commit comments

Comments
 (0)