convex-batch-processor 1.0.4 → 1.0.6
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/README.md +3 -3
- 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 +78 -14
- package/dist/component/lib.d.ts.map +1 -1
- package/dist/component/lib.js +254 -129
- package/dist/component/lib.js.map +1 -1
- package/dist/component/schema.d.ts +4 -5
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +2 -1
- 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 +289 -162
- package/src/component/schema.ts +2 -1
package/src/component/lib.ts
CHANGED
|
@@ -32,7 +32,7 @@ export const addItems = mutation({
|
|
|
32
32
|
? batchId.split("::")[0]
|
|
33
33
|
: batchId;
|
|
34
34
|
|
|
35
|
-
// Find
|
|
35
|
+
// 1. Find accumulating batch (READ only)
|
|
36
36
|
let batch = await ctx.db
|
|
37
37
|
.query("batches")
|
|
38
38
|
.withIndex("by_baseBatchId_status", (q) =>
|
|
@@ -40,14 +40,12 @@ export const addItems = mutation({
|
|
|
40
40
|
)
|
|
41
41
|
.first();
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
// 2. If no batch, create one WITH timer (one-time INSERT)
|
|
44
44
|
if (!batch) {
|
|
45
|
-
isNewBatch = true;
|
|
46
|
-
|
|
47
45
|
// Find highest sequence number for this base ID
|
|
48
46
|
const latestBatch = await ctx.db
|
|
49
47
|
.query("batches")
|
|
50
|
-
.withIndex("
|
|
48
|
+
.withIndex("by_baseBatchId_sequence", (q) => q.eq("baseBatchId", baseBatchId))
|
|
51
49
|
.order("desc")
|
|
52
50
|
.first();
|
|
53
51
|
|
|
@@ -58,20 +56,25 @@ export const addItems = mutation({
|
|
|
58
56
|
batchId: newBatchId,
|
|
59
57
|
baseBatchId,
|
|
60
58
|
sequence: nextSequence,
|
|
61
|
-
itemCount: 0,
|
|
62
59
|
createdAt: now,
|
|
63
60
|
lastUpdatedAt: now,
|
|
64
61
|
status: "accumulating",
|
|
65
62
|
config,
|
|
66
63
|
});
|
|
67
|
-
batch = await ctx.db.get(batchDocId)
|
|
68
|
-
}
|
|
64
|
+
batch = (await ctx.db.get(batchDocId))!;
|
|
69
65
|
|
|
70
|
-
|
|
71
|
-
|
|
66
|
+
// Schedule timer at creation (not on every add)
|
|
67
|
+
if (config.flushIntervalMs > 0) {
|
|
68
|
+
const scheduledFlushId = await ctx.scheduler.runAfter(
|
|
69
|
+
config.flushIntervalMs,
|
|
70
|
+
internal.lib.scheduledIntervalFlush,
|
|
71
|
+
{ batchDocId: batch._id }
|
|
72
|
+
);
|
|
73
|
+
await ctx.db.patch(batch._id, { scheduledFlushId });
|
|
74
|
+
}
|
|
72
75
|
}
|
|
73
76
|
|
|
74
|
-
// INSERT
|
|
77
|
+
// 3. INSERT items (NEVER conflicts - always a new document)
|
|
75
78
|
await ctx.db.insert("batchItems", {
|
|
76
79
|
batchDocId: batch._id,
|
|
77
80
|
items,
|
|
@@ -79,59 +82,29 @@ export const addItems = mutation({
|
|
|
79
82
|
createdAt: now,
|
|
80
83
|
});
|
|
81
84
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
scheduledFlushId: undefined,
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
await ctx.scheduler.runAfter(0, internal.lib.executeFlush, {
|
|
85
|
+
// 4. Schedule flush check ONLY if this single call could complete a batch
|
|
86
|
+
// DO NOT query batchItems to count - that causes OCC conflicts when
|
|
87
|
+
// multiple concurrent addItems all read the same index.
|
|
88
|
+
//
|
|
89
|
+
// Dual-trigger pattern:
|
|
90
|
+
// - SIZE trigger: items.length >= maxBatchSize (handled here)
|
|
91
|
+
// - TIME trigger: flushIntervalMs timer (scheduled at batch creation)
|
|
92
|
+
//
|
|
93
|
+
// For high-throughput small items, the interval timer handles flushing.
|
|
94
|
+
// For large single calls, we trigger immediate flush check.
|
|
95
|
+
if (items.length >= config.maxBatchSize) {
|
|
96
|
+
await ctx.scheduler.runAfter(0, internal.lib.maybeFlush, {
|
|
98
97
|
batchDocId: batch._id,
|
|
99
|
-
processBatchHandle: config.processBatchHandle,
|
|
100
98
|
});
|
|
101
|
-
|
|
102
|
-
return {
|
|
103
|
-
batchId: baseBatchId,
|
|
104
|
-
itemCount: newItemCount,
|
|
105
|
-
flushed: true,
|
|
106
|
-
status: "flushing",
|
|
107
|
-
};
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
let scheduledFlushId = batch.scheduledFlushId;
|
|
111
|
-
const shouldScheduleFlush =
|
|
112
|
-
config.flushIntervalMs > 0 &&
|
|
113
|
-
!scheduledFlushId &&
|
|
114
|
-
(isNewBatch || batch.itemCount === 0);
|
|
115
|
-
|
|
116
|
-
if (shouldScheduleFlush) {
|
|
117
|
-
scheduledFlushId = await ctx.scheduler.runAfter(
|
|
118
|
-
config.flushIntervalMs,
|
|
119
|
-
internal.lib.scheduledIntervalFlush,
|
|
120
|
-
{ batchDocId: batch._id }
|
|
121
|
-
);
|
|
122
99
|
}
|
|
123
100
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
config,
|
|
128
|
-
scheduledFlushId,
|
|
129
|
-
});
|
|
130
|
-
|
|
101
|
+
// 5. Return success - NO PATCH, NO COUNT QUERY!
|
|
102
|
+
// We return the count of items added in THIS call only.
|
|
103
|
+
// Total count can be obtained via getBatchStatus query if needed.
|
|
131
104
|
return {
|
|
132
105
|
batchId: baseBatchId,
|
|
133
|
-
itemCount:
|
|
134
|
-
flushed: false,
|
|
106
|
+
itemCount: items.length,
|
|
107
|
+
flushed: false, // Flush happens via interval timer or large batch detection
|
|
135
108
|
status: "accumulating",
|
|
136
109
|
};
|
|
137
110
|
},
|
|
@@ -140,8 +113,6 @@ export const addItems = mutation({
|
|
|
140
113
|
export const flushBatch = mutation({
|
|
141
114
|
args: { batchId: v.string() },
|
|
142
115
|
handler: async (ctx, { batchId }) => {
|
|
143
|
-
const now = Date.now();
|
|
144
|
-
|
|
145
116
|
// First try exact match (for full batch IDs like "base::0")
|
|
146
117
|
let batch = await ctx.db
|
|
147
118
|
.query("batches")
|
|
@@ -166,7 +137,14 @@ export const flushBatch = mutation({
|
|
|
166
137
|
throw new Error(`Batch ${batch.baseBatchId} is not in accumulating state (current: ${batch.status})`);
|
|
167
138
|
}
|
|
168
139
|
|
|
169
|
-
|
|
140
|
+
// Compute item count from batchItems
|
|
141
|
+
const batchItemDocs = await ctx.db
|
|
142
|
+
.query("batchItems")
|
|
143
|
+
.withIndex("by_batchDocId", (q) => q.eq("batchDocId", batch._id))
|
|
144
|
+
.collect();
|
|
145
|
+
const itemCount = batchItemDocs.reduce((sum, doc) => sum + doc.itemCount, 0);
|
|
146
|
+
|
|
147
|
+
if (itemCount === 0) {
|
|
170
148
|
return { batchId, itemCount: 0, flushed: false, reason: "Batch is empty" };
|
|
171
149
|
}
|
|
172
150
|
|
|
@@ -174,25 +152,17 @@ export const flushBatch = mutation({
|
|
|
174
152
|
throw new Error(`Batch ${batchId} has no processBatchHandle configured`);
|
|
175
153
|
}
|
|
176
154
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
await ctx.db.patch(batch._id, {
|
|
182
|
-
status: "flushing",
|
|
183
|
-
flushStartedAt: now,
|
|
184
|
-
scheduledFlushId: undefined,
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
await ctx.scheduler.runAfter(0, internal.lib.executeFlush, {
|
|
155
|
+
// Schedule maybeFlush to handle the transition (avoids OCC in user-facing mutation)
|
|
156
|
+
// Pass force: true to bypass threshold check for manual flushes
|
|
157
|
+
await ctx.scheduler.runAfter(0, internal.lib.maybeFlush, {
|
|
188
158
|
batchDocId: batch._id,
|
|
189
|
-
|
|
159
|
+
force: true,
|
|
190
160
|
});
|
|
191
161
|
|
|
192
162
|
return {
|
|
193
163
|
batchId,
|
|
194
|
-
itemCount
|
|
195
|
-
flushed: true,
|
|
164
|
+
itemCount,
|
|
165
|
+
flushed: true, // Will be flushed by maybeFlush
|
|
196
166
|
status: "flushing",
|
|
197
167
|
};
|
|
198
168
|
},
|
|
@@ -230,14 +200,30 @@ export const getBatchStatus = query({
|
|
|
230
200
|
// Use config from any batch (they should all have the same config)
|
|
231
201
|
const config = activeBatches[0].config;
|
|
232
202
|
|
|
203
|
+
// Compute itemCount and lastUpdatedAt from batchItems for each batch
|
|
204
|
+
const batchesWithCounts = await Promise.all(
|
|
205
|
+
activeBatches.map(async (batch) => {
|
|
206
|
+
const batchItemDocs = await ctx.db
|
|
207
|
+
.query("batchItems")
|
|
208
|
+
.withIndex("by_batchDocId", (q) => q.eq("batchDocId", batch._id))
|
|
209
|
+
.collect();
|
|
210
|
+
const itemCount = batchItemDocs.reduce((sum, doc) => sum + doc.itemCount, 0);
|
|
211
|
+
// Compute lastUpdatedAt as max of batchItems.createdAt, or fall back to batch.lastUpdatedAt
|
|
212
|
+
const lastUpdatedAt = batchItemDocs.length > 0
|
|
213
|
+
? Math.max(...batchItemDocs.map((doc) => doc.createdAt))
|
|
214
|
+
: batch.lastUpdatedAt;
|
|
215
|
+
return {
|
|
216
|
+
status: batch.status as "accumulating" | "flushing",
|
|
217
|
+
itemCount,
|
|
218
|
+
createdAt: batch.createdAt,
|
|
219
|
+
lastUpdatedAt,
|
|
220
|
+
};
|
|
221
|
+
})
|
|
222
|
+
);
|
|
223
|
+
|
|
233
224
|
return {
|
|
234
225
|
batchId: baseBatchId,
|
|
235
|
-
batches:
|
|
236
|
-
status: batch.status as "accumulating" | "flushing",
|
|
237
|
-
itemCount: batch.itemCount,
|
|
238
|
-
createdAt: batch.createdAt,
|
|
239
|
-
lastUpdatedAt: batch.lastUpdatedAt,
|
|
240
|
-
})),
|
|
226
|
+
batches: batchesWithCounts,
|
|
241
227
|
config: {
|
|
242
228
|
maxBatchSize: config.maxBatchSize,
|
|
243
229
|
flushIntervalMs: config.flushIntervalMs,
|
|
@@ -254,15 +240,30 @@ export const getAllBatchesForBaseId = query({
|
|
|
254
240
|
.withIndex("by_baseBatchId_status", (q) => q.eq("baseBatchId", baseBatchId))
|
|
255
241
|
.collect();
|
|
256
242
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
243
|
+
// Compute itemCount and lastUpdatedAt from batchItems for each batch
|
|
244
|
+
return Promise.all(
|
|
245
|
+
batches.map(async (batch) => {
|
|
246
|
+
const batchItemDocs = await ctx.db
|
|
247
|
+
.query("batchItems")
|
|
248
|
+
.withIndex("by_batchDocId", (q) => q.eq("batchDocId", batch._id))
|
|
249
|
+
.collect();
|
|
250
|
+
const itemCount = batchItemDocs.reduce((sum, doc) => sum + doc.itemCount, 0);
|
|
251
|
+
// Compute lastUpdatedAt as max of batchItems.createdAt, or fall back to batch.lastUpdatedAt
|
|
252
|
+
const lastUpdatedAt =
|
|
253
|
+
batchItemDocs.length > 0
|
|
254
|
+
? Math.max(...batchItemDocs.map((doc) => doc.createdAt))
|
|
255
|
+
: batch.lastUpdatedAt;
|
|
256
|
+
return {
|
|
257
|
+
batchId: batch.batchId,
|
|
258
|
+
baseBatchId: batch.baseBatchId,
|
|
259
|
+
sequence: batch.sequence,
|
|
260
|
+
itemCount,
|
|
261
|
+
status: batch.status,
|
|
262
|
+
createdAt: batch.createdAt,
|
|
263
|
+
lastUpdatedAt,
|
|
264
|
+
};
|
|
265
|
+
})
|
|
266
|
+
);
|
|
266
267
|
},
|
|
267
268
|
});
|
|
268
269
|
|
|
@@ -301,7 +302,14 @@ export const deleteBatch = mutation({
|
|
|
301
302
|
return { deleted: false, reason: "Cannot delete batch while flushing" };
|
|
302
303
|
}
|
|
303
304
|
|
|
304
|
-
|
|
305
|
+
// Compute item count from batchItems
|
|
306
|
+
const batchItems = await ctx.db
|
|
307
|
+
.query("batchItems")
|
|
308
|
+
.withIndex("by_batchDocId", (q) => q.eq("batchDocId", batch._id))
|
|
309
|
+
.collect();
|
|
310
|
+
const itemCount = batchItems.reduce((sum, doc) => sum + doc.itemCount, 0);
|
|
311
|
+
|
|
312
|
+
if (batch.status === "accumulating" && itemCount > 0) {
|
|
305
313
|
return { deleted: false, reason: "Cannot delete batch with pending items" };
|
|
306
314
|
}
|
|
307
315
|
|
|
@@ -310,10 +318,6 @@ export const deleteBatch = mutation({
|
|
|
310
318
|
}
|
|
311
319
|
|
|
312
320
|
// Delete associated batchItems
|
|
313
|
-
const batchItems = await ctx.db
|
|
314
|
-
.query("batchItems")
|
|
315
|
-
.withIndex("by_batchDocId", (q) => q.eq("batchDocId", batch._id))
|
|
316
|
-
.collect();
|
|
317
321
|
for (const item of batchItems) {
|
|
318
322
|
await ctx.db.delete(item._id);
|
|
319
323
|
}
|
|
@@ -365,6 +369,135 @@ export const collectBatchItems = internalQuery({
|
|
|
365
369
|
},
|
|
366
370
|
});
|
|
367
371
|
|
|
372
|
+
// Type for flush transition result
|
|
373
|
+
type FlushTransitionResult =
|
|
374
|
+
| { flushed: true; itemCount: number }
|
|
375
|
+
| { flushed: false; reason: string };
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* maybeFlush - Attempts to transition a batch from "accumulating" to "flushing" state.
|
|
379
|
+
*
|
|
380
|
+
* This is an internal action scheduled by addItems (when threshold is reached) or
|
|
381
|
+
* flushBatch (for manual flushes). It serves as a lightweight coordinator that
|
|
382
|
+
* delegates the actual state transition to doFlushTransition (a mutation).
|
|
383
|
+
*
|
|
384
|
+
* ## Why this architecture?
|
|
385
|
+
*
|
|
386
|
+
* 1. **OCC is handled automatically by Convex**: When doFlushTransition (a mutation)
|
|
387
|
+
* encounters an OCC conflict, Convex automatically retries it. We don't need
|
|
388
|
+
* external retry logic like ActionRetrier for database operations.
|
|
389
|
+
*
|
|
390
|
+
* 2. **Race conditions are handled gracefully**: Multiple maybeFlush calls can be
|
|
391
|
+
* scheduled concurrently (e.g., rapid addItems calls all hitting threshold).
|
|
392
|
+
* The first one to execute wins the race and transitions the batch to "flushing".
|
|
393
|
+
* Subsequent calls see status !== "accumulating" and return early with
|
|
394
|
+
* reason: "not_accumulating". This is expected behavior, not an error.
|
|
395
|
+
*
|
|
396
|
+
* 3. **Mutations can't call actions directly**: Convex mutations are deterministic
|
|
397
|
+
* and can't have side effects. To execute the user's processBatchHandle (an action),
|
|
398
|
+
* we need this action layer. The flow is:
|
|
399
|
+
* mutation (addItems) → schedules action (maybeFlush)
|
|
400
|
+
* action (maybeFlush) → calls mutation (doFlushTransition)
|
|
401
|
+
* mutation (doFlushTransition) → schedules action (executeFlush)
|
|
402
|
+
*
|
|
403
|
+
* 4. **Non-blocking for callers**: addItems returns immediately after scheduling
|
|
404
|
+
* maybeFlush. Users don't wait for the flush to complete.
|
|
405
|
+
*
|
|
406
|
+
* ## Failure scenarios
|
|
407
|
+
*
|
|
408
|
+
* - If maybeFlush fails completely (rare), the batch stays in "accumulating" state.
|
|
409
|
+
* The next addItems call that hits threshold will schedule another maybeFlush.
|
|
410
|
+
* - If a scheduled interval flush exists, it will also attempt the transition.
|
|
411
|
+
*
|
|
412
|
+
* @param batchDocId - The batch document ID to potentially flush
|
|
413
|
+
* @param force - If true, flush regardless of threshold (used by manual flush and interval timer)
|
|
414
|
+
*/
|
|
415
|
+
export const maybeFlush = internalAction({
|
|
416
|
+
args: {
|
|
417
|
+
batchDocId: v.id("batches"),
|
|
418
|
+
force: v.optional(v.boolean()),
|
|
419
|
+
},
|
|
420
|
+
handler: async (ctx, { batchDocId, force }): Promise<void> => {
|
|
421
|
+
// Call the mutation directly - Convex handles OCC retries automatically.
|
|
422
|
+
// If another maybeFlush already transitioned this batch, the mutation
|
|
423
|
+
// returns { flushed: false, reason: "not_accumulating" } which is fine.
|
|
424
|
+
await ctx.runMutation(internal.lib.doFlushTransition, {
|
|
425
|
+
batchDocId,
|
|
426
|
+
force: force ?? false,
|
|
427
|
+
});
|
|
428
|
+
},
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* doFlushTransition - The actual state machine transition from "accumulating" to "flushing".
|
|
433
|
+
*
|
|
434
|
+
* This mutation is the source of truth for batch state transitions. It's designed to be
|
|
435
|
+
* idempotent and race-condition safe:
|
|
436
|
+
*
|
|
437
|
+
* - Returns early if batch is already flushing/completed (another caller won the race)
|
|
438
|
+
* - Returns early if batch is empty or below threshold (unless force=true)
|
|
439
|
+
* - On success, atomically updates status and schedules executeFlush
|
|
440
|
+
*
|
|
441
|
+
* OCC (Optimistic Concurrency Control) note:
|
|
442
|
+
* If two doFlushTransition calls race, Convex detects the conflict when both try to
|
|
443
|
+
* patch the same batch document. One succeeds, the other is auto-retried by Convex.
|
|
444
|
+
* On retry, it sees status="flushing" and returns { flushed: false, reason: "not_accumulating" }.
|
|
445
|
+
*/
|
|
446
|
+
export const doFlushTransition = internalMutation({
|
|
447
|
+
args: {
|
|
448
|
+
batchDocId: v.id("batches"),
|
|
449
|
+
force: v.optional(v.boolean()),
|
|
450
|
+
},
|
|
451
|
+
handler: async (ctx, { batchDocId, force }): Promise<FlushTransitionResult> => {
|
|
452
|
+
const batch = await ctx.db.get(batchDocId);
|
|
453
|
+
|
|
454
|
+
// Already flushing or completed? Nothing to do. This handles the race condition
|
|
455
|
+
// where multiple maybeFlush calls are scheduled - the first one wins.
|
|
456
|
+
if (!batch || batch.status !== "accumulating") {
|
|
457
|
+
return { flushed: false, reason: "not_accumulating" };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Check actual count from batchItems
|
|
461
|
+
const batchItemDocs = await ctx.db
|
|
462
|
+
.query("batchItems")
|
|
463
|
+
.withIndex("by_batchDocId", (q) => q.eq("batchDocId", batchDocId))
|
|
464
|
+
.collect();
|
|
465
|
+
const totalCount = batchItemDocs.reduce((sum, doc) => sum + doc.itemCount, 0);
|
|
466
|
+
|
|
467
|
+
// Empty batch? Nothing to flush.
|
|
468
|
+
if (totalCount === 0) {
|
|
469
|
+
return { flushed: false, reason: "empty" };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Not at threshold? Skip only if not forced (interval flush uses force=true).
|
|
473
|
+
if (!force && totalCount < batch.config.maxBatchSize) {
|
|
474
|
+
return { flushed: false, reason: "below_threshold" };
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Cancel scheduled timer if exists
|
|
478
|
+
if (batch.scheduledFlushId) {
|
|
479
|
+
await ctx.scheduler.cancel(batch.scheduledFlushId);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Transition to flushing
|
|
483
|
+
const now = Date.now();
|
|
484
|
+
await ctx.db.patch(batchDocId, {
|
|
485
|
+
status: "flushing",
|
|
486
|
+
flushStartedAt: now,
|
|
487
|
+
lastUpdatedAt: now,
|
|
488
|
+
scheduledFlushId: undefined,
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
// Schedule the actual flush
|
|
492
|
+
await ctx.scheduler.runAfter(0, internal.lib.executeFlush, {
|
|
493
|
+
batchDocId,
|
|
494
|
+
processBatchHandle: batch.config.processBatchHandle,
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
return { flushed: true, itemCount: totalCount };
|
|
498
|
+
},
|
|
499
|
+
});
|
|
500
|
+
|
|
368
501
|
export const executeFlush = internalAction({
|
|
369
502
|
args: {
|
|
370
503
|
batchDocId: v.id("batches"),
|
|
@@ -450,43 +583,66 @@ export const recordFlushResult = internalMutation({
|
|
|
450
583
|
await ctx.db.delete(item._id);
|
|
451
584
|
}
|
|
452
585
|
|
|
453
|
-
//
|
|
586
|
+
// Check for stranded items (added after flushStartedAt)
|
|
454
587
|
const remainingItems = await ctx.db
|
|
455
588
|
.query("batchItems")
|
|
456
589
|
.withIndex("by_batchDocId", (q) => q.eq("batchDocId", batchDocId))
|
|
457
590
|
.collect();
|
|
458
|
-
const
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
591
|
+
const remainingCount = remainingItems.reduce((sum, item) => sum + item.itemCount, 0);
|
|
592
|
+
|
|
593
|
+
if (remainingCount > 0) {
|
|
594
|
+
// Don't complete - keep accumulating for stranded items
|
|
595
|
+
await ctx.db.patch(batchDocId, {
|
|
596
|
+
status: "accumulating",
|
|
597
|
+
flushStartedAt: undefined,
|
|
598
|
+
lastUpdatedAt: Date.now(),
|
|
599
|
+
});
|
|
466
600
|
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
.
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
601
|
+
// Schedule another maybeFlush if at threshold
|
|
602
|
+
if (remainingCount >= batch.config.maxBatchSize) {
|
|
603
|
+
await ctx.scheduler.runAfter(0, internal.lib.maybeFlush, { batchDocId });
|
|
604
|
+
} else if (batch.config.flushIntervalMs > 0) {
|
|
605
|
+
// Re-schedule interval timer
|
|
606
|
+
const scheduledFlushId = await ctx.scheduler.runAfter(
|
|
607
|
+
batch.config.flushIntervalMs,
|
|
608
|
+
internal.lib.scheduledIntervalFlush,
|
|
609
|
+
{ batchDocId }
|
|
610
|
+
);
|
|
611
|
+
await ctx.db.patch(batchDocId, { scheduledFlushId });
|
|
612
|
+
}
|
|
613
|
+
} else {
|
|
614
|
+
// No stranded items - mark completed
|
|
615
|
+
await ctx.db.patch(batchDocId, {
|
|
616
|
+
status: "completed",
|
|
617
|
+
flushStartedAt: undefined,
|
|
618
|
+
lastUpdatedAt: Date.now(),
|
|
619
|
+
});
|
|
475
620
|
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
621
|
+
// Clean up old completed batches for the same base ID
|
|
622
|
+
// Keep only the most recent completed batch to reduce clutter
|
|
623
|
+
const completedBatches = await ctx.db
|
|
624
|
+
.query("batches")
|
|
625
|
+
.withIndex("by_baseBatchId_status", (q) =>
|
|
626
|
+
q.eq("baseBatchId", batch.baseBatchId).eq("status", "completed")
|
|
627
|
+
)
|
|
483
628
|
.collect();
|
|
484
|
-
|
|
485
|
-
|
|
629
|
+
|
|
630
|
+
// Sort by sequence number descending and delete all but the most recent
|
|
631
|
+
const sortedCompleted = completedBatches.sort((a, b) => b.sequence - a.sequence);
|
|
632
|
+
for (let i = 1; i < sortedCompleted.length; i++) {
|
|
633
|
+
// Also delete batchItems for old completed batches
|
|
634
|
+
const oldBatchItems = await ctx.db
|
|
635
|
+
.query("batchItems")
|
|
636
|
+
.withIndex("by_batchDocId", (q) => q.eq("batchDocId", sortedCompleted[i]._id))
|
|
637
|
+
.collect();
|
|
638
|
+
for (const item of oldBatchItems) {
|
|
639
|
+
await ctx.db.delete(item._id);
|
|
640
|
+
}
|
|
641
|
+
await ctx.db.delete(sortedCompleted[i]._id);
|
|
486
642
|
}
|
|
487
|
-
await ctx.db.delete(sortedCompleted[i]._id);
|
|
488
643
|
}
|
|
489
644
|
} else {
|
|
645
|
+
// Failure case - revert to accumulating
|
|
490
646
|
let scheduledFlushId: typeof batch.scheduledFlushId = undefined;
|
|
491
647
|
if (batch.config.flushIntervalMs > 0 && batch.config.processBatchHandle) {
|
|
492
648
|
scheduledFlushId = await ctx.scheduler.runAfter(
|
|
@@ -505,50 +661,21 @@ export const recordFlushResult = internalMutation({
|
|
|
505
661
|
},
|
|
506
662
|
});
|
|
507
663
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
await ctx.db.patch(batchDocId, {
|
|
518
|
-
status: "flushing",
|
|
519
|
-
flushStartedAt: now,
|
|
520
|
-
scheduledFlushId: undefined,
|
|
521
|
-
});
|
|
522
|
-
|
|
523
|
-
return {
|
|
524
|
-
processBatchHandle: batch.config.processBatchHandle,
|
|
525
|
-
};
|
|
526
|
-
},
|
|
527
|
-
});
|
|
528
|
-
|
|
664
|
+
/**
|
|
665
|
+
* scheduledIntervalFlush - Timer-triggered flush that runs after flushIntervalMs.
|
|
666
|
+
*
|
|
667
|
+
* Scheduled once when a batch is created (if flushIntervalMs > 0). Uses force=true
|
|
668
|
+
* to flush regardless of whether the batch has reached maxBatchSize threshold.
|
|
669
|
+
* This ensures batches don't sit indefinitely waiting for more items.
|
|
670
|
+
*/
|
|
529
671
|
export const scheduledIntervalFlush = internalAction({
|
|
530
672
|
args: { batchDocId: v.id("batches") },
|
|
531
|
-
handler: async (ctx, { batchDocId }): Promise<{
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
success?: boolean;
|
|
535
|
-
errorMessage?: string;
|
|
536
|
-
durationMs?: number;
|
|
537
|
-
}> => {
|
|
538
|
-
const batchData: { processBatchHandle: string } | null = await ctx.runMutation(internal.lib.markBatchFlushing, {
|
|
539
|
-
batchDocId,
|
|
540
|
-
});
|
|
541
|
-
|
|
542
|
-
if (!batchData || !batchData.processBatchHandle) {
|
|
543
|
-
return { flushed: false, reason: "Batch not ready for flush" };
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
const result: { success: boolean; errorMessage?: string; durationMs: number } = await ctx.runAction(internal.lib.executeFlush, {
|
|
673
|
+
handler: async (ctx, { batchDocId }): Promise<void> => {
|
|
674
|
+
// Use force=true to flush regardless of threshold
|
|
675
|
+
await ctx.runMutation(internal.lib.doFlushTransition, {
|
|
547
676
|
batchDocId,
|
|
548
|
-
|
|
677
|
+
force: true,
|
|
549
678
|
});
|
|
550
|
-
|
|
551
|
-
return { flushed: true, ...result };
|
|
552
679
|
},
|
|
553
680
|
});
|
|
554
681
|
|
package/src/component/schema.ts
CHANGED
|
@@ -6,7 +6,7 @@ export default defineSchema({
|
|
|
6
6
|
batchId: v.string(), // Full ID with sequence: "base::0"
|
|
7
7
|
baseBatchId: v.string(), // Base ID: "base"
|
|
8
8
|
sequence: v.number(), // Sequence number: 0, 1, 2...
|
|
9
|
-
itemCount
|
|
9
|
+
// itemCount is computed on-demand from batchItems table
|
|
10
10
|
createdAt: v.number(),
|
|
11
11
|
lastUpdatedAt: v.number(),
|
|
12
12
|
status: v.union(v.literal("accumulating"), v.literal("flushing"), v.literal("completed")),
|
|
@@ -20,6 +20,7 @@ export default defineSchema({
|
|
|
20
20
|
})
|
|
21
21
|
.index("by_batchId", ["batchId"])
|
|
22
22
|
.index("by_baseBatchId_status", ["baseBatchId", "status"])
|
|
23
|
+
.index("by_baseBatchId_sequence", ["baseBatchId", "sequence"])
|
|
23
24
|
.index("by_status", ["status"]),
|
|
24
25
|
|
|
25
26
|
batchItems: defineTable({
|