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.
@@ -20,18 +20,17 @@ export const addItems = mutation({
20
20
  const baseBatchId = batchId.includes("::")
21
21
  ? batchId.split("::")[0]
22
22
  : batchId;
23
- // Find an accumulating batch for this base ID
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
- let isNewBatch = false;
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("by_baseBatchId_status", (q) => q.eq("baseBatchId", baseBatchId))
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
- if (!batch) {
53
- throw new Error(`Failed to create batch for ${baseBatchId}`);
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
- await ctx.db.patch(batch._id, {
62
- items: newItems,
63
- itemCount: newItemCount,
64
- lastUpdatedAt: now,
65
- status: "flushing",
66
- scheduledFlushId: undefined,
67
- });
68
- await ctx.scheduler.runAfter(0, internal.lib.executeFlush, {
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
- let scheduledFlushId = batch.scheduledFlushId;
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: newItemCount,
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
- if (batch.itemCount === 0) {
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
- if (batch.scheduledFlushId) {
130
- await ctx.scheduler.cancel(batch.scheduledFlushId);
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
- items: batch.items,
139
- processBatchHandle: batch.config.processBatchHandle,
120
+ force: true,
140
121
  });
141
122
  return {
142
123
  batchId,
143
- itemCount: batch.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
- return {
172
- batchId: baseBatchId,
173
- batches: activeBatches.map((batch) => ({
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: batch.itemCount,
165
+ itemCount,
176
166
  createdAt: batch.createdAt,
177
- lastUpdatedAt: batch.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
- return batches.map((batch) => ({
194
- batchId: batch.batchId,
195
- baseBatchId: batch.baseBatchId,
196
- sequence: batch.sequence,
197
- itemCount: batch.itemCount,
198
- status: batch.status,
199
- createdAt: batch.createdAt,
200
- lastUpdatedAt: batch.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
- if (batch.status === "accumulating" && batch.itemCount > 0) {
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, items, processBatchHandle }) => {
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
- await ctx.db.patch(batchDocId, {
306
- status: "completed",
307
- items: [],
308
- itemCount: 0,
309
- scheduledFlushId: undefined,
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
- // Sort by sequence number descending and delete all but the most recent
318
- const sortedCompleted = completedBatches.sort((a, b) => b.sequence - a.sequence);
319
- for (let i = 1; i < sortedCompleted.length; i++) {
320
- await ctx.db.delete(sortedCompleted[i]._id);
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
- export const markBatchFlushing = internalMutation({
336
- args: { batchDocId: v.id("batches") },
337
- handler: async (ctx, { batchDocId }) => {
338
- const batch = await ctx.db.get(batchDocId);
339
- if (!batch || batch.status !== "accumulating" || batch.itemCount === 0) {
340
- return null;
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
- const batchData = await ctx.runMutation(internal.lib.markBatchFlushing, {
356
- batchDocId,
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
- items: batchData.items,
364
- processBatchHandle: batchData.processBatchHandle,
558
+ force: true,
365
559
  });
366
- return { flushed: true, ...result };
367
560
  },
368
561
  });
369
562
  // ============================================================================