convex-batch-processor 1.0.3 → 1.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/component/_generated/component.d.ts +3 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/convex.config.d.ts +2 -2
- package/dist/component/convex.config.d.ts.map +1 -1
- package/dist/component/convex.config.js +2 -1
- package/dist/component/convex.config.js.map +1 -1
- package/dist/component/lib.d.ts +93 -17
- package/dist/component/lib.d.ts.map +1 -1
- package/dist/component/lib.js +314 -121
- package/dist/component/lib.js.map +1 -1
- package/dist/component/schema.d.ts +18 -5
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +11 -2
- package/dist/component/schema.js.map +1 -1
- package/package.json +1 -1
- package/src/component/_generated/component.ts +7 -0
- package/src/component/convex.config.ts +2 -1
- package/src/component/lib.ts +363 -150
- package/src/component/schema.ts +12 -2
package/dist/component/lib.js
CHANGED
|
@@ -20,18 +20,17 @@ export const addItems = mutation({
|
|
|
20
20
|
const baseBatchId = batchId.includes("::")
|
|
21
21
|
? batchId.split("::")[0]
|
|
22
22
|
: batchId;
|
|
23
|
-
// Find
|
|
23
|
+
// 1. Find accumulating batch (READ only)
|
|
24
24
|
let batch = await ctx.db
|
|
25
25
|
.query("batches")
|
|
26
26
|
.withIndex("by_baseBatchId_status", (q) => q.eq("baseBatchId", baseBatchId).eq("status", "accumulating"))
|
|
27
27
|
.first();
|
|
28
|
-
|
|
28
|
+
// 2. If no batch, create one WITH timer (one-time INSERT)
|
|
29
29
|
if (!batch) {
|
|
30
|
-
isNewBatch = true;
|
|
31
30
|
// Find highest sequence number for this base ID
|
|
32
31
|
const latestBatch = await ctx.db
|
|
33
32
|
.query("batches")
|
|
34
|
-
.withIndex("
|
|
33
|
+
.withIndex("by_baseBatchId_sequence", (q) => q.eq("baseBatchId", baseBatchId))
|
|
35
34
|
.order("desc")
|
|
36
35
|
.first();
|
|
37
36
|
const nextSequence = latestBatch ? latestBatch.sequence + 1 : 0;
|
|
@@ -40,61 +39,43 @@ export const addItems = mutation({
|
|
|
40
39
|
batchId: newBatchId,
|
|
41
40
|
baseBatchId,
|
|
42
41
|
sequence: nextSequence,
|
|
43
|
-
items: [],
|
|
44
|
-
itemCount: 0,
|
|
45
42
|
createdAt: now,
|
|
46
43
|
lastUpdatedAt: now,
|
|
47
44
|
status: "accumulating",
|
|
48
45
|
config,
|
|
49
46
|
});
|
|
50
|
-
batch = await ctx.db.get(batchDocId);
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const newItems = [...batch.items, ...items];
|
|
56
|
-
const newItemCount = newItems.length;
|
|
57
|
-
if (newItemCount >= config.maxBatchSize) {
|
|
58
|
-
if (batch.scheduledFlushId) {
|
|
59
|
-
await ctx.scheduler.cancel(batch.scheduledFlushId);
|
|
47
|
+
batch = (await ctx.db.get(batchDocId));
|
|
48
|
+
// Schedule timer at creation (not on every add)
|
|
49
|
+
if (config.flushIntervalMs > 0) {
|
|
50
|
+
const scheduledFlushId = await ctx.scheduler.runAfter(config.flushIntervalMs, internal.lib.scheduledIntervalFlush, { batchDocId: batch._id });
|
|
51
|
+
await ctx.db.patch(batch._id, { scheduledFlushId });
|
|
60
52
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
53
|
+
}
|
|
54
|
+
// 3. INSERT items (NEVER conflicts - always a new document)
|
|
55
|
+
await ctx.db.insert("batchItems", {
|
|
56
|
+
batchDocId: batch._id,
|
|
57
|
+
items,
|
|
58
|
+
itemCount: items.length,
|
|
59
|
+
createdAt: now,
|
|
60
|
+
});
|
|
61
|
+
// 4. Query total count (READ only)
|
|
62
|
+
const batchItemDocs = await ctx.db
|
|
63
|
+
.query("batchItems")
|
|
64
|
+
.withIndex("by_batchDocId", (q) => q.eq("batchDocId", batch._id))
|
|
65
|
+
.collect();
|
|
66
|
+
const totalCount = batchItemDocs.reduce((sum, doc) => sum + doc.itemCount, 0);
|
|
67
|
+
// 5. If threshold reached, schedule background flush check (NON-BLOCKING)
|
|
68
|
+
// DO NOT patch batch here - let maybeFlush handle it
|
|
69
|
+
if (totalCount >= config.maxBatchSize) {
|
|
70
|
+
await ctx.scheduler.runAfter(0, internal.lib.maybeFlush, {
|
|
69
71
|
batchDocId: batch._id,
|
|
70
|
-
items: newItems,
|
|
71
|
-
processBatchHandle: config.processBatchHandle,
|
|
72
72
|
});
|
|
73
|
-
return {
|
|
74
|
-
batchId: baseBatchId,
|
|
75
|
-
itemCount: newItemCount,
|
|
76
|
-
flushed: true,
|
|
77
|
-
status: "flushing",
|
|
78
|
-
};
|
|
79
73
|
}
|
|
80
|
-
|
|
81
|
-
const shouldScheduleFlush = config.flushIntervalMs > 0 &&
|
|
82
|
-
!scheduledFlushId &&
|
|
83
|
-
(isNewBatch || batch.itemCount === 0);
|
|
84
|
-
if (shouldScheduleFlush) {
|
|
85
|
-
scheduledFlushId = await ctx.scheduler.runAfter(config.flushIntervalMs, internal.lib.scheduledIntervalFlush, { batchDocId: batch._id });
|
|
86
|
-
}
|
|
87
|
-
await ctx.db.patch(batch._id, {
|
|
88
|
-
items: newItems,
|
|
89
|
-
itemCount: newItemCount,
|
|
90
|
-
lastUpdatedAt: now,
|
|
91
|
-
config,
|
|
92
|
-
scheduledFlushId,
|
|
93
|
-
});
|
|
74
|
+
// 6. Return success - NO PATCH to existing batch!
|
|
94
75
|
return {
|
|
95
76
|
batchId: baseBatchId,
|
|
96
|
-
itemCount:
|
|
97
|
-
flushed: false,
|
|
77
|
+
itemCount: totalCount,
|
|
78
|
+
flushed: false, // We don't know yet - maybeFlush will handle it
|
|
98
79
|
status: "accumulating",
|
|
99
80
|
};
|
|
100
81
|
},
|
|
@@ -120,28 +101,28 @@ export const flushBatch = mutation({
|
|
|
120
101
|
if (batch.status !== "accumulating") {
|
|
121
102
|
throw new Error(`Batch ${batch.baseBatchId} is not in accumulating state (current: ${batch.status})`);
|
|
122
103
|
}
|
|
123
|
-
|
|
104
|
+
// Compute item count from batchItems
|
|
105
|
+
const batchItemDocs = await ctx.db
|
|
106
|
+
.query("batchItems")
|
|
107
|
+
.withIndex("by_batchDocId", (q) => q.eq("batchDocId", batch._id))
|
|
108
|
+
.collect();
|
|
109
|
+
const itemCount = batchItemDocs.reduce((sum, doc) => sum + doc.itemCount, 0);
|
|
110
|
+
if (itemCount === 0) {
|
|
124
111
|
return { batchId, itemCount: 0, flushed: false, reason: "Batch is empty" };
|
|
125
112
|
}
|
|
126
113
|
if (!batch.config.processBatchHandle) {
|
|
127
114
|
throw new Error(`Batch ${batchId} has no processBatchHandle configured`);
|
|
128
115
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
await ctx.db.patch(batch._id, {
|
|
133
|
-
status: "flushing",
|
|
134
|
-
scheduledFlushId: undefined,
|
|
135
|
-
});
|
|
136
|
-
await ctx.scheduler.runAfter(0, internal.lib.executeFlush, {
|
|
116
|
+
// Schedule maybeFlush to handle the transition (avoids OCC in user-facing mutation)
|
|
117
|
+
// Pass force: true to bypass threshold check for manual flushes
|
|
118
|
+
await ctx.scheduler.runAfter(0, internal.lib.maybeFlush, {
|
|
137
119
|
batchDocId: batch._id,
|
|
138
|
-
|
|
139
|
-
processBatchHandle: batch.config.processBatchHandle,
|
|
120
|
+
force: true,
|
|
140
121
|
});
|
|
141
122
|
return {
|
|
142
123
|
batchId,
|
|
143
|
-
itemCount
|
|
144
|
-
flushed: true,
|
|
124
|
+
itemCount,
|
|
125
|
+
flushed: true, // Will be flushed by maybeFlush
|
|
145
126
|
status: "flushing",
|
|
146
127
|
};
|
|
147
128
|
},
|
|
@@ -168,14 +149,27 @@ export const getBatchStatus = query({
|
|
|
168
149
|
}
|
|
169
150
|
// Use config from any batch (they should all have the same config)
|
|
170
151
|
const config = activeBatches[0].config;
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
152
|
+
// Compute itemCount and lastUpdatedAt from batchItems for each batch
|
|
153
|
+
const batchesWithCounts = await Promise.all(activeBatches.map(async (batch) => {
|
|
154
|
+
const batchItemDocs = await ctx.db
|
|
155
|
+
.query("batchItems")
|
|
156
|
+
.withIndex("by_batchDocId", (q) => q.eq("batchDocId", batch._id))
|
|
157
|
+
.collect();
|
|
158
|
+
const itemCount = batchItemDocs.reduce((sum, doc) => sum + doc.itemCount, 0);
|
|
159
|
+
// Compute lastUpdatedAt as max of batchItems.createdAt, or fall back to batch.lastUpdatedAt
|
|
160
|
+
const lastUpdatedAt = batchItemDocs.length > 0
|
|
161
|
+
? Math.max(...batchItemDocs.map((doc) => doc.createdAt))
|
|
162
|
+
: batch.lastUpdatedAt;
|
|
163
|
+
return {
|
|
174
164
|
status: batch.status,
|
|
175
|
-
itemCount
|
|
165
|
+
itemCount,
|
|
176
166
|
createdAt: batch.createdAt,
|
|
177
|
-
lastUpdatedAt
|
|
178
|
-
}
|
|
167
|
+
lastUpdatedAt,
|
|
168
|
+
};
|
|
169
|
+
}));
|
|
170
|
+
return {
|
|
171
|
+
batchId: baseBatchId,
|
|
172
|
+
batches: batchesWithCounts,
|
|
179
173
|
config: {
|
|
180
174
|
maxBatchSize: config.maxBatchSize,
|
|
181
175
|
flushIntervalMs: config.flushIntervalMs,
|
|
@@ -190,14 +184,26 @@ export const getAllBatchesForBaseId = query({
|
|
|
190
184
|
.query("batches")
|
|
191
185
|
.withIndex("by_baseBatchId_status", (q) => q.eq("baseBatchId", baseBatchId))
|
|
192
186
|
.collect();
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
lastUpdatedAt
|
|
187
|
+
// Compute itemCount and lastUpdatedAt from batchItems for each batch
|
|
188
|
+
return Promise.all(batches.map(async (batch) => {
|
|
189
|
+
const batchItemDocs = await ctx.db
|
|
190
|
+
.query("batchItems")
|
|
191
|
+
.withIndex("by_batchDocId", (q) => q.eq("batchDocId", batch._id))
|
|
192
|
+
.collect();
|
|
193
|
+
const itemCount = batchItemDocs.reduce((sum, doc) => sum + doc.itemCount, 0);
|
|
194
|
+
// Compute lastUpdatedAt as max of batchItems.createdAt, or fall back to batch.lastUpdatedAt
|
|
195
|
+
const lastUpdatedAt = batchItemDocs.length > 0
|
|
196
|
+
? Math.max(...batchItemDocs.map((doc) => doc.createdAt))
|
|
197
|
+
: batch.lastUpdatedAt;
|
|
198
|
+
return {
|
|
199
|
+
batchId: batch.batchId,
|
|
200
|
+
baseBatchId: batch.baseBatchId,
|
|
201
|
+
sequence: batch.sequence,
|
|
202
|
+
itemCount,
|
|
203
|
+
status: batch.status,
|
|
204
|
+
createdAt: batch.createdAt,
|
|
205
|
+
lastUpdatedAt,
|
|
206
|
+
};
|
|
201
207
|
}));
|
|
202
208
|
},
|
|
203
209
|
});
|
|
@@ -230,12 +236,22 @@ export const deleteBatch = mutation({
|
|
|
230
236
|
if (batch.status === "flushing") {
|
|
231
237
|
return { deleted: false, reason: "Cannot delete batch while flushing" };
|
|
232
238
|
}
|
|
233
|
-
|
|
239
|
+
// Compute item count from batchItems
|
|
240
|
+
const batchItems = await ctx.db
|
|
241
|
+
.query("batchItems")
|
|
242
|
+
.withIndex("by_batchDocId", (q) => q.eq("batchDocId", batch._id))
|
|
243
|
+
.collect();
|
|
244
|
+
const itemCount = batchItems.reduce((sum, doc) => sum + doc.itemCount, 0);
|
|
245
|
+
if (batch.status === "accumulating" && itemCount > 0) {
|
|
234
246
|
return { deleted: false, reason: "Cannot delete batch with pending items" };
|
|
235
247
|
}
|
|
236
248
|
if (batch.scheduledFlushId) {
|
|
237
249
|
await ctx.scheduler.cancel(batch.scheduledFlushId);
|
|
238
250
|
}
|
|
251
|
+
// Delete associated batchItems
|
|
252
|
+
for (const item of batchItems) {
|
|
253
|
+
await ctx.db.delete(item._id);
|
|
254
|
+
}
|
|
239
255
|
await ctx.db.delete(batch._id);
|
|
240
256
|
return { deleted: true };
|
|
241
257
|
},
|
|
@@ -252,16 +268,164 @@ export const getBatch = internalQuery({
|
|
|
252
268
|
.first();
|
|
253
269
|
},
|
|
254
270
|
});
|
|
271
|
+
export const collectBatchItems = internalQuery({
|
|
272
|
+
args: { batchDocId: v.id("batches") },
|
|
273
|
+
handler: async (ctx, { batchDocId }) => {
|
|
274
|
+
const batch = await ctx.db.get(batchDocId);
|
|
275
|
+
if (!batch) {
|
|
276
|
+
return { items: [], flushStartedAt: undefined };
|
|
277
|
+
}
|
|
278
|
+
const flushStartedAt = batch.flushStartedAt ?? Date.now();
|
|
279
|
+
// Get all batchItems created before flushStartedAt
|
|
280
|
+
const batchItemDocs = await ctx.db
|
|
281
|
+
.query("batchItems")
|
|
282
|
+
.withIndex("by_batchDocId_createdAt", (q) => q.eq("batchDocId", batchDocId).lt("createdAt", flushStartedAt + 1))
|
|
283
|
+
.collect();
|
|
284
|
+
// Flatten all items from the batchItem documents
|
|
285
|
+
const items = [];
|
|
286
|
+
for (const doc of batchItemDocs) {
|
|
287
|
+
items.push(...doc.items);
|
|
288
|
+
}
|
|
289
|
+
return { items, flushStartedAt };
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
/**
|
|
293
|
+
* maybeFlush - Attempts to transition a batch from "accumulating" to "flushing" state.
|
|
294
|
+
*
|
|
295
|
+
* This is an internal action scheduled by addItems (when threshold is reached) or
|
|
296
|
+
* flushBatch (for manual flushes). It serves as a lightweight coordinator that
|
|
297
|
+
* delegates the actual state transition to doFlushTransition (a mutation).
|
|
298
|
+
*
|
|
299
|
+
* ## Why this architecture?
|
|
300
|
+
*
|
|
301
|
+
* 1. **OCC is handled automatically by Convex**: When doFlushTransition (a mutation)
|
|
302
|
+
* encounters an OCC conflict, Convex automatically retries it. We don't need
|
|
303
|
+
* external retry logic like ActionRetrier for database operations.
|
|
304
|
+
*
|
|
305
|
+
* 2. **Race conditions are handled gracefully**: Multiple maybeFlush calls can be
|
|
306
|
+
* scheduled concurrently (e.g., rapid addItems calls all hitting threshold).
|
|
307
|
+
* The first one to execute wins the race and transitions the batch to "flushing".
|
|
308
|
+
* Subsequent calls see status !== "accumulating" and return early with
|
|
309
|
+
* reason: "not_accumulating". This is expected behavior, not an error.
|
|
310
|
+
*
|
|
311
|
+
* 3. **Mutations can't call actions directly**: Convex mutations are deterministic
|
|
312
|
+
* and can't have side effects. To execute the user's processBatchHandle (an action),
|
|
313
|
+
* we need this action layer. The flow is:
|
|
314
|
+
* mutation (addItems) → schedules action (maybeFlush)
|
|
315
|
+
* action (maybeFlush) → calls mutation (doFlushTransition)
|
|
316
|
+
* mutation (doFlushTransition) → schedules action (executeFlush)
|
|
317
|
+
*
|
|
318
|
+
* 4. **Non-blocking for callers**: addItems returns immediately after scheduling
|
|
319
|
+
* maybeFlush. Users don't wait for the flush to complete.
|
|
320
|
+
*
|
|
321
|
+
* ## Failure scenarios
|
|
322
|
+
*
|
|
323
|
+
* - If maybeFlush fails completely (rare), the batch stays in "accumulating" state.
|
|
324
|
+
* The next addItems call that hits threshold will schedule another maybeFlush.
|
|
325
|
+
* - If a scheduled interval flush exists, it will also attempt the transition.
|
|
326
|
+
*
|
|
327
|
+
* @param batchDocId - The batch document ID to potentially flush
|
|
328
|
+
* @param force - If true, flush regardless of threshold (used by manual flush and interval timer)
|
|
329
|
+
*/
|
|
330
|
+
export const maybeFlush = internalAction({
|
|
331
|
+
args: {
|
|
332
|
+
batchDocId: v.id("batches"),
|
|
333
|
+
force: v.optional(v.boolean()),
|
|
334
|
+
},
|
|
335
|
+
handler: async (ctx, { batchDocId, force }) => {
|
|
336
|
+
// Call the mutation directly - Convex handles OCC retries automatically.
|
|
337
|
+
// If another maybeFlush already transitioned this batch, the mutation
|
|
338
|
+
// returns { flushed: false, reason: "not_accumulating" } which is fine.
|
|
339
|
+
await ctx.runMutation(internal.lib.doFlushTransition, {
|
|
340
|
+
batchDocId,
|
|
341
|
+
force: force ?? false,
|
|
342
|
+
});
|
|
343
|
+
},
|
|
344
|
+
});
|
|
345
|
+
/**
|
|
346
|
+
* doFlushTransition - The actual state machine transition from "accumulating" to "flushing".
|
|
347
|
+
*
|
|
348
|
+
* This mutation is the source of truth for batch state transitions. It's designed to be
|
|
349
|
+
* idempotent and race-condition safe:
|
|
350
|
+
*
|
|
351
|
+
* - Returns early if batch is already flushing/completed (another caller won the race)
|
|
352
|
+
* - Returns early if batch is empty or below threshold (unless force=true)
|
|
353
|
+
* - On success, atomically updates status and schedules executeFlush
|
|
354
|
+
*
|
|
355
|
+
* OCC (Optimistic Concurrency Control) note:
|
|
356
|
+
* If two doFlushTransition calls race, Convex detects the conflict when both try to
|
|
357
|
+
* patch the same batch document. One succeeds, the other is auto-retried by Convex.
|
|
358
|
+
* On retry, it sees status="flushing" and returns { flushed: false, reason: "not_accumulating" }.
|
|
359
|
+
*/
|
|
360
|
+
export const doFlushTransition = internalMutation({
|
|
361
|
+
args: {
|
|
362
|
+
batchDocId: v.id("batches"),
|
|
363
|
+
force: v.optional(v.boolean()),
|
|
364
|
+
},
|
|
365
|
+
handler: async (ctx, { batchDocId, force }) => {
|
|
366
|
+
const batch = await ctx.db.get(batchDocId);
|
|
367
|
+
// Already flushing or completed? Nothing to do. This handles the race condition
|
|
368
|
+
// where multiple maybeFlush calls are scheduled - the first one wins.
|
|
369
|
+
if (!batch || batch.status !== "accumulating") {
|
|
370
|
+
return { flushed: false, reason: "not_accumulating" };
|
|
371
|
+
}
|
|
372
|
+
// Check actual count from batchItems
|
|
373
|
+
const batchItemDocs = await ctx.db
|
|
374
|
+
.query("batchItems")
|
|
375
|
+
.withIndex("by_batchDocId", (q) => q.eq("batchDocId", batchDocId))
|
|
376
|
+
.collect();
|
|
377
|
+
const totalCount = batchItemDocs.reduce((sum, doc) => sum + doc.itemCount, 0);
|
|
378
|
+
// Empty batch? Nothing to flush.
|
|
379
|
+
if (totalCount === 0) {
|
|
380
|
+
return { flushed: false, reason: "empty" };
|
|
381
|
+
}
|
|
382
|
+
// Not at threshold? Skip only if not forced (interval flush uses force=true).
|
|
383
|
+
if (!force && totalCount < batch.config.maxBatchSize) {
|
|
384
|
+
return { flushed: false, reason: "below_threshold" };
|
|
385
|
+
}
|
|
386
|
+
// Cancel scheduled timer if exists
|
|
387
|
+
if (batch.scheduledFlushId) {
|
|
388
|
+
await ctx.scheduler.cancel(batch.scheduledFlushId);
|
|
389
|
+
}
|
|
390
|
+
// Transition to flushing
|
|
391
|
+
const now = Date.now();
|
|
392
|
+
await ctx.db.patch(batchDocId, {
|
|
393
|
+
status: "flushing",
|
|
394
|
+
flushStartedAt: now,
|
|
395
|
+
lastUpdatedAt: now,
|
|
396
|
+
scheduledFlushId: undefined,
|
|
397
|
+
});
|
|
398
|
+
// Schedule the actual flush
|
|
399
|
+
await ctx.scheduler.runAfter(0, internal.lib.executeFlush, {
|
|
400
|
+
batchDocId,
|
|
401
|
+
processBatchHandle: batch.config.processBatchHandle,
|
|
402
|
+
});
|
|
403
|
+
return { flushed: true, itemCount: totalCount };
|
|
404
|
+
},
|
|
405
|
+
});
|
|
255
406
|
export const executeFlush = internalAction({
|
|
256
407
|
args: {
|
|
257
408
|
batchDocId: v.id("batches"),
|
|
258
|
-
items: v.array(v.any()),
|
|
259
409
|
processBatchHandle: v.string(),
|
|
260
410
|
},
|
|
261
|
-
handler: async (ctx, { batchDocId,
|
|
411
|
+
handler: async (ctx, { batchDocId, processBatchHandle }) => {
|
|
262
412
|
const startTime = Date.now();
|
|
263
413
|
let success = true;
|
|
264
414
|
let errorMessage;
|
|
415
|
+
// Collect items from batchItems table
|
|
416
|
+
const { items, flushStartedAt } = await ctx.runQuery(internal.lib.collectBatchItems, {
|
|
417
|
+
batchDocId,
|
|
418
|
+
});
|
|
419
|
+
if (items.length === 0) {
|
|
420
|
+
await ctx.runMutation(internal.lib.recordFlushResult, {
|
|
421
|
+
batchDocId,
|
|
422
|
+
itemCount: 0,
|
|
423
|
+
durationMs: 0,
|
|
424
|
+
success: true,
|
|
425
|
+
flushStartedAt,
|
|
426
|
+
});
|
|
427
|
+
return { success: true, durationMs: 0 };
|
|
428
|
+
}
|
|
265
429
|
try {
|
|
266
430
|
const handle = processBatchHandle;
|
|
267
431
|
await ctx.runAction(handle, { items });
|
|
@@ -277,6 +441,7 @@ export const executeFlush = internalAction({
|
|
|
277
441
|
durationMs,
|
|
278
442
|
success,
|
|
279
443
|
errorMessage,
|
|
444
|
+
flushStartedAt,
|
|
280
445
|
});
|
|
281
446
|
return { success, errorMessage, durationMs };
|
|
282
447
|
},
|
|
@@ -288,8 +453,9 @@ export const recordFlushResult = internalMutation({
|
|
|
288
453
|
durationMs: v.number(),
|
|
289
454
|
success: v.boolean(),
|
|
290
455
|
errorMessage: v.optional(v.string()),
|
|
456
|
+
flushStartedAt: v.optional(v.number()),
|
|
291
457
|
},
|
|
292
|
-
handler: async (ctx, { batchDocId, itemCount, durationMs, success, errorMessage }) => {
|
|
458
|
+
handler: async (ctx, { batchDocId, itemCount, durationMs, success, errorMessage, flushStartedAt }) => {
|
|
293
459
|
const batch = await ctx.db.get(batchDocId);
|
|
294
460
|
if (!batch)
|
|
295
461
|
return;
|
|
@@ -302,68 +468,95 @@ export const recordFlushResult = internalMutation({
|
|
|
302
468
|
errorMessage,
|
|
303
469
|
});
|
|
304
470
|
if (success) {
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
});
|
|
311
|
-
// Clean up old completed batches for the same base ID
|
|
312
|
-
// Keep only the most recent completed batch to reduce clutter
|
|
313
|
-
const completedBatches = await ctx.db
|
|
314
|
-
.query("batches")
|
|
315
|
-
.withIndex("by_baseBatchId_status", (q) => q.eq("baseBatchId", batch.baseBatchId).eq("status", "completed"))
|
|
471
|
+
// Delete all batchItems that were included in this flush (created before flushStartedAt)
|
|
472
|
+
const cutoffTime = flushStartedAt ?? batch.flushStartedAt ?? Date.now();
|
|
473
|
+
const batchItemsToDelete = await ctx.db
|
|
474
|
+
.query("batchItems")
|
|
475
|
+
.withIndex("by_batchDocId_createdAt", (q) => q.eq("batchDocId", batchDocId).lt("createdAt", cutoffTime + 1))
|
|
316
476
|
.collect();
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
477
|
+
for (const item of batchItemsToDelete) {
|
|
478
|
+
await ctx.db.delete(item._id);
|
|
479
|
+
}
|
|
480
|
+
// Check for stranded items (added after flushStartedAt)
|
|
481
|
+
const remainingItems = await ctx.db
|
|
482
|
+
.query("batchItems")
|
|
483
|
+
.withIndex("by_batchDocId", (q) => q.eq("batchDocId", batchDocId))
|
|
484
|
+
.collect();
|
|
485
|
+
const remainingCount = remainingItems.reduce((sum, item) => sum + item.itemCount, 0);
|
|
486
|
+
if (remainingCount > 0) {
|
|
487
|
+
// Don't complete - keep accumulating for stranded items
|
|
488
|
+
await ctx.db.patch(batchDocId, {
|
|
489
|
+
status: "accumulating",
|
|
490
|
+
flushStartedAt: undefined,
|
|
491
|
+
lastUpdatedAt: Date.now(),
|
|
492
|
+
});
|
|
493
|
+
// Schedule another maybeFlush if at threshold
|
|
494
|
+
if (remainingCount >= batch.config.maxBatchSize) {
|
|
495
|
+
await ctx.scheduler.runAfter(0, internal.lib.maybeFlush, { batchDocId });
|
|
496
|
+
}
|
|
497
|
+
else if (batch.config.flushIntervalMs > 0) {
|
|
498
|
+
// Re-schedule interval timer
|
|
499
|
+
const scheduledFlushId = await ctx.scheduler.runAfter(batch.config.flushIntervalMs, internal.lib.scheduledIntervalFlush, { batchDocId });
|
|
500
|
+
await ctx.db.patch(batchDocId, { scheduledFlushId });
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
else {
|
|
504
|
+
// No stranded items - mark completed
|
|
505
|
+
await ctx.db.patch(batchDocId, {
|
|
506
|
+
status: "completed",
|
|
507
|
+
flushStartedAt: undefined,
|
|
508
|
+
lastUpdatedAt: Date.now(),
|
|
509
|
+
});
|
|
510
|
+
// Clean up old completed batches for the same base ID
|
|
511
|
+
// Keep only the most recent completed batch to reduce clutter
|
|
512
|
+
const completedBatches = await ctx.db
|
|
513
|
+
.query("batches")
|
|
514
|
+
.withIndex("by_baseBatchId_status", (q) => q.eq("baseBatchId", batch.baseBatchId).eq("status", "completed"))
|
|
515
|
+
.collect();
|
|
516
|
+
// Sort by sequence number descending and delete all but the most recent
|
|
517
|
+
const sortedCompleted = completedBatches.sort((a, b) => b.sequence - a.sequence);
|
|
518
|
+
for (let i = 1; i < sortedCompleted.length; i++) {
|
|
519
|
+
// Also delete batchItems for old completed batches
|
|
520
|
+
const oldBatchItems = await ctx.db
|
|
521
|
+
.query("batchItems")
|
|
522
|
+
.withIndex("by_batchDocId", (q) => q.eq("batchDocId", sortedCompleted[i]._id))
|
|
523
|
+
.collect();
|
|
524
|
+
for (const item of oldBatchItems) {
|
|
525
|
+
await ctx.db.delete(item._id);
|
|
526
|
+
}
|
|
527
|
+
await ctx.db.delete(sortedCompleted[i]._id);
|
|
528
|
+
}
|
|
321
529
|
}
|
|
322
530
|
}
|
|
323
531
|
else {
|
|
532
|
+
// Failure case - revert to accumulating
|
|
324
533
|
let scheduledFlushId = undefined;
|
|
325
534
|
if (batch.config.flushIntervalMs > 0 && batch.config.processBatchHandle) {
|
|
326
535
|
scheduledFlushId = await ctx.scheduler.runAfter(batch.config.flushIntervalMs, internal.lib.scheduledIntervalFlush, { batchDocId });
|
|
327
536
|
}
|
|
328
537
|
await ctx.db.patch(batchDocId, {
|
|
329
538
|
status: "accumulating",
|
|
539
|
+
flushStartedAt: undefined,
|
|
330
540
|
scheduledFlushId,
|
|
331
541
|
});
|
|
332
542
|
}
|
|
333
543
|
},
|
|
334
544
|
});
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
await ctx.db.patch(batchDocId, {
|
|
343
|
-
status: "flushing",
|
|
344
|
-
scheduledFlushId: undefined,
|
|
345
|
-
});
|
|
346
|
-
return {
|
|
347
|
-
items: batch.items,
|
|
348
|
-
processBatchHandle: batch.config.processBatchHandle,
|
|
349
|
-
};
|
|
350
|
-
},
|
|
351
|
-
});
|
|
545
|
+
/**
|
|
546
|
+
* scheduledIntervalFlush - Timer-triggered flush that runs after flushIntervalMs.
|
|
547
|
+
*
|
|
548
|
+
* Scheduled once when a batch is created (if flushIntervalMs > 0). Uses force=true
|
|
549
|
+
* to flush regardless of whether the batch has reached maxBatchSize threshold.
|
|
550
|
+
* This ensures batches don't sit indefinitely waiting for more items.
|
|
551
|
+
*/
|
|
352
552
|
export const scheduledIntervalFlush = internalAction({
|
|
353
553
|
args: { batchDocId: v.id("batches") },
|
|
354
554
|
handler: async (ctx, { batchDocId }) => {
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
});
|
|
358
|
-
if (!batchData || !batchData.processBatchHandle) {
|
|
359
|
-
return { flushed: false, reason: "Batch not ready for flush" };
|
|
360
|
-
}
|
|
361
|
-
const result = await ctx.runAction(internal.lib.executeFlush, {
|
|
555
|
+
// Use force=true to flush regardless of threshold
|
|
556
|
+
await ctx.runMutation(internal.lib.doFlushTransition, {
|
|
362
557
|
batchDocId,
|
|
363
|
-
|
|
364
|
-
processBatchHandle: batchData.processBatchHandle,
|
|
558
|
+
force: true,
|
|
365
559
|
});
|
|
366
|
-
return { flushed: true, ...result };
|
|
367
560
|
},
|
|
368
561
|
});
|
|
369
562
|
// ============================================================================
|