s3db.js 9.3.0 → 10.0.1
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 +72 -13
- package/dist/s3db.cjs.js +2342 -540
- package/dist/s3db.cjs.js.map +1 -1
- package/dist/s3db.es.js +2341 -541
- package/dist/s3db.es.js.map +1 -1
- package/package.json +1 -1
- package/src/client.class.js +8 -7
- package/src/concerns/high-performance-inserter.js +285 -0
- package/src/concerns/partition-queue.js +171 -0
- package/src/errors.js +10 -2
- package/src/partition-drivers/base-partition-driver.js +96 -0
- package/src/partition-drivers/index.js +60 -0
- package/src/partition-drivers/memory-partition-driver.js +274 -0
- package/src/partition-drivers/sqs-partition-driver.js +332 -0
- package/src/partition-drivers/sync-partition-driver.js +38 -0
- package/src/plugins/audit.plugin.js +4 -4
- package/src/plugins/backup.plugin.js +380 -105
- package/src/plugins/backup.plugin.js.backup +1 -1
- package/src/plugins/cache.plugin.js +203 -150
- package/src/plugins/eventual-consistency.plugin.js +1012 -0
- package/src/plugins/fulltext.plugin.js +6 -6
- package/src/plugins/index.js +2 -0
- package/src/plugins/metrics.plugin.js +13 -13
- 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/PLUGINS.md +0 -5036
|
@@ -0,0 +1,1012 @@
|
|
|
1
|
+
import Plugin from "./plugin.class.js";
|
|
2
|
+
import tryFn from "../concerns/try-fn.js";
|
|
3
|
+
import { idGenerator } from "../concerns/id.js";
|
|
4
|
+
import { PromisePool } from "@supercharge/promise-pool";
|
|
5
|
+
|
|
6
|
+
export class EventualConsistencyPlugin extends Plugin {
|
|
7
|
+
constructor(options = {}) {
|
|
8
|
+
super(options);
|
|
9
|
+
|
|
10
|
+
// Validate required options
|
|
11
|
+
if (!options.resource) {
|
|
12
|
+
throw new Error("EventualConsistencyPlugin requires 'resource' option");
|
|
13
|
+
}
|
|
14
|
+
if (!options.field) {
|
|
15
|
+
throw new Error("EventualConsistencyPlugin requires 'field' option");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Auto-detect timezone from environment or system
|
|
19
|
+
const detectedTimezone = this._detectTimezone();
|
|
20
|
+
|
|
21
|
+
this.config = {
|
|
22
|
+
resource: options.resource,
|
|
23
|
+
field: options.field,
|
|
24
|
+
cohort: {
|
|
25
|
+
timezone: options.cohort?.timezone || detectedTimezone
|
|
26
|
+
},
|
|
27
|
+
reducer: options.reducer || ((transactions) => {
|
|
28
|
+
// Default reducer: sum all increments from a base value
|
|
29
|
+
let baseValue = 0;
|
|
30
|
+
|
|
31
|
+
for (const t of transactions) {
|
|
32
|
+
if (t.operation === 'set') {
|
|
33
|
+
baseValue = t.value;
|
|
34
|
+
} else if (t.operation === 'add') {
|
|
35
|
+
baseValue += t.value;
|
|
36
|
+
} else if (t.operation === 'sub') {
|
|
37
|
+
baseValue -= t.value;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return baseValue;
|
|
42
|
+
}),
|
|
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)
|
|
46
|
+
autoConsolidate: options.autoConsolidate !== 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
|
|
49
|
+
batchSize: options.batchSize || 100,
|
|
50
|
+
mode: options.mode || 'async', // 'async' or 'sync'
|
|
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
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
this.transactionResource = null;
|
|
58
|
+
this.targetResource = null;
|
|
59
|
+
this.consolidationTimer = null;
|
|
60
|
+
this.gcTimer = null; // Garbage collection timer
|
|
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
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async onSetup() {
|
|
83
|
+
// Try to get the target resource
|
|
84
|
+
this.targetResource = this.database.resources[this.config.resource];
|
|
85
|
+
|
|
86
|
+
if (!this.targetResource) {
|
|
87
|
+
// Resource doesn't exist yet - defer setup
|
|
88
|
+
this.deferredSetup = true;
|
|
89
|
+
this.watchForResource();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Resource exists - continue with setup
|
|
94
|
+
await this.completeSetup();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
watchForResource() {
|
|
98
|
+
// Monitor for resource creation using database hooks
|
|
99
|
+
const hookCallback = async ({ resource, config }) => {
|
|
100
|
+
// Check if this is the resource we're waiting for
|
|
101
|
+
if (config.name === this.config.resource && this.deferredSetup) {
|
|
102
|
+
this.targetResource = resource;
|
|
103
|
+
this.deferredSetup = false;
|
|
104
|
+
await this.completeSetup();
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
this.database.addHook('afterCreateResource', hookCallback);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async completeSetup() {
|
|
112
|
+
if (!this.targetResource) return;
|
|
113
|
+
|
|
114
|
+
// Create transaction resource with partitions (includes field name to support multiple fields)
|
|
115
|
+
const transactionResourceName = `${this.config.resource}_transactions_${this.config.field}`;
|
|
116
|
+
const partitionConfig = this.createPartitionConfig();
|
|
117
|
+
|
|
118
|
+
const [ok, err, transactionResource] = await tryFn(() =>
|
|
119
|
+
this.database.createResource({
|
|
120
|
+
name: transactionResourceName,
|
|
121
|
+
attributes: {
|
|
122
|
+
id: 'string|required',
|
|
123
|
+
originalId: 'string|required',
|
|
124
|
+
field: 'string|required',
|
|
125
|
+
value: 'number|required',
|
|
126
|
+
operation: 'string|required', // 'set', 'add', or 'sub'
|
|
127
|
+
timestamp: 'string|required',
|
|
128
|
+
cohortDate: 'string|required', // For daily partitioning
|
|
129
|
+
cohortHour: 'string|required', // For hourly partitioning
|
|
130
|
+
cohortMonth: 'string|optional', // For monthly partitioning
|
|
131
|
+
source: 'string|optional',
|
|
132
|
+
applied: 'boolean|optional' // Track if transaction was applied
|
|
133
|
+
},
|
|
134
|
+
behavior: 'body-overflow',
|
|
135
|
+
timestamps: true,
|
|
136
|
+
partitions: partitionConfig,
|
|
137
|
+
asyncPartitions: true // Use async partitions for better performance
|
|
138
|
+
})
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
if (!ok && !this.database.resources[transactionResourceName]) {
|
|
142
|
+
throw new Error(`Failed to create transaction resource: ${err?.message}`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
this.transactionResource = ok ? transactionResource : this.database.resources[transactionResourceName];
|
|
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
|
+
|
|
168
|
+
// Add helper methods to the resource
|
|
169
|
+
this.addHelperMethods();
|
|
170
|
+
|
|
171
|
+
// Setup consolidation if enabled
|
|
172
|
+
if (this.config.autoConsolidate) {
|
|
173
|
+
this.startConsolidationTimer();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Setup garbage collection timer
|
|
177
|
+
this.startGarbageCollectionTimer();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async onStart() {
|
|
181
|
+
// Don't start if we're waiting for the resource
|
|
182
|
+
if (this.deferredSetup) {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Plugin is ready
|
|
187
|
+
this.emit('eventual-consistency.started', {
|
|
188
|
+
resource: this.config.resource,
|
|
189
|
+
field: this.config.field,
|
|
190
|
+
cohort: this.config.cohort
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async onStop() {
|
|
195
|
+
// Stop consolidation timer
|
|
196
|
+
if (this.consolidationTimer) {
|
|
197
|
+
clearInterval(this.consolidationTimer);
|
|
198
|
+
this.consolidationTimer = null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Stop garbage collection timer
|
|
202
|
+
if (this.gcTimer) {
|
|
203
|
+
clearInterval(this.gcTimer);
|
|
204
|
+
this.gcTimer = null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Flush pending transactions
|
|
208
|
+
await this.flushPendingTransactions();
|
|
209
|
+
|
|
210
|
+
this.emit('eventual-consistency.stopped', {
|
|
211
|
+
resource: this.config.resource,
|
|
212
|
+
field: this.config.field
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
createPartitionConfig() {
|
|
217
|
+
// Create hourly, daily and monthly partitions for transactions
|
|
218
|
+
const partitions = {
|
|
219
|
+
byHour: {
|
|
220
|
+
fields: {
|
|
221
|
+
cohortHour: 'string'
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
byDay: {
|
|
225
|
+
fields: {
|
|
226
|
+
cohortDate: 'string'
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
byMonth: {
|
|
230
|
+
fields: {
|
|
231
|
+
cohortMonth: 'string'
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
return partitions;
|
|
237
|
+
}
|
|
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
|
+
|
|
315
|
+
addHelperMethods() {
|
|
316
|
+
const resource = this.targetResource;
|
|
317
|
+
const defaultField = this.config.field;
|
|
318
|
+
const plugin = this;
|
|
319
|
+
|
|
320
|
+
// Store all plugins by field name for this resource
|
|
321
|
+
if (!resource._eventualConsistencyPlugins) {
|
|
322
|
+
resource._eventualConsistencyPlugins = {};
|
|
323
|
+
}
|
|
324
|
+
resource._eventualConsistencyPlugins[defaultField] = plugin;
|
|
325
|
+
|
|
326
|
+
// Add method to set value (replaces current value)
|
|
327
|
+
resource.set = async (id, fieldOrValue, value) => {
|
|
328
|
+
const { field, value: actualValue, plugin: fieldPlugin } =
|
|
329
|
+
plugin._resolveFieldAndPlugin(resource, fieldOrValue, value);
|
|
330
|
+
|
|
331
|
+
// Create set transaction
|
|
332
|
+
await fieldPlugin.createTransaction({
|
|
333
|
+
originalId: id,
|
|
334
|
+
operation: 'set',
|
|
335
|
+
value: actualValue,
|
|
336
|
+
source: 'set'
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// In sync mode, immediately consolidate and update (atomic with locking)
|
|
340
|
+
if (fieldPlugin.config.mode === 'sync') {
|
|
341
|
+
return await fieldPlugin._syncModeConsolidate(id, field);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return actualValue;
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
// Add method to increment value
|
|
348
|
+
resource.add = async (id, fieldOrAmount, amount) => {
|
|
349
|
+
const { field, value: actualAmount, plugin: fieldPlugin } =
|
|
350
|
+
plugin._resolveFieldAndPlugin(resource, fieldOrAmount, amount);
|
|
351
|
+
|
|
352
|
+
// Create add transaction
|
|
353
|
+
await fieldPlugin.createTransaction({
|
|
354
|
+
originalId: id,
|
|
355
|
+
operation: 'add',
|
|
356
|
+
value: actualAmount,
|
|
357
|
+
source: 'add'
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
// In sync mode, immediately consolidate and update (atomic with locking)
|
|
361
|
+
if (fieldPlugin.config.mode === 'sync') {
|
|
362
|
+
return await fieldPlugin._syncModeConsolidate(id, field);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// In async mode, return expected value (for user feedback)
|
|
366
|
+
const currentValue = await fieldPlugin.getConsolidatedValue(id);
|
|
367
|
+
return currentValue + actualAmount;
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
// Add method to decrement value
|
|
371
|
+
resource.sub = async (id, fieldOrAmount, amount) => {
|
|
372
|
+
const { field, value: actualAmount, plugin: fieldPlugin } =
|
|
373
|
+
plugin._resolveFieldAndPlugin(resource, fieldOrAmount, amount);
|
|
374
|
+
|
|
375
|
+
// Create sub transaction
|
|
376
|
+
await fieldPlugin.createTransaction({
|
|
377
|
+
originalId: id,
|
|
378
|
+
operation: 'sub',
|
|
379
|
+
value: actualAmount,
|
|
380
|
+
source: 'sub'
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// In sync mode, immediately consolidate and update (atomic with locking)
|
|
384
|
+
if (fieldPlugin.config.mode === 'sync') {
|
|
385
|
+
return await fieldPlugin._syncModeConsolidate(id, field);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// In async mode, return expected value (for user feedback)
|
|
389
|
+
const currentValue = await fieldPlugin.getConsolidatedValue(id);
|
|
390
|
+
return currentValue - actualAmount;
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
// Add method to manually trigger consolidation
|
|
394
|
+
resource.consolidate = async (id, field) => {
|
|
395
|
+
// Check if there are multiple fields with eventual consistency
|
|
396
|
+
const hasMultipleFields = Object.keys(resource._eventualConsistencyPlugins).length > 1;
|
|
397
|
+
|
|
398
|
+
// If multiple fields exist and no field given, throw error
|
|
399
|
+
if (hasMultipleFields && !field) {
|
|
400
|
+
throw new Error(`Multiple fields have eventual consistency. Please specify the field: consolidate(id, field)`);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Handle both signatures: consolidate(id) and consolidate(id, field)
|
|
404
|
+
const actualField = field || defaultField;
|
|
405
|
+
const fieldPlugin = resource._eventualConsistencyPlugins[actualField];
|
|
406
|
+
|
|
407
|
+
if (!fieldPlugin) {
|
|
408
|
+
throw new Error(`No eventual consistency plugin found for field "${actualField}"`);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return await fieldPlugin.consolidateRecord(id);
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
// Add method to get consolidated value without applying
|
|
415
|
+
resource.getConsolidatedValue = async (id, fieldOrOptions, options) => {
|
|
416
|
+
// Handle both signatures: getConsolidatedValue(id, options) and getConsolidatedValue(id, field, options)
|
|
417
|
+
if (typeof fieldOrOptions === 'string') {
|
|
418
|
+
const field = fieldOrOptions;
|
|
419
|
+
const fieldPlugin = resource._eventualConsistencyPlugins[field] || plugin;
|
|
420
|
+
return await fieldPlugin.getConsolidatedValue(id, options || {});
|
|
421
|
+
} else {
|
|
422
|
+
return await plugin.getConsolidatedValue(id, fieldOrOptions || {});
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async createTransaction(data) {
|
|
428
|
+
const now = new Date();
|
|
429
|
+
const cohortInfo = this.getCohortInfo(now);
|
|
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
|
+
|
|
459
|
+
const transaction = {
|
|
460
|
+
id: idGenerator(), // Use nanoid for guaranteed uniqueness
|
|
461
|
+
originalId: data.originalId,
|
|
462
|
+
field: this.config.field,
|
|
463
|
+
value: data.value || 0,
|
|
464
|
+
operation: data.operation || 'set',
|
|
465
|
+
timestamp: now.toISOString(),
|
|
466
|
+
cohortDate: cohortInfo.date,
|
|
467
|
+
cohortHour: cohortInfo.hour,
|
|
468
|
+
cohortMonth: cohortInfo.month,
|
|
469
|
+
source: data.source || 'unknown',
|
|
470
|
+
applied: false
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
// Batch transactions if configured
|
|
474
|
+
if (this.config.batchTransactions) {
|
|
475
|
+
this.pendingTransactions.set(transaction.id, transaction);
|
|
476
|
+
|
|
477
|
+
// Flush if batch size reached
|
|
478
|
+
if (this.pendingTransactions.size >= this.config.batchSize) {
|
|
479
|
+
await this.flushPendingTransactions();
|
|
480
|
+
}
|
|
481
|
+
} else {
|
|
482
|
+
await this.transactionResource.insert(transaction);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
return transaction;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
async flushPendingTransactions() {
|
|
489
|
+
if (this.pendingTransactions.size === 0) return;
|
|
490
|
+
|
|
491
|
+
const transactions = Array.from(this.pendingTransactions.values());
|
|
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;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
getCohortInfo(date) {
|
|
511
|
+
const tz = this.config.cohort.timezone;
|
|
512
|
+
|
|
513
|
+
// Simple timezone offset calculation (can be enhanced with a library)
|
|
514
|
+
const offset = this.getTimezoneOffset(tz);
|
|
515
|
+
const localDate = new Date(date.getTime() + offset);
|
|
516
|
+
|
|
517
|
+
const year = localDate.getFullYear();
|
|
518
|
+
const month = String(localDate.getMonth() + 1).padStart(2, '0');
|
|
519
|
+
const day = String(localDate.getDate()).padStart(2, '0');
|
|
520
|
+
const hour = String(localDate.getHours()).padStart(2, '0');
|
|
521
|
+
|
|
522
|
+
return {
|
|
523
|
+
date: `${year}-${month}-${day}`,
|
|
524
|
+
hour: `${year}-${month}-${day}T${hour}`, // ISO-like format for hour partition
|
|
525
|
+
month: `${year}-${month}`
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
getTimezoneOffset(timezone) {
|
|
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
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
startConsolidationTimer() {
|
|
571
|
+
const intervalMs = this.config.consolidationInterval * 1000; // Convert seconds to ms
|
|
572
|
+
|
|
573
|
+
this.consolidationTimer = setInterval(async () => {
|
|
574
|
+
await this.runConsolidation();
|
|
575
|
+
}, intervalMs);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
async runConsolidation() {
|
|
579
|
+
try {
|
|
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 : [];
|
|
602
|
+
})
|
|
603
|
+
);
|
|
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
|
+
}
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// Get unique originalIds
|
|
616
|
+
const uniqueIds = [...new Set(transactions.map(t => t.originalId))];
|
|
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);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
this.emit('eventual-consistency.consolidated', {
|
|
631
|
+
resource: this.config.resource,
|
|
632
|
+
field: this.config.field,
|
|
633
|
+
recordCount: uniqueIds.length,
|
|
634
|
+
successCount: results.length,
|
|
635
|
+
errorCount: errors.length
|
|
636
|
+
});
|
|
637
|
+
} catch (error) {
|
|
638
|
+
console.error('Consolidation error:', error);
|
|
639
|
+
this.emit('eventual-consistency.consolidation-error', error);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
async consolidateRecord(originalId) {
|
|
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'
|
|
654
|
+
})
|
|
655
|
+
);
|
|
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;
|
|
667
|
+
}
|
|
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;
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
if (errors && errors.length > 0 && this.config.verbose) {
|
|
730
|
+
console.warn(`[EventualConsistency] ${errors.length} transactions failed to mark as applied`);
|
|
731
|
+
}
|
|
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
|
+
}
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
async getConsolidatedValue(originalId, options = {}) {
|
|
746
|
+
const includeApplied = options.includeApplied || false;
|
|
747
|
+
const startDate = options.startDate;
|
|
748
|
+
const endDate = options.endDate;
|
|
749
|
+
|
|
750
|
+
// Build query
|
|
751
|
+
const query = { originalId };
|
|
752
|
+
if (!includeApplied) {
|
|
753
|
+
query.applied = false;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// Get transactions
|
|
757
|
+
const [ok, err, transactions] = await tryFn(() =>
|
|
758
|
+
this.transactionResource.query(query)
|
|
759
|
+
);
|
|
760
|
+
|
|
761
|
+
if (!ok || !transactions || transactions.length === 0) {
|
|
762
|
+
// If no transactions, check if record exists and return its current value
|
|
763
|
+
const [recordOk, recordErr, record] = await tryFn(() =>
|
|
764
|
+
this.targetResource.get(originalId)
|
|
765
|
+
);
|
|
766
|
+
|
|
767
|
+
if (recordOk && record) {
|
|
768
|
+
return record[this.config.field] || 0;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
return 0;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// Filter by date range if specified
|
|
775
|
+
let filtered = transactions;
|
|
776
|
+
if (startDate || endDate) {
|
|
777
|
+
filtered = transactions.filter(t => {
|
|
778
|
+
const timestamp = new Date(t.timestamp);
|
|
779
|
+
if (startDate && timestamp < new Date(startDate)) return false;
|
|
780
|
+
if (endDate && timestamp > new Date(endDate)) return false;
|
|
781
|
+
return true;
|
|
782
|
+
});
|
|
783
|
+
}
|
|
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
|
+
|
|
799
|
+
// Sort by timestamp
|
|
800
|
+
filtered.sort((a, b) =>
|
|
801
|
+
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
|
802
|
+
);
|
|
803
|
+
|
|
804
|
+
// Apply reducer
|
|
805
|
+
return this.config.reducer(filtered);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// Helper method to get cohort statistics
|
|
809
|
+
async getCohortStats(cohortDate) {
|
|
810
|
+
const [ok, err, transactions] = await tryFn(() =>
|
|
811
|
+
this.transactionResource.query({
|
|
812
|
+
cohortDate
|
|
813
|
+
})
|
|
814
|
+
);
|
|
815
|
+
|
|
816
|
+
if (!ok) return null;
|
|
817
|
+
|
|
818
|
+
const stats = {
|
|
819
|
+
date: cohortDate,
|
|
820
|
+
transactionCount: transactions.length,
|
|
821
|
+
totalValue: 0,
|
|
822
|
+
byOperation: { set: 0, add: 0, sub: 0 },
|
|
823
|
+
byOriginalId: {}
|
|
824
|
+
};
|
|
825
|
+
|
|
826
|
+
for (const txn of transactions) {
|
|
827
|
+
stats.totalValue += txn.value || 0;
|
|
828
|
+
stats.byOperation[txn.operation] = (stats.byOperation[txn.operation] || 0) + 1;
|
|
829
|
+
|
|
830
|
+
if (!stats.byOriginalId[txn.originalId]) {
|
|
831
|
+
stats.byOriginalId[txn.originalId] = {
|
|
832
|
+
count: 0,
|
|
833
|
+
value: 0
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
stats.byOriginalId[txn.originalId].count++;
|
|
837
|
+
stats.byOriginalId[txn.originalId].value += txn.value || 0;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
return stats;
|
|
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
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
export default EventualConsistencyPlugin;
|