perf(functions): B6b bound the unbounded couple scanners (#10)
assignDailyQuestion and aggregateOutcomeStats previously did an unbounded
db.collection('couples').get() (loading every couple into memory), and aggregate did a
serial outcomes.get() per couple (O(couples) round-trips). Both now paginate the couple
scan (orderBy __name__ + startAfter, 300/200 per page — no custom index needed):
- assignDailyQuestion: each page's create() writes fan out with the burst bounded to a page
instead of all couples at once; ALREADY_EXISTS stays the idempotent no-op.
- aggregateOutcomeStats: reads each page's outcomes in parallel instead of serially.
couples.length still counts every couple, so the aggregate windows + totalCouples are
unchanged (pure aggregate() helper and its tests untouched).
This is the one behavior-touching improvement flagged in the plan; the 512MiB/300s resource
options from B2 remain the safety net. Build clean; 70 tests green. dist rebuilt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
d601498e96
commit
eb4bab0b90
|
|
@ -86,11 +86,27 @@ function extractCoupleOutcome(outcomeDocs) {
|
|||
}
|
||||
exports.aggregateOutcomeStats = (0, scheduler_1.onSchedule)({ schedule: 'every 24 hours', memory: '512MiB', timeoutSeconds: 300 }, async () => {
|
||||
const db = admin.firestore();
|
||||
// Paginate the couple scan (was an unbounded couples.get()) and read each page's outcomes in
|
||||
// parallel (was one serial outcomes.get() per couple → O(couples) round-trips). couples.length
|
||||
// still counts every couple, so the aggregate + totalCouples are unchanged.
|
||||
const couples = [];
|
||||
const couplesSnap = await db.collection('couples').get();
|
||||
for (const couple of couplesSnap.docs) {
|
||||
const PAGE_SIZE = 200;
|
||||
let lastDoc;
|
||||
for (;;) {
|
||||
let query = db.collection('couples').orderBy('__name__').limit(PAGE_SIZE);
|
||||
if (lastDoc)
|
||||
query = query.startAfter(lastDoc);
|
||||
const page = await query.get();
|
||||
if (page.empty)
|
||||
break;
|
||||
const pageOutcomes = await Promise.all(page.docs.map(async (couple) => {
|
||||
const outcomesSnap = await couple.ref.collection('outcomes').get();
|
||||
couples.push(extractCoupleOutcome(outcomesSnap.docs.map((d) => ({ id: d.id, data: d.data() }))));
|
||||
return extractCoupleOutcome(outcomesSnap.docs.map((d) => ({ id: d.id, data: d.data() })));
|
||||
}));
|
||||
couples.push(...pageOutcomes);
|
||||
lastDoc = page.docs[page.docs.length - 1];
|
||||
if (page.size < PAGE_SIZE)
|
||||
break;
|
||||
}
|
||||
const stats = aggregate(couples);
|
||||
await db.collection('aggregate_stats').doc('outcomes').set(Object.assign(Object.assign({}, stats), { totalCouples: couples.length, generatedAt: admin.firestore.Timestamp.now() }));
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
{"version":3,"file":"aggregateOutcomes.js","sourceRoot":"","sources":["../../src/couples/aggregateOutcomes.ts"],"names":[],"mappings":"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"}
|
||||
{"version":3,"file":"aggregateOutcomes.js","sourceRoot":"","sources":["../../src/couples/aggregateOutcomes.ts"],"names":[],"mappings":"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"}
|
||||
|
|
@ -63,35 +63,43 @@ exports.assignDailyQuestion = (0, scheduler_1.onSchedule)({ schedule: '0 23 * *
|
|||
log_1.logger.error('[assignDailyQuestion] questions pool not seeded — skipping assignment');
|
||||
return;
|
||||
}
|
||||
const couplesSnap = await db.collection('couples').get();
|
||||
const assignedAt = admin.firestore.FieldValue.serverTimestamp();
|
||||
const expiresAt = timestampAt6PmCst(nextDay);
|
||||
const writes = couplesSnap.docs.map(async (coupleDoc) => {
|
||||
// Paginate the couple scan so we never load every couple into memory at once, and each write
|
||||
// burst is bounded to a page (instead of an unbounded parallel fan-out). Ordering by __name__
|
||||
// needs no custom index. create() is idempotent — ALREADY_EXISTS (code 6) is the no-op path.
|
||||
const PAGE_SIZE = 300;
|
||||
let scanned = 0;
|
||||
let lastDoc;
|
||||
for (;;) {
|
||||
let query = db.collection('couples').orderBy('__name__').limit(PAGE_SIZE);
|
||||
if (lastDoc)
|
||||
query = query.startAfter(lastDoc);
|
||||
const page = await query.get();
|
||||
if (page.empty)
|
||||
break;
|
||||
scanned += page.size;
|
||||
await Promise.allSettled(page.docs.map(async (coupleDoc) => {
|
||||
var _a;
|
||||
const coupleId = coupleDoc.id;
|
||||
const docRef = db
|
||||
.collection('couples')
|
||||
.doc(coupleId)
|
||||
.doc(coupleDoc.id)
|
||||
.collection('daily_question')
|
||||
.doc(today);
|
||||
try {
|
||||
await docRef.create({
|
||||
questionId,
|
||||
date: today,
|
||||
assignedAt,
|
||||
expiresAt,
|
||||
});
|
||||
await docRef.create({ questionId, date: today, assignedAt, expiresAt });
|
||||
}
|
||||
catch (err) {
|
||||
if ((err === null || err === void 0 ? void 0 : err.code) === 6 || ((_a = err === null || err === void 0 ? void 0 : err.message) === null || _a === void 0 ? void 0 : _a.includes('ALREADY_EXISTS'))) {
|
||||
// Already assigned for today — idempotent.
|
||||
return;
|
||||
if ((err === null || err === void 0 ? void 0 : err.code) === 6 || ((_a = err === null || err === void 0 ? void 0 : err.message) === null || _a === void 0 ? void 0 : _a.includes('ALREADY_EXISTS')))
|
||||
return; // idempotent
|
||||
log_1.logger.error(`[assignDailyQuestion] failed for ${coupleDoc.id}`, { error: String(err) });
|
||||
}
|
||||
log_1.logger.error(`[assignDailyQuestion] failed for ${coupleId}`, { error: String(err) });
|
||||
}));
|
||||
lastDoc = page.docs[page.docs.length - 1];
|
||||
if (page.size < PAGE_SIZE)
|
||||
break;
|
||||
}
|
||||
});
|
||||
await Promise.all(writes);
|
||||
log_1.logger.log(`[assignDailyQuestion] assigned ${questionId} to ${couplesSnap.size} couples for ${today}`);
|
||||
log_1.logger.log(`[assignDailyQuestion] assigned ${questionId} to ${scanned} couples for ${today}`);
|
||||
});
|
||||
/**
|
||||
* Callable function for immediate daily question assignment.
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -96,13 +96,26 @@ export const aggregateOutcomeStats = onSchedule(
|
|||
async () => {
|
||||
const db = admin.firestore()
|
||||
|
||||
// Paginate the couple scan (was an unbounded couples.get()) and read each page's outcomes in
|
||||
// parallel (was one serial outcomes.get() per couple → O(couples) round-trips). couples.length
|
||||
// still counts every couple, so the aggregate + totalCouples are unchanged.
|
||||
const couples: CoupleOutcome[] = []
|
||||
const couplesSnap = await db.collection('couples').get()
|
||||
for (const couple of couplesSnap.docs) {
|
||||
const PAGE_SIZE = 200
|
||||
let lastDoc: FirebaseFirestore.QueryDocumentSnapshot | undefined
|
||||
for (;;) {
|
||||
let query = db.collection('couples').orderBy('__name__').limit(PAGE_SIZE)
|
||||
if (lastDoc) query = query.startAfter(lastDoc)
|
||||
const page = await query.get()
|
||||
if (page.empty) break
|
||||
const pageOutcomes = await Promise.all(
|
||||
page.docs.map(async (couple) => {
|
||||
const outcomesSnap = await couple.ref.collection('outcomes').get()
|
||||
couples.push(
|
||||
extractCoupleOutcome(outcomesSnap.docs.map((d) => ({ id: d.id, data: d.data() })))
|
||||
return extractCoupleOutcome(outcomesSnap.docs.map((d) => ({ id: d.id, data: d.data() })))
|
||||
})
|
||||
)
|
||||
couples.push(...pageOutcomes)
|
||||
lastDoc = page.docs[page.docs.length - 1]
|
||||
if (page.size < PAGE_SIZE) break
|
||||
}
|
||||
|
||||
const stats = aggregate(couples)
|
||||
|
|
|
|||
|
|
@ -29,38 +29,43 @@ export const assignDailyQuestion = onSchedule(
|
|||
return
|
||||
}
|
||||
|
||||
const couplesSnap = await db.collection('couples').get()
|
||||
const assignedAt = admin.firestore.FieldValue.serverTimestamp()
|
||||
const expiresAt = timestampAt6PmCst(nextDay)
|
||||
|
||||
const writes = couplesSnap.docs.map(async (coupleDoc) => {
|
||||
const coupleId = coupleDoc.id
|
||||
// Paginate the couple scan so we never load every couple into memory at once, and each write
|
||||
// burst is bounded to a page (instead of an unbounded parallel fan-out). Ordering by __name__
|
||||
// needs no custom index. create() is idempotent — ALREADY_EXISTS (code 6) is the no-op path.
|
||||
const PAGE_SIZE = 300
|
||||
let scanned = 0
|
||||
let lastDoc: FirebaseFirestore.QueryDocumentSnapshot | undefined
|
||||
for (;;) {
|
||||
let query = db.collection('couples').orderBy('__name__').limit(PAGE_SIZE)
|
||||
if (lastDoc) query = query.startAfter(lastDoc)
|
||||
const page = await query.get()
|
||||
if (page.empty) break
|
||||
scanned += page.size
|
||||
|
||||
await Promise.allSettled(
|
||||
page.docs.map(async (coupleDoc) => {
|
||||
const docRef = db
|
||||
.collection('couples')
|
||||
.doc(coupleId)
|
||||
.doc(coupleDoc.id)
|
||||
.collection('daily_question')
|
||||
.doc(today)
|
||||
|
||||
try {
|
||||
await docRef.create({
|
||||
questionId,
|
||||
date: today,
|
||||
assignedAt,
|
||||
expiresAt,
|
||||
})
|
||||
await docRef.create({ questionId, date: today, assignedAt, expiresAt })
|
||||
} catch (err: any) {
|
||||
if (err?.code === 6 || err?.message?.includes('ALREADY_EXISTS')) {
|
||||
// Already assigned for today — idempotent.
|
||||
return
|
||||
}
|
||||
logger.error(`[assignDailyQuestion] failed for ${coupleId}`, { error: String(err) })
|
||||
if (err?.code === 6 || err?.message?.includes('ALREADY_EXISTS')) return // idempotent
|
||||
logger.error(`[assignDailyQuestion] failed for ${coupleDoc.id}`, { error: String(err) })
|
||||
}
|
||||
})
|
||||
|
||||
await Promise.all(writes)
|
||||
logger.log(
|
||||
`[assignDailyQuestion] assigned ${questionId} to ${couplesSnap.size} couples for ${today}`
|
||||
)
|
||||
|
||||
lastDoc = page.docs[page.docs.length - 1]
|
||||
if (page.size < PAGE_SIZE) break
|
||||
}
|
||||
|
||||
logger.log(`[assignDailyQuestion] assigned ${questionId} to ${scanned} couples for ${today}`)
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue