Skip to content

Commit 4fba1b8

Browse files
authored
fix: aggregate backfill kickoff reads whole member table, failing deploy past 32k rows (#296) (#297)
1 parent 52aa69c commit 4fba1b8

18 files changed

Lines changed: 485 additions & 47 deletions
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"kitcn": patch
3+
---
4+
5+
## Patches
6+
7+
- Fix `kitcn deploy` and `kitcn aggregate backfill|rebuild|prune` failing with `Too many documents read in a single function execution (limit: 32000)` once a table with an `aggregateIndex()` grows past ~32k rows. Backfill kickoff now discovers removed aggregate indexes with bounded distinct-key index scans instead of reading every aggregate row. Clearing a removed index whose aggregate rows already exceed platform limits still requires a chunked prune.
8+
- Fix `backend=concave` failing to locate the Concave CLI with `@concavejs/cli` releases that do not export `./package.json`.
9+
- Pin `@concavejs/cli` in concave scaffolds to the supported version instead of `latest`.

convex/orm/count.test.ts

Lines changed: 364 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,81 @@ const schedulerStub = {
3333
const passthroughInternalMutation = ((definition: unknown) =>
3434
definition) as never;
3535
const METRIC_STATE_KIND = 'metric' as const;
36+
const RANK_STATE_KIND = 'rank' as const;
37+
38+
const createReadCountingDb = (db: unknown) => {
39+
const reads = new Map<string, number>();
40+
const recordReads = (table: string, count: number) => {
41+
reads.set(table, (reads.get(table) ?? 0) + count);
42+
};
43+
const wrapQuery = (query: object, table: string): object =>
44+
new Proxy(query, {
45+
get(target, prop) {
46+
const value = Reflect.get(target, prop);
47+
if (typeof value !== 'function') {
48+
return value;
49+
}
50+
if (prop === Symbol.asyncIterator) {
51+
return () => {
52+
const iterator = value.call(target) as AsyncIterator<unknown>;
53+
return {
54+
next: async () => {
55+
const step = await iterator.next();
56+
if (!step.done) {
57+
recordReads(table, 1);
58+
}
59+
return step;
60+
},
61+
return: iterator.return?.bind(iterator),
62+
throw: iterator.throw?.bind(iterator),
63+
};
64+
};
65+
}
66+
return (...args: unknown[]) => {
67+
const result = value.apply(target, args);
68+
if (prop === 'collect' || prop === 'take') {
69+
return (result as Promise<unknown[]>).then((rows) => {
70+
recordReads(table, rows.length);
71+
return rows;
72+
});
73+
}
74+
if (prop === 'first' || prop === 'unique') {
75+
return (result as Promise<unknown>).then((row) => {
76+
recordReads(table, row === null ? 0 : 1);
77+
return row;
78+
});
79+
}
80+
if (prop === 'paginate') {
81+
return (result as Promise<{ page: unknown[] }>).then((page) => {
82+
recordReads(table, page.page.length);
83+
return page;
84+
});
85+
}
86+
return typeof result === 'object' && result !== null
87+
? wrapQuery(result, table)
88+
: result;
89+
};
90+
},
91+
});
92+
const dbProxy = new Proxy(db as object, {
93+
get(target, prop) {
94+
const value = Reflect.get(target, prop);
95+
if (prop === 'query') {
96+
return (table: string) =>
97+
wrapQuery((value as Function).call(target, table) as object, table);
98+
}
99+
if (prop === 'get') {
100+
return async (...args: unknown[]) => {
101+
const doc = await (value as Function).apply(target, args);
102+
recordReads('#db.get', doc === null ? 0 : 1);
103+
return doc;
104+
};
105+
}
106+
return typeof value === 'function' ? value.bind(target) : value;
107+
},
108+
});
109+
return { db: dbProxy, reads };
110+
};
36111

37112
const buildCountIndexedFixtures = (options?: {
38113
includeOrgStatusIndex?: boolean;
@@ -1336,6 +1411,295 @@ describe('ORM count() with aggregateIndex', () => {
13361411
});
13371412
});
13381413

1414+
it('resume kickoff prunes orphans across kinds, tables, and indexes while keeping active data', async () => {
1415+
const { schema } = buildCountIndexedFixtures({
1416+
includeOrgStatusIndex: true,
1417+
});
1418+
const { relations: relationsWithoutOrgStatus } = buildCountIndexedFixtures({
1419+
includeOrgStatusIndex: false,
1420+
});
1421+
const t = convexTest(schema);
1422+
1423+
await t.run(async (baseCtx) => {
1424+
const insertMember = async (
1425+
kind: string,
1426+
tableKey: string,
1427+
indexName: string,
1428+
docId: string
1429+
) => {
1430+
await baseCtx.db.insert('aggregate_member', {
1431+
kind,
1432+
tableKey,
1433+
indexName,
1434+
docId,
1435+
keyHash: '["org-1"]',
1436+
keyParts: ['org-1'],
1437+
sumValues: {},
1438+
nonNullCountValues: {},
1439+
extremaValues: {},
1440+
updatedAt: 0,
1441+
});
1442+
};
1443+
1444+
await insertMember(
1445+
METRIC_STATE_KIND,
1446+
'countUsers',
1447+
'by_org_status',
1448+
'doc_orphan_1'
1449+
);
1450+
await insertMember(
1451+
METRIC_STATE_KIND,
1452+
'countUsers',
1453+
'by_org_status',
1454+
'doc_orphan_2'
1455+
);
1456+
await insertMember(
1457+
METRIC_STATE_KIND,
1458+
'countUsers',
1459+
'by_org_tier',
1460+
'doc_active_1'
1461+
);
1462+
await insertMember(
1463+
METRIC_STATE_KIND,
1464+
'countPosts',
1465+
'by_org',
1466+
'doc_active_2'
1467+
);
1468+
await insertMember(
1469+
RANK_STATE_KIND,
1470+
'countUsers',
1471+
'by_rank_removed_a',
1472+
'doc_rank_orphan_a'
1473+
);
1474+
await insertMember(
1475+
RANK_STATE_KIND,
1476+
'countUsers',
1477+
'by_rank_removed_b',
1478+
'doc_rank_orphan_b'
1479+
);
1480+
1481+
await baseCtx.db.insert('aggregate_bucket', {
1482+
tableKey: 'countUsers',
1483+
indexName: 'by_org_status',
1484+
keyHash: '["org-1"]',
1485+
keyParts: ['org-1'],
1486+
count: 2,
1487+
sumValues: {},
1488+
nonNullCountValues: {},
1489+
updatedAt: 0,
1490+
});
1491+
for (const orphanBucketIndex of ['by_removed_x', 'by_removed_y']) {
1492+
await baseCtx.db.insert('aggregate_bucket', {
1493+
tableKey: 'countPosts',
1494+
indexName: orphanBucketIndex,
1495+
keyHash: '["org-1"]',
1496+
keyParts: ['org-1'],
1497+
count: 1,
1498+
sumValues: {},
1499+
nonNullCountValues: {},
1500+
updatedAt: 0,
1501+
});
1502+
}
1503+
await baseCtx.db.insert('aggregate_bucket', {
1504+
tableKey: 'countUsers',
1505+
indexName: 'by_org_tier',
1506+
keyHash: '["org-1"]',
1507+
keyParts: ['org-1'],
1508+
count: 1,
1509+
sumValues: {},
1510+
nonNullCountValues: {},
1511+
updatedAt: 0,
1512+
});
1513+
await baseCtx.db.insert('aggregate_extrema', {
1514+
tableKey: 'countUsers',
1515+
indexName: 'by_org_status',
1516+
keyHash: '["org-1"]',
1517+
fieldName: 'score',
1518+
valueHash: 'value_hash',
1519+
value: 1,
1520+
sortKey: 'n:1',
1521+
count: 1,
1522+
updatedAt: 0,
1523+
});
1524+
1525+
const prunedOrmClient = createOrm({
1526+
schema: relationsWithoutOrgStatus,
1527+
ormFunctions: {
1528+
scheduledDelete: {} as any,
1529+
scheduledMutationBatch: {} as any,
1530+
},
1531+
internalMutation: passthroughInternalMutation,
1532+
});
1533+
const prunedApi = prunedOrmClient.api();
1534+
1535+
const result = await (prunedApi as any).aggregateBackfill.handler(
1536+
{ db: baseCtx.db, scheduler: schedulerStub },
1537+
{}
1538+
);
1539+
expect(result).toMatchObject({
1540+
mode: 'resume',
1541+
pruned: 5,
1542+
});
1543+
1544+
const membersByIndex = async (
1545+
kind: string,
1546+
tableKey: string,
1547+
indexName: string
1548+
) =>
1549+
baseCtx.db
1550+
.query('aggregate_member')
1551+
.withIndex('by_kind_table_index', (q: any) =>
1552+
q
1553+
.eq('kind', kind)
1554+
.eq('tableKey', tableKey)
1555+
.eq('indexName', indexName)
1556+
)
1557+
.collect();
1558+
1559+
await expect(
1560+
membersByIndex(METRIC_STATE_KIND, 'countUsers', 'by_org_status')
1561+
).resolves.toHaveLength(0);
1562+
await expect(
1563+
membersByIndex(RANK_STATE_KIND, 'countUsers', 'by_rank_removed_a')
1564+
).resolves.toHaveLength(0);
1565+
await expect(
1566+
membersByIndex(RANK_STATE_KIND, 'countUsers', 'by_rank_removed_b')
1567+
).resolves.toHaveLength(0);
1568+
await expect(
1569+
membersByIndex(METRIC_STATE_KIND, 'countUsers', 'by_org_tier')
1570+
).resolves.toHaveLength(1);
1571+
await expect(
1572+
membersByIndex(METRIC_STATE_KIND, 'countPosts', 'by_org')
1573+
).resolves.toHaveLength(1);
1574+
1575+
const orphanBuckets = await baseCtx.db
1576+
.query('aggregate_bucket')
1577+
.withIndex('by_table_index', (q: any) =>
1578+
q.eq('tableKey', 'countUsers').eq('indexName', 'by_org_status')
1579+
)
1580+
.collect();
1581+
expect(orphanBuckets).toHaveLength(0);
1582+
1583+
const activeBuckets = await baseCtx.db
1584+
.query('aggregate_bucket')
1585+
.withIndex('by_table_index', (q: any) =>
1586+
q.eq('tableKey', 'countUsers').eq('indexName', 'by_org_tier')
1587+
)
1588+
.collect();
1589+
expect(activeBuckets).toHaveLength(1);
1590+
1591+
for (const orphanBucketIndex of ['by_removed_x', 'by_removed_y']) {
1592+
const orphanOnlyBuckets = await baseCtx.db
1593+
.query('aggregate_bucket')
1594+
.withIndex('by_table_index', (q: any) =>
1595+
q.eq('tableKey', 'countPosts').eq('indexName', orphanBucketIndex)
1596+
)
1597+
.collect();
1598+
expect(orphanOnlyBuckets).toHaveLength(0);
1599+
}
1600+
1601+
const orphanExtrema = await baseCtx.db
1602+
.query('aggregate_extrema')
1603+
.withIndex('by_table_index', (q: any) =>
1604+
q.eq('tableKey', 'countUsers').eq('indexName', 'by_org_status')
1605+
)
1606+
.collect();
1607+
expect(orphanExtrema).toHaveLength(0);
1608+
});
1609+
});
1610+
1611+
it('resume kickoff reads a bounded number of aggregate rows regardless of member table size', async () => {
1612+
const { schema, relations } = buildCountIndexedFixtures();
1613+
const t = convexTest(schema);
1614+
1615+
await t.run(async (baseCtx) => {
1616+
const ormClient = createOrm({
1617+
schema: relations,
1618+
ormFunctions: {
1619+
scheduledDelete: {} as any,
1620+
scheduledMutationBatch: {} as any,
1621+
},
1622+
internalMutation: passthroughInternalMutation,
1623+
});
1624+
const api = ormClient.api();
1625+
1626+
const memberTuples = [
1627+
{
1628+
kind: METRIC_STATE_KIND,
1629+
tableKey: 'countUsers',
1630+
indexName: 'by_org_status',
1631+
},
1632+
{
1633+
kind: METRIC_STATE_KIND,
1634+
tableKey: 'countUsers',
1635+
indexName: 'by_org_tier',
1636+
},
1637+
{
1638+
kind: METRIC_STATE_KIND,
1639+
tableKey: 'countPosts',
1640+
indexName: 'by_org',
1641+
},
1642+
];
1643+
for (const [tupleIndex, tuple] of memberTuples.entries()) {
1644+
for (let i = 0; i < 100; i += 1) {
1645+
await baseCtx.db.insert('aggregate_member', {
1646+
...tuple,
1647+
docId: `doc_${tupleIndex}_${i}`,
1648+
keyHash: '["org-1","active"]',
1649+
keyParts: ['org-1', 'active'],
1650+
sumValues: {},
1651+
nonNullCountValues: {},
1652+
extremaValues: {},
1653+
updatedAt: 0,
1654+
});
1655+
}
1656+
}
1657+
const bucketTuples = [
1658+
{ tableKey: 'countUsers', indexName: 'by_org_status' },
1659+
{ tableKey: 'countUsers', indexName: 'by_org_tier' },
1660+
];
1661+
for (const tuple of bucketTuples) {
1662+
for (let i = 0; i < 30; i += 1) {
1663+
await baseCtx.db.insert('aggregate_bucket', {
1664+
...tuple,
1665+
keyHash: `["org-${i}","active"]`,
1666+
keyParts: [`org-${i}`, 'active'],
1667+
count: 1,
1668+
sumValues: {},
1669+
nonNullCountValues: {},
1670+
updatedAt: 0,
1671+
});
1672+
await baseCtx.db.insert('aggregate_extrema', {
1673+
...tuple,
1674+
keyHash: `["org-${i}","active"]`,
1675+
fieldName: 'score',
1676+
valueHash: `value_hash_${i}`,
1677+
value: i,
1678+
sortKey: `n:${i}`,
1679+
count: 1,
1680+
updatedAt: 0,
1681+
});
1682+
}
1683+
}
1684+
1685+
const { db: countingDb, reads } = createReadCountingDb(baseCtx.db);
1686+
1687+
const result = await (api as any).aggregateBackfill.handler(
1688+
{ db: countingDb, scheduler: schedulerStub },
1689+
{}
1690+
);
1691+
1692+
expect(result).toMatchObject({ mode: 'resume', status: 'ok' });
1693+
expect(reads.get('aggregate_member') ?? 0).toBeGreaterThan(0);
1694+
expect(reads.get('aggregate_member') ?? 0).toBeLessThan(20);
1695+
expect(reads.get('aggregate_bucket') ?? 0).toBeGreaterThan(0);
1696+
expect(reads.get('aggregate_bucket') ?? 0).toBeLessThan(20);
1697+
expect(reads.get('aggregate_extrema') ?? 0).toBeGreaterThan(0);
1698+
expect(reads.get('aggregate_extrema') ?? 0).toBeLessThan(20);
1699+
expect(reads.get('#db.get') ?? 0).toBeLessThan(20);
1700+
});
1701+
});
1702+
13391703
it('processes at most one paginated target per chunk invocation', async () => {
13401704
const { schema, relations } = buildCountIndexedFixtures();
13411705
const t = convexTest(schema);

0 commit comments

Comments
 (0)