s3db.js 10.0.16 → 10.0.17
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/dist/s3db.cjs.js +1758 -1549
- package/dist/s3db.cjs.js.map +1 -1
- package/dist/s3db.es.js +1758 -1549
- package/dist/s3db.es.js.map +1 -1
- package/package.json +1 -1
- package/src/plugins/eventual-consistency/analytics.js +668 -0
- package/src/plugins/eventual-consistency/config.js +120 -0
- package/src/plugins/eventual-consistency/consolidation.js +770 -0
- package/src/plugins/eventual-consistency/garbage-collection.js +126 -0
- package/src/plugins/eventual-consistency/helpers.js +179 -0
- package/src/plugins/eventual-consistency/index.js +455 -0
- package/src/plugins/eventual-consistency/locks.js +77 -0
- package/src/plugins/eventual-consistency/partitions.js +45 -0
- package/src/plugins/eventual-consistency/setup.js +298 -0
- package/src/plugins/eventual-consistency/transactions.js +119 -0
- package/src/plugins/eventual-consistency/utils.js +182 -0
- package/src/plugins/eventual-consistency.plugin.js +195 -2
- package/src/plugins/index.js +1 -1
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Garbage collection for EventualConsistencyPlugin
|
|
3
|
+
* @module eventual-consistency/garbage-collection
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import tryFn from "../../concerns/try-fn.js";
|
|
7
|
+
import { PromisePool } from "@supercharge/promise-pool";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Start garbage collection timer for a handler
|
|
11
|
+
*
|
|
12
|
+
* @param {Object} handler - Field handler
|
|
13
|
+
* @param {string} resourceName - Resource name
|
|
14
|
+
* @param {string} fieldName - Field name
|
|
15
|
+
* @param {Function} runGCCallback - Callback to run GC
|
|
16
|
+
* @param {Object} config - Plugin configuration
|
|
17
|
+
* @returns {NodeJS.Timeout} GC timer
|
|
18
|
+
*/
|
|
19
|
+
export function startGarbageCollectionTimer(handler, resourceName, fieldName, runGCCallback, config) {
|
|
20
|
+
const gcIntervalMs = config.gcInterval * 1000; // Convert seconds to ms
|
|
21
|
+
|
|
22
|
+
handler.gcTimer = setInterval(async () => {
|
|
23
|
+
await runGCCallback(handler, resourceName, fieldName);
|
|
24
|
+
}, gcIntervalMs);
|
|
25
|
+
|
|
26
|
+
return handler.gcTimer;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Delete old applied transactions based on retention policy
|
|
31
|
+
* Uses distributed locking to prevent multiple containers from running GC simultaneously
|
|
32
|
+
*
|
|
33
|
+
* @param {Object} transactionResource - Transaction resource
|
|
34
|
+
* @param {Object} lockResource - Lock resource
|
|
35
|
+
* @param {Object} config - Plugin configuration
|
|
36
|
+
* @param {Function} emitFn - Function to emit events
|
|
37
|
+
* @returns {Promise<void>}
|
|
38
|
+
*/
|
|
39
|
+
export async function runGarbageCollection(transactionResource, lockResource, config, emitFn) {
|
|
40
|
+
// Acquire distributed lock for GC operation
|
|
41
|
+
const gcLockId = `lock-gc-${config.resource}-${config.field}`;
|
|
42
|
+
const [lockAcquired] = await tryFn(() =>
|
|
43
|
+
lockResource.insert({
|
|
44
|
+
id: gcLockId,
|
|
45
|
+
lockedAt: Date.now(),
|
|
46
|
+
workerId: process.pid ? String(process.pid) : 'unknown'
|
|
47
|
+
})
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
// If another container is already running GC, skip
|
|
51
|
+
if (!lockAcquired) {
|
|
52
|
+
if (config.verbose) {
|
|
53
|
+
console.log(`[EventualConsistency] GC already running in another container`);
|
|
54
|
+
}
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const now = Date.now();
|
|
60
|
+
const retentionMs = config.transactionRetention * 24 * 60 * 60 * 1000; // Days to ms
|
|
61
|
+
const cutoffDate = new Date(now - retentionMs);
|
|
62
|
+
const cutoffIso = cutoffDate.toISOString();
|
|
63
|
+
|
|
64
|
+
if (config.verbose) {
|
|
65
|
+
console.log(`[EventualConsistency] Running GC for transactions older than ${cutoffIso} (${config.transactionRetention} days)`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Query old applied transactions
|
|
69
|
+
const [ok, err, oldTransactions] = await tryFn(() =>
|
|
70
|
+
transactionResource.query({
|
|
71
|
+
applied: true,
|
|
72
|
+
timestamp: { '<': cutoffIso }
|
|
73
|
+
})
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
if (!ok) {
|
|
77
|
+
if (config.verbose) {
|
|
78
|
+
console.warn(`[EventualConsistency] GC failed to query transactions:`, err?.message);
|
|
79
|
+
}
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!oldTransactions || oldTransactions.length === 0) {
|
|
84
|
+
if (config.verbose) {
|
|
85
|
+
console.log(`[EventualConsistency] No old transactions to clean up`);
|
|
86
|
+
}
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (config.verbose) {
|
|
91
|
+
console.log(`[EventualConsistency] Deleting ${oldTransactions.length} old transactions`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Delete old transactions using PromisePool
|
|
95
|
+
const { results, errors } = await PromisePool
|
|
96
|
+
.for(oldTransactions)
|
|
97
|
+
.withConcurrency(10)
|
|
98
|
+
.process(async (txn) => {
|
|
99
|
+
const [deleted] = await tryFn(() => transactionResource.delete(txn.id));
|
|
100
|
+
return deleted;
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
if (config.verbose) {
|
|
104
|
+
console.log(`[EventualConsistency] GC completed: ${results.length} deleted, ${errors.length} errors`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (emitFn) {
|
|
108
|
+
emitFn('eventual-consistency.gc-completed', {
|
|
109
|
+
resource: config.resource,
|
|
110
|
+
field: config.field,
|
|
111
|
+
deletedCount: results.length,
|
|
112
|
+
errorCount: errors.length
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
} catch (error) {
|
|
116
|
+
if (config.verbose) {
|
|
117
|
+
console.warn(`[EventualConsistency] GC error:`, error.message);
|
|
118
|
+
}
|
|
119
|
+
if (emitFn) {
|
|
120
|
+
emitFn('eventual-consistency.gc-error', error);
|
|
121
|
+
}
|
|
122
|
+
} finally {
|
|
123
|
+
// Always release GC lock
|
|
124
|
+
await tryFn(() => lockResource.delete(gcLockId));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helper methods added to resources for EventualConsistencyPlugin
|
|
3
|
+
* @module eventual-consistency/helpers
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { idGenerator } from "../../concerns/id.js";
|
|
7
|
+
import tryFn from "../../concerns/try-fn.js";
|
|
8
|
+
import { getCohortInfo, resolveFieldAndPlugin } from "./utils.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Add helper methods to resources
|
|
12
|
+
* This adds: set(), add(), sub(), consolidate(), getConsolidatedValue(), recalculate()
|
|
13
|
+
*
|
|
14
|
+
* @param {Object} resource - Resource to add methods to
|
|
15
|
+
* @param {Object} plugin - Plugin instance
|
|
16
|
+
* @param {Object} config - Plugin configuration
|
|
17
|
+
*/
|
|
18
|
+
export function addHelperMethods(resource, plugin, config) {
|
|
19
|
+
// Add method to set value (replaces current value)
|
|
20
|
+
// Signature: set(id, field, value)
|
|
21
|
+
resource.set = async (id, field, value) => {
|
|
22
|
+
const { plugin: handler } = resolveFieldAndPlugin(resource, field, value);
|
|
23
|
+
|
|
24
|
+
// Create transaction inline
|
|
25
|
+
const now = new Date();
|
|
26
|
+
const cohortInfo = getCohortInfo(now, config.cohort.timezone, config.verbose);
|
|
27
|
+
|
|
28
|
+
const transaction = {
|
|
29
|
+
id: idGenerator(),
|
|
30
|
+
originalId: id,
|
|
31
|
+
field: handler.field,
|
|
32
|
+
value: value,
|
|
33
|
+
operation: 'set',
|
|
34
|
+
timestamp: now.toISOString(),
|
|
35
|
+
cohortDate: cohortInfo.date,
|
|
36
|
+
cohortHour: cohortInfo.hour,
|
|
37
|
+
cohortMonth: cohortInfo.month,
|
|
38
|
+
source: 'set',
|
|
39
|
+
applied: false
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
await handler.transactionResource.insert(transaction);
|
|
43
|
+
|
|
44
|
+
// In sync mode, immediately consolidate
|
|
45
|
+
if (config.mode === 'sync') {
|
|
46
|
+
return await plugin._syncModeConsolidate(handler, id, field);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return value;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// Add method to increment value
|
|
53
|
+
// Signature: add(id, field, amount)
|
|
54
|
+
resource.add = async (id, field, amount) => {
|
|
55
|
+
const { plugin: handler } = resolveFieldAndPlugin(resource, field, amount);
|
|
56
|
+
|
|
57
|
+
// Create transaction inline
|
|
58
|
+
const now = new Date();
|
|
59
|
+
const cohortInfo = getCohortInfo(now, config.cohort.timezone, config.verbose);
|
|
60
|
+
|
|
61
|
+
const transaction = {
|
|
62
|
+
id: idGenerator(),
|
|
63
|
+
originalId: id,
|
|
64
|
+
field: handler.field,
|
|
65
|
+
value: amount,
|
|
66
|
+
operation: 'add',
|
|
67
|
+
timestamp: now.toISOString(),
|
|
68
|
+
cohortDate: cohortInfo.date,
|
|
69
|
+
cohortHour: cohortInfo.hour,
|
|
70
|
+
cohortMonth: cohortInfo.month,
|
|
71
|
+
source: 'add',
|
|
72
|
+
applied: false
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
await handler.transactionResource.insert(transaction);
|
|
76
|
+
|
|
77
|
+
// In sync mode, immediately consolidate
|
|
78
|
+
if (config.mode === 'sync') {
|
|
79
|
+
return await plugin._syncModeConsolidate(handler, id, field);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Async mode - return current value (optimistic)
|
|
83
|
+
const [ok, err, record] = await tryFn(() => handler.targetResource.get(id));
|
|
84
|
+
const currentValue = (ok && record) ? (record[field] || 0) : 0;
|
|
85
|
+
return currentValue + amount;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// Add method to decrement value
|
|
89
|
+
// Signature: sub(id, field, amount)
|
|
90
|
+
resource.sub = async (id, field, amount) => {
|
|
91
|
+
const { plugin: handler } = resolveFieldAndPlugin(resource, field, amount);
|
|
92
|
+
|
|
93
|
+
// Create transaction inline
|
|
94
|
+
const now = new Date();
|
|
95
|
+
const cohortInfo = getCohortInfo(now, config.cohort.timezone, config.verbose);
|
|
96
|
+
|
|
97
|
+
const transaction = {
|
|
98
|
+
id: idGenerator(),
|
|
99
|
+
originalId: id,
|
|
100
|
+
field: handler.field,
|
|
101
|
+
value: amount,
|
|
102
|
+
operation: 'sub',
|
|
103
|
+
timestamp: now.toISOString(),
|
|
104
|
+
cohortDate: cohortInfo.date,
|
|
105
|
+
cohortHour: cohortInfo.hour,
|
|
106
|
+
cohortMonth: cohortInfo.month,
|
|
107
|
+
source: 'sub',
|
|
108
|
+
applied: false
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
await handler.transactionResource.insert(transaction);
|
|
112
|
+
|
|
113
|
+
// In sync mode, immediately consolidate
|
|
114
|
+
if (config.mode === 'sync') {
|
|
115
|
+
return await plugin._syncModeConsolidate(handler, id, field);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Async mode - return current value (optimistic)
|
|
119
|
+
const [ok, err, record] = await tryFn(() => handler.targetResource.get(id));
|
|
120
|
+
const currentValue = (ok && record) ? (record[field] || 0) : 0;
|
|
121
|
+
return currentValue - amount;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// Add method to manually trigger consolidation
|
|
125
|
+
// Signature: consolidate(id, field)
|
|
126
|
+
resource.consolidate = async (id, field) => {
|
|
127
|
+
if (!field) {
|
|
128
|
+
throw new Error(`Field parameter is required: consolidate(id, field)`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const handler = resource._eventualConsistencyPlugins[field];
|
|
132
|
+
|
|
133
|
+
if (!handler) {
|
|
134
|
+
const availableFields = Object.keys(resource._eventualConsistencyPlugins).join(', ');
|
|
135
|
+
throw new Error(
|
|
136
|
+
`No eventual consistency plugin found for field "${field}". ` +
|
|
137
|
+
`Available fields: ${availableFields}`
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return await plugin._consolidateWithHandler(handler, id);
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
// Add method to get consolidated value without applying
|
|
145
|
+
// Signature: getConsolidatedValue(id, field, options)
|
|
146
|
+
resource.getConsolidatedValue = async (id, field, options = {}) => {
|
|
147
|
+
const handler = resource._eventualConsistencyPlugins[field];
|
|
148
|
+
|
|
149
|
+
if (!handler) {
|
|
150
|
+
const availableFields = Object.keys(resource._eventualConsistencyPlugins).join(', ');
|
|
151
|
+
throw new Error(
|
|
152
|
+
`No eventual consistency plugin found for field "${field}". ` +
|
|
153
|
+
`Available fields: ${availableFields}`
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return await plugin._getConsolidatedValueWithHandler(handler, id, options);
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
// Add method to recalculate from scratch
|
|
161
|
+
// Signature: recalculate(id, field)
|
|
162
|
+
resource.recalculate = async (id, field) => {
|
|
163
|
+
if (!field) {
|
|
164
|
+
throw new Error(`Field parameter is required: recalculate(id, field)`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const handler = resource._eventualConsistencyPlugins[field];
|
|
168
|
+
|
|
169
|
+
if (!handler) {
|
|
170
|
+
const availableFields = Object.keys(resource._eventualConsistencyPlugins).join(', ');
|
|
171
|
+
throw new Error(
|
|
172
|
+
`No eventual consistency plugin found for field "${field}". ` +
|
|
173
|
+
`Available fields: ${availableFields}`
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return await plugin._recalculateWithHandler(handler, id);
|
|
178
|
+
};
|
|
179
|
+
}
|