s3db.js 10.0.0 → 10.0.3

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.
@@ -1,5 +1,7 @@
1
1
  import Plugin from "./plugin.class.js";
2
2
  import tryFn from "../concerns/try-fn.js";
3
+ import { idGenerator } from "../concerns/id.js";
4
+ import { PromisePool } from "@supercharge/promise-pool";
3
5
 
4
6
  export class EventualConsistencyPlugin extends Plugin {
5
7
  constructor(options = {}) {
@@ -13,18 +15,19 @@ export class EventualConsistencyPlugin extends Plugin {
13
15
  throw new Error("EventualConsistencyPlugin requires 'field' option");
14
16
  }
15
17
 
18
+ // Auto-detect timezone from environment or system
19
+ const detectedTimezone = this._detectTimezone();
20
+
16
21
  this.config = {
17
22
  resource: options.resource,
18
23
  field: options.field,
19
24
  cohort: {
20
- interval: options.cohort?.interval || '24h',
21
- timezone: options.cohort?.timezone || 'UTC',
22
- ...options.cohort
25
+ timezone: options.cohort?.timezone || detectedTimezone
23
26
  },
24
27
  reducer: options.reducer || ((transactions) => {
25
28
  // Default reducer: sum all increments from a base value
26
29
  let baseValue = 0;
27
-
30
+
28
31
  for (const t of transactions) {
29
32
  if (t.operation === 'set') {
30
33
  baseValue = t.value;
@@ -34,21 +37,46 @@ export class EventualConsistencyPlugin extends Plugin {
34
37
  baseValue -= t.value;
35
38
  }
36
39
  }
37
-
40
+
38
41
  return baseValue;
39
42
  }),
40
- consolidationInterval: options.consolidationInterval || 3600000, // 1 hour default
43
+ consolidationInterval: options.consolidationInterval ?? 300, // 5 minutes (in seconds)
44
+ consolidationConcurrency: options.consolidationConcurrency || 5,
45
+ consolidationWindow: options.consolidationWindow || 24, // Hours to look back for pending transactions (watermark)
41
46
  autoConsolidate: options.autoConsolidate !== false,
42
- batchTransactions: options.batchTransactions || false,
47
+ lateArrivalStrategy: options.lateArrivalStrategy || 'warn', // 'ignore', 'warn', 'process'
48
+ batchTransactions: options.batchTransactions || false, // CAUTION: Not safe in distributed environments! Loses data on container crash
43
49
  batchSize: options.batchSize || 100,
44
50
  mode: options.mode || 'async', // 'async' or 'sync'
45
- ...options
51
+ lockTimeout: options.lockTimeout || 300, // 5 minutes (in seconds, configurable)
52
+ transactionRetention: options.transactionRetention || 30, // Days to keep applied transactions
53
+ gcInterval: options.gcInterval || 86400, // 24 hours (in seconds)
54
+ verbose: options.verbose || false
46
55
  };
47
56
 
48
57
  this.transactionResource = null;
49
58
  this.targetResource = null;
50
59
  this.consolidationTimer = null;
60
+ this.gcTimer = null; // Garbage collection timer
51
61
  this.pendingTransactions = new Map(); // Cache for batching
62
+
63
+ // Warn about batching in distributed environments
64
+ if (this.config.batchTransactions && !this.config.verbose) {
65
+ console.warn(
66
+ `[EventualConsistency] WARNING: batchTransactions is enabled. ` +
67
+ `This stores transactions in memory and will lose data if container crashes. ` +
68
+ `Not recommended for distributed/production environments. ` +
69
+ `Set verbose: true to suppress this warning.`
70
+ );
71
+ }
72
+
73
+ // Log detected timezone if verbose
74
+ if (this.config.verbose && !options.cohort?.timezone) {
75
+ console.log(
76
+ `[EventualConsistency] Auto-detected timezone: ${this.config.cohort.timezone} ` +
77
+ `(from ${process.env.TZ ? 'TZ env var' : 'system Intl API'})`
78
+ );
79
+ }
52
80
  }
53
81
 
54
82
  async onSetup() {
@@ -82,12 +110,12 @@ export class EventualConsistencyPlugin extends Plugin {
82
110
 
83
111
  async completeSetup() {
84
112
  if (!this.targetResource) return;
85
-
113
+
86
114
  // Create transaction resource with partitions (includes field name to support multiple fields)
87
115
  const transactionResourceName = `${this.config.resource}_transactions_${this.config.field}`;
88
116
  const partitionConfig = this.createPartitionConfig();
89
-
90
- const [ok, err, transactionResource] = await tryFn(() =>
117
+
118
+ const [ok, err, transactionResource] = await tryFn(() =>
91
119
  this.database.createResource({
92
120
  name: transactionResourceName,
93
121
  attributes: {
@@ -97,7 +125,8 @@ export class EventualConsistencyPlugin extends Plugin {
97
125
  value: 'number|required',
98
126
  operation: 'string|required', // 'set', 'add', or 'sub'
99
127
  timestamp: 'string|required',
100
- cohortDate: 'string|required', // For partitioning
128
+ cohortDate: 'string|required', // For daily partitioning
129
+ cohortHour: 'string|required', // For hourly partitioning
101
130
  cohortMonth: 'string|optional', // For monthly partitioning
102
131
  source: 'string|optional',
103
132
  applied: 'boolean|optional' // Track if transaction was applied
@@ -108,20 +137,44 @@ export class EventualConsistencyPlugin extends Plugin {
108
137
  asyncPartitions: true // Use async partitions for better performance
109
138
  })
110
139
  );
111
-
140
+
112
141
  if (!ok && !this.database.resources[transactionResourceName]) {
113
142
  throw new Error(`Failed to create transaction resource: ${err?.message}`);
114
143
  }
115
-
144
+
116
145
  this.transactionResource = ok ? transactionResource : this.database.resources[transactionResourceName];
117
-
146
+
147
+ // Create lock resource for atomic consolidation
148
+ const lockResourceName = `${this.config.resource}_consolidation_locks_${this.config.field}`;
149
+ const [lockOk, lockErr, lockResource] = await tryFn(() =>
150
+ this.database.createResource({
151
+ name: lockResourceName,
152
+ attributes: {
153
+ id: 'string|required',
154
+ lockedAt: 'number|required',
155
+ workerId: 'string|optional'
156
+ },
157
+ behavior: 'body-only',
158
+ timestamps: false
159
+ })
160
+ );
161
+
162
+ if (!lockOk && !this.database.resources[lockResourceName]) {
163
+ throw new Error(`Failed to create lock resource: ${lockErr?.message}`);
164
+ }
165
+
166
+ this.lockResource = lockOk ? lockResource : this.database.resources[lockResourceName];
167
+
118
168
  // Add helper methods to the resource
119
169
  this.addHelperMethods();
120
-
170
+
121
171
  // Setup consolidation if enabled
122
172
  if (this.config.autoConsolidate) {
123
173
  this.startConsolidationTimer();
124
174
  }
175
+
176
+ // Setup garbage collection timer
177
+ this.startGarbageCollectionTimer();
125
178
  }
126
179
 
127
180
  async onStart() {
@@ -144,10 +197,16 @@ export class EventualConsistencyPlugin extends Plugin {
144
197
  clearInterval(this.consolidationTimer);
145
198
  this.consolidationTimer = null;
146
199
  }
147
-
200
+
201
+ // Stop garbage collection timer
202
+ if (this.gcTimer) {
203
+ clearInterval(this.gcTimer);
204
+ this.gcTimer = null;
205
+ }
206
+
148
207
  // Flush pending transactions
149
208
  await this.flushPendingTransactions();
150
-
209
+
151
210
  this.emit('eventual-consistency.stopped', {
152
211
  resource: this.config.resource,
153
212
  field: this.config.field
@@ -155,8 +214,13 @@ export class EventualConsistencyPlugin extends Plugin {
155
214
  }
156
215
 
157
216
  createPartitionConfig() {
158
- // Always create both daily and monthly partitions for transactions
217
+ // Create hourly, daily and monthly partitions for transactions
159
218
  const partitions = {
219
+ byHour: {
220
+ fields: {
221
+ cohortHour: 'string'
222
+ }
223
+ },
160
224
  byDay: {
161
225
  fields: {
162
226
  cohortDate: 'string'
@@ -168,15 +232,91 @@ export class EventualConsistencyPlugin extends Plugin {
168
232
  }
169
233
  }
170
234
  };
171
-
235
+
172
236
  return partitions;
173
237
  }
174
238
 
239
+ /**
240
+ * Auto-detect timezone from environment or system
241
+ * @private
242
+ */
243
+ _detectTimezone() {
244
+ // 1. Try TZ environment variable (common in Docker/K8s)
245
+ if (process.env.TZ) {
246
+ return process.env.TZ;
247
+ }
248
+
249
+ // 2. Try Intl API (works in Node.js and browsers)
250
+ try {
251
+ const systemTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
252
+ if (systemTimezone) {
253
+ return systemTimezone;
254
+ }
255
+ } catch (err) {
256
+ // Intl API not available or failed
257
+ }
258
+
259
+ // 3. Fallback to UTC
260
+ return 'UTC';
261
+ }
262
+
263
+ /**
264
+ * Helper method to resolve field and plugin from arguments
265
+ * Supports both single-field (field, value) and multi-field (field, value) signatures
266
+ * @private
267
+ */
268
+ _resolveFieldAndPlugin(resource, fieldOrValue, value) {
269
+ const hasMultipleFields = Object.keys(resource._eventualConsistencyPlugins).length > 1;
270
+
271
+ // If multiple fields exist and only 2 params given, throw error
272
+ if (hasMultipleFields && value === undefined) {
273
+ throw new Error(`Multiple fields have eventual consistency. Please specify the field explicitly.`);
274
+ }
275
+
276
+ // Handle both signatures: method(id, value) and method(id, field, value)
277
+ const field = value !== undefined ? fieldOrValue : this.config.field;
278
+ const actualValue = value !== undefined ? value : fieldOrValue;
279
+ const fieldPlugin = resource._eventualConsistencyPlugins[field];
280
+
281
+ if (!fieldPlugin) {
282
+ throw new Error(`No eventual consistency plugin found for field "${field}"`);
283
+ }
284
+
285
+ return { field, value: actualValue, plugin: fieldPlugin };
286
+ }
287
+
288
+ /**
289
+ * Helper method to perform atomic consolidation in sync mode
290
+ * @private
291
+ */
292
+ async _syncModeConsolidate(id, field) {
293
+ // consolidateRecord already has distributed locking, so it's atomic
294
+ const consolidatedValue = await this.consolidateRecord(id);
295
+ await this.targetResource.update(id, {
296
+ [field]: consolidatedValue
297
+ });
298
+ return consolidatedValue;
299
+ }
300
+
301
+ /**
302
+ * Create synthetic 'set' transaction from current value
303
+ * @private
304
+ */
305
+ _createSyntheticSetTransaction(currentValue) {
306
+ return {
307
+ id: '__synthetic__',
308
+ operation: 'set',
309
+ value: currentValue,
310
+ timestamp: new Date(0).toISOString(),
311
+ synthetic: true
312
+ };
313
+ }
314
+
175
315
  addHelperMethods() {
176
316
  const resource = this.targetResource;
177
317
  const defaultField = this.config.field;
178
318
  const plugin = this;
179
-
319
+
180
320
  // Store all plugins by field name for this resource
181
321
  if (!resource._eventualConsistencyPlugins) {
182
322
  resource._eventualConsistencyPlugins = {};
@@ -185,23 +325,9 @@ export class EventualConsistencyPlugin extends Plugin {
185
325
 
186
326
  // Add method to set value (replaces current value)
187
327
  resource.set = async (id, fieldOrValue, value) => {
188
- // Check if there are multiple fields with eventual consistency
189
- const hasMultipleFields = Object.keys(resource._eventualConsistencyPlugins).length > 1;
190
-
191
- // If multiple fields exist and only 2 params given, throw error
192
- if (hasMultipleFields && value === undefined) {
193
- throw new Error(`Multiple fields have eventual consistency. Please specify the field: set(id, field, value)`);
194
- }
195
-
196
- // Handle both signatures: set(id, value) and set(id, field, value)
197
- const field = value !== undefined ? fieldOrValue : defaultField;
198
- const actualValue = value !== undefined ? value : fieldOrValue;
199
- const fieldPlugin = resource._eventualConsistencyPlugins[field];
200
-
201
- if (!fieldPlugin) {
202
- throw new Error(`No eventual consistency plugin found for field "${field}"`);
203
- }
204
-
328
+ const { field, value: actualValue, plugin: fieldPlugin } =
329
+ plugin._resolveFieldAndPlugin(resource, fieldOrValue, value);
330
+
205
331
  // Create set transaction
206
332
  await fieldPlugin.createTransaction({
207
333
  originalId: id,
@@ -209,38 +335,20 @@ export class EventualConsistencyPlugin extends Plugin {
209
335
  value: actualValue,
210
336
  source: 'set'
211
337
  });
212
-
213
- // In sync mode, immediately consolidate and update
338
+
339
+ // In sync mode, immediately consolidate and update (atomic with locking)
214
340
  if (fieldPlugin.config.mode === 'sync') {
215
- const consolidatedValue = await fieldPlugin.consolidateRecord(id);
216
- await resource.update(id, {
217
- [field]: consolidatedValue
218
- });
219
- return consolidatedValue;
341
+ return await fieldPlugin._syncModeConsolidate(id, field);
220
342
  }
221
-
343
+
222
344
  return actualValue;
223
345
  };
224
346
 
225
347
  // Add method to increment value
226
348
  resource.add = async (id, fieldOrAmount, amount) => {
227
- // Check if there are multiple fields with eventual consistency
228
- const hasMultipleFields = Object.keys(resource._eventualConsistencyPlugins).length > 1;
229
-
230
- // If multiple fields exist and only 2 params given, throw error
231
- if (hasMultipleFields && amount === undefined) {
232
- throw new Error(`Multiple fields have eventual consistency. Please specify the field: add(id, field, amount)`);
233
- }
234
-
235
- // Handle both signatures: add(id, amount) and add(id, field, amount)
236
- const field = amount !== undefined ? fieldOrAmount : defaultField;
237
- const actualAmount = amount !== undefined ? amount : fieldOrAmount;
238
- const fieldPlugin = resource._eventualConsistencyPlugins[field];
239
-
240
- if (!fieldPlugin) {
241
- throw new Error(`No eventual consistency plugin found for field "${field}"`);
242
- }
243
-
349
+ const { field, value: actualAmount, plugin: fieldPlugin } =
350
+ plugin._resolveFieldAndPlugin(resource, fieldOrAmount, amount);
351
+
244
352
  // Create add transaction
245
353
  await fieldPlugin.createTransaction({
246
354
  originalId: id,
@@ -248,16 +356,12 @@ export class EventualConsistencyPlugin extends Plugin {
248
356
  value: actualAmount,
249
357
  source: 'add'
250
358
  });
251
-
252
- // In sync mode, immediately consolidate and update
359
+
360
+ // In sync mode, immediately consolidate and update (atomic with locking)
253
361
  if (fieldPlugin.config.mode === 'sync') {
254
- const consolidatedValue = await fieldPlugin.consolidateRecord(id);
255
- await resource.update(id, {
256
- [field]: consolidatedValue
257
- });
258
- return consolidatedValue;
362
+ return await fieldPlugin._syncModeConsolidate(id, field);
259
363
  }
260
-
364
+
261
365
  // In async mode, return expected value (for user feedback)
262
366
  const currentValue = await fieldPlugin.getConsolidatedValue(id);
263
367
  return currentValue + actualAmount;
@@ -265,23 +369,9 @@ export class EventualConsistencyPlugin extends Plugin {
265
369
 
266
370
  // Add method to decrement value
267
371
  resource.sub = async (id, fieldOrAmount, amount) => {
268
- // Check if there are multiple fields with eventual consistency
269
- const hasMultipleFields = Object.keys(resource._eventualConsistencyPlugins).length > 1;
270
-
271
- // If multiple fields exist and only 2 params given, throw error
272
- if (hasMultipleFields && amount === undefined) {
273
- throw new Error(`Multiple fields have eventual consistency. Please specify the field: sub(id, field, amount)`);
274
- }
275
-
276
- // Handle both signatures: sub(id, amount) and sub(id, field, amount)
277
- const field = amount !== undefined ? fieldOrAmount : defaultField;
278
- const actualAmount = amount !== undefined ? amount : fieldOrAmount;
279
- const fieldPlugin = resource._eventualConsistencyPlugins[field];
280
-
281
- if (!fieldPlugin) {
282
- throw new Error(`No eventual consistency plugin found for field "${field}"`);
283
- }
284
-
372
+ const { field, value: actualAmount, plugin: fieldPlugin } =
373
+ plugin._resolveFieldAndPlugin(resource, fieldOrAmount, amount);
374
+
285
375
  // Create sub transaction
286
376
  await fieldPlugin.createTransaction({
287
377
  originalId: id,
@@ -289,16 +379,12 @@ export class EventualConsistencyPlugin extends Plugin {
289
379
  value: actualAmount,
290
380
  source: 'sub'
291
381
  });
292
-
293
- // In sync mode, immediately consolidate and update
382
+
383
+ // In sync mode, immediately consolidate and update (atomic with locking)
294
384
  if (fieldPlugin.config.mode === 'sync') {
295
- const consolidatedValue = await fieldPlugin.consolidateRecord(id);
296
- await resource.update(id, {
297
- [field]: consolidatedValue
298
- });
299
- return consolidatedValue;
385
+ return await fieldPlugin._syncModeConsolidate(id, field);
300
386
  }
301
-
387
+
302
388
  // In async mode, return expected value (for user feedback)
303
389
  const currentValue = await fieldPlugin.getConsolidatedValue(id);
304
390
  return currentValue - actualAmount;
@@ -341,15 +427,44 @@ export class EventualConsistencyPlugin extends Plugin {
341
427
  async createTransaction(data) {
342
428
  const now = new Date();
343
429
  const cohortInfo = this.getCohortInfo(now);
344
-
430
+
431
+ // Check for late arrivals (transaction older than watermark)
432
+ const watermarkMs = this.config.consolidationWindow * 60 * 60 * 1000;
433
+ const watermarkTime = now.getTime() - watermarkMs;
434
+ const cohortHourDate = new Date(cohortInfo.hour + ':00:00Z'); // Parse cohortHour back to date
435
+
436
+ if (cohortHourDate.getTime() < watermarkTime) {
437
+ // Late arrival detected!
438
+ const hoursLate = Math.floor((now.getTime() - cohortHourDate.getTime()) / (60 * 60 * 1000));
439
+
440
+ if (this.config.lateArrivalStrategy === 'ignore') {
441
+ if (this.config.verbose) {
442
+ console.warn(
443
+ `[EventualConsistency] Late arrival ignored: transaction for ${cohortInfo.hour} ` +
444
+ `is ${hoursLate}h late (watermark: ${this.config.consolidationWindow}h)`
445
+ );
446
+ }
447
+ // Don't create transaction
448
+ return null;
449
+ } else if (this.config.lateArrivalStrategy === 'warn') {
450
+ console.warn(
451
+ `[EventualConsistency] Late arrival detected: transaction for ${cohortInfo.hour} ` +
452
+ `is ${hoursLate}h late (watermark: ${this.config.consolidationWindow}h). ` +
453
+ `Processing anyway, but consolidation may not pick it up.`
454
+ );
455
+ }
456
+ // 'process' strategy: continue normally
457
+ }
458
+
345
459
  const transaction = {
346
- id: `txn-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`,
460
+ id: idGenerator(), // Use nanoid for guaranteed uniqueness
347
461
  originalId: data.originalId,
348
462
  field: this.config.field,
349
463
  value: data.value || 0,
350
464
  operation: data.operation || 'set',
351
465
  timestamp: now.toISOString(),
352
466
  cohortDate: cohortInfo.date,
467
+ cohortHour: cohortInfo.hour,
353
468
  cohortMonth: cohortInfo.month,
354
469
  source: data.source || 'unknown',
355
470
  applied: false
@@ -372,88 +487,152 @@ export class EventualConsistencyPlugin extends Plugin {
372
487
 
373
488
  async flushPendingTransactions() {
374
489
  if (this.pendingTransactions.size === 0) return;
375
-
490
+
376
491
  const transactions = Array.from(this.pendingTransactions.values());
377
- this.pendingTransactions.clear();
378
-
379
- // Insert all pending transactions
380
- for (const transaction of transactions) {
381
- await this.transactionResource.insert(transaction);
492
+
493
+ try {
494
+ // Insert all pending transactions in parallel
495
+ await Promise.all(
496
+ transactions.map(transaction =>
497
+ this.transactionResource.insert(transaction)
498
+ )
499
+ );
500
+
501
+ // Only clear after successful inserts (prevents data loss on crashes)
502
+ this.pendingTransactions.clear();
503
+ } catch (error) {
504
+ // Keep pending transactions for retry on next flush
505
+ console.error('Failed to flush pending transactions:', error);
506
+ throw error;
382
507
  }
383
508
  }
384
509
 
385
510
  getCohortInfo(date) {
386
511
  const tz = this.config.cohort.timezone;
387
-
512
+
388
513
  // Simple timezone offset calculation (can be enhanced with a library)
389
514
  const offset = this.getTimezoneOffset(tz);
390
515
  const localDate = new Date(date.getTime() + offset);
391
-
516
+
392
517
  const year = localDate.getFullYear();
393
518
  const month = String(localDate.getMonth() + 1).padStart(2, '0');
394
519
  const day = String(localDate.getDate()).padStart(2, '0');
395
-
520
+ const hour = String(localDate.getHours()).padStart(2, '0');
521
+
396
522
  return {
397
523
  date: `${year}-${month}-${day}`,
524
+ hour: `${year}-${month}-${day}T${hour}`, // ISO-like format for hour partition
398
525
  month: `${year}-${month}`
399
526
  };
400
527
  }
401
528
 
402
529
  getTimezoneOffset(timezone) {
403
- // Simplified timezone offset calculation
404
- // In production, use a proper timezone library
405
- const offsets = {
406
- 'UTC': 0,
407
- 'America/New_York': -5 * 3600000,
408
- 'America/Chicago': -6 * 3600000,
409
- 'America/Denver': -7 * 3600000,
410
- 'America/Los_Angeles': -8 * 3600000,
411
- 'America/Sao_Paulo': -3 * 3600000,
412
- 'Europe/London': 0,
413
- 'Europe/Paris': 1 * 3600000,
414
- 'Europe/Berlin': 1 * 3600000,
415
- 'Asia/Tokyo': 9 * 3600000,
416
- 'Asia/Shanghai': 8 * 3600000,
417
- 'Australia/Sydney': 10 * 3600000
418
- };
419
-
420
- return offsets[timezone] || 0;
530
+ // Try to calculate offset using Intl API (handles DST automatically)
531
+ try {
532
+ const now = new Date();
533
+
534
+ // Get UTC time
535
+ const utcDate = new Date(now.toLocaleString('en-US', { timeZone: 'UTC' }));
536
+
537
+ // Get time in target timezone
538
+ const tzDate = new Date(now.toLocaleString('en-US', { timeZone: timezone }));
539
+
540
+ // Calculate offset in milliseconds
541
+ return tzDate.getTime() - utcDate.getTime();
542
+ } catch (err) {
543
+ // Intl API failed, fallback to manual offsets (without DST support)
544
+ const offsets = {
545
+ 'UTC': 0,
546
+ 'America/New_York': -5 * 3600000,
547
+ 'America/Chicago': -6 * 3600000,
548
+ 'America/Denver': -7 * 3600000,
549
+ 'America/Los_Angeles': -8 * 3600000,
550
+ 'America/Sao_Paulo': -3 * 3600000,
551
+ 'Europe/London': 0,
552
+ 'Europe/Paris': 1 * 3600000,
553
+ 'Europe/Berlin': 1 * 3600000,
554
+ 'Asia/Tokyo': 9 * 3600000,
555
+ 'Asia/Shanghai': 8 * 3600000,
556
+ 'Australia/Sydney': 10 * 3600000
557
+ };
558
+
559
+ if (this.config.verbose && !offsets[timezone]) {
560
+ console.warn(
561
+ `[EventualConsistency] Unknown timezone '${timezone}', using UTC. ` +
562
+ `Consider using a valid IANA timezone (e.g., 'America/New_York')`
563
+ );
564
+ }
565
+
566
+ return offsets[timezone] || 0;
567
+ }
421
568
  }
422
569
 
423
570
  startConsolidationTimer() {
424
- const interval = this.config.consolidationInterval;
425
-
571
+ const intervalMs = this.config.consolidationInterval * 1000; // Convert seconds to ms
572
+
426
573
  this.consolidationTimer = setInterval(async () => {
427
574
  await this.runConsolidation();
428
- }, interval);
575
+ }, intervalMs);
429
576
  }
430
577
 
431
578
  async runConsolidation() {
432
579
  try {
433
- // Get all unique originalIds from transactions that need consolidation
434
- const [ok, err, transactions] = await tryFn(() =>
435
- this.transactionResource.query({
436
- applied: false
580
+ // Query unapplied transactions from recent cohorts (last 24 hours by default)
581
+ // This uses hourly partition for O(1) performance instead of full scan
582
+ const now = new Date();
583
+ const hoursToCheck = this.config.consolidationWindow || 24; // Configurable lookback window (in hours)
584
+ const cohortHours = [];
585
+
586
+ for (let i = 0; i < hoursToCheck; i++) {
587
+ const date = new Date(now.getTime() - (i * 60 * 60 * 1000)); // Subtract hours
588
+ const cohortInfo = this.getCohortInfo(date);
589
+ cohortHours.push(cohortInfo.hour);
590
+ }
591
+
592
+ // Query transactions by partition for each hour (parallel for speed)
593
+ const transactionsByHour = await Promise.all(
594
+ cohortHours.map(async (cohortHour) => {
595
+ const [ok, err, txns] = await tryFn(() =>
596
+ this.transactionResource.query({
597
+ cohortHour,
598
+ applied: false
599
+ })
600
+ );
601
+ return ok ? txns : [];
437
602
  })
438
603
  );
439
-
440
- if (!ok) {
441
- console.error('Consolidation failed to query transactions:', err);
604
+
605
+ // Flatten all transactions
606
+ const transactions = transactionsByHour.flat();
607
+
608
+ if (transactions.length === 0) {
609
+ if (this.config.verbose) {
610
+ console.log(`[EventualConsistency] No pending transactions to consolidate`);
611
+ }
442
612
  return;
443
613
  }
444
-
614
+
445
615
  // Get unique originalIds
446
616
  const uniqueIds = [...new Set(transactions.map(t => t.originalId))];
447
-
448
- // Consolidate each record
449
- for (const id of uniqueIds) {
450
- await this.consolidateRecord(id);
617
+
618
+ // Consolidate each record in parallel with concurrency limit
619
+ const { results, errors } = await PromisePool
620
+ .for(uniqueIds)
621
+ .withConcurrency(this.config.consolidationConcurrency)
622
+ .process(async (id) => {
623
+ return await this.consolidateRecord(id);
624
+ });
625
+
626
+ if (errors && errors.length > 0) {
627
+ console.error(`Consolidation completed with ${errors.length} errors:`, errors);
451
628
  }
452
-
629
+
453
630
  this.emit('eventual-consistency.consolidated', {
454
631
  resource: this.config.resource,
455
632
  field: this.config.field,
456
- recordCount: uniqueIds.length
633
+ recordCount: uniqueIds.length,
634
+ successCount: results.length,
635
+ errorCount: errors.length
457
636
  });
458
637
  } catch (error) {
459
638
  console.error('Consolidation error:', error);
@@ -462,94 +641,136 @@ export class EventualConsistencyPlugin extends Plugin {
462
641
  }
463
642
 
464
643
  async consolidateRecord(originalId) {
465
- // Get the current record value first
466
- const [recordOk, recordErr, record] = await tryFn(() =>
467
- this.targetResource.get(originalId)
468
- );
469
-
470
- const currentValue = (recordOk && record) ? (record[this.config.field] || 0) : 0;
471
-
472
- // Get all transactions for this record
473
- const [ok, err, transactions] = await tryFn(() =>
474
- this.transactionResource.query({
475
- originalId,
476
- applied: false
644
+ // Clean up stale locks before attempting to acquire
645
+ await this.cleanupStaleLocks();
646
+
647
+ // Acquire distributed lock to prevent concurrent consolidation
648
+ const lockId = `lock-${originalId}`;
649
+ const [lockAcquired, lockErr, lock] = await tryFn(() =>
650
+ this.lockResource.insert({
651
+ id: lockId,
652
+ lockedAt: Date.now(),
653
+ workerId: process.pid ? String(process.pid) : 'unknown'
477
654
  })
478
655
  );
479
-
480
- if (!ok || !transactions || transactions.length === 0) {
481
- return currentValue;
482
- }
483
-
484
- // Sort transactions by timestamp
485
- transactions.sort((a, b) =>
486
- new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
487
- );
488
-
489
- // If there's a current value and no 'set' operations, prepend a synthetic set transaction
490
- const hasSetOperation = transactions.some(t => t.operation === 'set');
491
- if (currentValue !== 0 && !hasSetOperation) {
492
- transactions.unshift({
493
- id: '__synthetic__', // Synthetic ID that we'll skip when marking as applied
494
- operation: 'set',
495
- value: currentValue,
496
- timestamp: new Date(0).toISOString() // Very old timestamp to ensure it's first
497
- });
656
+
657
+ // If lock couldn't be acquired, another worker is consolidating
658
+ if (!lockAcquired) {
659
+ if (this.config.verbose) {
660
+ console.log(`[EventualConsistency] Lock for ${originalId} already held, skipping`);
661
+ }
662
+ // Get current value and return (another worker will consolidate)
663
+ const [recordOk, recordErr, record] = await tryFn(() =>
664
+ this.targetResource.get(originalId)
665
+ );
666
+ return (recordOk && record) ? (record[this.config.field] || 0) : 0;
498
667
  }
499
-
500
- // Apply reducer to get consolidated value
501
- const consolidatedValue = this.config.reducer(transactions);
502
-
503
- // Update the original record
504
- const [updateOk, updateErr] = await tryFn(() =>
505
- this.targetResource.update(originalId, {
506
- [this.config.field]: consolidatedValue
507
- })
508
- );
509
-
510
- if (updateOk) {
511
- // Mark transactions as applied (skip synthetic ones)
512
- for (const txn of transactions) {
513
- if (txn.id !== '__synthetic__') {
514
- await this.transactionResource.update(txn.id, {
515
- applied: true
668
+
669
+ try {
670
+ // Get the current record value first
671
+ const [recordOk, recordErr, record] = await tryFn(() =>
672
+ this.targetResource.get(originalId)
673
+ );
674
+
675
+ const currentValue = (recordOk && record) ? (record[this.config.field] || 0) : 0;
676
+
677
+ // Get all transactions for this record
678
+ const [ok, err, transactions] = await tryFn(() =>
679
+ this.transactionResource.query({
680
+ originalId,
681
+ applied: false
682
+ })
683
+ );
684
+
685
+ if (!ok || !transactions || transactions.length === 0) {
686
+ return currentValue;
687
+ }
688
+
689
+ // Sort transactions by timestamp
690
+ transactions.sort((a, b) =>
691
+ new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
692
+ );
693
+
694
+ // If there's a current value and no 'set' operations, prepend a synthetic set transaction
695
+ const hasSetOperation = transactions.some(t => t.operation === 'set');
696
+ if (currentValue !== 0 && !hasSetOperation) {
697
+ transactions.unshift(this._createSyntheticSetTransaction(currentValue));
698
+ }
699
+
700
+ // Apply reducer to get consolidated value
701
+ const consolidatedValue = this.config.reducer(transactions);
702
+
703
+ // Update the original record
704
+ const [updateOk, updateErr] = await tryFn(() =>
705
+ this.targetResource.update(originalId, {
706
+ [this.config.field]: consolidatedValue
707
+ })
708
+ );
709
+
710
+ if (updateOk) {
711
+ // Mark transactions as applied (skip synthetic ones) - use PromisePool for controlled concurrency
712
+ const transactionsToUpdate = transactions.filter(txn => txn.id !== '__synthetic__');
713
+
714
+ const { results, errors } = await PromisePool
715
+ .for(transactionsToUpdate)
716
+ .withConcurrency(10) // Limit parallel updates
717
+ .process(async (txn) => {
718
+ const [ok, err] = await tryFn(() =>
719
+ this.transactionResource.update(txn.id, { applied: true })
720
+ );
721
+
722
+ if (!ok && this.config.verbose) {
723
+ console.warn(`[EventualConsistency] Failed to mark transaction ${txn.id} as applied:`, err?.message);
724
+ }
725
+
726
+ return ok;
516
727
  });
728
+
729
+ if (errors && errors.length > 0 && this.config.verbose) {
730
+ console.warn(`[EventualConsistency] ${errors.length} transactions failed to mark as applied`);
517
731
  }
518
732
  }
733
+
734
+ return consolidatedValue;
735
+ } finally {
736
+ // Always release the lock
737
+ const [lockReleased, lockReleaseErr] = await tryFn(() => this.lockResource.delete(lockId));
738
+
739
+ if (!lockReleased && this.config.verbose) {
740
+ console.warn(`[EventualConsistency] Failed to release lock ${lockId}:`, lockReleaseErr?.message);
741
+ }
519
742
  }
520
-
521
- return consolidatedValue;
522
743
  }
523
744
 
524
745
  async getConsolidatedValue(originalId, options = {}) {
525
746
  const includeApplied = options.includeApplied || false;
526
747
  const startDate = options.startDate;
527
748
  const endDate = options.endDate;
528
-
749
+
529
750
  // Build query
530
751
  const query = { originalId };
531
752
  if (!includeApplied) {
532
753
  query.applied = false;
533
754
  }
534
-
755
+
535
756
  // Get transactions
536
757
  const [ok, err, transactions] = await tryFn(() =>
537
758
  this.transactionResource.query(query)
538
759
  );
539
-
760
+
540
761
  if (!ok || !transactions || transactions.length === 0) {
541
762
  // If no transactions, check if record exists and return its current value
542
763
  const [recordOk, recordErr, record] = await tryFn(() =>
543
764
  this.targetResource.get(originalId)
544
765
  );
545
-
766
+
546
767
  if (recordOk && record) {
547
768
  return record[this.config.field] || 0;
548
769
  }
549
-
770
+
550
771
  return 0;
551
772
  }
552
-
773
+
553
774
  // Filter by date range if specified
554
775
  let filtered = transactions;
555
776
  if (startDate || endDate) {
@@ -560,12 +781,26 @@ export class EventualConsistencyPlugin extends Plugin {
560
781
  return true;
561
782
  });
562
783
  }
563
-
784
+
785
+ // Get current value from record
786
+ const [recordOk, recordErr, record] = await tryFn(() =>
787
+ this.targetResource.get(originalId)
788
+ );
789
+ const currentValue = (recordOk && record) ? (record[this.config.field] || 0) : 0;
790
+
791
+ // Check if there's a 'set' operation in filtered transactions
792
+ const hasSetOperation = filtered.some(t => t.operation === 'set');
793
+
794
+ // If current value exists and no 'set', prepend synthetic set transaction
795
+ if (currentValue !== 0 && !hasSetOperation) {
796
+ filtered.unshift(this._createSyntheticSetTransaction(currentValue));
797
+ }
798
+
564
799
  // Sort by timestamp
565
- filtered.sort((a, b) =>
800
+ filtered.sort((a, b) =>
566
801
  new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
567
802
  );
568
-
803
+
569
804
  // Apply reducer
570
805
  return this.config.reducer(filtered);
571
806
  }
@@ -577,9 +812,9 @@ export class EventualConsistencyPlugin extends Plugin {
577
812
  cohortDate
578
813
  })
579
814
  );
580
-
815
+
581
816
  if (!ok) return null;
582
-
817
+
583
818
  const stats = {
584
819
  date: cohortDate,
585
820
  transactionCount: transactions.length,
@@ -587,11 +822,11 @@ export class EventualConsistencyPlugin extends Plugin {
587
822
  byOperation: { set: 0, add: 0, sub: 0 },
588
823
  byOriginalId: {}
589
824
  };
590
-
825
+
591
826
  for (const txn of transactions) {
592
827
  stats.totalValue += txn.value || 0;
593
828
  stats.byOperation[txn.operation] = (stats.byOperation[txn.operation] || 0) + 1;
594
-
829
+
595
830
  if (!stats.byOriginalId[txn.originalId]) {
596
831
  stats.byOriginalId[txn.originalId] = {
597
832
  count: 0,
@@ -601,9 +836,177 @@ export class EventualConsistencyPlugin extends Plugin {
601
836
  stats.byOriginalId[txn.originalId].count++;
602
837
  stats.byOriginalId[txn.originalId].value += txn.value || 0;
603
838
  }
604
-
839
+
605
840
  return stats;
606
841
  }
842
+
843
+ /**
844
+ * Clean up stale locks that exceed the configured timeout
845
+ * Uses distributed locking to prevent multiple containers from cleaning simultaneously
846
+ */
847
+ async cleanupStaleLocks() {
848
+ const now = Date.now();
849
+ const lockTimeoutMs = this.config.lockTimeout * 1000; // Convert seconds to ms
850
+ const cutoffTime = now - lockTimeoutMs;
851
+
852
+ // Acquire distributed lock for cleanup operation
853
+ const cleanupLockId = `lock-cleanup-${this.config.resource}-${this.config.field}`;
854
+ const [lockAcquired] = await tryFn(() =>
855
+ this.lockResource.insert({
856
+ id: cleanupLockId,
857
+ lockedAt: Date.now(),
858
+ workerId: process.pid ? String(process.pid) : 'unknown'
859
+ })
860
+ );
861
+
862
+ // If another container is already cleaning, skip
863
+ if (!lockAcquired) {
864
+ if (this.config.verbose) {
865
+ console.log(`[EventualConsistency] Lock cleanup already running in another container`);
866
+ }
867
+ return;
868
+ }
869
+
870
+ try {
871
+ // Get all locks
872
+ const [ok, err, locks] = await tryFn(() => this.lockResource.list());
873
+
874
+ if (!ok || !locks || locks.length === 0) return;
875
+
876
+ // Find stale locks (excluding the cleanup lock itself)
877
+ const staleLocks = locks.filter(lock =>
878
+ lock.id !== cleanupLockId && lock.lockedAt < cutoffTime
879
+ );
880
+
881
+ if (staleLocks.length === 0) return;
882
+
883
+ if (this.config.verbose) {
884
+ console.log(`[EventualConsistency] Cleaning up ${staleLocks.length} stale locks`);
885
+ }
886
+
887
+ // Delete stale locks using PromisePool
888
+ const { results, errors } = await PromisePool
889
+ .for(staleLocks)
890
+ .withConcurrency(5)
891
+ .process(async (lock) => {
892
+ const [deleted] = await tryFn(() => this.lockResource.delete(lock.id));
893
+ return deleted;
894
+ });
895
+
896
+ if (errors && errors.length > 0 && this.config.verbose) {
897
+ console.warn(`[EventualConsistency] ${errors.length} stale locks failed to delete`);
898
+ }
899
+ } catch (error) {
900
+ if (this.config.verbose) {
901
+ console.warn(`[EventualConsistency] Error cleaning up stale locks:`, error.message);
902
+ }
903
+ } finally {
904
+ // Always release cleanup lock
905
+ await tryFn(() => this.lockResource.delete(cleanupLockId));
906
+ }
907
+ }
908
+
909
+ /**
910
+ * Start garbage collection timer for old applied transactions
911
+ */
912
+ startGarbageCollectionTimer() {
913
+ const gcIntervalMs = this.config.gcInterval * 1000; // Convert seconds to ms
914
+
915
+ this.gcTimer = setInterval(async () => {
916
+ await this.runGarbageCollection();
917
+ }, gcIntervalMs);
918
+ }
919
+
920
+ /**
921
+ * Delete old applied transactions based on retention policy
922
+ * Uses distributed locking to prevent multiple containers from running GC simultaneously
923
+ */
924
+ async runGarbageCollection() {
925
+ // Acquire distributed lock for GC operation
926
+ const gcLockId = `lock-gc-${this.config.resource}-${this.config.field}`;
927
+ const [lockAcquired] = await tryFn(() =>
928
+ this.lockResource.insert({
929
+ id: gcLockId,
930
+ lockedAt: Date.now(),
931
+ workerId: process.pid ? String(process.pid) : 'unknown'
932
+ })
933
+ );
934
+
935
+ // If another container is already running GC, skip
936
+ if (!lockAcquired) {
937
+ if (this.config.verbose) {
938
+ console.log(`[EventualConsistency] GC already running in another container`);
939
+ }
940
+ return;
941
+ }
942
+
943
+ try {
944
+ const now = Date.now();
945
+ const retentionMs = this.config.transactionRetention * 24 * 60 * 60 * 1000; // Days to ms
946
+ const cutoffDate = new Date(now - retentionMs);
947
+ const cutoffIso = cutoffDate.toISOString();
948
+
949
+ if (this.config.verbose) {
950
+ console.log(`[EventualConsistency] Running GC for transactions older than ${cutoffIso} (${this.config.transactionRetention} days)`);
951
+ }
952
+
953
+ // Query old applied transactions
954
+ const cutoffMonth = cutoffDate.toISOString().substring(0, 7); // YYYY-MM
955
+
956
+ const [ok, err, oldTransactions] = await tryFn(() =>
957
+ this.transactionResource.query({
958
+ applied: true,
959
+ timestamp: { '<': cutoffIso }
960
+ })
961
+ );
962
+
963
+ if (!ok) {
964
+ if (this.config.verbose) {
965
+ console.warn(`[EventualConsistency] GC failed to query transactions:`, err?.message);
966
+ }
967
+ return;
968
+ }
969
+
970
+ if (!oldTransactions || oldTransactions.length === 0) {
971
+ if (this.config.verbose) {
972
+ console.log(`[EventualConsistency] No old transactions to clean up`);
973
+ }
974
+ return;
975
+ }
976
+
977
+ if (this.config.verbose) {
978
+ console.log(`[EventualConsistency] Deleting ${oldTransactions.length} old transactions`);
979
+ }
980
+
981
+ // Delete old transactions using PromisePool
982
+ const { results, errors } = await PromisePool
983
+ .for(oldTransactions)
984
+ .withConcurrency(10)
985
+ .process(async (txn) => {
986
+ const [deleted] = await tryFn(() => this.transactionResource.delete(txn.id));
987
+ return deleted;
988
+ });
989
+
990
+ if (this.config.verbose) {
991
+ console.log(`[EventualConsistency] GC completed: ${results.length} deleted, ${errors.length} errors`);
992
+ }
993
+
994
+ this.emit('eventual-consistency.gc-completed', {
995
+ resource: this.config.resource,
996
+ field: this.config.field,
997
+ deletedCount: results.length,
998
+ errorCount: errors.length
999
+ });
1000
+ } catch (error) {
1001
+ if (this.config.verbose) {
1002
+ console.warn(`[EventualConsistency] GC error:`, error.message);
1003
+ }
1004
+ this.emit('eventual-consistency.gc-error', error);
1005
+ } finally {
1006
+ // Always release GC lock
1007
+ await tryFn(() => this.lockResource.delete(gcLockId));
1008
+ }
1009
+ }
607
1010
  }
608
1011
 
609
1012
  export default EventualConsistencyPlugin;