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.
- package/README.md +2 -1
- package/dist/s3db.cjs.js +2204 -612
- package/dist/s3db.cjs.js.map +1 -1
- package/dist/s3db.d.ts +70 -3
- package/dist/s3db.es.js +2203 -613
- package/dist/s3db.es.js.map +1 -1
- package/package.json +1 -1
- package/src/client.class.js +6 -5
- package/src/plugins/audit.plugin.js +8 -6
- package/src/plugins/backup.plugin.js +383 -106
- package/src/plugins/cache.plugin.js +203 -150
- package/src/plugins/eventual-consistency.plugin.js +609 -206
- package/src/plugins/fulltext.plugin.js +6 -6
- package/src/plugins/index.js +1 -0
- package/src/plugins/metrics.plugin.js +13 -13
- package/src/plugins/queue-consumer.plugin.js +4 -2
- package/src/plugins/replicator.plugin.js +108 -70
- package/src/plugins/replicators/s3db-replicator.class.js +7 -3
- package/src/plugins/replicators/sqs-replicator.class.js +11 -3
- package/src/plugins/s3-queue.plugin.js +776 -0
- package/src/plugins/scheduler.plugin.js +226 -164
- package/src/plugins/state-machine.plugin.js +109 -81
- package/src/resource.class.js +205 -0
- package/src/s3db.d.ts +70 -3
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
189
|
-
|
|
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
|
-
|
|
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
|
-
|
|
228
|
-
|
|
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
|
-
|
|
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
|
-
|
|
269
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
//
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
'
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
'
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
|
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
|
-
},
|
|
575
|
+
}, intervalMs);
|
|
429
576
|
}
|
|
430
577
|
|
|
431
578
|
async runConsolidation() {
|
|
432
579
|
try {
|
|
433
|
-
//
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
|
|
441
|
-
|
|
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
|
-
|
|
450
|
-
|
|
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
|
-
//
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
const
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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;
|