s3db.js 10.0.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 +2 -1
- package/dist/s3db.cjs.js +1919 -575
- package/dist/s3db.cjs.js.map +1 -1
- package/dist/s3db.es.js +1919 -576
- 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 +4 -4
- package/src/plugins/backup.plugin.js +380 -105
- 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/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/dist/s3db.cjs.js
CHANGED
|
@@ -10,11 +10,12 @@ var promises$1 = require('stream/promises');
|
|
|
10
10
|
var path = require('path');
|
|
11
11
|
var crypto = require('crypto');
|
|
12
12
|
var zlib = require('node:zlib');
|
|
13
|
+
var os = require('os');
|
|
14
|
+
var jsonStableStringify = require('json-stable-stringify');
|
|
13
15
|
var stream = require('stream');
|
|
14
16
|
var promisePool = require('@supercharge/promise-pool');
|
|
15
17
|
var web = require('node:stream/web');
|
|
16
18
|
var lodashEs = require('lodash-es');
|
|
17
|
-
var jsonStableStringify = require('json-stable-stringify');
|
|
18
19
|
var http = require('http');
|
|
19
20
|
var https = require('https');
|
|
20
21
|
var nodeHttpHandler = require('@smithy/node-http-handler');
|
|
@@ -842,7 +843,7 @@ class AuditPlugin extends Plugin {
|
|
|
842
843
|
}
|
|
843
844
|
async onSetup() {
|
|
844
845
|
const [ok, err, auditResource] = await tryFn(() => this.database.createResource({
|
|
845
|
-
name: "
|
|
846
|
+
name: "plg_audits",
|
|
846
847
|
attributes: {
|
|
847
848
|
id: "string|required",
|
|
848
849
|
resourceName: "string|required",
|
|
@@ -858,15 +859,15 @@ class AuditPlugin extends Plugin {
|
|
|
858
859
|
},
|
|
859
860
|
behavior: "body-overflow"
|
|
860
861
|
}));
|
|
861
|
-
this.auditResource = ok ? auditResource : this.database.resources.
|
|
862
|
+
this.auditResource = ok ? auditResource : this.database.resources.plg_audits || null;
|
|
862
863
|
if (!ok && !this.auditResource) return;
|
|
863
864
|
this.database.addHook("afterCreateResource", (context) => {
|
|
864
|
-
if (context.resource.name !== "
|
|
865
|
+
if (context.resource.name !== "plg_audits") {
|
|
865
866
|
this.setupResourceAuditing(context.resource);
|
|
866
867
|
}
|
|
867
868
|
});
|
|
868
869
|
for (const resource of Object.values(this.database.resources)) {
|
|
869
|
-
if (resource.name !== "
|
|
870
|
+
if (resource.name !== "plg_audits") {
|
|
870
871
|
this.setupResourceAuditing(resource);
|
|
871
872
|
}
|
|
872
873
|
}
|
|
@@ -1886,11 +1887,10 @@ function validateBackupConfig(driver, config = {}) {
|
|
|
1886
1887
|
class BackupPlugin extends Plugin {
|
|
1887
1888
|
constructor(options = {}) {
|
|
1888
1889
|
super();
|
|
1889
|
-
this.driverName = options.driver || "filesystem";
|
|
1890
|
-
this.driverConfig = options.config || {};
|
|
1891
1890
|
this.config = {
|
|
1892
|
-
//
|
|
1893
|
-
|
|
1891
|
+
// Driver configuration
|
|
1892
|
+
driver: options.driver || "filesystem",
|
|
1893
|
+
driverConfig: options.config || {},
|
|
1894
1894
|
// Scheduling configuration
|
|
1895
1895
|
schedule: options.schedule || {},
|
|
1896
1896
|
// Retention policy (Grandfather-Father-Son)
|
|
@@ -1908,8 +1908,8 @@ class BackupPlugin extends Plugin {
|
|
|
1908
1908
|
parallelism: options.parallelism || 4,
|
|
1909
1909
|
include: options.include || null,
|
|
1910
1910
|
exclude: options.exclude || [],
|
|
1911
|
-
backupMetadataResource: options.backupMetadataResource || "
|
|
1912
|
-
tempDir: options.tempDir || "
|
|
1911
|
+
backupMetadataResource: options.backupMetadataResource || "plg_backup_metadata",
|
|
1912
|
+
tempDir: options.tempDir || path.join(os.tmpdir(), "s3db", "backups"),
|
|
1913
1913
|
verbose: options.verbose || false,
|
|
1914
1914
|
// Hooks
|
|
1915
1915
|
onBackupStart: options.onBackupStart || null,
|
|
@@ -1921,32 +1921,9 @@ class BackupPlugin extends Plugin {
|
|
|
1921
1921
|
};
|
|
1922
1922
|
this.driver = null;
|
|
1923
1923
|
this.activeBackups = /* @__PURE__ */ new Set();
|
|
1924
|
-
this.
|
|
1925
|
-
validateBackupConfig(this.driverName, this.driverConfig);
|
|
1924
|
+
validateBackupConfig(this.config.driver, this.config.driverConfig);
|
|
1926
1925
|
this._validateConfiguration();
|
|
1927
1926
|
}
|
|
1928
|
-
/**
|
|
1929
|
-
* Convert legacy destinations format to multi driver format
|
|
1930
|
-
*/
|
|
1931
|
-
_handleLegacyDestinations() {
|
|
1932
|
-
if (this.config.destinations && Array.isArray(this.config.destinations)) {
|
|
1933
|
-
this.driverName = "multi";
|
|
1934
|
-
this.driverConfig = {
|
|
1935
|
-
strategy: "all",
|
|
1936
|
-
destinations: this.config.destinations.map((dest) => {
|
|
1937
|
-
const { type, ...config } = dest;
|
|
1938
|
-
return {
|
|
1939
|
-
driver: type,
|
|
1940
|
-
config
|
|
1941
|
-
};
|
|
1942
|
-
})
|
|
1943
|
-
};
|
|
1944
|
-
this.config.destinations = null;
|
|
1945
|
-
if (this.config.verbose) {
|
|
1946
|
-
console.log("[BackupPlugin] Converted legacy destinations format to multi driver");
|
|
1947
|
-
}
|
|
1948
|
-
}
|
|
1949
|
-
}
|
|
1950
1927
|
_validateConfiguration() {
|
|
1951
1928
|
if (this.config.encryption && (!this.config.encryption.key || !this.config.encryption.algorithm)) {
|
|
1952
1929
|
throw new Error("BackupPlugin: Encryption requires both key and algorithm");
|
|
@@ -1956,7 +1933,7 @@ class BackupPlugin extends Plugin {
|
|
|
1956
1933
|
}
|
|
1957
1934
|
}
|
|
1958
1935
|
async onSetup() {
|
|
1959
|
-
this.driver = createBackupDriver(this.
|
|
1936
|
+
this.driver = createBackupDriver(this.config.driver, this.config.driverConfig);
|
|
1960
1937
|
await this.driver.setup(this.database);
|
|
1961
1938
|
await promises.mkdir(this.config.tempDir, { recursive: true });
|
|
1962
1939
|
await this._createBackupMetadataResource();
|
|
@@ -2004,6 +1981,9 @@ class BackupPlugin extends Plugin {
|
|
|
2004
1981
|
async backup(type = "full", options = {}) {
|
|
2005
1982
|
const backupId = this._generateBackupId(type);
|
|
2006
1983
|
const startTime = Date.now();
|
|
1984
|
+
if (this.activeBackups.has(backupId)) {
|
|
1985
|
+
throw new Error(`Backup '${backupId}' is already in progress`);
|
|
1986
|
+
}
|
|
2007
1987
|
try {
|
|
2008
1988
|
this.activeBackups.add(backupId);
|
|
2009
1989
|
if (this.config.onBackupStart) {
|
|
@@ -2019,16 +1999,9 @@ class BackupPlugin extends Plugin {
|
|
|
2019
1999
|
if (exportedFiles.length === 0) {
|
|
2020
2000
|
throw new Error("No resources were exported for backup");
|
|
2021
2001
|
}
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
finalPath = path.join(tempBackupDir, `${backupId}.tar.gz`);
|
|
2026
|
-
totalSize = await this._createCompressedArchive(exportedFiles, finalPath);
|
|
2027
|
-
} else {
|
|
2028
|
-
finalPath = exportedFiles[0];
|
|
2029
|
-
const [statOk, , stats] = await tryFn(() => promises.stat(finalPath));
|
|
2030
|
-
totalSize = statOk ? stats.size : 0;
|
|
2031
|
-
}
|
|
2002
|
+
const archiveExtension = this.config.compression !== "none" ? ".tar.gz" : ".json";
|
|
2003
|
+
const finalPath = path.join(tempBackupDir, `${backupId}${archiveExtension}`);
|
|
2004
|
+
const totalSize = await this._createArchive(exportedFiles, finalPath, this.config.compression);
|
|
2032
2005
|
const checksum = await this._generateChecksum(finalPath);
|
|
2033
2006
|
const uploadResult = await this.driver.upload(finalPath, backupId, manifest);
|
|
2034
2007
|
if (this.config.verification) {
|
|
@@ -2137,15 +2110,35 @@ class BackupPlugin extends Plugin {
|
|
|
2137
2110
|
for (const resourceName of resourceNames) {
|
|
2138
2111
|
const resource = this.database.resources[resourceName];
|
|
2139
2112
|
if (!resource) {
|
|
2140
|
-
|
|
2113
|
+
if (this.config.verbose) {
|
|
2114
|
+
console.warn(`[BackupPlugin] Resource '${resourceName}' not found, skipping`);
|
|
2115
|
+
}
|
|
2141
2116
|
continue;
|
|
2142
2117
|
}
|
|
2143
2118
|
const exportPath = path.join(tempDir, `${resourceName}.json`);
|
|
2144
2119
|
let records;
|
|
2145
2120
|
if (type === "incremental") {
|
|
2146
|
-
const
|
|
2121
|
+
const [lastBackupOk, , lastBackups] = await tryFn(
|
|
2122
|
+
() => this.database.resource(this.config.backupMetadataResource).list({
|
|
2123
|
+
filter: {
|
|
2124
|
+
status: "completed",
|
|
2125
|
+
type: { $in: ["full", "incremental"] }
|
|
2126
|
+
},
|
|
2127
|
+
sort: { timestamp: -1 },
|
|
2128
|
+
limit: 1
|
|
2129
|
+
})
|
|
2130
|
+
);
|
|
2131
|
+
let sinceTimestamp;
|
|
2132
|
+
if (lastBackupOk && lastBackups && lastBackups.length > 0) {
|
|
2133
|
+
sinceTimestamp = new Date(lastBackups[0].timestamp);
|
|
2134
|
+
} else {
|
|
2135
|
+
sinceTimestamp = new Date(Date.now() - 24 * 60 * 60 * 1e3);
|
|
2136
|
+
}
|
|
2137
|
+
if (this.config.verbose) {
|
|
2138
|
+
console.log(`[BackupPlugin] Incremental backup for '${resourceName}' since ${sinceTimestamp.toISOString()}`);
|
|
2139
|
+
}
|
|
2147
2140
|
records = await resource.list({
|
|
2148
|
-
filter: { updatedAt: { ">":
|
|
2141
|
+
filter: { updatedAt: { ">": sinceTimestamp.toISOString() } }
|
|
2149
2142
|
});
|
|
2150
2143
|
} else {
|
|
2151
2144
|
records = await resource.list();
|
|
@@ -2165,29 +2158,57 @@ class BackupPlugin extends Plugin {
|
|
|
2165
2158
|
}
|
|
2166
2159
|
return exportedFiles;
|
|
2167
2160
|
}
|
|
2168
|
-
async
|
|
2169
|
-
const
|
|
2170
|
-
|
|
2161
|
+
async _createArchive(files, targetPath, compressionType) {
|
|
2162
|
+
const archive = {
|
|
2163
|
+
version: "1.0",
|
|
2164
|
+
created: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2165
|
+
files: []
|
|
2166
|
+
};
|
|
2171
2167
|
let totalSize = 0;
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
yield content;
|
|
2168
|
+
for (const filePath of files) {
|
|
2169
|
+
const [readOk, readErr, content] = await tryFn(() => promises.readFile(filePath, "utf8"));
|
|
2170
|
+
if (!readOk) {
|
|
2171
|
+
if (this.config.verbose) {
|
|
2172
|
+
console.warn(`[BackupPlugin] Failed to read ${filePath}: ${readErr?.message}`);
|
|
2178
2173
|
}
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2174
|
+
continue;
|
|
2175
|
+
}
|
|
2176
|
+
const fileName = path.basename(filePath);
|
|
2177
|
+
totalSize += content.length;
|
|
2178
|
+
archive.files.push({
|
|
2179
|
+
name: fileName,
|
|
2180
|
+
size: content.length,
|
|
2181
|
+
content
|
|
2182
|
+
});
|
|
2183
|
+
}
|
|
2184
|
+
const archiveJson = JSON.stringify(archive);
|
|
2185
|
+
if (compressionType === "none") {
|
|
2186
|
+
await promises.writeFile(targetPath, archiveJson, "utf8");
|
|
2187
|
+
} else {
|
|
2188
|
+
const output = fs.createWriteStream(targetPath);
|
|
2189
|
+
const gzip = zlib.createGzip({ level: 6 });
|
|
2190
|
+
await promises$1.pipeline(
|
|
2191
|
+
async function* () {
|
|
2192
|
+
yield Buffer.from(archiveJson, "utf8");
|
|
2193
|
+
},
|
|
2194
|
+
gzip,
|
|
2195
|
+
output
|
|
2196
|
+
);
|
|
2197
|
+
}
|
|
2183
2198
|
const [statOk, , stats] = await tryFn(() => promises.stat(targetPath));
|
|
2184
2199
|
return statOk ? stats.size : totalSize;
|
|
2185
2200
|
}
|
|
2186
2201
|
async _generateChecksum(filePath) {
|
|
2187
|
-
const
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2202
|
+
const [ok, err, result] = await tryFn(async () => {
|
|
2203
|
+
const hash = crypto.createHash("sha256");
|
|
2204
|
+
const stream = fs.createReadStream(filePath);
|
|
2205
|
+
await promises$1.pipeline(stream, hash);
|
|
2206
|
+
return hash.digest("hex");
|
|
2207
|
+
});
|
|
2208
|
+
if (!ok) {
|
|
2209
|
+
throw new Error(`Failed to generate checksum for ${filePath}: ${err?.message}`);
|
|
2210
|
+
}
|
|
2211
|
+
return result;
|
|
2191
2212
|
}
|
|
2192
2213
|
async _cleanupTempFiles(tempDir) {
|
|
2193
2214
|
const [ok] = await tryFn(
|
|
@@ -2249,7 +2270,109 @@ class BackupPlugin extends Plugin {
|
|
|
2249
2270
|
}
|
|
2250
2271
|
async _restoreFromBackup(backupPath, options) {
|
|
2251
2272
|
const restoredResources = [];
|
|
2252
|
-
|
|
2273
|
+
try {
|
|
2274
|
+
let archiveData = "";
|
|
2275
|
+
if (this.config.compression !== "none") {
|
|
2276
|
+
const input = fs.createReadStream(backupPath);
|
|
2277
|
+
const gunzip = zlib.createGunzip();
|
|
2278
|
+
const chunks = [];
|
|
2279
|
+
await new Promise((resolve, reject) => {
|
|
2280
|
+
input.pipe(gunzip).on("data", (chunk) => chunks.push(chunk)).on("end", resolve).on("error", reject);
|
|
2281
|
+
});
|
|
2282
|
+
archiveData = Buffer.concat(chunks).toString("utf8");
|
|
2283
|
+
} else {
|
|
2284
|
+
archiveData = await promises.readFile(backupPath, "utf8");
|
|
2285
|
+
}
|
|
2286
|
+
let archive;
|
|
2287
|
+
try {
|
|
2288
|
+
archive = JSON.parse(archiveData);
|
|
2289
|
+
} catch (parseError) {
|
|
2290
|
+
throw new Error(`Failed to parse backup archive: ${parseError.message}`);
|
|
2291
|
+
}
|
|
2292
|
+
if (!archive || typeof archive !== "object") {
|
|
2293
|
+
throw new Error("Invalid backup archive: not a valid JSON object");
|
|
2294
|
+
}
|
|
2295
|
+
if (!archive.version || !archive.files) {
|
|
2296
|
+
throw new Error("Invalid backup archive format: missing version or files array");
|
|
2297
|
+
}
|
|
2298
|
+
if (this.config.verbose) {
|
|
2299
|
+
console.log(`[BackupPlugin] Restoring ${archive.files.length} files from backup`);
|
|
2300
|
+
}
|
|
2301
|
+
for (const file of archive.files) {
|
|
2302
|
+
try {
|
|
2303
|
+
const resourceData = JSON.parse(file.content);
|
|
2304
|
+
if (!resourceData.resourceName || !resourceData.definition) {
|
|
2305
|
+
if (this.config.verbose) {
|
|
2306
|
+
console.warn(`[BackupPlugin] Skipping invalid file: ${file.name}`);
|
|
2307
|
+
}
|
|
2308
|
+
continue;
|
|
2309
|
+
}
|
|
2310
|
+
const resourceName = resourceData.resourceName;
|
|
2311
|
+
if (options.resources && !options.resources.includes(resourceName)) {
|
|
2312
|
+
continue;
|
|
2313
|
+
}
|
|
2314
|
+
let resource = this.database.resources[resourceName];
|
|
2315
|
+
if (!resource) {
|
|
2316
|
+
if (this.config.verbose) {
|
|
2317
|
+
console.log(`[BackupPlugin] Creating resource '${resourceName}'`);
|
|
2318
|
+
}
|
|
2319
|
+
const [createOk, createErr] = await tryFn(
|
|
2320
|
+
() => this.database.createResource(resourceData.definition)
|
|
2321
|
+
);
|
|
2322
|
+
if (!createOk) {
|
|
2323
|
+
if (this.config.verbose) {
|
|
2324
|
+
console.warn(`[BackupPlugin] Failed to create resource '${resourceName}': ${createErr?.message}`);
|
|
2325
|
+
}
|
|
2326
|
+
continue;
|
|
2327
|
+
}
|
|
2328
|
+
resource = this.database.resources[resourceName];
|
|
2329
|
+
}
|
|
2330
|
+
if (resourceData.records && Array.isArray(resourceData.records)) {
|
|
2331
|
+
const mode = options.mode || "merge";
|
|
2332
|
+
if (mode === "replace") {
|
|
2333
|
+
const ids = await resource.listIds();
|
|
2334
|
+
for (const id of ids) {
|
|
2335
|
+
await resource.delete(id);
|
|
2336
|
+
}
|
|
2337
|
+
}
|
|
2338
|
+
let insertedCount = 0;
|
|
2339
|
+
for (const record of resourceData.records) {
|
|
2340
|
+
const [insertOk] = await tryFn(async () => {
|
|
2341
|
+
if (mode === "skip") {
|
|
2342
|
+
const existing = await resource.get(record.id);
|
|
2343
|
+
if (existing) {
|
|
2344
|
+
return false;
|
|
2345
|
+
}
|
|
2346
|
+
}
|
|
2347
|
+
await resource.insert(record);
|
|
2348
|
+
return true;
|
|
2349
|
+
});
|
|
2350
|
+
if (insertOk) {
|
|
2351
|
+
insertedCount++;
|
|
2352
|
+
}
|
|
2353
|
+
}
|
|
2354
|
+
restoredResources.push({
|
|
2355
|
+
name: resourceName,
|
|
2356
|
+
recordsRestored: insertedCount,
|
|
2357
|
+
totalRecords: resourceData.records.length
|
|
2358
|
+
});
|
|
2359
|
+
if (this.config.verbose) {
|
|
2360
|
+
console.log(`[BackupPlugin] Restored ${insertedCount}/${resourceData.records.length} records to '${resourceName}'`);
|
|
2361
|
+
}
|
|
2362
|
+
}
|
|
2363
|
+
} catch (fileError) {
|
|
2364
|
+
if (this.config.verbose) {
|
|
2365
|
+
console.warn(`[BackupPlugin] Error processing file ${file.name}: ${fileError.message}`);
|
|
2366
|
+
}
|
|
2367
|
+
}
|
|
2368
|
+
}
|
|
2369
|
+
return restoredResources;
|
|
2370
|
+
} catch (error) {
|
|
2371
|
+
if (this.config.verbose) {
|
|
2372
|
+
console.error(`[BackupPlugin] Error restoring backup: ${error.message}`);
|
|
2373
|
+
}
|
|
2374
|
+
throw new Error(`Failed to restore backup: ${error.message}`);
|
|
2375
|
+
}
|
|
2253
2376
|
}
|
|
2254
2377
|
/**
|
|
2255
2378
|
* List available backups
|
|
@@ -2293,6 +2416,90 @@ class BackupPlugin extends Plugin {
|
|
|
2293
2416
|
return ok ? backup : null;
|
|
2294
2417
|
}
|
|
2295
2418
|
async _cleanupOldBackups() {
|
|
2419
|
+
try {
|
|
2420
|
+
const [listOk, , allBackups] = await tryFn(
|
|
2421
|
+
() => this.database.resource(this.config.backupMetadataResource).list({
|
|
2422
|
+
filter: { status: "completed" },
|
|
2423
|
+
sort: { timestamp: -1 }
|
|
2424
|
+
})
|
|
2425
|
+
);
|
|
2426
|
+
if (!listOk || !allBackups || allBackups.length === 0) {
|
|
2427
|
+
return;
|
|
2428
|
+
}
|
|
2429
|
+
const now = Date.now();
|
|
2430
|
+
const msPerDay = 24 * 60 * 60 * 1e3;
|
|
2431
|
+
const msPerWeek = 7 * msPerDay;
|
|
2432
|
+
const msPerMonth = 30 * msPerDay;
|
|
2433
|
+
const msPerYear = 365 * msPerDay;
|
|
2434
|
+
const categorized = {
|
|
2435
|
+
daily: [],
|
|
2436
|
+
weekly: [],
|
|
2437
|
+
monthly: [],
|
|
2438
|
+
yearly: []
|
|
2439
|
+
};
|
|
2440
|
+
for (const backup of allBackups) {
|
|
2441
|
+
const age = now - backup.timestamp;
|
|
2442
|
+
if (age <= msPerDay * this.config.retention.daily) {
|
|
2443
|
+
categorized.daily.push(backup);
|
|
2444
|
+
} else if (age <= msPerWeek * this.config.retention.weekly) {
|
|
2445
|
+
categorized.weekly.push(backup);
|
|
2446
|
+
} else if (age <= msPerMonth * this.config.retention.monthly) {
|
|
2447
|
+
categorized.monthly.push(backup);
|
|
2448
|
+
} else if (age <= msPerYear * this.config.retention.yearly) {
|
|
2449
|
+
categorized.yearly.push(backup);
|
|
2450
|
+
}
|
|
2451
|
+
}
|
|
2452
|
+
const toKeep = /* @__PURE__ */ new Set();
|
|
2453
|
+
categorized.daily.forEach((b) => toKeep.add(b.id));
|
|
2454
|
+
const weeklyByWeek = /* @__PURE__ */ new Map();
|
|
2455
|
+
for (const backup of categorized.weekly) {
|
|
2456
|
+
const weekNum = Math.floor((now - backup.timestamp) / msPerWeek);
|
|
2457
|
+
if (!weeklyByWeek.has(weekNum)) {
|
|
2458
|
+
weeklyByWeek.set(weekNum, backup);
|
|
2459
|
+
toKeep.add(backup.id);
|
|
2460
|
+
}
|
|
2461
|
+
}
|
|
2462
|
+
const monthlyByMonth = /* @__PURE__ */ new Map();
|
|
2463
|
+
for (const backup of categorized.monthly) {
|
|
2464
|
+
const monthNum = Math.floor((now - backup.timestamp) / msPerMonth);
|
|
2465
|
+
if (!monthlyByMonth.has(monthNum)) {
|
|
2466
|
+
monthlyByMonth.set(monthNum, backup);
|
|
2467
|
+
toKeep.add(backup.id);
|
|
2468
|
+
}
|
|
2469
|
+
}
|
|
2470
|
+
const yearlyByYear = /* @__PURE__ */ new Map();
|
|
2471
|
+
for (const backup of categorized.yearly) {
|
|
2472
|
+
const yearNum = Math.floor((now - backup.timestamp) / msPerYear);
|
|
2473
|
+
if (!yearlyByYear.has(yearNum)) {
|
|
2474
|
+
yearlyByYear.set(yearNum, backup);
|
|
2475
|
+
toKeep.add(backup.id);
|
|
2476
|
+
}
|
|
2477
|
+
}
|
|
2478
|
+
const backupsToDelete = allBackups.filter((b) => !toKeep.has(b.id));
|
|
2479
|
+
if (backupsToDelete.length === 0) {
|
|
2480
|
+
return;
|
|
2481
|
+
}
|
|
2482
|
+
if (this.config.verbose) {
|
|
2483
|
+
console.log(`[BackupPlugin] Cleaning up ${backupsToDelete.length} old backups (keeping ${toKeep.size})`);
|
|
2484
|
+
}
|
|
2485
|
+
for (const backup of backupsToDelete) {
|
|
2486
|
+
try {
|
|
2487
|
+
await this.driver.delete(backup.id, backup.driverInfo);
|
|
2488
|
+
await this.database.resource(this.config.backupMetadataResource).delete(backup.id);
|
|
2489
|
+
if (this.config.verbose) {
|
|
2490
|
+
console.log(`[BackupPlugin] Deleted old backup: ${backup.id}`);
|
|
2491
|
+
}
|
|
2492
|
+
} catch (deleteError) {
|
|
2493
|
+
if (this.config.verbose) {
|
|
2494
|
+
console.warn(`[BackupPlugin] Failed to delete backup ${backup.id}: ${deleteError.message}`);
|
|
2495
|
+
}
|
|
2496
|
+
}
|
|
2497
|
+
}
|
|
2498
|
+
} catch (error) {
|
|
2499
|
+
if (this.config.verbose) {
|
|
2500
|
+
console.warn(`[BackupPlugin] Error during cleanup: ${error.message}`);
|
|
2501
|
+
}
|
|
2502
|
+
}
|
|
2296
2503
|
}
|
|
2297
2504
|
async _executeHook(hook, ...args) {
|
|
2298
2505
|
if (typeof hook === "function") {
|
|
@@ -3582,81 +3789,58 @@ class PartitionAwareFilesystemCache extends FilesystemCache {
|
|
|
3582
3789
|
class CachePlugin extends Plugin {
|
|
3583
3790
|
constructor(options = {}) {
|
|
3584
3791
|
super(options);
|
|
3585
|
-
this.
|
|
3586
|
-
|
|
3587
|
-
|
|
3588
|
-
|
|
3589
|
-
|
|
3590
|
-
|
|
3591
|
-
|
|
3592
|
-
|
|
3593
|
-
|
|
3594
|
-
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
|
|
3792
|
+
this.config = {
|
|
3793
|
+
// Driver configuration
|
|
3794
|
+
driver: options.driver || "s3",
|
|
3795
|
+
config: {
|
|
3796
|
+
ttl: options.ttl,
|
|
3797
|
+
maxSize: options.maxSize,
|
|
3798
|
+
...options.config
|
|
3799
|
+
// Driver-specific config (can override ttl/maxSize)
|
|
3800
|
+
},
|
|
3801
|
+
// Resource filtering
|
|
3802
|
+
include: options.include || null,
|
|
3803
|
+
// Array of resource names to cache (null = all)
|
|
3804
|
+
exclude: options.exclude || [],
|
|
3805
|
+
// Array of resource names to exclude
|
|
3806
|
+
// Partition settings
|
|
3807
|
+
includePartitions: options.includePartitions !== false,
|
|
3808
|
+
partitionStrategy: options.partitionStrategy || "hierarchical",
|
|
3809
|
+
partitionAware: options.partitionAware !== false,
|
|
3810
|
+
trackUsage: options.trackUsage !== false,
|
|
3811
|
+
preloadRelated: options.preloadRelated !== false,
|
|
3812
|
+
// Retry configuration
|
|
3813
|
+
retryAttempts: options.retryAttempts || 3,
|
|
3814
|
+
retryDelay: options.retryDelay || 100,
|
|
3815
|
+
// ms
|
|
3816
|
+
// Logging
|
|
3817
|
+
verbose: options.verbose || false
|
|
3599
3818
|
};
|
|
3600
3819
|
}
|
|
3601
3820
|
async setup(database) {
|
|
3602
3821
|
await super.setup(database);
|
|
3603
3822
|
}
|
|
3604
3823
|
async onSetup() {
|
|
3605
|
-
if (this.
|
|
3606
|
-
this.driver = this.
|
|
3607
|
-
} else if (this.
|
|
3608
|
-
|
|
3609
|
-
|
|
3610
|
-
|
|
3611
|
-
...this.config
|
|
3612
|
-
// New config format (medium priority)
|
|
3613
|
-
};
|
|
3614
|
-
if (this.ttl !== void 0) {
|
|
3615
|
-
driverConfig.ttl = this.ttl;
|
|
3616
|
-
}
|
|
3617
|
-
if (this.maxSize !== void 0) {
|
|
3618
|
-
driverConfig.maxSize = this.maxSize;
|
|
3619
|
-
}
|
|
3620
|
-
this.driver = new MemoryCache(driverConfig);
|
|
3621
|
-
} else if (this.driverName === "filesystem") {
|
|
3622
|
-
const driverConfig = {
|
|
3623
|
-
...this.legacyConfig.filesystemOptions,
|
|
3624
|
-
// Legacy support (lowest priority)
|
|
3625
|
-
...this.config
|
|
3626
|
-
// New config format (medium priority)
|
|
3627
|
-
};
|
|
3628
|
-
if (this.ttl !== void 0) {
|
|
3629
|
-
driverConfig.ttl = this.ttl;
|
|
3630
|
-
}
|
|
3631
|
-
if (this.maxSize !== void 0) {
|
|
3632
|
-
driverConfig.maxSize = this.maxSize;
|
|
3633
|
-
}
|
|
3634
|
-
if (this.partitionAware) {
|
|
3824
|
+
if (this.config.driver && typeof this.config.driver === "object") {
|
|
3825
|
+
this.driver = this.config.driver;
|
|
3826
|
+
} else if (this.config.driver === "memory") {
|
|
3827
|
+
this.driver = new MemoryCache(this.config.config);
|
|
3828
|
+
} else if (this.config.driver === "filesystem") {
|
|
3829
|
+
if (this.config.partitionAware) {
|
|
3635
3830
|
this.driver = new PartitionAwareFilesystemCache({
|
|
3636
|
-
partitionStrategy: this.partitionStrategy,
|
|
3637
|
-
trackUsage: this.trackUsage,
|
|
3638
|
-
preloadRelated: this.preloadRelated,
|
|
3639
|
-
...
|
|
3831
|
+
partitionStrategy: this.config.partitionStrategy,
|
|
3832
|
+
trackUsage: this.config.trackUsage,
|
|
3833
|
+
preloadRelated: this.config.preloadRelated,
|
|
3834
|
+
...this.config.config
|
|
3640
3835
|
});
|
|
3641
3836
|
} else {
|
|
3642
|
-
this.driver = new FilesystemCache(
|
|
3837
|
+
this.driver = new FilesystemCache(this.config.config);
|
|
3643
3838
|
}
|
|
3644
3839
|
} else {
|
|
3645
|
-
|
|
3840
|
+
this.driver = new S3Cache({
|
|
3646
3841
|
client: this.database.client,
|
|
3647
|
-
|
|
3648
|
-
|
|
3649
|
-
// Legacy support (lowest priority)
|
|
3650
|
-
...this.config
|
|
3651
|
-
// New config format (medium priority)
|
|
3652
|
-
};
|
|
3653
|
-
if (this.ttl !== void 0) {
|
|
3654
|
-
driverConfig.ttl = this.ttl;
|
|
3655
|
-
}
|
|
3656
|
-
if (this.maxSize !== void 0) {
|
|
3657
|
-
driverConfig.maxSize = this.maxSize;
|
|
3658
|
-
}
|
|
3659
|
-
this.driver = new S3Cache(driverConfig);
|
|
3842
|
+
...this.config.config
|
|
3843
|
+
});
|
|
3660
3844
|
}
|
|
3661
3845
|
this.installDatabaseHooks();
|
|
3662
3846
|
this.installResourceHooks();
|
|
@@ -3666,7 +3850,9 @@ class CachePlugin extends Plugin {
|
|
|
3666
3850
|
*/
|
|
3667
3851
|
installDatabaseHooks() {
|
|
3668
3852
|
this.database.addHook("afterCreateResource", async ({ resource }) => {
|
|
3669
|
-
this.
|
|
3853
|
+
if (this.shouldCacheResource(resource.name)) {
|
|
3854
|
+
this.installResourceHooksForResource(resource);
|
|
3855
|
+
}
|
|
3670
3856
|
});
|
|
3671
3857
|
}
|
|
3672
3858
|
async onStart() {
|
|
@@ -3676,9 +3862,24 @@ class CachePlugin extends Plugin {
|
|
|
3676
3862
|
// Remove the old installDatabaseProxy method
|
|
3677
3863
|
installResourceHooks() {
|
|
3678
3864
|
for (const resource of Object.values(this.database.resources)) {
|
|
3865
|
+
if (!this.shouldCacheResource(resource.name)) {
|
|
3866
|
+
continue;
|
|
3867
|
+
}
|
|
3679
3868
|
this.installResourceHooksForResource(resource);
|
|
3680
3869
|
}
|
|
3681
3870
|
}
|
|
3871
|
+
shouldCacheResource(resourceName) {
|
|
3872
|
+
if (resourceName.startsWith("plg_") && !this.config.include) {
|
|
3873
|
+
return false;
|
|
3874
|
+
}
|
|
3875
|
+
if (this.config.exclude.includes(resourceName)) {
|
|
3876
|
+
return false;
|
|
3877
|
+
}
|
|
3878
|
+
if (this.config.include && !this.config.include.includes(resourceName)) {
|
|
3879
|
+
return false;
|
|
3880
|
+
}
|
|
3881
|
+
return true;
|
|
3882
|
+
}
|
|
3682
3883
|
installResourceHooksForResource(resource) {
|
|
3683
3884
|
if (!this.driver) return;
|
|
3684
3885
|
Object.defineProperty(resource, "cache", {
|
|
@@ -3827,37 +4028,74 @@ class CachePlugin extends Plugin {
|
|
|
3827
4028
|
if (data && data.id) {
|
|
3828
4029
|
const itemSpecificMethods = ["get", "exists", "content", "hasContent"];
|
|
3829
4030
|
for (const method of itemSpecificMethods) {
|
|
3830
|
-
|
|
3831
|
-
|
|
3832
|
-
|
|
3833
|
-
|
|
4031
|
+
const specificKey = await this.generateCacheKey(resource, method, { id: data.id });
|
|
4032
|
+
const [ok2, err2] = await this.clearCacheWithRetry(resource.cache, specificKey);
|
|
4033
|
+
if (!ok2) {
|
|
4034
|
+
this.emit("cache_clear_error", {
|
|
4035
|
+
resource: resource.name,
|
|
4036
|
+
method,
|
|
4037
|
+
id: data.id,
|
|
4038
|
+
error: err2.message
|
|
4039
|
+
});
|
|
4040
|
+
if (this.config.verbose) {
|
|
4041
|
+
console.warn(`[CachePlugin] Failed to clear ${method} cache for ${resource.name}:${data.id}:`, err2.message);
|
|
4042
|
+
}
|
|
3834
4043
|
}
|
|
3835
4044
|
}
|
|
3836
4045
|
if (this.config.includePartitions === true && resource.config?.partitions && Object.keys(resource.config.partitions).length > 0) {
|
|
3837
4046
|
const partitionValues = this.getPartitionValues(data, resource);
|
|
3838
4047
|
for (const [partitionName, values] of Object.entries(partitionValues)) {
|
|
3839
4048
|
if (values && Object.keys(values).length > 0 && Object.values(values).some((v) => v !== null && v !== void 0)) {
|
|
3840
|
-
|
|
3841
|
-
|
|
3842
|
-
|
|
3843
|
-
|
|
4049
|
+
const partitionKeyPrefix = path.join(keyPrefix, `partition=${partitionName}`);
|
|
4050
|
+
const [ok2, err2] = await this.clearCacheWithRetry(resource.cache, partitionKeyPrefix);
|
|
4051
|
+
if (!ok2) {
|
|
4052
|
+
this.emit("cache_clear_error", {
|
|
4053
|
+
resource: resource.name,
|
|
4054
|
+
partition: partitionName,
|
|
4055
|
+
error: err2.message
|
|
4056
|
+
});
|
|
4057
|
+
if (this.config.verbose) {
|
|
4058
|
+
console.warn(`[CachePlugin] Failed to clear partition cache for ${resource.name}/${partitionName}:`, err2.message);
|
|
4059
|
+
}
|
|
3844
4060
|
}
|
|
3845
4061
|
}
|
|
3846
4062
|
}
|
|
3847
4063
|
}
|
|
3848
4064
|
}
|
|
3849
|
-
|
|
3850
|
-
|
|
3851
|
-
|
|
4065
|
+
const [ok, err] = await this.clearCacheWithRetry(resource.cache, keyPrefix);
|
|
4066
|
+
if (!ok) {
|
|
4067
|
+
this.emit("cache_clear_error", {
|
|
4068
|
+
resource: resource.name,
|
|
4069
|
+
type: "broad",
|
|
4070
|
+
error: err.message
|
|
4071
|
+
});
|
|
4072
|
+
if (this.config.verbose) {
|
|
4073
|
+
console.warn(`[CachePlugin] Failed to clear broad cache for ${resource.name}, trying specific methods:`, err.message);
|
|
4074
|
+
}
|
|
3852
4075
|
const aggregateMethods = ["count", "list", "listIds", "getAll", "page", "query"];
|
|
3853
4076
|
for (const method of aggregateMethods) {
|
|
3854
|
-
|
|
3855
|
-
|
|
3856
|
-
|
|
3857
|
-
|
|
3858
|
-
|
|
4077
|
+
await this.clearCacheWithRetry(resource.cache, `${keyPrefix}/action=${method}`);
|
|
4078
|
+
await this.clearCacheWithRetry(resource.cache, `resource=${resource.name}/action=${method}`);
|
|
4079
|
+
}
|
|
4080
|
+
}
|
|
4081
|
+
}
|
|
4082
|
+
async clearCacheWithRetry(cache, key) {
|
|
4083
|
+
let lastError;
|
|
4084
|
+
for (let attempt = 0; attempt < this.config.retryAttempts; attempt++) {
|
|
4085
|
+
const [ok, err] = await tryFn(() => cache.clear(key));
|
|
4086
|
+
if (ok) {
|
|
4087
|
+
return [true, null];
|
|
4088
|
+
}
|
|
4089
|
+
lastError = err;
|
|
4090
|
+
if (err.name === "NoSuchKey" || err.code === "NoSuchKey") {
|
|
4091
|
+
return [true, null];
|
|
4092
|
+
}
|
|
4093
|
+
if (attempt < this.config.retryAttempts - 1) {
|
|
4094
|
+
const delay = this.config.retryDelay * Math.pow(2, attempt);
|
|
4095
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
3859
4096
|
}
|
|
3860
4097
|
}
|
|
4098
|
+
return [false, lastError];
|
|
3861
4099
|
}
|
|
3862
4100
|
async generateCacheKey(resource, action, params = {}, partition = null, partitionValues = null) {
|
|
3863
4101
|
const keyParts = [
|
|
@@ -3873,14 +4111,14 @@ class CachePlugin extends Plugin {
|
|
|
3873
4111
|
}
|
|
3874
4112
|
}
|
|
3875
4113
|
if (Object.keys(params).length > 0) {
|
|
3876
|
-
const paramsHash =
|
|
4114
|
+
const paramsHash = this.hashParams(params);
|
|
3877
4115
|
keyParts.push(paramsHash);
|
|
3878
4116
|
}
|
|
3879
4117
|
return path.join(...keyParts) + ".json.gz";
|
|
3880
4118
|
}
|
|
3881
|
-
|
|
3882
|
-
const
|
|
3883
|
-
return
|
|
4119
|
+
hashParams(params) {
|
|
4120
|
+
const serialized = jsonStableStringify(params) || "empty";
|
|
4121
|
+
return crypto.createHash("md5").update(serialized).digest("hex").substring(0, 16);
|
|
3884
4122
|
}
|
|
3885
4123
|
// Utility methods
|
|
3886
4124
|
async getCacheStats() {
|
|
@@ -3905,50 +4143,48 @@ class CachePlugin extends Plugin {
|
|
|
3905
4143
|
if (!resource) {
|
|
3906
4144
|
throw new Error(`Resource '${resourceName}' not found`);
|
|
3907
4145
|
}
|
|
3908
|
-
const { includePartitions = true } = options;
|
|
4146
|
+
const { includePartitions = true, sampleSize = 100 } = options;
|
|
3909
4147
|
if (this.driver instanceof PartitionAwareFilesystemCache && resource.warmPartitionCache) {
|
|
3910
4148
|
const partitionNames = resource.config.partitions ? Object.keys(resource.config.partitions) : [];
|
|
3911
4149
|
return await resource.warmPartitionCache(partitionNames, options);
|
|
3912
4150
|
}
|
|
3913
|
-
|
|
3914
|
-
|
|
4151
|
+
let offset = 0;
|
|
4152
|
+
const pageSize = 100;
|
|
4153
|
+
const sampledRecords = [];
|
|
4154
|
+
while (sampledRecords.length < sampleSize) {
|
|
4155
|
+
const [ok, err, pageResult] = await tryFn(() => resource.page({ offset, size: pageSize }));
|
|
4156
|
+
if (!ok || !pageResult) {
|
|
4157
|
+
break;
|
|
4158
|
+
}
|
|
4159
|
+
const pageItems = Array.isArray(pageResult) ? pageResult : pageResult.items || [];
|
|
4160
|
+
if (pageItems.length === 0) {
|
|
4161
|
+
break;
|
|
4162
|
+
}
|
|
4163
|
+
sampledRecords.push(...pageItems);
|
|
4164
|
+
offset += pageSize;
|
|
4165
|
+
}
|
|
4166
|
+
if (includePartitions && resource.config.partitions && sampledRecords.length > 0) {
|
|
3915
4167
|
for (const [partitionName, partitionDef] of Object.entries(resource.config.partitions)) {
|
|
3916
4168
|
if (partitionDef.fields) {
|
|
3917
|
-
const
|
|
3918
|
-
const
|
|
3919
|
-
const partitionValues = /* @__PURE__ */ new Set();
|
|
3920
|
-
for (const record of recordsArray.slice(0, 10)) {
|
|
4169
|
+
const partitionValuesSet = /* @__PURE__ */ new Set();
|
|
4170
|
+
for (const record of sampledRecords) {
|
|
3921
4171
|
const values = this.getPartitionValues(record, resource);
|
|
3922
4172
|
if (values[partitionName]) {
|
|
3923
|
-
|
|
4173
|
+
partitionValuesSet.add(JSON.stringify(values[partitionName]));
|
|
3924
4174
|
}
|
|
3925
4175
|
}
|
|
3926
|
-
for (const partitionValueStr of
|
|
3927
|
-
const
|
|
3928
|
-
await resource.list({ partition: partitionName, partitionValues
|
|
4176
|
+
for (const partitionValueStr of partitionValuesSet) {
|
|
4177
|
+
const partitionValues = JSON.parse(partitionValueStr);
|
|
4178
|
+
await tryFn(() => resource.list({ partition: partitionName, partitionValues }));
|
|
3929
4179
|
}
|
|
3930
4180
|
}
|
|
3931
4181
|
}
|
|
3932
4182
|
}
|
|
3933
|
-
|
|
3934
|
-
|
|
3935
|
-
|
|
3936
|
-
|
|
3937
|
-
|
|
3938
|
-
}
|
|
3939
|
-
return await this.driver.getPartitionStats(resourceName, partition);
|
|
3940
|
-
}
|
|
3941
|
-
async getCacheRecommendations(resourceName) {
|
|
3942
|
-
if (!(this.driver instanceof PartitionAwareFilesystemCache)) {
|
|
3943
|
-
throw new Error("Cache recommendations are only available with PartitionAwareFilesystemCache");
|
|
3944
|
-
}
|
|
3945
|
-
return await this.driver.getCacheRecommendations(resourceName);
|
|
3946
|
-
}
|
|
3947
|
-
async clearPartitionCache(resourceName, partition, partitionValues = {}) {
|
|
3948
|
-
if (!(this.driver instanceof PartitionAwareFilesystemCache)) {
|
|
3949
|
-
throw new Error("Partition cache clearing is only available with PartitionAwareFilesystemCache");
|
|
3950
|
-
}
|
|
3951
|
-
return await this.driver.clearPartition(resourceName, partition, partitionValues);
|
|
4183
|
+
return {
|
|
4184
|
+
resourceName,
|
|
4185
|
+
recordsSampled: sampledRecords.length,
|
|
4186
|
+
partitionsWarmed: includePartitions && resource.config.partitions ? Object.keys(resource.config.partitions).length : 0
|
|
4187
|
+
};
|
|
3952
4188
|
}
|
|
3953
4189
|
async analyzeCacheUsage() {
|
|
3954
4190
|
if (!(this.driver instanceof PartitionAwareFilesystemCache)) {
|
|
@@ -3965,6 +4201,9 @@ class CachePlugin extends Plugin {
|
|
|
3965
4201
|
}
|
|
3966
4202
|
};
|
|
3967
4203
|
for (const [resourceName, resource] of Object.entries(this.database.resources)) {
|
|
4204
|
+
if (!this.shouldCacheResource(resourceName)) {
|
|
4205
|
+
continue;
|
|
4206
|
+
}
|
|
3968
4207
|
try {
|
|
3969
4208
|
analysis.resourceStats[resourceName] = await this.driver.getPartitionStats(resourceName);
|
|
3970
4209
|
analysis.recommendations[resourceName] = await this.driver.getCacheRecommendations(resourceName);
|
|
@@ -4065,13 +4304,12 @@ class EventualConsistencyPlugin extends Plugin {
|
|
|
4065
4304
|
if (!options.field) {
|
|
4066
4305
|
throw new Error("EventualConsistencyPlugin requires 'field' option");
|
|
4067
4306
|
}
|
|
4307
|
+
const detectedTimezone = this._detectTimezone();
|
|
4068
4308
|
this.config = {
|
|
4069
4309
|
resource: options.resource,
|
|
4070
4310
|
field: options.field,
|
|
4071
4311
|
cohort: {
|
|
4072
|
-
|
|
4073
|
-
timezone: options.cohort?.timezone || "UTC",
|
|
4074
|
-
...options.cohort
|
|
4312
|
+
timezone: options.cohort?.timezone || detectedTimezone
|
|
4075
4313
|
},
|
|
4076
4314
|
reducer: options.reducer || ((transactions) => {
|
|
4077
4315
|
let baseValue = 0;
|
|
@@ -4086,19 +4324,42 @@ class EventualConsistencyPlugin extends Plugin {
|
|
|
4086
4324
|
}
|
|
4087
4325
|
return baseValue;
|
|
4088
4326
|
}),
|
|
4089
|
-
consolidationInterval: options.consolidationInterval
|
|
4090
|
-
//
|
|
4327
|
+
consolidationInterval: options.consolidationInterval ?? 300,
|
|
4328
|
+
// 5 minutes (in seconds)
|
|
4329
|
+
consolidationConcurrency: options.consolidationConcurrency || 5,
|
|
4330
|
+
consolidationWindow: options.consolidationWindow || 24,
|
|
4331
|
+
// Hours to look back for pending transactions (watermark)
|
|
4091
4332
|
autoConsolidate: options.autoConsolidate !== false,
|
|
4333
|
+
lateArrivalStrategy: options.lateArrivalStrategy || "warn",
|
|
4334
|
+
// 'ignore', 'warn', 'process'
|
|
4092
4335
|
batchTransactions: options.batchTransactions || false,
|
|
4336
|
+
// CAUTION: Not safe in distributed environments! Loses data on container crash
|
|
4093
4337
|
batchSize: options.batchSize || 100,
|
|
4094
4338
|
mode: options.mode || "async",
|
|
4095
4339
|
// 'async' or 'sync'
|
|
4096
|
-
|
|
4340
|
+
lockTimeout: options.lockTimeout || 300,
|
|
4341
|
+
// 5 minutes (in seconds, configurable)
|
|
4342
|
+
transactionRetention: options.transactionRetention || 30,
|
|
4343
|
+
// Days to keep applied transactions
|
|
4344
|
+
gcInterval: options.gcInterval || 86400,
|
|
4345
|
+
// 24 hours (in seconds)
|
|
4346
|
+
verbose: options.verbose || false
|
|
4097
4347
|
};
|
|
4098
4348
|
this.transactionResource = null;
|
|
4099
4349
|
this.targetResource = null;
|
|
4100
4350
|
this.consolidationTimer = null;
|
|
4351
|
+
this.gcTimer = null;
|
|
4101
4352
|
this.pendingTransactions = /* @__PURE__ */ new Map();
|
|
4353
|
+
if (this.config.batchTransactions && !this.config.verbose) {
|
|
4354
|
+
console.warn(
|
|
4355
|
+
`[EventualConsistency] WARNING: batchTransactions is enabled. This stores transactions in memory and will lose data if container crashes. Not recommended for distributed/production environments. Set verbose: true to suppress this warning.`
|
|
4356
|
+
);
|
|
4357
|
+
}
|
|
4358
|
+
if (this.config.verbose && !options.cohort?.timezone) {
|
|
4359
|
+
console.log(
|
|
4360
|
+
`[EventualConsistency] Auto-detected timezone: ${this.config.cohort.timezone} (from ${process.env.TZ ? "TZ env var" : "system Intl API"})`
|
|
4361
|
+
);
|
|
4362
|
+
}
|
|
4102
4363
|
}
|
|
4103
4364
|
async onSetup() {
|
|
4104
4365
|
this.targetResource = this.database.resources[this.config.resource];
|
|
@@ -4135,7 +4396,9 @@ class EventualConsistencyPlugin extends Plugin {
|
|
|
4135
4396
|
// 'set', 'add', or 'sub'
|
|
4136
4397
|
timestamp: "string|required",
|
|
4137
4398
|
cohortDate: "string|required",
|
|
4138
|
-
// For partitioning
|
|
4399
|
+
// For daily partitioning
|
|
4400
|
+
cohortHour: "string|required",
|
|
4401
|
+
// For hourly partitioning
|
|
4139
4402
|
cohortMonth: "string|optional",
|
|
4140
4403
|
// For monthly partitioning
|
|
4141
4404
|
source: "string|optional",
|
|
@@ -4153,10 +4416,28 @@ class EventualConsistencyPlugin extends Plugin {
|
|
|
4153
4416
|
throw new Error(`Failed to create transaction resource: ${err?.message}`);
|
|
4154
4417
|
}
|
|
4155
4418
|
this.transactionResource = ok ? transactionResource : this.database.resources[transactionResourceName];
|
|
4419
|
+
const lockResourceName = `${this.config.resource}_consolidation_locks_${this.config.field}`;
|
|
4420
|
+
const [lockOk, lockErr, lockResource] = await tryFn(
|
|
4421
|
+
() => this.database.createResource({
|
|
4422
|
+
name: lockResourceName,
|
|
4423
|
+
attributes: {
|
|
4424
|
+
id: "string|required",
|
|
4425
|
+
lockedAt: "number|required",
|
|
4426
|
+
workerId: "string|optional"
|
|
4427
|
+
},
|
|
4428
|
+
behavior: "body-only",
|
|
4429
|
+
timestamps: false
|
|
4430
|
+
})
|
|
4431
|
+
);
|
|
4432
|
+
if (!lockOk && !this.database.resources[lockResourceName]) {
|
|
4433
|
+
throw new Error(`Failed to create lock resource: ${lockErr?.message}`);
|
|
4434
|
+
}
|
|
4435
|
+
this.lockResource = lockOk ? lockResource : this.database.resources[lockResourceName];
|
|
4156
4436
|
this.addHelperMethods();
|
|
4157
4437
|
if (this.config.autoConsolidate) {
|
|
4158
4438
|
this.startConsolidationTimer();
|
|
4159
4439
|
}
|
|
4440
|
+
this.startGarbageCollectionTimer();
|
|
4160
4441
|
}
|
|
4161
4442
|
async onStart() {
|
|
4162
4443
|
if (this.deferredSetup) {
|
|
@@ -4173,6 +4454,10 @@ class EventualConsistencyPlugin extends Plugin {
|
|
|
4173
4454
|
clearInterval(this.consolidationTimer);
|
|
4174
4455
|
this.consolidationTimer = null;
|
|
4175
4456
|
}
|
|
4457
|
+
if (this.gcTimer) {
|
|
4458
|
+
clearInterval(this.gcTimer);
|
|
4459
|
+
this.gcTimer = null;
|
|
4460
|
+
}
|
|
4176
4461
|
await this.flushPendingTransactions();
|
|
4177
4462
|
this.emit("eventual-consistency.stopped", {
|
|
4178
4463
|
resource: this.config.resource,
|
|
@@ -4181,6 +4466,11 @@ class EventualConsistencyPlugin extends Plugin {
|
|
|
4181
4466
|
}
|
|
4182
4467
|
createPartitionConfig() {
|
|
4183
4468
|
const partitions = {
|
|
4469
|
+
byHour: {
|
|
4470
|
+
fields: {
|
|
4471
|
+
cohortHour: "string"
|
|
4472
|
+
}
|
|
4473
|
+
},
|
|
4184
4474
|
byDay: {
|
|
4185
4475
|
fields: {
|
|
4186
4476
|
cohortDate: "string"
|
|
@@ -4194,6 +4484,65 @@ class EventualConsistencyPlugin extends Plugin {
|
|
|
4194
4484
|
};
|
|
4195
4485
|
return partitions;
|
|
4196
4486
|
}
|
|
4487
|
+
/**
|
|
4488
|
+
* Auto-detect timezone from environment or system
|
|
4489
|
+
* @private
|
|
4490
|
+
*/
|
|
4491
|
+
_detectTimezone() {
|
|
4492
|
+
if (process.env.TZ) {
|
|
4493
|
+
return process.env.TZ;
|
|
4494
|
+
}
|
|
4495
|
+
try {
|
|
4496
|
+
const systemTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
4497
|
+
if (systemTimezone) {
|
|
4498
|
+
return systemTimezone;
|
|
4499
|
+
}
|
|
4500
|
+
} catch (err) {
|
|
4501
|
+
}
|
|
4502
|
+
return "UTC";
|
|
4503
|
+
}
|
|
4504
|
+
/**
|
|
4505
|
+
* Helper method to resolve field and plugin from arguments
|
|
4506
|
+
* Supports both single-field (field, value) and multi-field (field, value) signatures
|
|
4507
|
+
* @private
|
|
4508
|
+
*/
|
|
4509
|
+
_resolveFieldAndPlugin(resource, fieldOrValue, value) {
|
|
4510
|
+
const hasMultipleFields = Object.keys(resource._eventualConsistencyPlugins).length > 1;
|
|
4511
|
+
if (hasMultipleFields && value === void 0) {
|
|
4512
|
+
throw new Error(`Multiple fields have eventual consistency. Please specify the field explicitly.`);
|
|
4513
|
+
}
|
|
4514
|
+
const field = value !== void 0 ? fieldOrValue : this.config.field;
|
|
4515
|
+
const actualValue = value !== void 0 ? value : fieldOrValue;
|
|
4516
|
+
const fieldPlugin = resource._eventualConsistencyPlugins[field];
|
|
4517
|
+
if (!fieldPlugin) {
|
|
4518
|
+
throw new Error(`No eventual consistency plugin found for field "${field}"`);
|
|
4519
|
+
}
|
|
4520
|
+
return { field, value: actualValue, plugin: fieldPlugin };
|
|
4521
|
+
}
|
|
4522
|
+
/**
|
|
4523
|
+
* Helper method to perform atomic consolidation in sync mode
|
|
4524
|
+
* @private
|
|
4525
|
+
*/
|
|
4526
|
+
async _syncModeConsolidate(id, field) {
|
|
4527
|
+
const consolidatedValue = await this.consolidateRecord(id);
|
|
4528
|
+
await this.targetResource.update(id, {
|
|
4529
|
+
[field]: consolidatedValue
|
|
4530
|
+
});
|
|
4531
|
+
return consolidatedValue;
|
|
4532
|
+
}
|
|
4533
|
+
/**
|
|
4534
|
+
* Create synthetic 'set' transaction from current value
|
|
4535
|
+
* @private
|
|
4536
|
+
*/
|
|
4537
|
+
_createSyntheticSetTransaction(currentValue) {
|
|
4538
|
+
return {
|
|
4539
|
+
id: "__synthetic__",
|
|
4540
|
+
operation: "set",
|
|
4541
|
+
value: currentValue,
|
|
4542
|
+
timestamp: (/* @__PURE__ */ new Date(0)).toISOString(),
|
|
4543
|
+
synthetic: true
|
|
4544
|
+
};
|
|
4545
|
+
}
|
|
4197
4546
|
addHelperMethods() {
|
|
4198
4547
|
const resource = this.targetResource;
|
|
4199
4548
|
const defaultField = this.config.field;
|
|
@@ -4203,16 +4552,7 @@ class EventualConsistencyPlugin extends Plugin {
|
|
|
4203
4552
|
}
|
|
4204
4553
|
resource._eventualConsistencyPlugins[defaultField] = plugin;
|
|
4205
4554
|
resource.set = async (id, fieldOrValue, value) => {
|
|
4206
|
-
const
|
|
4207
|
-
if (hasMultipleFields && value === void 0) {
|
|
4208
|
-
throw new Error(`Multiple fields have eventual consistency. Please specify the field: set(id, field, value)`);
|
|
4209
|
-
}
|
|
4210
|
-
const field = value !== void 0 ? fieldOrValue : defaultField;
|
|
4211
|
-
const actualValue = value !== void 0 ? value : fieldOrValue;
|
|
4212
|
-
const fieldPlugin = resource._eventualConsistencyPlugins[field];
|
|
4213
|
-
if (!fieldPlugin) {
|
|
4214
|
-
throw new Error(`No eventual consistency plugin found for field "${field}"`);
|
|
4215
|
-
}
|
|
4555
|
+
const { field, value: actualValue, plugin: fieldPlugin } = plugin._resolveFieldAndPlugin(resource, fieldOrValue, value);
|
|
4216
4556
|
await fieldPlugin.createTransaction({
|
|
4217
4557
|
originalId: id,
|
|
4218
4558
|
operation: "set",
|
|
@@ -4220,25 +4560,12 @@ class EventualConsistencyPlugin extends Plugin {
|
|
|
4220
4560
|
source: "set"
|
|
4221
4561
|
});
|
|
4222
4562
|
if (fieldPlugin.config.mode === "sync") {
|
|
4223
|
-
|
|
4224
|
-
await resource.update(id, {
|
|
4225
|
-
[field]: consolidatedValue
|
|
4226
|
-
});
|
|
4227
|
-
return consolidatedValue;
|
|
4563
|
+
return await fieldPlugin._syncModeConsolidate(id, field);
|
|
4228
4564
|
}
|
|
4229
4565
|
return actualValue;
|
|
4230
4566
|
};
|
|
4231
4567
|
resource.add = async (id, fieldOrAmount, amount) => {
|
|
4232
|
-
const
|
|
4233
|
-
if (hasMultipleFields && amount === void 0) {
|
|
4234
|
-
throw new Error(`Multiple fields have eventual consistency. Please specify the field: add(id, field, amount)`);
|
|
4235
|
-
}
|
|
4236
|
-
const field = amount !== void 0 ? fieldOrAmount : defaultField;
|
|
4237
|
-
const actualAmount = amount !== void 0 ? amount : fieldOrAmount;
|
|
4238
|
-
const fieldPlugin = resource._eventualConsistencyPlugins[field];
|
|
4239
|
-
if (!fieldPlugin) {
|
|
4240
|
-
throw new Error(`No eventual consistency plugin found for field "${field}"`);
|
|
4241
|
-
}
|
|
4568
|
+
const { field, value: actualAmount, plugin: fieldPlugin } = plugin._resolveFieldAndPlugin(resource, fieldOrAmount, amount);
|
|
4242
4569
|
await fieldPlugin.createTransaction({
|
|
4243
4570
|
originalId: id,
|
|
4244
4571
|
operation: "add",
|
|
@@ -4246,26 +4573,13 @@ class EventualConsistencyPlugin extends Plugin {
|
|
|
4246
4573
|
source: "add"
|
|
4247
4574
|
});
|
|
4248
4575
|
if (fieldPlugin.config.mode === "sync") {
|
|
4249
|
-
|
|
4250
|
-
await resource.update(id, {
|
|
4251
|
-
[field]: consolidatedValue
|
|
4252
|
-
});
|
|
4253
|
-
return consolidatedValue;
|
|
4576
|
+
return await fieldPlugin._syncModeConsolidate(id, field);
|
|
4254
4577
|
}
|
|
4255
4578
|
const currentValue = await fieldPlugin.getConsolidatedValue(id);
|
|
4256
4579
|
return currentValue + actualAmount;
|
|
4257
4580
|
};
|
|
4258
4581
|
resource.sub = async (id, fieldOrAmount, amount) => {
|
|
4259
|
-
const
|
|
4260
|
-
if (hasMultipleFields && amount === void 0) {
|
|
4261
|
-
throw new Error(`Multiple fields have eventual consistency. Please specify the field: sub(id, field, amount)`);
|
|
4262
|
-
}
|
|
4263
|
-
const field = amount !== void 0 ? fieldOrAmount : defaultField;
|
|
4264
|
-
const actualAmount = amount !== void 0 ? amount : fieldOrAmount;
|
|
4265
|
-
const fieldPlugin = resource._eventualConsistencyPlugins[field];
|
|
4266
|
-
if (!fieldPlugin) {
|
|
4267
|
-
throw new Error(`No eventual consistency plugin found for field "${field}"`);
|
|
4268
|
-
}
|
|
4582
|
+
const { field, value: actualAmount, plugin: fieldPlugin } = plugin._resolveFieldAndPlugin(resource, fieldOrAmount, amount);
|
|
4269
4583
|
await fieldPlugin.createTransaction({
|
|
4270
4584
|
originalId: id,
|
|
4271
4585
|
operation: "sub",
|
|
@@ -4273,11 +4587,7 @@ class EventualConsistencyPlugin extends Plugin {
|
|
|
4273
4587
|
source: "sub"
|
|
4274
4588
|
});
|
|
4275
4589
|
if (fieldPlugin.config.mode === "sync") {
|
|
4276
|
-
|
|
4277
|
-
await resource.update(id, {
|
|
4278
|
-
[field]: consolidatedValue
|
|
4279
|
-
});
|
|
4280
|
-
return consolidatedValue;
|
|
4590
|
+
return await fieldPlugin._syncModeConsolidate(id, field);
|
|
4281
4591
|
}
|
|
4282
4592
|
const currentValue = await fieldPlugin.getConsolidatedValue(id);
|
|
4283
4593
|
return currentValue - actualAmount;
|
|
@@ -4307,14 +4617,34 @@ class EventualConsistencyPlugin extends Plugin {
|
|
|
4307
4617
|
async createTransaction(data) {
|
|
4308
4618
|
const now = /* @__PURE__ */ new Date();
|
|
4309
4619
|
const cohortInfo = this.getCohortInfo(now);
|
|
4620
|
+
const watermarkMs = this.config.consolidationWindow * 60 * 60 * 1e3;
|
|
4621
|
+
const watermarkTime = now.getTime() - watermarkMs;
|
|
4622
|
+
const cohortHourDate = /* @__PURE__ */ new Date(cohortInfo.hour + ":00:00Z");
|
|
4623
|
+
if (cohortHourDate.getTime() < watermarkTime) {
|
|
4624
|
+
const hoursLate = Math.floor((now.getTime() - cohortHourDate.getTime()) / (60 * 60 * 1e3));
|
|
4625
|
+
if (this.config.lateArrivalStrategy === "ignore") {
|
|
4626
|
+
if (this.config.verbose) {
|
|
4627
|
+
console.warn(
|
|
4628
|
+
`[EventualConsistency] Late arrival ignored: transaction for ${cohortInfo.hour} is ${hoursLate}h late (watermark: ${this.config.consolidationWindow}h)`
|
|
4629
|
+
);
|
|
4630
|
+
}
|
|
4631
|
+
return null;
|
|
4632
|
+
} else if (this.config.lateArrivalStrategy === "warn") {
|
|
4633
|
+
console.warn(
|
|
4634
|
+
`[EventualConsistency] Late arrival detected: transaction for ${cohortInfo.hour} is ${hoursLate}h late (watermark: ${this.config.consolidationWindow}h). Processing anyway, but consolidation may not pick it up.`
|
|
4635
|
+
);
|
|
4636
|
+
}
|
|
4637
|
+
}
|
|
4310
4638
|
const transaction = {
|
|
4311
|
-
id:
|
|
4639
|
+
id: idGenerator(),
|
|
4640
|
+
// Use nanoid for guaranteed uniqueness
|
|
4312
4641
|
originalId: data.originalId,
|
|
4313
4642
|
field: this.config.field,
|
|
4314
4643
|
value: data.value || 0,
|
|
4315
4644
|
operation: data.operation || "set",
|
|
4316
4645
|
timestamp: now.toISOString(),
|
|
4317
4646
|
cohortDate: cohortInfo.date,
|
|
4647
|
+
cohortHour: cohortInfo.hour,
|
|
4318
4648
|
cohortMonth: cohortInfo.month,
|
|
4319
4649
|
source: data.source || "unknown",
|
|
4320
4650
|
applied: false
|
|
@@ -4332,9 +4662,16 @@ class EventualConsistencyPlugin extends Plugin {
|
|
|
4332
4662
|
async flushPendingTransactions() {
|
|
4333
4663
|
if (this.pendingTransactions.size === 0) return;
|
|
4334
4664
|
const transactions = Array.from(this.pendingTransactions.values());
|
|
4335
|
-
|
|
4336
|
-
|
|
4337
|
-
|
|
4665
|
+
try {
|
|
4666
|
+
await Promise.all(
|
|
4667
|
+
transactions.map(
|
|
4668
|
+
(transaction) => this.transactionResource.insert(transaction)
|
|
4669
|
+
)
|
|
4670
|
+
);
|
|
4671
|
+
this.pendingTransactions.clear();
|
|
4672
|
+
} catch (error) {
|
|
4673
|
+
console.error("Failed to flush pending transactions:", error);
|
|
4674
|
+
throw error;
|
|
4338
4675
|
}
|
|
4339
4676
|
}
|
|
4340
4677
|
getCohortInfo(date) {
|
|
@@ -4344,53 +4681,90 @@ class EventualConsistencyPlugin extends Plugin {
|
|
|
4344
4681
|
const year = localDate.getFullYear();
|
|
4345
4682
|
const month = String(localDate.getMonth() + 1).padStart(2, "0");
|
|
4346
4683
|
const day = String(localDate.getDate()).padStart(2, "0");
|
|
4684
|
+
const hour = String(localDate.getHours()).padStart(2, "0");
|
|
4347
4685
|
return {
|
|
4348
4686
|
date: `${year}-${month}-${day}`,
|
|
4687
|
+
hour: `${year}-${month}-${day}T${hour}`,
|
|
4688
|
+
// ISO-like format for hour partition
|
|
4349
4689
|
month: `${year}-${month}`
|
|
4350
4690
|
};
|
|
4351
4691
|
}
|
|
4352
4692
|
getTimezoneOffset(timezone) {
|
|
4353
|
-
|
|
4354
|
-
|
|
4355
|
-
"
|
|
4356
|
-
"
|
|
4357
|
-
|
|
4358
|
-
|
|
4359
|
-
|
|
4360
|
-
|
|
4361
|
-
|
|
4362
|
-
|
|
4363
|
-
|
|
4364
|
-
|
|
4365
|
-
|
|
4366
|
-
|
|
4367
|
-
|
|
4368
|
-
|
|
4369
|
-
|
|
4370
|
-
|
|
4371
|
-
|
|
4372
|
-
|
|
4373
|
-
|
|
4693
|
+
try {
|
|
4694
|
+
const now = /* @__PURE__ */ new Date();
|
|
4695
|
+
const utcDate = new Date(now.toLocaleString("en-US", { timeZone: "UTC" }));
|
|
4696
|
+
const tzDate = new Date(now.toLocaleString("en-US", { timeZone: timezone }));
|
|
4697
|
+
return tzDate.getTime() - utcDate.getTime();
|
|
4698
|
+
} catch (err) {
|
|
4699
|
+
const offsets = {
|
|
4700
|
+
"UTC": 0,
|
|
4701
|
+
"America/New_York": -5 * 36e5,
|
|
4702
|
+
"America/Chicago": -6 * 36e5,
|
|
4703
|
+
"America/Denver": -7 * 36e5,
|
|
4704
|
+
"America/Los_Angeles": -8 * 36e5,
|
|
4705
|
+
"America/Sao_Paulo": -3 * 36e5,
|
|
4706
|
+
"Europe/London": 0,
|
|
4707
|
+
"Europe/Paris": 1 * 36e5,
|
|
4708
|
+
"Europe/Berlin": 1 * 36e5,
|
|
4709
|
+
"Asia/Tokyo": 9 * 36e5,
|
|
4710
|
+
"Asia/Shanghai": 8 * 36e5,
|
|
4711
|
+
"Australia/Sydney": 10 * 36e5
|
|
4712
|
+
};
|
|
4713
|
+
if (this.config.verbose && !offsets[timezone]) {
|
|
4714
|
+
console.warn(
|
|
4715
|
+
`[EventualConsistency] Unknown timezone '${timezone}', using UTC. Consider using a valid IANA timezone (e.g., 'America/New_York')`
|
|
4716
|
+
);
|
|
4717
|
+
}
|
|
4718
|
+
return offsets[timezone] || 0;
|
|
4719
|
+
}
|
|
4720
|
+
}
|
|
4721
|
+
startConsolidationTimer() {
|
|
4722
|
+
const intervalMs = this.config.consolidationInterval * 1e3;
|
|
4723
|
+
this.consolidationTimer = setInterval(async () => {
|
|
4724
|
+
await this.runConsolidation();
|
|
4725
|
+
}, intervalMs);
|
|
4374
4726
|
}
|
|
4375
4727
|
async runConsolidation() {
|
|
4376
4728
|
try {
|
|
4377
|
-
const
|
|
4378
|
-
|
|
4379
|
-
|
|
4729
|
+
const now = /* @__PURE__ */ new Date();
|
|
4730
|
+
const hoursToCheck = this.config.consolidationWindow || 24;
|
|
4731
|
+
const cohortHours = [];
|
|
4732
|
+
for (let i = 0; i < hoursToCheck; i++) {
|
|
4733
|
+
const date = new Date(now.getTime() - i * 60 * 60 * 1e3);
|
|
4734
|
+
const cohortInfo = this.getCohortInfo(date);
|
|
4735
|
+
cohortHours.push(cohortInfo.hour);
|
|
4736
|
+
}
|
|
4737
|
+
const transactionsByHour = await Promise.all(
|
|
4738
|
+
cohortHours.map(async (cohortHour) => {
|
|
4739
|
+
const [ok, err, txns] = await tryFn(
|
|
4740
|
+
() => this.transactionResource.query({
|
|
4741
|
+
cohortHour,
|
|
4742
|
+
applied: false
|
|
4743
|
+
})
|
|
4744
|
+
);
|
|
4745
|
+
return ok ? txns : [];
|
|
4380
4746
|
})
|
|
4381
4747
|
);
|
|
4382
|
-
|
|
4383
|
-
|
|
4748
|
+
const transactions = transactionsByHour.flat();
|
|
4749
|
+
if (transactions.length === 0) {
|
|
4750
|
+
if (this.config.verbose) {
|
|
4751
|
+
console.log(`[EventualConsistency] No pending transactions to consolidate`);
|
|
4752
|
+
}
|
|
4384
4753
|
return;
|
|
4385
4754
|
}
|
|
4386
4755
|
const uniqueIds = [...new Set(transactions.map((t) => t.originalId))];
|
|
4387
|
-
|
|
4388
|
-
await this.consolidateRecord(id);
|
|
4756
|
+
const { results, errors } = await promisePool.PromisePool.for(uniqueIds).withConcurrency(this.config.consolidationConcurrency).process(async (id) => {
|
|
4757
|
+
return await this.consolidateRecord(id);
|
|
4758
|
+
});
|
|
4759
|
+
if (errors && errors.length > 0) {
|
|
4760
|
+
console.error(`Consolidation completed with ${errors.length} errors:`, errors);
|
|
4389
4761
|
}
|
|
4390
4762
|
this.emit("eventual-consistency.consolidated", {
|
|
4391
4763
|
resource: this.config.resource,
|
|
4392
4764
|
field: this.config.field,
|
|
4393
|
-
recordCount: uniqueIds.length
|
|
4765
|
+
recordCount: uniqueIds.length,
|
|
4766
|
+
successCount: results.length,
|
|
4767
|
+
errorCount: errors.length
|
|
4394
4768
|
});
|
|
4395
4769
|
} catch (error) {
|
|
4396
4770
|
console.error("Consolidation error:", error);
|
|
@@ -4398,49 +4772,73 @@ class EventualConsistencyPlugin extends Plugin {
|
|
|
4398
4772
|
}
|
|
4399
4773
|
}
|
|
4400
4774
|
async consolidateRecord(originalId) {
|
|
4401
|
-
|
|
4402
|
-
|
|
4403
|
-
|
|
4404
|
-
|
|
4405
|
-
|
|
4406
|
-
|
|
4407
|
-
|
|
4408
|
-
applied: false
|
|
4775
|
+
await this.cleanupStaleLocks();
|
|
4776
|
+
const lockId = `lock-${originalId}`;
|
|
4777
|
+
const [lockAcquired, lockErr, lock] = await tryFn(
|
|
4778
|
+
() => this.lockResource.insert({
|
|
4779
|
+
id: lockId,
|
|
4780
|
+
lockedAt: Date.now(),
|
|
4781
|
+
workerId: process.pid ? String(process.pid) : "unknown"
|
|
4409
4782
|
})
|
|
4410
4783
|
);
|
|
4411
|
-
if (!
|
|
4412
|
-
|
|
4413
|
-
|
|
4414
|
-
|
|
4415
|
-
|
|
4416
|
-
|
|
4417
|
-
|
|
4418
|
-
|
|
4419
|
-
transactions.unshift({
|
|
4420
|
-
id: "__synthetic__",
|
|
4421
|
-
// Synthetic ID that we'll skip when marking as applied
|
|
4422
|
-
operation: "set",
|
|
4423
|
-
value: currentValue,
|
|
4424
|
-
timestamp: (/* @__PURE__ */ new Date(0)).toISOString()
|
|
4425
|
-
// Very old timestamp to ensure it's first
|
|
4426
|
-
});
|
|
4784
|
+
if (!lockAcquired) {
|
|
4785
|
+
if (this.config.verbose) {
|
|
4786
|
+
console.log(`[EventualConsistency] Lock for ${originalId} already held, skipping`);
|
|
4787
|
+
}
|
|
4788
|
+
const [recordOk, recordErr, record] = await tryFn(
|
|
4789
|
+
() => this.targetResource.get(originalId)
|
|
4790
|
+
);
|
|
4791
|
+
return recordOk && record ? record[this.config.field] || 0 : 0;
|
|
4427
4792
|
}
|
|
4428
|
-
|
|
4429
|
-
|
|
4430
|
-
|
|
4431
|
-
|
|
4432
|
-
|
|
4433
|
-
|
|
4434
|
-
|
|
4435
|
-
|
|
4436
|
-
|
|
4437
|
-
|
|
4438
|
-
|
|
4439
|
-
|
|
4793
|
+
try {
|
|
4794
|
+
const [recordOk, recordErr, record] = await tryFn(
|
|
4795
|
+
() => this.targetResource.get(originalId)
|
|
4796
|
+
);
|
|
4797
|
+
const currentValue = recordOk && record ? record[this.config.field] || 0 : 0;
|
|
4798
|
+
const [ok, err, transactions] = await tryFn(
|
|
4799
|
+
() => this.transactionResource.query({
|
|
4800
|
+
originalId,
|
|
4801
|
+
applied: false
|
|
4802
|
+
})
|
|
4803
|
+
);
|
|
4804
|
+
if (!ok || !transactions || transactions.length === 0) {
|
|
4805
|
+
return currentValue;
|
|
4806
|
+
}
|
|
4807
|
+
transactions.sort(
|
|
4808
|
+
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
|
4809
|
+
);
|
|
4810
|
+
const hasSetOperation = transactions.some((t) => t.operation === "set");
|
|
4811
|
+
if (currentValue !== 0 && !hasSetOperation) {
|
|
4812
|
+
transactions.unshift(this._createSyntheticSetTransaction(currentValue));
|
|
4813
|
+
}
|
|
4814
|
+
const consolidatedValue = this.config.reducer(transactions);
|
|
4815
|
+
const [updateOk, updateErr] = await tryFn(
|
|
4816
|
+
() => this.targetResource.update(originalId, {
|
|
4817
|
+
[this.config.field]: consolidatedValue
|
|
4818
|
+
})
|
|
4819
|
+
);
|
|
4820
|
+
if (updateOk) {
|
|
4821
|
+
const transactionsToUpdate = transactions.filter((txn) => txn.id !== "__synthetic__");
|
|
4822
|
+
const { results, errors } = await promisePool.PromisePool.for(transactionsToUpdate).withConcurrency(10).process(async (txn) => {
|
|
4823
|
+
const [ok2, err2] = await tryFn(
|
|
4824
|
+
() => this.transactionResource.update(txn.id, { applied: true })
|
|
4825
|
+
);
|
|
4826
|
+
if (!ok2 && this.config.verbose) {
|
|
4827
|
+
console.warn(`[EventualConsistency] Failed to mark transaction ${txn.id} as applied:`, err2?.message);
|
|
4828
|
+
}
|
|
4829
|
+
return ok2;
|
|
4830
|
+
});
|
|
4831
|
+
if (errors && errors.length > 0 && this.config.verbose) {
|
|
4832
|
+
console.warn(`[EventualConsistency] ${errors.length} transactions failed to mark as applied`);
|
|
4440
4833
|
}
|
|
4441
4834
|
}
|
|
4835
|
+
return consolidatedValue;
|
|
4836
|
+
} finally {
|
|
4837
|
+
const [lockReleased, lockReleaseErr] = await tryFn(() => this.lockResource.delete(lockId));
|
|
4838
|
+
if (!lockReleased && this.config.verbose) {
|
|
4839
|
+
console.warn(`[EventualConsistency] Failed to release lock ${lockId}:`, lockReleaseErr?.message);
|
|
4840
|
+
}
|
|
4442
4841
|
}
|
|
4443
|
-
return consolidatedValue;
|
|
4444
4842
|
}
|
|
4445
4843
|
async getConsolidatedValue(originalId, options = {}) {
|
|
4446
4844
|
const includeApplied = options.includeApplied || false;
|
|
@@ -4454,11 +4852,11 @@ class EventualConsistencyPlugin extends Plugin {
|
|
|
4454
4852
|
() => this.transactionResource.query(query)
|
|
4455
4853
|
);
|
|
4456
4854
|
if (!ok || !transactions || transactions.length === 0) {
|
|
4457
|
-
const [
|
|
4855
|
+
const [recordOk2, recordErr2, record2] = await tryFn(
|
|
4458
4856
|
() => this.targetResource.get(originalId)
|
|
4459
4857
|
);
|
|
4460
|
-
if (
|
|
4461
|
-
return
|
|
4858
|
+
if (recordOk2 && record2) {
|
|
4859
|
+
return record2[this.config.field] || 0;
|
|
4462
4860
|
}
|
|
4463
4861
|
return 0;
|
|
4464
4862
|
}
|
|
@@ -4471,6 +4869,14 @@ class EventualConsistencyPlugin extends Plugin {
|
|
|
4471
4869
|
return true;
|
|
4472
4870
|
});
|
|
4473
4871
|
}
|
|
4872
|
+
const [recordOk, recordErr, record] = await tryFn(
|
|
4873
|
+
() => this.targetResource.get(originalId)
|
|
4874
|
+
);
|
|
4875
|
+
const currentValue = recordOk && record ? record[this.config.field] || 0 : 0;
|
|
4876
|
+
const hasSetOperation = filtered.some((t) => t.operation === "set");
|
|
4877
|
+
if (currentValue !== 0 && !hasSetOperation) {
|
|
4878
|
+
filtered.unshift(this._createSyntheticSetTransaction(currentValue));
|
|
4879
|
+
}
|
|
4474
4880
|
filtered.sort(
|
|
4475
4881
|
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
|
4476
4882
|
);
|
|
@@ -4505,6 +4911,133 @@ class EventualConsistencyPlugin extends Plugin {
|
|
|
4505
4911
|
}
|
|
4506
4912
|
return stats;
|
|
4507
4913
|
}
|
|
4914
|
+
/**
|
|
4915
|
+
* Clean up stale locks that exceed the configured timeout
|
|
4916
|
+
* Uses distributed locking to prevent multiple containers from cleaning simultaneously
|
|
4917
|
+
*/
|
|
4918
|
+
async cleanupStaleLocks() {
|
|
4919
|
+
const now = Date.now();
|
|
4920
|
+
const lockTimeoutMs = this.config.lockTimeout * 1e3;
|
|
4921
|
+
const cutoffTime = now - lockTimeoutMs;
|
|
4922
|
+
const cleanupLockId = `lock-cleanup-${this.config.resource}-${this.config.field}`;
|
|
4923
|
+
const [lockAcquired] = await tryFn(
|
|
4924
|
+
() => this.lockResource.insert({
|
|
4925
|
+
id: cleanupLockId,
|
|
4926
|
+
lockedAt: Date.now(),
|
|
4927
|
+
workerId: process.pid ? String(process.pid) : "unknown"
|
|
4928
|
+
})
|
|
4929
|
+
);
|
|
4930
|
+
if (!lockAcquired) {
|
|
4931
|
+
if (this.config.verbose) {
|
|
4932
|
+
console.log(`[EventualConsistency] Lock cleanup already running in another container`);
|
|
4933
|
+
}
|
|
4934
|
+
return;
|
|
4935
|
+
}
|
|
4936
|
+
try {
|
|
4937
|
+
const [ok, err, locks] = await tryFn(() => this.lockResource.list());
|
|
4938
|
+
if (!ok || !locks || locks.length === 0) return;
|
|
4939
|
+
const staleLocks = locks.filter(
|
|
4940
|
+
(lock) => lock.id !== cleanupLockId && lock.lockedAt < cutoffTime
|
|
4941
|
+
);
|
|
4942
|
+
if (staleLocks.length === 0) return;
|
|
4943
|
+
if (this.config.verbose) {
|
|
4944
|
+
console.log(`[EventualConsistency] Cleaning up ${staleLocks.length} stale locks`);
|
|
4945
|
+
}
|
|
4946
|
+
const { results, errors } = await promisePool.PromisePool.for(staleLocks).withConcurrency(5).process(async (lock) => {
|
|
4947
|
+
const [deleted] = await tryFn(() => this.lockResource.delete(lock.id));
|
|
4948
|
+
return deleted;
|
|
4949
|
+
});
|
|
4950
|
+
if (errors && errors.length > 0 && this.config.verbose) {
|
|
4951
|
+
console.warn(`[EventualConsistency] ${errors.length} stale locks failed to delete`);
|
|
4952
|
+
}
|
|
4953
|
+
} catch (error) {
|
|
4954
|
+
if (this.config.verbose) {
|
|
4955
|
+
console.warn(`[EventualConsistency] Error cleaning up stale locks:`, error.message);
|
|
4956
|
+
}
|
|
4957
|
+
} finally {
|
|
4958
|
+
await tryFn(() => this.lockResource.delete(cleanupLockId));
|
|
4959
|
+
}
|
|
4960
|
+
}
|
|
4961
|
+
/**
|
|
4962
|
+
* Start garbage collection timer for old applied transactions
|
|
4963
|
+
*/
|
|
4964
|
+
startGarbageCollectionTimer() {
|
|
4965
|
+
const gcIntervalMs = this.config.gcInterval * 1e3;
|
|
4966
|
+
this.gcTimer = setInterval(async () => {
|
|
4967
|
+
await this.runGarbageCollection();
|
|
4968
|
+
}, gcIntervalMs);
|
|
4969
|
+
}
|
|
4970
|
+
/**
|
|
4971
|
+
* Delete old applied transactions based on retention policy
|
|
4972
|
+
* Uses distributed locking to prevent multiple containers from running GC simultaneously
|
|
4973
|
+
*/
|
|
4974
|
+
async runGarbageCollection() {
|
|
4975
|
+
const gcLockId = `lock-gc-${this.config.resource}-${this.config.field}`;
|
|
4976
|
+
const [lockAcquired] = await tryFn(
|
|
4977
|
+
() => this.lockResource.insert({
|
|
4978
|
+
id: gcLockId,
|
|
4979
|
+
lockedAt: Date.now(),
|
|
4980
|
+
workerId: process.pid ? String(process.pid) : "unknown"
|
|
4981
|
+
})
|
|
4982
|
+
);
|
|
4983
|
+
if (!lockAcquired) {
|
|
4984
|
+
if (this.config.verbose) {
|
|
4985
|
+
console.log(`[EventualConsistency] GC already running in another container`);
|
|
4986
|
+
}
|
|
4987
|
+
return;
|
|
4988
|
+
}
|
|
4989
|
+
try {
|
|
4990
|
+
const now = Date.now();
|
|
4991
|
+
const retentionMs = this.config.transactionRetention * 24 * 60 * 60 * 1e3;
|
|
4992
|
+
const cutoffDate = new Date(now - retentionMs);
|
|
4993
|
+
const cutoffIso = cutoffDate.toISOString();
|
|
4994
|
+
if (this.config.verbose) {
|
|
4995
|
+
console.log(`[EventualConsistency] Running GC for transactions older than ${cutoffIso} (${this.config.transactionRetention} days)`);
|
|
4996
|
+
}
|
|
4997
|
+
const cutoffMonth = cutoffDate.toISOString().substring(0, 7);
|
|
4998
|
+
const [ok, err, oldTransactions] = await tryFn(
|
|
4999
|
+
() => this.transactionResource.query({
|
|
5000
|
+
applied: true,
|
|
5001
|
+
timestamp: { "<": cutoffIso }
|
|
5002
|
+
})
|
|
5003
|
+
);
|
|
5004
|
+
if (!ok) {
|
|
5005
|
+
if (this.config.verbose) {
|
|
5006
|
+
console.warn(`[EventualConsistency] GC failed to query transactions:`, err?.message);
|
|
5007
|
+
}
|
|
5008
|
+
return;
|
|
5009
|
+
}
|
|
5010
|
+
if (!oldTransactions || oldTransactions.length === 0) {
|
|
5011
|
+
if (this.config.verbose) {
|
|
5012
|
+
console.log(`[EventualConsistency] No old transactions to clean up`);
|
|
5013
|
+
}
|
|
5014
|
+
return;
|
|
5015
|
+
}
|
|
5016
|
+
if (this.config.verbose) {
|
|
5017
|
+
console.log(`[EventualConsistency] Deleting ${oldTransactions.length} old transactions`);
|
|
5018
|
+
}
|
|
5019
|
+
const { results, errors } = await promisePool.PromisePool.for(oldTransactions).withConcurrency(10).process(async (txn) => {
|
|
5020
|
+
const [deleted] = await tryFn(() => this.transactionResource.delete(txn.id));
|
|
5021
|
+
return deleted;
|
|
5022
|
+
});
|
|
5023
|
+
if (this.config.verbose) {
|
|
5024
|
+
console.log(`[EventualConsistency] GC completed: ${results.length} deleted, ${errors.length} errors`);
|
|
5025
|
+
}
|
|
5026
|
+
this.emit("eventual-consistency.gc-completed", {
|
|
5027
|
+
resource: this.config.resource,
|
|
5028
|
+
field: this.config.field,
|
|
5029
|
+
deletedCount: results.length,
|
|
5030
|
+
errorCount: errors.length
|
|
5031
|
+
});
|
|
5032
|
+
} catch (error) {
|
|
5033
|
+
if (this.config.verbose) {
|
|
5034
|
+
console.warn(`[EventualConsistency] GC error:`, error.message);
|
|
5035
|
+
}
|
|
5036
|
+
this.emit("eventual-consistency.gc-error", error);
|
|
5037
|
+
} finally {
|
|
5038
|
+
await tryFn(() => this.lockResource.delete(gcLockId));
|
|
5039
|
+
}
|
|
5040
|
+
}
|
|
4508
5041
|
}
|
|
4509
5042
|
|
|
4510
5043
|
class FullTextPlugin extends Plugin {
|
|
@@ -4521,7 +5054,7 @@ class FullTextPlugin extends Plugin {
|
|
|
4521
5054
|
async setup(database) {
|
|
4522
5055
|
this.database = database;
|
|
4523
5056
|
const [ok, err, indexResource] = await tryFn(() => database.createResource({
|
|
4524
|
-
name: "
|
|
5057
|
+
name: "plg_fulltext_indexes",
|
|
4525
5058
|
attributes: {
|
|
4526
5059
|
id: "string|required",
|
|
4527
5060
|
resourceName: "string|required",
|
|
@@ -4580,7 +5113,7 @@ class FullTextPlugin extends Plugin {
|
|
|
4580
5113
|
}
|
|
4581
5114
|
installDatabaseHooks() {
|
|
4582
5115
|
this.database.addHook("afterCreateResource", (resource) => {
|
|
4583
|
-
if (resource.name !== "
|
|
5116
|
+
if (resource.name !== "plg_fulltext_indexes") {
|
|
4584
5117
|
this.installResourceHooks(resource);
|
|
4585
5118
|
}
|
|
4586
5119
|
});
|
|
@@ -4594,14 +5127,14 @@ class FullTextPlugin extends Plugin {
|
|
|
4594
5127
|
}
|
|
4595
5128
|
this.database.plugins.fulltext = this;
|
|
4596
5129
|
for (const resource of Object.values(this.database.resources)) {
|
|
4597
|
-
if (resource.name === "
|
|
5130
|
+
if (resource.name === "plg_fulltext_indexes") continue;
|
|
4598
5131
|
this.installResourceHooks(resource);
|
|
4599
5132
|
}
|
|
4600
5133
|
if (!this.database._fulltextProxyInstalled) {
|
|
4601
5134
|
this.database._previousCreateResourceForFullText = this.database.createResource;
|
|
4602
5135
|
this.database.createResource = async function(...args) {
|
|
4603
5136
|
const resource = await this._previousCreateResourceForFullText(...args);
|
|
4604
|
-
if (this.plugins?.fulltext && resource.name !== "
|
|
5137
|
+
if (this.plugins?.fulltext && resource.name !== "plg_fulltext_indexes") {
|
|
4605
5138
|
this.plugins.fulltext.installResourceHooks(resource);
|
|
4606
5139
|
}
|
|
4607
5140
|
return resource;
|
|
@@ -4609,7 +5142,7 @@ class FullTextPlugin extends Plugin {
|
|
|
4609
5142
|
this.database._fulltextProxyInstalled = true;
|
|
4610
5143
|
}
|
|
4611
5144
|
for (const resource of Object.values(this.database.resources)) {
|
|
4612
|
-
if (resource.name !== "
|
|
5145
|
+
if (resource.name !== "plg_fulltext_indexes") {
|
|
4613
5146
|
this.installResourceHooks(resource);
|
|
4614
5147
|
}
|
|
4615
5148
|
}
|
|
@@ -4852,7 +5385,7 @@ class FullTextPlugin extends Plugin {
|
|
|
4852
5385
|
return this._rebuildAllIndexesInternal();
|
|
4853
5386
|
}
|
|
4854
5387
|
async _rebuildAllIndexesInternal() {
|
|
4855
|
-
const resourceNames = Object.keys(this.database.resources).filter((name) => name !== "
|
|
5388
|
+
const resourceNames = Object.keys(this.database.resources).filter((name) => name !== "plg_fulltext_indexes");
|
|
4856
5389
|
for (const resourceName of resourceNames) {
|
|
4857
5390
|
const [ok, err] = await tryFn(() => this.rebuildIndex(resourceName));
|
|
4858
5391
|
}
|
|
@@ -4904,7 +5437,7 @@ class MetricsPlugin extends Plugin {
|
|
|
4904
5437
|
if (typeof process !== "undefined" && process.env.NODE_ENV === "test") return;
|
|
4905
5438
|
const [ok, err] = await tryFn(async () => {
|
|
4906
5439
|
const [ok1, err1, metricsResource] = await tryFn(() => database.createResource({
|
|
4907
|
-
name: "
|
|
5440
|
+
name: "plg_metrics",
|
|
4908
5441
|
attributes: {
|
|
4909
5442
|
id: "string|required",
|
|
4910
5443
|
type: "string|required",
|
|
@@ -4919,9 +5452,9 @@ class MetricsPlugin extends Plugin {
|
|
|
4919
5452
|
metadata: "json"
|
|
4920
5453
|
}
|
|
4921
5454
|
}));
|
|
4922
|
-
this.metricsResource = ok1 ? metricsResource : database.resources.
|
|
5455
|
+
this.metricsResource = ok1 ? metricsResource : database.resources.plg_metrics;
|
|
4923
5456
|
const [ok2, err2, errorsResource] = await tryFn(() => database.createResource({
|
|
4924
|
-
name: "
|
|
5457
|
+
name: "plg_error_logs",
|
|
4925
5458
|
attributes: {
|
|
4926
5459
|
id: "string|required",
|
|
4927
5460
|
resourceName: "string|required",
|
|
@@ -4931,9 +5464,9 @@ class MetricsPlugin extends Plugin {
|
|
|
4931
5464
|
metadata: "json"
|
|
4932
5465
|
}
|
|
4933
5466
|
}));
|
|
4934
|
-
this.errorsResource = ok2 ? errorsResource : database.resources.
|
|
5467
|
+
this.errorsResource = ok2 ? errorsResource : database.resources.plg_error_logs;
|
|
4935
5468
|
const [ok3, err3, performanceResource] = await tryFn(() => database.createResource({
|
|
4936
|
-
name: "
|
|
5469
|
+
name: "plg_performance_logs",
|
|
4937
5470
|
attributes: {
|
|
4938
5471
|
id: "string|required",
|
|
4939
5472
|
resourceName: "string|required",
|
|
@@ -4943,12 +5476,12 @@ class MetricsPlugin extends Plugin {
|
|
|
4943
5476
|
metadata: "json"
|
|
4944
5477
|
}
|
|
4945
5478
|
}));
|
|
4946
|
-
this.performanceResource = ok3 ? performanceResource : database.resources.
|
|
5479
|
+
this.performanceResource = ok3 ? performanceResource : database.resources.plg_performance_logs;
|
|
4947
5480
|
});
|
|
4948
5481
|
if (!ok) {
|
|
4949
|
-
this.metricsResource = database.resources.
|
|
4950
|
-
this.errorsResource = database.resources.
|
|
4951
|
-
this.performanceResource = database.resources.
|
|
5482
|
+
this.metricsResource = database.resources.plg_metrics;
|
|
5483
|
+
this.errorsResource = database.resources.plg_error_logs;
|
|
5484
|
+
this.performanceResource = database.resources.plg_performance_logs;
|
|
4952
5485
|
}
|
|
4953
5486
|
this.installDatabaseHooks();
|
|
4954
5487
|
this.installMetricsHooks();
|
|
@@ -4967,7 +5500,7 @@ class MetricsPlugin extends Plugin {
|
|
|
4967
5500
|
}
|
|
4968
5501
|
installDatabaseHooks() {
|
|
4969
5502
|
this.database.addHook("afterCreateResource", (resource) => {
|
|
4970
|
-
if (resource.name !== "
|
|
5503
|
+
if (resource.name !== "plg_metrics" && resource.name !== "plg_error_logs" && resource.name !== "plg_performance_logs") {
|
|
4971
5504
|
this.installResourceHooks(resource);
|
|
4972
5505
|
}
|
|
4973
5506
|
});
|
|
@@ -4977,7 +5510,7 @@ class MetricsPlugin extends Plugin {
|
|
|
4977
5510
|
}
|
|
4978
5511
|
installMetricsHooks() {
|
|
4979
5512
|
for (const resource of Object.values(this.database.resources)) {
|
|
4980
|
-
if (["
|
|
5513
|
+
if (["plg_metrics", "plg_error_logs", "plg_performance_logs"].includes(resource.name)) {
|
|
4981
5514
|
continue;
|
|
4982
5515
|
}
|
|
4983
5516
|
this.installResourceHooks(resource);
|
|
@@ -4985,7 +5518,7 @@ class MetricsPlugin extends Plugin {
|
|
|
4985
5518
|
this.database._createResource = this.database.createResource;
|
|
4986
5519
|
this.database.createResource = async function(...args) {
|
|
4987
5520
|
const resource = await this._createResource(...args);
|
|
4988
|
-
if (this.plugins?.metrics && !["
|
|
5521
|
+
if (this.plugins?.metrics && !["plg_metrics", "plg_error_logs", "plg_performance_logs"].includes(resource.name)) {
|
|
4989
5522
|
this.plugins.metrics.installResourceHooks(resource);
|
|
4990
5523
|
}
|
|
4991
5524
|
return resource;
|
|
@@ -6356,7 +6889,7 @@ class Client extends EventEmitter {
|
|
|
6356
6889
|
this.emit("command.response", command.constructor.name, response, command.input);
|
|
6357
6890
|
return response;
|
|
6358
6891
|
}
|
|
6359
|
-
async putObject({ key, metadata, contentType, body, contentEncoding, contentLength }) {
|
|
6892
|
+
async putObject({ key, metadata, contentType, body, contentEncoding, contentLength, ifMatch }) {
|
|
6360
6893
|
const keyPrefix = typeof this.config.keyPrefix === "string" ? this.config.keyPrefix : "";
|
|
6361
6894
|
keyPrefix ? path.join(keyPrefix, key) : key;
|
|
6362
6895
|
const stringMetadata = {};
|
|
@@ -6376,6 +6909,7 @@ class Client extends EventEmitter {
|
|
|
6376
6909
|
if (contentType !== void 0) options.ContentType = contentType;
|
|
6377
6910
|
if (contentEncoding !== void 0) options.ContentEncoding = contentEncoding;
|
|
6378
6911
|
if (contentLength !== void 0) options.ContentLength = contentLength;
|
|
6912
|
+
if (ifMatch !== void 0) options.IfMatch = ifMatch;
|
|
6379
6913
|
let response, error;
|
|
6380
6914
|
try {
|
|
6381
6915
|
response = await this.sendCommand(new clientS3.PutObjectCommand(options));
|
|
@@ -8539,6 +9073,7 @@ ${errorDetails}`,
|
|
|
8539
9073
|
data._lastModified = request.LastModified;
|
|
8540
9074
|
data._hasContent = request.ContentLength > 0;
|
|
8541
9075
|
data._mimeType = request.ContentType || null;
|
|
9076
|
+
data._etag = request.ETag;
|
|
8542
9077
|
data._v = objectVersion;
|
|
8543
9078
|
if (request.VersionId) data._versionId = request.VersionId;
|
|
8544
9079
|
if (request.Expiration) data._expiresAt = request.Expiration;
|
|
@@ -8751,65 +9286,231 @@ ${errorDetails}`,
|
|
|
8751
9286
|
}
|
|
8752
9287
|
}
|
|
8753
9288
|
/**
|
|
8754
|
-
*
|
|
9289
|
+
* Update with conditional check (If-Match ETag)
|
|
8755
9290
|
* @param {string} id - Resource ID
|
|
8756
|
-
* @
|
|
9291
|
+
* @param {Object} attributes - Attributes to update
|
|
9292
|
+
* @param {Object} options - Options including ifMatch (ETag)
|
|
9293
|
+
* @returns {Promise<Object>} { success: boolean, data?: Object, etag?: string, error?: string }
|
|
8757
9294
|
* @example
|
|
8758
|
-
* await resource.
|
|
9295
|
+
* const msg = await resource.get('msg-123');
|
|
9296
|
+
* const result = await resource.updateConditional('msg-123', { status: 'processing' }, { ifMatch: msg._etag });
|
|
9297
|
+
* if (!result.success) {
|
|
9298
|
+
* console.log('Update failed - object was modified by another process');
|
|
9299
|
+
* }
|
|
8759
9300
|
*/
|
|
8760
|
-
async
|
|
9301
|
+
async updateConditional(id, attributes, options = {}) {
|
|
8761
9302
|
if (lodashEs.isEmpty(id)) {
|
|
8762
9303
|
throw new Error("id cannot be empty");
|
|
8763
9304
|
}
|
|
8764
|
-
|
|
8765
|
-
|
|
8766
|
-
|
|
8767
|
-
if (ok) {
|
|
8768
|
-
objectData = data;
|
|
8769
|
-
} else {
|
|
8770
|
-
objectData = { id };
|
|
8771
|
-
deleteError = err;
|
|
9305
|
+
const { ifMatch } = options;
|
|
9306
|
+
if (!ifMatch) {
|
|
9307
|
+
throw new Error("updateConditional requires ifMatch option with ETag value");
|
|
8772
9308
|
}
|
|
8773
|
-
await this.
|
|
8774
|
-
|
|
8775
|
-
|
|
8776
|
-
|
|
8777
|
-
|
|
8778
|
-
|
|
8779
|
-
|
|
9309
|
+
const exists = await this.exists(id);
|
|
9310
|
+
if (!exists) {
|
|
9311
|
+
return {
|
|
9312
|
+
success: false,
|
|
9313
|
+
error: `Resource with id '${id}' does not exist`
|
|
9314
|
+
};
|
|
9315
|
+
}
|
|
9316
|
+
const originalData = await this.get(id);
|
|
9317
|
+
const attributesClone = lodashEs.cloneDeep(attributes);
|
|
9318
|
+
let mergedData = lodashEs.cloneDeep(originalData);
|
|
9319
|
+
for (const [key2, value] of Object.entries(attributesClone)) {
|
|
9320
|
+
if (key2.includes(".")) {
|
|
9321
|
+
let ref = mergedData;
|
|
9322
|
+
const parts = key2.split(".");
|
|
9323
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
9324
|
+
if (typeof ref[parts[i]] !== "object" || ref[parts[i]] === null) {
|
|
9325
|
+
ref[parts[i]] = {};
|
|
9326
|
+
}
|
|
9327
|
+
ref = ref[parts[i]];
|
|
9328
|
+
}
|
|
9329
|
+
ref[parts[parts.length - 1]] = lodashEs.cloneDeep(value);
|
|
9330
|
+
} else if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
9331
|
+
mergedData[key2] = lodashEs.merge({}, mergedData[key2], value);
|
|
9332
|
+
} else {
|
|
9333
|
+
mergedData[key2] = lodashEs.cloneDeep(value);
|
|
9334
|
+
}
|
|
9335
|
+
}
|
|
9336
|
+
if (this.config.timestamps) {
|
|
9337
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
9338
|
+
mergedData.updatedAt = now;
|
|
9339
|
+
if (!mergedData.metadata) mergedData.metadata = {};
|
|
9340
|
+
mergedData.metadata.updatedAt = now;
|
|
9341
|
+
}
|
|
9342
|
+
const preProcessedData = await this.executeHooks("beforeUpdate", lodashEs.cloneDeep(mergedData));
|
|
9343
|
+
const completeData = { ...originalData, ...preProcessedData, id };
|
|
9344
|
+
const { isValid, errors, data } = await this.validate(lodashEs.cloneDeep(completeData));
|
|
9345
|
+
if (!isValid) {
|
|
9346
|
+
return {
|
|
9347
|
+
success: false,
|
|
9348
|
+
error: "Validation failed: " + (errors && errors.length ? JSON.stringify(errors) : "unknown"),
|
|
9349
|
+
validationErrors: errors
|
|
9350
|
+
};
|
|
9351
|
+
}
|
|
9352
|
+
const { id: validatedId, ...validatedAttributes } = data;
|
|
9353
|
+
const mappedData = await this.schema.mapper(validatedAttributes);
|
|
9354
|
+
mappedData._v = String(this.version);
|
|
9355
|
+
const behaviorImpl = getBehavior(this.behavior);
|
|
9356
|
+
const { mappedData: processedMetadata, body } = await behaviorImpl.handleUpdate({
|
|
9357
|
+
resource: this,
|
|
9358
|
+
id,
|
|
9359
|
+
data: validatedAttributes,
|
|
9360
|
+
mappedData,
|
|
9361
|
+
originalData: { ...attributesClone, id }
|
|
8780
9362
|
});
|
|
8781
|
-
|
|
8782
|
-
|
|
8783
|
-
|
|
8784
|
-
|
|
8785
|
-
|
|
8786
|
-
|
|
8787
|
-
|
|
8788
|
-
|
|
9363
|
+
const key = this.getResourceKey(id);
|
|
9364
|
+
let existingContentType = void 0;
|
|
9365
|
+
let finalBody = body;
|
|
9366
|
+
if (body === "" && this.behavior !== "body-overflow") {
|
|
9367
|
+
const [ok2, err2, existingObject] = await tryFn(() => this.client.getObject(key));
|
|
9368
|
+
if (ok2 && existingObject.ContentLength > 0) {
|
|
9369
|
+
const existingBodyBuffer = Buffer.from(await existingObject.Body.transformToByteArray());
|
|
9370
|
+
const existingBodyString = existingBodyBuffer.toString();
|
|
9371
|
+
const [okParse, errParse] = await tryFn(() => Promise.resolve(JSON.parse(existingBodyString)));
|
|
9372
|
+
if (!okParse) {
|
|
9373
|
+
finalBody = existingBodyBuffer;
|
|
9374
|
+
existingContentType = existingObject.ContentType;
|
|
9375
|
+
}
|
|
9376
|
+
}
|
|
8789
9377
|
}
|
|
8790
|
-
|
|
9378
|
+
let finalContentType = existingContentType;
|
|
9379
|
+
if (finalBody && finalBody !== "" && !finalContentType) {
|
|
9380
|
+
const [okParse, errParse] = await tryFn(() => Promise.resolve(JSON.parse(finalBody)));
|
|
9381
|
+
if (okParse) finalContentType = "application/json";
|
|
9382
|
+
}
|
|
9383
|
+
const [ok, err, response] = await tryFn(() => this.client.putObject({
|
|
8791
9384
|
key,
|
|
8792
|
-
|
|
8793
|
-
|
|
8794
|
-
|
|
9385
|
+
body: finalBody,
|
|
9386
|
+
contentType: finalContentType,
|
|
9387
|
+
metadata: processedMetadata,
|
|
9388
|
+
ifMatch
|
|
9389
|
+
// ← Conditional write with ETag
|
|
9390
|
+
}));
|
|
9391
|
+
if (!ok) {
|
|
9392
|
+
if (err.name === "PreconditionFailed" || err.$metadata?.httpStatusCode === 412) {
|
|
9393
|
+
return {
|
|
9394
|
+
success: false,
|
|
9395
|
+
error: "ETag mismatch - object was modified by another process"
|
|
9396
|
+
};
|
|
9397
|
+
}
|
|
9398
|
+
return {
|
|
9399
|
+
success: false,
|
|
9400
|
+
error: err.message || "Update failed"
|
|
9401
|
+
};
|
|
9402
|
+
}
|
|
9403
|
+
const updatedData = await this.composeFullObjectFromWrite({
|
|
9404
|
+
id,
|
|
9405
|
+
metadata: processedMetadata,
|
|
9406
|
+
body: finalBody,
|
|
9407
|
+
behavior: this.behavior
|
|
8795
9408
|
});
|
|
9409
|
+
const oldData = { ...originalData, id };
|
|
9410
|
+
const newData = { ...validatedAttributes, id };
|
|
8796
9411
|
if (this.config.asyncPartitions && this.config.partitions && Object.keys(this.config.partitions).length > 0) {
|
|
8797
9412
|
setImmediate(() => {
|
|
8798
|
-
this.
|
|
9413
|
+
this.handlePartitionReferenceUpdates(oldData, newData).catch((err2) => {
|
|
8799
9414
|
this.emit("partitionIndexError", {
|
|
8800
|
-
operation: "
|
|
9415
|
+
operation: "updateConditional",
|
|
8801
9416
|
id,
|
|
8802
|
-
error:
|
|
8803
|
-
message:
|
|
9417
|
+
error: err2,
|
|
9418
|
+
message: err2.message
|
|
8804
9419
|
});
|
|
8805
9420
|
});
|
|
8806
9421
|
});
|
|
8807
|
-
const nonPartitionHooks = this.hooks.
|
|
8808
|
-
(hook) => !hook.toString().includes("
|
|
9422
|
+
const nonPartitionHooks = this.hooks.afterUpdate.filter(
|
|
9423
|
+
(hook) => !hook.toString().includes("handlePartitionReferenceUpdates")
|
|
8809
9424
|
);
|
|
8810
|
-
let
|
|
9425
|
+
let finalResult = updatedData;
|
|
8811
9426
|
for (const hook of nonPartitionHooks) {
|
|
8812
|
-
|
|
9427
|
+
finalResult = await hook(finalResult);
|
|
9428
|
+
}
|
|
9429
|
+
this.emit("update", {
|
|
9430
|
+
...updatedData,
|
|
9431
|
+
$before: { ...originalData },
|
|
9432
|
+
$after: { ...finalResult }
|
|
9433
|
+
});
|
|
9434
|
+
return {
|
|
9435
|
+
success: true,
|
|
9436
|
+
data: finalResult,
|
|
9437
|
+
etag: response.ETag
|
|
9438
|
+
};
|
|
9439
|
+
} else {
|
|
9440
|
+
await this.handlePartitionReferenceUpdates(oldData, newData);
|
|
9441
|
+
const finalResult = await this.executeHooks("afterUpdate", updatedData);
|
|
9442
|
+
this.emit("update", {
|
|
9443
|
+
...updatedData,
|
|
9444
|
+
$before: { ...originalData },
|
|
9445
|
+
$after: { ...finalResult }
|
|
9446
|
+
});
|
|
9447
|
+
return {
|
|
9448
|
+
success: true,
|
|
9449
|
+
data: finalResult,
|
|
9450
|
+
etag: response.ETag
|
|
9451
|
+
};
|
|
9452
|
+
}
|
|
9453
|
+
}
|
|
9454
|
+
/**
|
|
9455
|
+
* Delete a resource object by ID
|
|
9456
|
+
* @param {string} id - Resource ID
|
|
9457
|
+
* @returns {Promise<Object>} S3 delete response
|
|
9458
|
+
* @example
|
|
9459
|
+
* await resource.delete('user-123');
|
|
9460
|
+
*/
|
|
9461
|
+
async delete(id) {
|
|
9462
|
+
if (lodashEs.isEmpty(id)) {
|
|
9463
|
+
throw new Error("id cannot be empty");
|
|
9464
|
+
}
|
|
9465
|
+
let objectData;
|
|
9466
|
+
let deleteError = null;
|
|
9467
|
+
const [ok, err, data] = await tryFn(() => this.get(id));
|
|
9468
|
+
if (ok) {
|
|
9469
|
+
objectData = data;
|
|
9470
|
+
} else {
|
|
9471
|
+
objectData = { id };
|
|
9472
|
+
deleteError = err;
|
|
9473
|
+
}
|
|
9474
|
+
await this.executeHooks("beforeDelete", objectData);
|
|
9475
|
+
const key = this.getResourceKey(id);
|
|
9476
|
+
const [ok2, err2, response] = await tryFn(() => this.client.deleteObject(key));
|
|
9477
|
+
this.emit("delete", {
|
|
9478
|
+
...objectData,
|
|
9479
|
+
$before: { ...objectData },
|
|
9480
|
+
$after: null
|
|
9481
|
+
});
|
|
9482
|
+
if (deleteError) {
|
|
9483
|
+
throw mapAwsError(deleteError, {
|
|
9484
|
+
bucket: this.client.config.bucket,
|
|
9485
|
+
key,
|
|
9486
|
+
resourceName: this.name,
|
|
9487
|
+
operation: "delete",
|
|
9488
|
+
id
|
|
9489
|
+
});
|
|
9490
|
+
}
|
|
9491
|
+
if (!ok2) throw mapAwsError(err2, {
|
|
9492
|
+
key,
|
|
9493
|
+
resourceName: this.name,
|
|
9494
|
+
operation: "delete",
|
|
9495
|
+
id
|
|
9496
|
+
});
|
|
9497
|
+
if (this.config.asyncPartitions && this.config.partitions && Object.keys(this.config.partitions).length > 0) {
|
|
9498
|
+
setImmediate(() => {
|
|
9499
|
+
this.deletePartitionReferences(objectData).catch((err3) => {
|
|
9500
|
+
this.emit("partitionIndexError", {
|
|
9501
|
+
operation: "delete",
|
|
9502
|
+
id,
|
|
9503
|
+
error: err3,
|
|
9504
|
+
message: err3.message
|
|
9505
|
+
});
|
|
9506
|
+
});
|
|
9507
|
+
});
|
|
9508
|
+
const nonPartitionHooks = this.hooks.afterDelete.filter(
|
|
9509
|
+
(hook) => !hook.toString().includes("deletePartitionReferences")
|
|
9510
|
+
);
|
|
9511
|
+
let afterDeleteData = objectData;
|
|
9512
|
+
for (const hook of nonPartitionHooks) {
|
|
9513
|
+
afterDeleteData = await hook(afterDeleteData);
|
|
8813
9514
|
}
|
|
8814
9515
|
return response;
|
|
8815
9516
|
} else {
|
|
@@ -10157,7 +10858,7 @@ class Database extends EventEmitter {
|
|
|
10157
10858
|
this.id = idGenerator(7);
|
|
10158
10859
|
this.version = "1";
|
|
10159
10860
|
this.s3dbVersion = (() => {
|
|
10160
|
-
const [ok, err, version] = tryFn(() => true ? "10.0.
|
|
10861
|
+
const [ok, err, version] = tryFn(() => true ? "10.0.1" : "latest");
|
|
10161
10862
|
return ok ? version : "latest";
|
|
10162
10863
|
})();
|
|
10163
10864
|
this.resources = {};
|
|
@@ -11410,16 +12111,20 @@ class S3dbReplicator extends BaseReplicator {
|
|
|
11410
12111
|
return resource;
|
|
11411
12112
|
}
|
|
11412
12113
|
_getDestResourceObj(resource) {
|
|
11413
|
-
const
|
|
12114
|
+
const db = this.targetDatabase || this.client;
|
|
12115
|
+
const available = Object.keys(db.resources || {});
|
|
11414
12116
|
const norm = normalizeResourceName$1(resource);
|
|
11415
12117
|
const found = available.find((r) => normalizeResourceName$1(r) === norm);
|
|
11416
12118
|
if (!found) {
|
|
11417
12119
|
throw new Error(`[S3dbReplicator] Destination resource not found: ${resource}. Available: ${available.join(", ")}`);
|
|
11418
12120
|
}
|
|
11419
|
-
return
|
|
12121
|
+
return db.resources[found];
|
|
11420
12122
|
}
|
|
11421
12123
|
async replicateBatch(resourceName, records) {
|
|
11422
|
-
if (
|
|
12124
|
+
if (this.enabled === false) {
|
|
12125
|
+
return { skipped: true, reason: "replicator_disabled" };
|
|
12126
|
+
}
|
|
12127
|
+
if (!this.shouldReplicateResource(resourceName)) {
|
|
11423
12128
|
return { skipped: true, reason: "resource_not_included" };
|
|
11424
12129
|
}
|
|
11425
12130
|
const results = [];
|
|
@@ -11530,11 +12235,12 @@ class SqsReplicator extends BaseReplicator {
|
|
|
11530
12235
|
this.client = client;
|
|
11531
12236
|
this.queueUrl = config.queueUrl;
|
|
11532
12237
|
this.queues = config.queues || {};
|
|
11533
|
-
this.defaultQueue = config.defaultQueue || config.defaultQueueUrl || config.queueUrlDefault;
|
|
12238
|
+
this.defaultQueue = config.defaultQueue || config.defaultQueueUrl || config.queueUrlDefault || null;
|
|
11534
12239
|
this.region = config.region || "us-east-1";
|
|
11535
12240
|
this.sqsClient = client || null;
|
|
11536
12241
|
this.messageGroupId = config.messageGroupId;
|
|
11537
12242
|
this.deduplicationId = config.deduplicationId;
|
|
12243
|
+
this.resourceQueueMap = config.resourceQueueMap || null;
|
|
11538
12244
|
if (Array.isArray(resources)) {
|
|
11539
12245
|
this.resources = {};
|
|
11540
12246
|
for (const resource of resources) {
|
|
@@ -11665,7 +12371,10 @@ class SqsReplicator extends BaseReplicator {
|
|
|
11665
12371
|
}
|
|
11666
12372
|
}
|
|
11667
12373
|
async replicate(resource, operation, data, id, beforeData = null) {
|
|
11668
|
-
if (
|
|
12374
|
+
if (this.enabled === false) {
|
|
12375
|
+
return { skipped: true, reason: "replicator_disabled" };
|
|
12376
|
+
}
|
|
12377
|
+
if (!this.shouldReplicateResource(resource)) {
|
|
11669
12378
|
return { skipped: true, reason: "resource_not_included" };
|
|
11670
12379
|
}
|
|
11671
12380
|
const [ok, err, result] = await tryFn(async () => {
|
|
@@ -11709,7 +12418,10 @@ class SqsReplicator extends BaseReplicator {
|
|
|
11709
12418
|
return { success: false, error: err.message };
|
|
11710
12419
|
}
|
|
11711
12420
|
async replicateBatch(resource, records) {
|
|
11712
|
-
if (
|
|
12421
|
+
if (this.enabled === false) {
|
|
12422
|
+
return { skipped: true, reason: "replicator_disabled" };
|
|
12423
|
+
}
|
|
12424
|
+
if (!this.shouldReplicateResource(resource)) {
|
|
11713
12425
|
return { skipped: true, reason: "resource_not_included" };
|
|
11714
12426
|
}
|
|
11715
12427
|
const [ok, err, result] = await tryFn(async () => {
|
|
@@ -11863,22 +12575,23 @@ class ReplicatorPlugin extends Plugin {
|
|
|
11863
12575
|
replicators: options.replicators || [],
|
|
11864
12576
|
logErrors: options.logErrors !== false,
|
|
11865
12577
|
replicatorLogResource: options.replicatorLogResource || "replicator_log",
|
|
12578
|
+
persistReplicatorLog: options.persistReplicatorLog || false,
|
|
11866
12579
|
enabled: options.enabled !== false,
|
|
11867
12580
|
batchSize: options.batchSize || 100,
|
|
11868
12581
|
maxRetries: options.maxRetries || 3,
|
|
11869
12582
|
timeout: options.timeout || 3e4,
|
|
11870
|
-
verbose: options.verbose || false
|
|
11871
|
-
...options
|
|
12583
|
+
verbose: options.verbose || false
|
|
11872
12584
|
};
|
|
11873
12585
|
this.replicators = [];
|
|
11874
12586
|
this.database = null;
|
|
11875
12587
|
this.eventListenersInstalled = /* @__PURE__ */ new Set();
|
|
11876
|
-
|
|
11877
|
-
|
|
11878
|
-
|
|
11879
|
-
|
|
11880
|
-
|
|
11881
|
-
|
|
12588
|
+
this.eventHandlers = /* @__PURE__ */ new Map();
|
|
12589
|
+
this.stats = {
|
|
12590
|
+
totalReplications: 0,
|
|
12591
|
+
totalErrors: 0,
|
|
12592
|
+
lastSync: null
|
|
12593
|
+
};
|
|
12594
|
+
this._afterCreateResourceHook = null;
|
|
11882
12595
|
}
|
|
11883
12596
|
// Helper to filter out internal S3DB fields
|
|
11884
12597
|
filterInternalFields(obj) {
|
|
@@ -11899,7 +12612,7 @@ class ReplicatorPlugin extends Plugin {
|
|
|
11899
12612
|
if (!resource || this.eventListenersInstalled.has(resource.name) || resource.name === this.config.replicatorLogResource) {
|
|
11900
12613
|
return;
|
|
11901
12614
|
}
|
|
11902
|
-
|
|
12615
|
+
const insertHandler = async (data) => {
|
|
11903
12616
|
const [ok, error] = await tryFn(async () => {
|
|
11904
12617
|
const completeData = { ...data, createdAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
11905
12618
|
await plugin.processReplicatorEvent("insert", resource.name, completeData.id, completeData);
|
|
@@ -11910,8 +12623,8 @@ class ReplicatorPlugin extends Plugin {
|
|
|
11910
12623
|
}
|
|
11911
12624
|
this.emit("error", { operation: "insert", error: error.message, resource: resource.name });
|
|
11912
12625
|
}
|
|
11913
|
-
}
|
|
11914
|
-
|
|
12626
|
+
};
|
|
12627
|
+
const updateHandler = async (data, beforeData) => {
|
|
11915
12628
|
const [ok, error] = await tryFn(async () => {
|
|
11916
12629
|
const completeData = await plugin.getCompleteData(resource, data);
|
|
11917
12630
|
const dataWithTimestamp = { ...completeData, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
@@ -11923,8 +12636,8 @@ class ReplicatorPlugin extends Plugin {
|
|
|
11923
12636
|
}
|
|
11924
12637
|
this.emit("error", { operation: "update", error: error.message, resource: resource.name });
|
|
11925
12638
|
}
|
|
11926
|
-
}
|
|
11927
|
-
|
|
12639
|
+
};
|
|
12640
|
+
const deleteHandler = async (data) => {
|
|
11928
12641
|
const [ok, error] = await tryFn(async () => {
|
|
11929
12642
|
await plugin.processReplicatorEvent("delete", resource.name, data.id, data);
|
|
11930
12643
|
});
|
|
@@ -11934,14 +12647,22 @@ class ReplicatorPlugin extends Plugin {
|
|
|
11934
12647
|
}
|
|
11935
12648
|
this.emit("error", { operation: "delete", error: error.message, resource: resource.name });
|
|
11936
12649
|
}
|
|
11937
|
-
}
|
|
12650
|
+
};
|
|
12651
|
+
this.eventHandlers.set(resource.name, {
|
|
12652
|
+
insert: insertHandler,
|
|
12653
|
+
update: updateHandler,
|
|
12654
|
+
delete: deleteHandler
|
|
12655
|
+
});
|
|
12656
|
+
resource.on("insert", insertHandler);
|
|
12657
|
+
resource.on("update", updateHandler);
|
|
12658
|
+
resource.on("delete", deleteHandler);
|
|
11938
12659
|
this.eventListenersInstalled.add(resource.name);
|
|
11939
12660
|
}
|
|
11940
12661
|
async setup(database) {
|
|
11941
12662
|
this.database = database;
|
|
11942
12663
|
if (this.config.persistReplicatorLog) {
|
|
11943
12664
|
const [ok, err, logResource] = await tryFn(() => database.createResource({
|
|
11944
|
-
name: this.config.replicatorLogResource || "
|
|
12665
|
+
name: this.config.replicatorLogResource || "plg_replicator_logs",
|
|
11945
12666
|
attributes: {
|
|
11946
12667
|
id: "string|required",
|
|
11947
12668
|
resource: "string|required",
|
|
@@ -11955,13 +12676,13 @@ class ReplicatorPlugin extends Plugin {
|
|
|
11955
12676
|
if (ok) {
|
|
11956
12677
|
this.replicatorLogResource = logResource;
|
|
11957
12678
|
} else {
|
|
11958
|
-
this.replicatorLogResource = database.resources[this.config.replicatorLogResource || "
|
|
12679
|
+
this.replicatorLogResource = database.resources[this.config.replicatorLogResource || "plg_replicator_logs"];
|
|
11959
12680
|
}
|
|
11960
12681
|
}
|
|
11961
12682
|
await this.initializeReplicators(database);
|
|
11962
12683
|
this.installDatabaseHooks();
|
|
11963
12684
|
for (const resource of Object.values(database.resources)) {
|
|
11964
|
-
if (resource.name !== (this.config.replicatorLogResource || "
|
|
12685
|
+
if (resource.name !== (this.config.replicatorLogResource || "plg_replicator_logs")) {
|
|
11965
12686
|
this.installEventListeners(resource, database, this);
|
|
11966
12687
|
}
|
|
11967
12688
|
}
|
|
@@ -11977,14 +12698,18 @@ class ReplicatorPlugin extends Plugin {
|
|
|
11977
12698
|
this.removeDatabaseHooks();
|
|
11978
12699
|
}
|
|
11979
12700
|
installDatabaseHooks() {
|
|
11980
|
-
this.
|
|
11981
|
-
if (resource.name !== (this.config.replicatorLogResource || "
|
|
12701
|
+
this._afterCreateResourceHook = (resource) => {
|
|
12702
|
+
if (resource.name !== (this.config.replicatorLogResource || "plg_replicator_logs")) {
|
|
11982
12703
|
this.installEventListeners(resource, this.database, this);
|
|
11983
12704
|
}
|
|
11984
|
-
}
|
|
12705
|
+
};
|
|
12706
|
+
this.database.addHook("afterCreateResource", this._afterCreateResourceHook);
|
|
11985
12707
|
}
|
|
11986
12708
|
removeDatabaseHooks() {
|
|
11987
|
-
|
|
12709
|
+
if (this._afterCreateResourceHook) {
|
|
12710
|
+
this.database.removeHook("afterCreateResource", this._afterCreateResourceHook);
|
|
12711
|
+
this._afterCreateResourceHook = null;
|
|
12712
|
+
}
|
|
11988
12713
|
}
|
|
11989
12714
|
createReplicator(driver, config, resources, client) {
|
|
11990
12715
|
return createReplicator(driver, config, resources, client);
|
|
@@ -12009,9 +12734,9 @@ class ReplicatorPlugin extends Plugin {
|
|
|
12009
12734
|
async retryWithBackoff(operation, maxRetries = 3) {
|
|
12010
12735
|
let lastError;
|
|
12011
12736
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
12012
|
-
const [ok, error] = await tryFn(operation);
|
|
12737
|
+
const [ok, error, result] = await tryFn(operation);
|
|
12013
12738
|
if (ok) {
|
|
12014
|
-
return
|
|
12739
|
+
return result;
|
|
12015
12740
|
} else {
|
|
12016
12741
|
lastError = error;
|
|
12017
12742
|
if (this.config.verbose) {
|
|
@@ -12106,7 +12831,7 @@ class ReplicatorPlugin extends Plugin {
|
|
|
12106
12831
|
});
|
|
12107
12832
|
return Promise.allSettled(promises);
|
|
12108
12833
|
}
|
|
12109
|
-
async
|
|
12834
|
+
async processReplicatorItem(item) {
|
|
12110
12835
|
const applicableReplicators = this.replicators.filter((replicator) => {
|
|
12111
12836
|
const should = replicator.shouldReplicateResource && replicator.shouldReplicateResource(item.resourceName, item.operation);
|
|
12112
12837
|
return should;
|
|
@@ -12166,12 +12891,9 @@ class ReplicatorPlugin extends Plugin {
|
|
|
12166
12891
|
});
|
|
12167
12892
|
return Promise.allSettled(promises);
|
|
12168
12893
|
}
|
|
12169
|
-
async
|
|
12894
|
+
async logReplicator(item) {
|
|
12170
12895
|
const logRes = this.replicatorLog || this.database.resources[normalizeResourceName(this.config.replicatorLogResource)];
|
|
12171
12896
|
if (!logRes) {
|
|
12172
|
-
if (this.database) {
|
|
12173
|
-
if (this.database.options && this.database.options.connectionString) ;
|
|
12174
|
-
}
|
|
12175
12897
|
this.emit("replicator.log.failed", { error: "replicator log resource not found", item });
|
|
12176
12898
|
return;
|
|
12177
12899
|
}
|
|
@@ -12193,7 +12915,7 @@ class ReplicatorPlugin extends Plugin {
|
|
|
12193
12915
|
this.emit("replicator.log.failed", { error: err, item });
|
|
12194
12916
|
}
|
|
12195
12917
|
}
|
|
12196
|
-
async
|
|
12918
|
+
async updateReplicatorLog(logId, updates) {
|
|
12197
12919
|
if (!this.replicatorLog) return;
|
|
12198
12920
|
const [ok, err] = await tryFn(async () => {
|
|
12199
12921
|
await this.replicatorLog.update(logId, {
|
|
@@ -12206,7 +12928,7 @@ class ReplicatorPlugin extends Plugin {
|
|
|
12206
12928
|
}
|
|
12207
12929
|
}
|
|
12208
12930
|
// Utility methods
|
|
12209
|
-
async
|
|
12931
|
+
async getReplicatorStats() {
|
|
12210
12932
|
const replicatorStats = await Promise.all(
|
|
12211
12933
|
this.replicators.map(async (replicator) => {
|
|
12212
12934
|
const status = await replicator.getStatus();
|
|
@@ -12220,15 +12942,11 @@ class ReplicatorPlugin extends Plugin {
|
|
|
12220
12942
|
);
|
|
12221
12943
|
return {
|
|
12222
12944
|
replicators: replicatorStats,
|
|
12223
|
-
queue: {
|
|
12224
|
-
length: this.queue.length,
|
|
12225
|
-
isProcessing: this.isProcessing
|
|
12226
|
-
},
|
|
12227
12945
|
stats: this.stats,
|
|
12228
12946
|
lastSync: this.stats.lastSync
|
|
12229
12947
|
};
|
|
12230
12948
|
}
|
|
12231
|
-
async
|
|
12949
|
+
async getReplicatorLogs(options = {}) {
|
|
12232
12950
|
if (!this.replicatorLog) {
|
|
12233
12951
|
return [];
|
|
12234
12952
|
}
|
|
@@ -12239,32 +12957,32 @@ class ReplicatorPlugin extends Plugin {
|
|
|
12239
12957
|
limit = 100,
|
|
12240
12958
|
offset = 0
|
|
12241
12959
|
} = options;
|
|
12242
|
-
|
|
12960
|
+
const filter = {};
|
|
12243
12961
|
if (resourceName) {
|
|
12244
|
-
|
|
12962
|
+
filter.resourceName = resourceName;
|
|
12245
12963
|
}
|
|
12246
12964
|
if (operation) {
|
|
12247
|
-
|
|
12965
|
+
filter.operation = operation;
|
|
12248
12966
|
}
|
|
12249
12967
|
if (status) {
|
|
12250
|
-
|
|
12968
|
+
filter.status = status;
|
|
12251
12969
|
}
|
|
12252
|
-
const logs = await this.replicatorLog.
|
|
12253
|
-
return logs
|
|
12970
|
+
const logs = await this.replicatorLog.query(filter, { limit, offset });
|
|
12971
|
+
return logs || [];
|
|
12254
12972
|
}
|
|
12255
|
-
async
|
|
12973
|
+
async retryFailedReplicators() {
|
|
12256
12974
|
if (!this.replicatorLog) {
|
|
12257
12975
|
return { retried: 0 };
|
|
12258
12976
|
}
|
|
12259
|
-
const failedLogs = await this.replicatorLog.
|
|
12977
|
+
const failedLogs = await this.replicatorLog.query({
|
|
12260
12978
|
status: "failed"
|
|
12261
12979
|
});
|
|
12262
12980
|
let retried = 0;
|
|
12263
|
-
for (const log of failedLogs) {
|
|
12981
|
+
for (const log of failedLogs || []) {
|
|
12264
12982
|
const [ok, err] = await tryFn(async () => {
|
|
12265
12983
|
await this.processReplicatorEvent(
|
|
12266
|
-
log.resourceName,
|
|
12267
12984
|
log.operation,
|
|
12985
|
+
log.resourceName,
|
|
12268
12986
|
log.recordId,
|
|
12269
12987
|
log.data
|
|
12270
12988
|
);
|
|
@@ -12282,13 +13000,21 @@ class ReplicatorPlugin extends Plugin {
|
|
|
12282
13000
|
}
|
|
12283
13001
|
this.stats.lastSync = (/* @__PURE__ */ new Date()).toISOString();
|
|
12284
13002
|
for (const resourceName in this.database.resources) {
|
|
12285
|
-
if (normalizeResourceName(resourceName) === normalizeResourceName("
|
|
13003
|
+
if (normalizeResourceName(resourceName) === normalizeResourceName("plg_replicator_logs")) continue;
|
|
12286
13004
|
if (replicator.shouldReplicateResource(resourceName)) {
|
|
12287
13005
|
this.emit("replicator.sync.resource", { resourceName, replicatorId });
|
|
12288
13006
|
const resource = this.database.resources[resourceName];
|
|
12289
|
-
|
|
12290
|
-
|
|
12291
|
-
|
|
13007
|
+
let offset = 0;
|
|
13008
|
+
const pageSize = this.config.batchSize || 100;
|
|
13009
|
+
while (true) {
|
|
13010
|
+
const [ok, err, page] = await tryFn(() => resource.page({ offset, size: pageSize }));
|
|
13011
|
+
if (!ok || !page) break;
|
|
13012
|
+
const records = Array.isArray(page) ? page : page.items || [];
|
|
13013
|
+
if (records.length === 0) break;
|
|
13014
|
+
for (const record of records) {
|
|
13015
|
+
await replicator.replicate(resourceName, "insert", record, record.id);
|
|
13016
|
+
}
|
|
13017
|
+
offset += pageSize;
|
|
12292
13018
|
}
|
|
12293
13019
|
}
|
|
12294
13020
|
}
|
|
@@ -12316,9 +13042,21 @@ class ReplicatorPlugin extends Plugin {
|
|
|
12316
13042
|
});
|
|
12317
13043
|
await Promise.allSettled(cleanupPromises);
|
|
12318
13044
|
}
|
|
13045
|
+
if (this.database && this.database.resources) {
|
|
13046
|
+
for (const resourceName of this.eventListenersInstalled) {
|
|
13047
|
+
const resource = this.database.resources[resourceName];
|
|
13048
|
+
const handlers = this.eventHandlers.get(resourceName);
|
|
13049
|
+
if (resource && handlers) {
|
|
13050
|
+
resource.off("insert", handlers.insert);
|
|
13051
|
+
resource.off("update", handlers.update);
|
|
13052
|
+
resource.off("delete", handlers.delete);
|
|
13053
|
+
}
|
|
13054
|
+
}
|
|
13055
|
+
}
|
|
12319
13056
|
this.replicators = [];
|
|
12320
13057
|
this.database = null;
|
|
12321
13058
|
this.eventListenersInstalled.clear();
|
|
13059
|
+
this.eventHandlers.clear();
|
|
12322
13060
|
this.removeAllListeners();
|
|
12323
13061
|
});
|
|
12324
13062
|
if (!ok) {
|
|
@@ -12332,6 +13070,543 @@ class ReplicatorPlugin extends Plugin {
|
|
|
12332
13070
|
}
|
|
12333
13071
|
}
|
|
12334
13072
|
|
|
13073
|
+
class S3QueuePlugin extends Plugin {
|
|
13074
|
+
constructor(options = {}) {
|
|
13075
|
+
super(options);
|
|
13076
|
+
if (!options.resource) {
|
|
13077
|
+
throw new Error('S3QueuePlugin requires "resource" option');
|
|
13078
|
+
}
|
|
13079
|
+
this.config = {
|
|
13080
|
+
resource: options.resource,
|
|
13081
|
+
visibilityTimeout: options.visibilityTimeout || 3e4,
|
|
13082
|
+
// 30 seconds
|
|
13083
|
+
pollInterval: options.pollInterval || 1e3,
|
|
13084
|
+
// 1 second
|
|
13085
|
+
maxAttempts: options.maxAttempts || 3,
|
|
13086
|
+
concurrency: options.concurrency || 1,
|
|
13087
|
+
deadLetterResource: options.deadLetterResource || null,
|
|
13088
|
+
autoStart: options.autoStart !== false,
|
|
13089
|
+
onMessage: options.onMessage,
|
|
13090
|
+
onError: options.onError,
|
|
13091
|
+
onComplete: options.onComplete,
|
|
13092
|
+
verbose: options.verbose || false,
|
|
13093
|
+
...options
|
|
13094
|
+
};
|
|
13095
|
+
this.queueResource = null;
|
|
13096
|
+
this.targetResource = null;
|
|
13097
|
+
this.deadLetterResourceObj = null;
|
|
13098
|
+
this.workers = [];
|
|
13099
|
+
this.isRunning = false;
|
|
13100
|
+
this.workerId = `worker-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
|
13101
|
+
this.processedCache = /* @__PURE__ */ new Map();
|
|
13102
|
+
this.cacheCleanupInterval = null;
|
|
13103
|
+
this.lockCleanupInterval = null;
|
|
13104
|
+
}
|
|
13105
|
+
async onSetup() {
|
|
13106
|
+
this.targetResource = this.database.resources[this.config.resource];
|
|
13107
|
+
if (!this.targetResource) {
|
|
13108
|
+
throw new Error(`S3QueuePlugin: resource '${this.config.resource}' not found`);
|
|
13109
|
+
}
|
|
13110
|
+
const queueName = `${this.config.resource}_queue`;
|
|
13111
|
+
const [ok, err] = await tryFn(
|
|
13112
|
+
() => this.database.createResource({
|
|
13113
|
+
name: queueName,
|
|
13114
|
+
attributes: {
|
|
13115
|
+
id: "string|required",
|
|
13116
|
+
originalId: "string|required",
|
|
13117
|
+
// ID do registro original
|
|
13118
|
+
status: "string|required",
|
|
13119
|
+
// pending/processing/completed/failed/dead
|
|
13120
|
+
visibleAt: "number|required",
|
|
13121
|
+
// Timestamp de visibilidade
|
|
13122
|
+
claimedBy: "string|optional",
|
|
13123
|
+
// Worker que claimed
|
|
13124
|
+
claimedAt: "number|optional",
|
|
13125
|
+
// Timestamp do claim
|
|
13126
|
+
attempts: "number|default:0",
|
|
13127
|
+
maxAttempts: "number|default:3",
|
|
13128
|
+
error: "string|optional",
|
|
13129
|
+
result: "json|optional",
|
|
13130
|
+
createdAt: "string|required",
|
|
13131
|
+
completedAt: "number|optional"
|
|
13132
|
+
},
|
|
13133
|
+
behavior: "body-overflow",
|
|
13134
|
+
timestamps: true,
|
|
13135
|
+
asyncPartitions: true,
|
|
13136
|
+
partitions: {
|
|
13137
|
+
byStatus: { fields: { status: "string" } },
|
|
13138
|
+
byDate: { fields: { createdAt: "string|maxlength:10" } }
|
|
13139
|
+
}
|
|
13140
|
+
})
|
|
13141
|
+
);
|
|
13142
|
+
if (!ok && !this.database.resources[queueName]) {
|
|
13143
|
+
throw new Error(`Failed to create queue resource: ${err?.message}`);
|
|
13144
|
+
}
|
|
13145
|
+
this.queueResource = this.database.resources[queueName];
|
|
13146
|
+
const lockName = `${this.config.resource}_locks`;
|
|
13147
|
+
const [okLock, errLock] = await tryFn(
|
|
13148
|
+
() => this.database.createResource({
|
|
13149
|
+
name: lockName,
|
|
13150
|
+
attributes: {
|
|
13151
|
+
id: "string|required",
|
|
13152
|
+
workerId: "string|required",
|
|
13153
|
+
timestamp: "number|required",
|
|
13154
|
+
ttl: "number|default:5000"
|
|
13155
|
+
},
|
|
13156
|
+
behavior: "body-overflow",
|
|
13157
|
+
timestamps: false
|
|
13158
|
+
})
|
|
13159
|
+
);
|
|
13160
|
+
if (okLock || this.database.resources[lockName]) {
|
|
13161
|
+
this.lockResource = this.database.resources[lockName];
|
|
13162
|
+
} else {
|
|
13163
|
+
this.lockResource = null;
|
|
13164
|
+
if (this.config.verbose) {
|
|
13165
|
+
console.log(`[S3QueuePlugin] Lock resource creation failed, locking disabled: ${errLock?.message}`);
|
|
13166
|
+
}
|
|
13167
|
+
}
|
|
13168
|
+
this.addHelperMethods();
|
|
13169
|
+
if (this.config.deadLetterResource) {
|
|
13170
|
+
await this.createDeadLetterResource();
|
|
13171
|
+
}
|
|
13172
|
+
if (this.config.verbose) {
|
|
13173
|
+
console.log(`[S3QueuePlugin] Setup completed for resource '${this.config.resource}'`);
|
|
13174
|
+
}
|
|
13175
|
+
}
|
|
13176
|
+
async onStart() {
|
|
13177
|
+
if (this.config.autoStart && this.config.onMessage) {
|
|
13178
|
+
await this.startProcessing();
|
|
13179
|
+
}
|
|
13180
|
+
}
|
|
13181
|
+
async onStop() {
|
|
13182
|
+
await this.stopProcessing();
|
|
13183
|
+
}
|
|
13184
|
+
addHelperMethods() {
|
|
13185
|
+
const plugin = this;
|
|
13186
|
+
const resource = this.targetResource;
|
|
13187
|
+
resource.enqueue = async function(data, options = {}) {
|
|
13188
|
+
const recordData = {
|
|
13189
|
+
id: data.id || idGenerator(),
|
|
13190
|
+
...data
|
|
13191
|
+
};
|
|
13192
|
+
const record = await resource.insert(recordData);
|
|
13193
|
+
const queueEntry = {
|
|
13194
|
+
id: idGenerator(),
|
|
13195
|
+
originalId: record.id,
|
|
13196
|
+
status: "pending",
|
|
13197
|
+
visibleAt: Date.now(),
|
|
13198
|
+
attempts: 0,
|
|
13199
|
+
maxAttempts: options.maxAttempts || plugin.config.maxAttempts,
|
|
13200
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10)
|
|
13201
|
+
};
|
|
13202
|
+
await plugin.queueResource.insert(queueEntry);
|
|
13203
|
+
plugin.emit("message.enqueued", { id: record.id, queueId: queueEntry.id });
|
|
13204
|
+
return record;
|
|
13205
|
+
};
|
|
13206
|
+
resource.queueStats = async function() {
|
|
13207
|
+
return await plugin.getStats();
|
|
13208
|
+
};
|
|
13209
|
+
resource.startProcessing = async function(handler, options = {}) {
|
|
13210
|
+
return await plugin.startProcessing(handler, options);
|
|
13211
|
+
};
|
|
13212
|
+
resource.stopProcessing = async function() {
|
|
13213
|
+
return await plugin.stopProcessing();
|
|
13214
|
+
};
|
|
13215
|
+
}
|
|
13216
|
+
async startProcessing(handler = null, options = {}) {
|
|
13217
|
+
if (this.isRunning) {
|
|
13218
|
+
if (this.config.verbose) {
|
|
13219
|
+
console.log("[S3QueuePlugin] Already running");
|
|
13220
|
+
}
|
|
13221
|
+
return;
|
|
13222
|
+
}
|
|
13223
|
+
const messageHandler = handler || this.config.onMessage;
|
|
13224
|
+
if (!messageHandler) {
|
|
13225
|
+
throw new Error("S3QueuePlugin: onMessage handler required");
|
|
13226
|
+
}
|
|
13227
|
+
this.isRunning = true;
|
|
13228
|
+
const concurrency = options.concurrency || this.config.concurrency;
|
|
13229
|
+
this.cacheCleanupInterval = setInterval(() => {
|
|
13230
|
+
const now = Date.now();
|
|
13231
|
+
const maxAge = 3e4;
|
|
13232
|
+
for (const [queueId, timestamp] of this.processedCache.entries()) {
|
|
13233
|
+
if (now - timestamp > maxAge) {
|
|
13234
|
+
this.processedCache.delete(queueId);
|
|
13235
|
+
}
|
|
13236
|
+
}
|
|
13237
|
+
}, 5e3);
|
|
13238
|
+
this.lockCleanupInterval = setInterval(() => {
|
|
13239
|
+
this.cleanupStaleLocks().catch((err) => {
|
|
13240
|
+
if (this.config.verbose) {
|
|
13241
|
+
console.log(`[lockCleanup] Error: ${err.message}`);
|
|
13242
|
+
}
|
|
13243
|
+
});
|
|
13244
|
+
}, 1e4);
|
|
13245
|
+
for (let i = 0; i < concurrency; i++) {
|
|
13246
|
+
const worker = this.createWorker(messageHandler, i);
|
|
13247
|
+
this.workers.push(worker);
|
|
13248
|
+
}
|
|
13249
|
+
if (this.config.verbose) {
|
|
13250
|
+
console.log(`[S3QueuePlugin] Started ${concurrency} workers`);
|
|
13251
|
+
}
|
|
13252
|
+
this.emit("workers.started", { concurrency, workerId: this.workerId });
|
|
13253
|
+
}
|
|
13254
|
+
async stopProcessing() {
|
|
13255
|
+
if (!this.isRunning) return;
|
|
13256
|
+
this.isRunning = false;
|
|
13257
|
+
if (this.cacheCleanupInterval) {
|
|
13258
|
+
clearInterval(this.cacheCleanupInterval);
|
|
13259
|
+
this.cacheCleanupInterval = null;
|
|
13260
|
+
}
|
|
13261
|
+
if (this.lockCleanupInterval) {
|
|
13262
|
+
clearInterval(this.lockCleanupInterval);
|
|
13263
|
+
this.lockCleanupInterval = null;
|
|
13264
|
+
}
|
|
13265
|
+
await Promise.all(this.workers);
|
|
13266
|
+
this.workers = [];
|
|
13267
|
+
this.processedCache.clear();
|
|
13268
|
+
if (this.config.verbose) {
|
|
13269
|
+
console.log("[S3QueuePlugin] Stopped all workers");
|
|
13270
|
+
}
|
|
13271
|
+
this.emit("workers.stopped", { workerId: this.workerId });
|
|
13272
|
+
}
|
|
13273
|
+
createWorker(handler, workerIndex) {
|
|
13274
|
+
return (async () => {
|
|
13275
|
+
while (this.isRunning) {
|
|
13276
|
+
try {
|
|
13277
|
+
const message = await this.claimMessage();
|
|
13278
|
+
if (message) {
|
|
13279
|
+
await this.processMessage(message, handler);
|
|
13280
|
+
} else {
|
|
13281
|
+
await new Promise((resolve) => setTimeout(resolve, this.config.pollInterval));
|
|
13282
|
+
}
|
|
13283
|
+
} catch (error) {
|
|
13284
|
+
if (this.config.verbose) {
|
|
13285
|
+
console.error(`[Worker ${workerIndex}] Error:`, error.message);
|
|
13286
|
+
}
|
|
13287
|
+
await new Promise((resolve) => setTimeout(resolve, 1e3));
|
|
13288
|
+
}
|
|
13289
|
+
}
|
|
13290
|
+
})();
|
|
13291
|
+
}
|
|
13292
|
+
async claimMessage() {
|
|
13293
|
+
const now = Date.now();
|
|
13294
|
+
const [ok, err, messages] = await tryFn(
|
|
13295
|
+
() => this.queueResource.query({
|
|
13296
|
+
status: "pending"
|
|
13297
|
+
})
|
|
13298
|
+
);
|
|
13299
|
+
if (!ok || !messages || messages.length === 0) {
|
|
13300
|
+
return null;
|
|
13301
|
+
}
|
|
13302
|
+
const available = messages.filter((m) => m.visibleAt <= now);
|
|
13303
|
+
if (available.length === 0) {
|
|
13304
|
+
return null;
|
|
13305
|
+
}
|
|
13306
|
+
for (const msg of available) {
|
|
13307
|
+
const claimed = await this.attemptClaim(msg);
|
|
13308
|
+
if (claimed) {
|
|
13309
|
+
return claimed;
|
|
13310
|
+
}
|
|
13311
|
+
}
|
|
13312
|
+
return null;
|
|
13313
|
+
}
|
|
13314
|
+
/**
|
|
13315
|
+
* Acquire a distributed lock using ETag-based conditional updates
|
|
13316
|
+
* This ensures only one worker can claim a message at a time
|
|
13317
|
+
*
|
|
13318
|
+
* Uses a two-step process:
|
|
13319
|
+
* 1. Create lock resource (similar to queue resource) if not exists
|
|
13320
|
+
* 2. Try to claim lock using ETag-based conditional update
|
|
13321
|
+
*/
|
|
13322
|
+
async acquireLock(messageId) {
|
|
13323
|
+
if (!this.lockResource) {
|
|
13324
|
+
return true;
|
|
13325
|
+
}
|
|
13326
|
+
const lockId = `lock-${messageId}`;
|
|
13327
|
+
const now = Date.now();
|
|
13328
|
+
try {
|
|
13329
|
+
const [okGet, errGet, existingLock] = await tryFn(
|
|
13330
|
+
() => this.lockResource.get(lockId)
|
|
13331
|
+
);
|
|
13332
|
+
if (existingLock) {
|
|
13333
|
+
const lockAge = now - existingLock.timestamp;
|
|
13334
|
+
if (lockAge < existingLock.ttl) {
|
|
13335
|
+
return false;
|
|
13336
|
+
}
|
|
13337
|
+
const [ok, err, result] = await tryFn(
|
|
13338
|
+
() => this.lockResource.updateConditional(lockId, {
|
|
13339
|
+
workerId: this.workerId,
|
|
13340
|
+
timestamp: now,
|
|
13341
|
+
ttl: 5e3
|
|
13342
|
+
}, {
|
|
13343
|
+
ifMatch: existingLock._etag
|
|
13344
|
+
})
|
|
13345
|
+
);
|
|
13346
|
+
return ok && result.success;
|
|
13347
|
+
}
|
|
13348
|
+
const [okCreate, errCreate] = await tryFn(
|
|
13349
|
+
() => this.lockResource.insert({
|
|
13350
|
+
id: lockId,
|
|
13351
|
+
workerId: this.workerId,
|
|
13352
|
+
timestamp: now,
|
|
13353
|
+
ttl: 5e3
|
|
13354
|
+
})
|
|
13355
|
+
);
|
|
13356
|
+
return okCreate;
|
|
13357
|
+
} catch (error) {
|
|
13358
|
+
if (this.config.verbose) {
|
|
13359
|
+
console.log(`[acquireLock] Error: ${error.message}`);
|
|
13360
|
+
}
|
|
13361
|
+
return false;
|
|
13362
|
+
}
|
|
13363
|
+
}
|
|
13364
|
+
/**
|
|
13365
|
+
* Release a distributed lock by deleting the lock record
|
|
13366
|
+
*/
|
|
13367
|
+
async releaseLock(messageId) {
|
|
13368
|
+
if (!this.lockResource) {
|
|
13369
|
+
return;
|
|
13370
|
+
}
|
|
13371
|
+
const lockId = `lock-${messageId}`;
|
|
13372
|
+
try {
|
|
13373
|
+
await this.lockResource.delete(lockId);
|
|
13374
|
+
} catch (error) {
|
|
13375
|
+
if (this.config.verbose) {
|
|
13376
|
+
console.log(`[releaseLock] Failed to release lock for ${messageId}: ${error.message}`);
|
|
13377
|
+
}
|
|
13378
|
+
}
|
|
13379
|
+
}
|
|
13380
|
+
/**
|
|
13381
|
+
* Clean up stale locks (older than TTL)
|
|
13382
|
+
* This prevents deadlocks if a worker crashes while holding a lock
|
|
13383
|
+
*/
|
|
13384
|
+
async cleanupStaleLocks() {
|
|
13385
|
+
if (!this.lockResource) {
|
|
13386
|
+
return;
|
|
13387
|
+
}
|
|
13388
|
+
const now = Date.now();
|
|
13389
|
+
try {
|
|
13390
|
+
const locks = await this.lockResource.list();
|
|
13391
|
+
for (const lock of locks) {
|
|
13392
|
+
const lockAge = now - lock.timestamp;
|
|
13393
|
+
if (lockAge > lock.ttl) {
|
|
13394
|
+
await this.lockResource.delete(lock.id);
|
|
13395
|
+
if (this.config.verbose) {
|
|
13396
|
+
console.log(`[cleanupStaleLocks] Removed expired lock: ${lock.id}`);
|
|
13397
|
+
}
|
|
13398
|
+
}
|
|
13399
|
+
}
|
|
13400
|
+
} catch (error) {
|
|
13401
|
+
if (this.config.verbose) {
|
|
13402
|
+
console.log(`[cleanupStaleLocks] Error during cleanup: ${error.message}`);
|
|
13403
|
+
}
|
|
13404
|
+
}
|
|
13405
|
+
}
|
|
13406
|
+
async attemptClaim(msg) {
|
|
13407
|
+
const now = Date.now();
|
|
13408
|
+
const lockAcquired = await this.acquireLock(msg.id);
|
|
13409
|
+
if (!lockAcquired) {
|
|
13410
|
+
return null;
|
|
13411
|
+
}
|
|
13412
|
+
if (this.processedCache.has(msg.id)) {
|
|
13413
|
+
await this.releaseLock(msg.id);
|
|
13414
|
+
if (this.config.verbose) {
|
|
13415
|
+
console.log(`[attemptClaim] Message ${msg.id} already processed (in cache)`);
|
|
13416
|
+
}
|
|
13417
|
+
return null;
|
|
13418
|
+
}
|
|
13419
|
+
this.processedCache.set(msg.id, Date.now());
|
|
13420
|
+
await this.releaseLock(msg.id);
|
|
13421
|
+
const [okGet, errGet, msgWithETag] = await tryFn(
|
|
13422
|
+
() => this.queueResource.get(msg.id)
|
|
13423
|
+
);
|
|
13424
|
+
if (!okGet || !msgWithETag) {
|
|
13425
|
+
this.processedCache.delete(msg.id);
|
|
13426
|
+
if (this.config.verbose) {
|
|
13427
|
+
console.log(`[attemptClaim] Message ${msg.id} not found or error: ${errGet?.message}`);
|
|
13428
|
+
}
|
|
13429
|
+
return null;
|
|
13430
|
+
}
|
|
13431
|
+
if (msgWithETag.status !== "pending" || msgWithETag.visibleAt > now) {
|
|
13432
|
+
this.processedCache.delete(msg.id);
|
|
13433
|
+
if (this.config.verbose) {
|
|
13434
|
+
console.log(`[attemptClaim] Message ${msg.id} not claimable: status=${msgWithETag.status}, visibleAt=${msgWithETag.visibleAt}, now=${now}`);
|
|
13435
|
+
}
|
|
13436
|
+
return null;
|
|
13437
|
+
}
|
|
13438
|
+
if (this.config.verbose) {
|
|
13439
|
+
console.log(`[attemptClaim] Attempting to claim ${msg.id} with ETag: ${msgWithETag._etag}`);
|
|
13440
|
+
}
|
|
13441
|
+
const [ok, err, result] = await tryFn(
|
|
13442
|
+
() => this.queueResource.updateConditional(msgWithETag.id, {
|
|
13443
|
+
status: "processing",
|
|
13444
|
+
claimedBy: this.workerId,
|
|
13445
|
+
claimedAt: now,
|
|
13446
|
+
visibleAt: now + this.config.visibilityTimeout,
|
|
13447
|
+
attempts: msgWithETag.attempts + 1
|
|
13448
|
+
}, {
|
|
13449
|
+
ifMatch: msgWithETag._etag
|
|
13450
|
+
// ← ATOMIC CLAIM using ETag!
|
|
13451
|
+
})
|
|
13452
|
+
);
|
|
13453
|
+
if (!ok || !result.success) {
|
|
13454
|
+
this.processedCache.delete(msg.id);
|
|
13455
|
+
if (this.config.verbose) {
|
|
13456
|
+
console.log(`[attemptClaim] Failed to claim ${msg.id}: ${err?.message || result.error}`);
|
|
13457
|
+
}
|
|
13458
|
+
return null;
|
|
13459
|
+
}
|
|
13460
|
+
if (this.config.verbose) {
|
|
13461
|
+
console.log(`[attemptClaim] Successfully claimed ${msg.id}`);
|
|
13462
|
+
}
|
|
13463
|
+
const [okRecord, errRecord, record] = await tryFn(
|
|
13464
|
+
() => this.targetResource.get(msgWithETag.originalId)
|
|
13465
|
+
);
|
|
13466
|
+
if (!okRecord) {
|
|
13467
|
+
await this.failMessage(msgWithETag.id, "Original record not found");
|
|
13468
|
+
return null;
|
|
13469
|
+
}
|
|
13470
|
+
return {
|
|
13471
|
+
queueId: msgWithETag.id,
|
|
13472
|
+
record,
|
|
13473
|
+
attempts: msgWithETag.attempts + 1,
|
|
13474
|
+
maxAttempts: msgWithETag.maxAttempts
|
|
13475
|
+
};
|
|
13476
|
+
}
|
|
13477
|
+
async processMessage(message, handler) {
|
|
13478
|
+
const startTime = Date.now();
|
|
13479
|
+
try {
|
|
13480
|
+
const result = await handler(message.record, {
|
|
13481
|
+
queueId: message.queueId,
|
|
13482
|
+
attempts: message.attempts,
|
|
13483
|
+
workerId: this.workerId
|
|
13484
|
+
});
|
|
13485
|
+
await this.completeMessage(message.queueId, result);
|
|
13486
|
+
const duration = Date.now() - startTime;
|
|
13487
|
+
this.emit("message.completed", {
|
|
13488
|
+
queueId: message.queueId,
|
|
13489
|
+
originalId: message.record.id,
|
|
13490
|
+
duration,
|
|
13491
|
+
attempts: message.attempts
|
|
13492
|
+
});
|
|
13493
|
+
if (this.config.onComplete) {
|
|
13494
|
+
await this.config.onComplete(message.record, result);
|
|
13495
|
+
}
|
|
13496
|
+
} catch (error) {
|
|
13497
|
+
const shouldRetry = message.attempts < message.maxAttempts;
|
|
13498
|
+
if (shouldRetry) {
|
|
13499
|
+
await this.retryMessage(message.queueId, message.attempts, error.message);
|
|
13500
|
+
this.emit("message.retry", {
|
|
13501
|
+
queueId: message.queueId,
|
|
13502
|
+
originalId: message.record.id,
|
|
13503
|
+
attempts: message.attempts,
|
|
13504
|
+
error: error.message
|
|
13505
|
+
});
|
|
13506
|
+
} else {
|
|
13507
|
+
await this.moveToDeadLetter(message.queueId, message.record, error.message);
|
|
13508
|
+
this.emit("message.dead", {
|
|
13509
|
+
queueId: message.queueId,
|
|
13510
|
+
originalId: message.record.id,
|
|
13511
|
+
error: error.message
|
|
13512
|
+
});
|
|
13513
|
+
}
|
|
13514
|
+
if (this.config.onError) {
|
|
13515
|
+
await this.config.onError(error, message.record);
|
|
13516
|
+
}
|
|
13517
|
+
}
|
|
13518
|
+
}
|
|
13519
|
+
async completeMessage(queueId, result) {
|
|
13520
|
+
await this.queueResource.update(queueId, {
|
|
13521
|
+
status: "completed",
|
|
13522
|
+
completedAt: Date.now(),
|
|
13523
|
+
result
|
|
13524
|
+
});
|
|
13525
|
+
}
|
|
13526
|
+
async failMessage(queueId, error) {
|
|
13527
|
+
await this.queueResource.update(queueId, {
|
|
13528
|
+
status: "failed",
|
|
13529
|
+
error
|
|
13530
|
+
});
|
|
13531
|
+
}
|
|
13532
|
+
async retryMessage(queueId, attempts, error) {
|
|
13533
|
+
const backoff = Math.min(Math.pow(2, attempts) * 1e3, 3e4);
|
|
13534
|
+
await this.queueResource.update(queueId, {
|
|
13535
|
+
status: "pending",
|
|
13536
|
+
visibleAt: Date.now() + backoff,
|
|
13537
|
+
error
|
|
13538
|
+
});
|
|
13539
|
+
this.processedCache.delete(queueId);
|
|
13540
|
+
}
|
|
13541
|
+
async moveToDeadLetter(queueId, record, error) {
|
|
13542
|
+
if (this.config.deadLetterResource && this.deadLetterResourceObj) {
|
|
13543
|
+
const msg = await this.queueResource.get(queueId);
|
|
13544
|
+
await this.deadLetterResourceObj.insert({
|
|
13545
|
+
id: idGenerator(),
|
|
13546
|
+
originalId: record.id,
|
|
13547
|
+
queueId,
|
|
13548
|
+
data: record,
|
|
13549
|
+
error,
|
|
13550
|
+
attempts: msg.attempts,
|
|
13551
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
13552
|
+
});
|
|
13553
|
+
}
|
|
13554
|
+
await this.queueResource.update(queueId, {
|
|
13555
|
+
status: "dead",
|
|
13556
|
+
error
|
|
13557
|
+
});
|
|
13558
|
+
}
|
|
13559
|
+
async getStats() {
|
|
13560
|
+
const [ok, err, allMessages] = await tryFn(
|
|
13561
|
+
() => this.queueResource.list()
|
|
13562
|
+
);
|
|
13563
|
+
if (!ok) {
|
|
13564
|
+
if (this.config.verbose) {
|
|
13565
|
+
console.warn("[S3QueuePlugin] Failed to get stats:", err.message);
|
|
13566
|
+
}
|
|
13567
|
+
return null;
|
|
13568
|
+
}
|
|
13569
|
+
const stats = {
|
|
13570
|
+
total: allMessages.length,
|
|
13571
|
+
pending: 0,
|
|
13572
|
+
processing: 0,
|
|
13573
|
+
completed: 0,
|
|
13574
|
+
failed: 0,
|
|
13575
|
+
dead: 0
|
|
13576
|
+
};
|
|
13577
|
+
for (const msg of allMessages) {
|
|
13578
|
+
if (stats[msg.status] !== void 0) {
|
|
13579
|
+
stats[msg.status]++;
|
|
13580
|
+
}
|
|
13581
|
+
}
|
|
13582
|
+
return stats;
|
|
13583
|
+
}
|
|
13584
|
+
async createDeadLetterResource() {
|
|
13585
|
+
const [ok, err] = await tryFn(
|
|
13586
|
+
() => this.database.createResource({
|
|
13587
|
+
name: this.config.deadLetterResource,
|
|
13588
|
+
attributes: {
|
|
13589
|
+
id: "string|required",
|
|
13590
|
+
originalId: "string|required",
|
|
13591
|
+
queueId: "string|required",
|
|
13592
|
+
data: "json|required",
|
|
13593
|
+
error: "string|required",
|
|
13594
|
+
attempts: "number|required",
|
|
13595
|
+
createdAt: "string|required"
|
|
13596
|
+
},
|
|
13597
|
+
behavior: "body-overflow",
|
|
13598
|
+
timestamps: true
|
|
13599
|
+
})
|
|
13600
|
+
);
|
|
13601
|
+
if (ok || this.database.resources[this.config.deadLetterResource]) {
|
|
13602
|
+
this.deadLetterResourceObj = this.database.resources[this.config.deadLetterResource];
|
|
13603
|
+
if (this.config.verbose) {
|
|
13604
|
+
console.log(`[S3QueuePlugin] Dead letter queue created: ${this.config.deadLetterResource}`);
|
|
13605
|
+
}
|
|
13606
|
+
}
|
|
13607
|
+
}
|
|
13608
|
+
}
|
|
13609
|
+
|
|
12335
13610
|
class SchedulerPlugin extends Plugin {
|
|
12336
13611
|
constructor(options = {}) {
|
|
12337
13612
|
super();
|
|
@@ -12341,7 +13616,7 @@ class SchedulerPlugin extends Plugin {
|
|
|
12341
13616
|
defaultTimeout: options.defaultTimeout || 3e5,
|
|
12342
13617
|
// 5 minutes
|
|
12343
13618
|
defaultRetries: options.defaultRetries || 1,
|
|
12344
|
-
jobHistoryResource: options.jobHistoryResource || "
|
|
13619
|
+
jobHistoryResource: options.jobHistoryResource || "plg_job_executions",
|
|
12345
13620
|
persistJobs: options.persistJobs !== false,
|
|
12346
13621
|
verbose: options.verbose || false,
|
|
12347
13622
|
onJobStart: options.onJobStart || null,
|
|
@@ -12350,12 +13625,20 @@ class SchedulerPlugin extends Plugin {
|
|
|
12350
13625
|
...options
|
|
12351
13626
|
};
|
|
12352
13627
|
this.database = null;
|
|
13628
|
+
this.lockResource = null;
|
|
12353
13629
|
this.jobs = /* @__PURE__ */ new Map();
|
|
12354
13630
|
this.activeJobs = /* @__PURE__ */ new Map();
|
|
12355
13631
|
this.timers = /* @__PURE__ */ new Map();
|
|
12356
13632
|
this.statistics = /* @__PURE__ */ new Map();
|
|
12357
13633
|
this._validateConfiguration();
|
|
12358
13634
|
}
|
|
13635
|
+
/**
|
|
13636
|
+
* Helper to detect test environment
|
|
13637
|
+
* @private
|
|
13638
|
+
*/
|
|
13639
|
+
_isTestEnvironment() {
|
|
13640
|
+
return process.env.NODE_ENV === "test" || process.env.JEST_WORKER_ID !== void 0 || global.expect !== void 0;
|
|
13641
|
+
}
|
|
12359
13642
|
_validateConfiguration() {
|
|
12360
13643
|
if (Object.keys(this.config.jobs).length === 0) {
|
|
12361
13644
|
throw new Error("SchedulerPlugin: At least one job must be defined");
|
|
@@ -12382,6 +13665,7 @@ class SchedulerPlugin extends Plugin {
|
|
|
12382
13665
|
}
|
|
12383
13666
|
async setup(database) {
|
|
12384
13667
|
this.database = database;
|
|
13668
|
+
await this._createLockResource();
|
|
12385
13669
|
if (this.config.persistJobs) {
|
|
12386
13670
|
await this._createJobHistoryResource();
|
|
12387
13671
|
}
|
|
@@ -12410,6 +13694,25 @@ class SchedulerPlugin extends Plugin {
|
|
|
12410
13694
|
await this._startScheduling();
|
|
12411
13695
|
this.emit("initialized", { jobs: this.jobs.size });
|
|
12412
13696
|
}
|
|
13697
|
+
async _createLockResource() {
|
|
13698
|
+
const [ok, err, lockResource] = await tryFn(
|
|
13699
|
+
() => this.database.createResource({
|
|
13700
|
+
name: "plg_scheduler_job_locks",
|
|
13701
|
+
attributes: {
|
|
13702
|
+
id: "string|required",
|
|
13703
|
+
jobName: "string|required",
|
|
13704
|
+
lockedAt: "number|required",
|
|
13705
|
+
instanceId: "string|optional"
|
|
13706
|
+
},
|
|
13707
|
+
behavior: "body-only",
|
|
13708
|
+
timestamps: false
|
|
13709
|
+
})
|
|
13710
|
+
);
|
|
13711
|
+
if (!ok && !this.database.resources.plg_scheduler_job_locks) {
|
|
13712
|
+
throw new Error(`Failed to create lock resource: ${err?.message}`);
|
|
13713
|
+
}
|
|
13714
|
+
this.lockResource = ok ? lockResource : this.database.resources.plg_scheduler_job_locks;
|
|
13715
|
+
}
|
|
12413
13716
|
async _createJobHistoryResource() {
|
|
12414
13717
|
const [ok] = await tryFn(() => this.database.createResource({
|
|
12415
13718
|
name: this.config.jobHistoryResource,
|
|
@@ -12503,18 +13806,37 @@ class SchedulerPlugin extends Plugin {
|
|
|
12503
13806
|
next.setHours(next.getHours() + 1);
|
|
12504
13807
|
}
|
|
12505
13808
|
}
|
|
12506
|
-
|
|
12507
|
-
if (isTestEnvironment) {
|
|
13809
|
+
if (this._isTestEnvironment()) {
|
|
12508
13810
|
next.setTime(next.getTime() + 1e3);
|
|
12509
13811
|
}
|
|
12510
13812
|
return next;
|
|
12511
13813
|
}
|
|
12512
13814
|
async _executeJob(jobName) {
|
|
12513
13815
|
const job = this.jobs.get(jobName);
|
|
12514
|
-
if (!job
|
|
13816
|
+
if (!job) {
|
|
12515
13817
|
return;
|
|
12516
13818
|
}
|
|
12517
|
-
|
|
13819
|
+
if (this.activeJobs.has(jobName)) {
|
|
13820
|
+
return;
|
|
13821
|
+
}
|
|
13822
|
+
this.activeJobs.set(jobName, "acquiring-lock");
|
|
13823
|
+
const lockId = `lock-${jobName}`;
|
|
13824
|
+
const [lockAcquired, lockErr] = await tryFn(
|
|
13825
|
+
() => this.lockResource.insert({
|
|
13826
|
+
id: lockId,
|
|
13827
|
+
jobName,
|
|
13828
|
+
lockedAt: Date.now(),
|
|
13829
|
+
instanceId: process.pid ? String(process.pid) : "unknown"
|
|
13830
|
+
})
|
|
13831
|
+
);
|
|
13832
|
+
if (!lockAcquired) {
|
|
13833
|
+
if (this.config.verbose) {
|
|
13834
|
+
console.log(`[SchedulerPlugin] Job '${jobName}' already running on another instance`);
|
|
13835
|
+
}
|
|
13836
|
+
this.activeJobs.delete(jobName);
|
|
13837
|
+
return;
|
|
13838
|
+
}
|
|
13839
|
+
const executionId = `${jobName}_${idGenerator()}`;
|
|
12518
13840
|
const startTime = Date.now();
|
|
12519
13841
|
const context = {
|
|
12520
13842
|
jobName,
|
|
@@ -12523,91 +13845,95 @@ class SchedulerPlugin extends Plugin {
|
|
|
12523
13845
|
database: this.database
|
|
12524
13846
|
};
|
|
12525
13847
|
this.activeJobs.set(jobName, executionId);
|
|
12526
|
-
|
|
12527
|
-
|
|
12528
|
-
|
|
12529
|
-
|
|
12530
|
-
|
|
12531
|
-
|
|
12532
|
-
|
|
12533
|
-
|
|
12534
|
-
|
|
12535
|
-
|
|
12536
|
-
|
|
12537
|
-
const actualTimeout = isTestEnvironment ? Math.min(job.timeout, 1e3) : job.timeout;
|
|
12538
|
-
let timeoutId;
|
|
12539
|
-
const timeoutPromise = new Promise((_, reject) => {
|
|
12540
|
-
timeoutId = setTimeout(() => reject(new Error("Job execution timeout")), actualTimeout);
|
|
12541
|
-
});
|
|
12542
|
-
const jobPromise = job.action(this.database, context, this);
|
|
13848
|
+
try {
|
|
13849
|
+
if (this.config.onJobStart) {
|
|
13850
|
+
await this._executeHook(this.config.onJobStart, jobName, context);
|
|
13851
|
+
}
|
|
13852
|
+
this.emit("job_start", { jobName, executionId, startTime });
|
|
13853
|
+
let attempt = 0;
|
|
13854
|
+
let lastError = null;
|
|
13855
|
+
let result = null;
|
|
13856
|
+
let status = "success";
|
|
13857
|
+
const isTestEnvironment = this._isTestEnvironment();
|
|
13858
|
+
while (attempt <= job.retries) {
|
|
12543
13859
|
try {
|
|
12544
|
-
|
|
12545
|
-
|
|
12546
|
-
|
|
12547
|
-
|
|
12548
|
-
|
|
12549
|
-
|
|
12550
|
-
|
|
12551
|
-
|
|
12552
|
-
|
|
12553
|
-
|
|
12554
|
-
|
|
12555
|
-
|
|
12556
|
-
|
|
12557
|
-
|
|
13860
|
+
const actualTimeout = isTestEnvironment ? Math.min(job.timeout, 1e3) : job.timeout;
|
|
13861
|
+
let timeoutId;
|
|
13862
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
13863
|
+
timeoutId = setTimeout(() => reject(new Error("Job execution timeout")), actualTimeout);
|
|
13864
|
+
});
|
|
13865
|
+
const jobPromise = job.action(this.database, context, this);
|
|
13866
|
+
try {
|
|
13867
|
+
result = await Promise.race([jobPromise, timeoutPromise]);
|
|
13868
|
+
clearTimeout(timeoutId);
|
|
13869
|
+
} catch (raceError) {
|
|
13870
|
+
clearTimeout(timeoutId);
|
|
13871
|
+
throw raceError;
|
|
13872
|
+
}
|
|
13873
|
+
status = "success";
|
|
13874
|
+
break;
|
|
13875
|
+
} catch (error) {
|
|
13876
|
+
lastError = error;
|
|
13877
|
+
attempt++;
|
|
13878
|
+
if (attempt <= job.retries) {
|
|
13879
|
+
if (this.config.verbose) {
|
|
13880
|
+
console.warn(`[SchedulerPlugin] Job '${jobName}' failed (attempt ${attempt + 1}):`, error.message);
|
|
13881
|
+
}
|
|
13882
|
+
const baseDelay = Math.min(Math.pow(2, attempt) * 1e3, 5e3);
|
|
13883
|
+
const delay = isTestEnvironment ? 1 : baseDelay;
|
|
13884
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
12558
13885
|
}
|
|
12559
|
-
const baseDelay = Math.min(Math.pow(2, attempt) * 1e3, 5e3);
|
|
12560
|
-
const delay = isTestEnvironment ? 1 : baseDelay;
|
|
12561
|
-
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
12562
13886
|
}
|
|
12563
13887
|
}
|
|
12564
|
-
|
|
12565
|
-
|
|
12566
|
-
|
|
12567
|
-
|
|
12568
|
-
|
|
12569
|
-
|
|
12570
|
-
|
|
12571
|
-
|
|
12572
|
-
|
|
12573
|
-
|
|
12574
|
-
|
|
12575
|
-
|
|
12576
|
-
|
|
12577
|
-
|
|
12578
|
-
|
|
12579
|
-
|
|
12580
|
-
|
|
12581
|
-
|
|
12582
|
-
|
|
12583
|
-
|
|
12584
|
-
|
|
12585
|
-
|
|
12586
|
-
|
|
12587
|
-
|
|
12588
|
-
|
|
12589
|
-
|
|
12590
|
-
|
|
12591
|
-
|
|
12592
|
-
|
|
12593
|
-
|
|
12594
|
-
|
|
12595
|
-
|
|
12596
|
-
|
|
12597
|
-
|
|
12598
|
-
|
|
12599
|
-
|
|
12600
|
-
|
|
12601
|
-
|
|
12602
|
-
|
|
12603
|
-
|
|
12604
|
-
|
|
12605
|
-
|
|
12606
|
-
|
|
12607
|
-
|
|
12608
|
-
|
|
12609
|
-
|
|
12610
|
-
|
|
13888
|
+
const endTime = Date.now();
|
|
13889
|
+
const duration = Math.max(1, endTime - startTime);
|
|
13890
|
+
if (lastError && attempt > job.retries) {
|
|
13891
|
+
status = lastError.message.includes("timeout") ? "timeout" : "error";
|
|
13892
|
+
}
|
|
13893
|
+
job.lastRun = new Date(endTime);
|
|
13894
|
+
job.runCount++;
|
|
13895
|
+
if (status === "success") {
|
|
13896
|
+
job.successCount++;
|
|
13897
|
+
} else {
|
|
13898
|
+
job.errorCount++;
|
|
13899
|
+
}
|
|
13900
|
+
const stats = this.statistics.get(jobName);
|
|
13901
|
+
stats.totalRuns++;
|
|
13902
|
+
stats.lastRun = new Date(endTime);
|
|
13903
|
+
if (status === "success") {
|
|
13904
|
+
stats.totalSuccesses++;
|
|
13905
|
+
stats.lastSuccess = new Date(endTime);
|
|
13906
|
+
} else {
|
|
13907
|
+
stats.totalErrors++;
|
|
13908
|
+
stats.lastError = { time: new Date(endTime), message: lastError?.message };
|
|
13909
|
+
}
|
|
13910
|
+
stats.avgDuration = (stats.avgDuration * (stats.totalRuns - 1) + duration) / stats.totalRuns;
|
|
13911
|
+
if (this.config.persistJobs) {
|
|
13912
|
+
await this._persistJobExecution(jobName, executionId, startTime, endTime, duration, status, result, lastError, attempt);
|
|
13913
|
+
}
|
|
13914
|
+
if (status === "success" && this.config.onJobComplete) {
|
|
13915
|
+
await this._executeHook(this.config.onJobComplete, jobName, result, duration);
|
|
13916
|
+
} else if (status !== "success" && this.config.onJobError) {
|
|
13917
|
+
await this._executeHook(this.config.onJobError, jobName, lastError, attempt);
|
|
13918
|
+
}
|
|
13919
|
+
this.emit("job_complete", {
|
|
13920
|
+
jobName,
|
|
13921
|
+
executionId,
|
|
13922
|
+
status,
|
|
13923
|
+
duration,
|
|
13924
|
+
result,
|
|
13925
|
+
error: lastError?.message,
|
|
13926
|
+
retryCount: attempt
|
|
13927
|
+
});
|
|
13928
|
+
this.activeJobs.delete(jobName);
|
|
13929
|
+
if (job.enabled) {
|
|
13930
|
+
this._scheduleNextExecution(jobName);
|
|
13931
|
+
}
|
|
13932
|
+
if (lastError && status !== "success") {
|
|
13933
|
+
throw lastError;
|
|
13934
|
+
}
|
|
13935
|
+
} finally {
|
|
13936
|
+
await tryFn(() => this.lockResource.delete(lockId));
|
|
12611
13937
|
}
|
|
12612
13938
|
}
|
|
12613
13939
|
async _persistJobExecution(jobName, executionId, startTime, endTime, duration, status, result, error, retryCount) {
|
|
@@ -12639,6 +13965,7 @@ class SchedulerPlugin extends Plugin {
|
|
|
12639
13965
|
}
|
|
12640
13966
|
/**
|
|
12641
13967
|
* Manually trigger a job execution
|
|
13968
|
+
* Note: Race conditions are prevented by distributed locking in _executeJob()
|
|
12642
13969
|
*/
|
|
12643
13970
|
async runJob(jobName, context = {}) {
|
|
12644
13971
|
const job = this.jobs.get(jobName);
|
|
@@ -12724,12 +14051,15 @@ class SchedulerPlugin extends Plugin {
|
|
|
12724
14051
|
return [];
|
|
12725
14052
|
}
|
|
12726
14053
|
const { limit = 50, status = null } = options;
|
|
12727
|
-
const
|
|
12728
|
-
|
|
12729
|
-
|
|
12730
|
-
|
|
12731
|
-
|
|
12732
|
-
|
|
14054
|
+
const queryParams = {
|
|
14055
|
+
jobName
|
|
14056
|
+
// Uses byJob partition for efficient lookup
|
|
14057
|
+
};
|
|
14058
|
+
if (status) {
|
|
14059
|
+
queryParams.status = status;
|
|
14060
|
+
}
|
|
14061
|
+
const [ok, err, history] = await tryFn(
|
|
14062
|
+
() => this.database.resource(this.config.jobHistoryResource).query(queryParams)
|
|
12733
14063
|
);
|
|
12734
14064
|
if (!ok) {
|
|
12735
14065
|
if (this.config.verbose) {
|
|
@@ -12737,11 +14067,7 @@ class SchedulerPlugin extends Plugin {
|
|
|
12737
14067
|
}
|
|
12738
14068
|
return [];
|
|
12739
14069
|
}
|
|
12740
|
-
let filtered =
|
|
12741
|
-
if (status) {
|
|
12742
|
-
filtered = filtered.filter((h) => h.status === status);
|
|
12743
|
-
}
|
|
12744
|
-
filtered = filtered.sort((a, b) => b.startTime - a.startTime).slice(0, limit);
|
|
14070
|
+
let filtered = history.sort((a, b) => b.startTime - a.startTime).slice(0, limit);
|
|
12745
14071
|
return filtered.map((h) => {
|
|
12746
14072
|
let result = null;
|
|
12747
14073
|
if (h.result) {
|
|
@@ -12836,8 +14162,7 @@ class SchedulerPlugin extends Plugin {
|
|
|
12836
14162
|
clearTimeout(timer);
|
|
12837
14163
|
}
|
|
12838
14164
|
this.timers.clear();
|
|
12839
|
-
|
|
12840
|
-
if (!isTestEnvironment && this.activeJobs.size > 0) {
|
|
14165
|
+
if (!this._isTestEnvironment() && this.activeJobs.size > 0) {
|
|
12841
14166
|
if (this.config.verbose) {
|
|
12842
14167
|
console.log(`[SchedulerPlugin] Waiting for ${this.activeJobs.size} active jobs to complete...`);
|
|
12843
14168
|
}
|
|
@@ -12850,7 +14175,7 @@ class SchedulerPlugin extends Plugin {
|
|
|
12850
14175
|
console.warn(`[SchedulerPlugin] ${this.activeJobs.size} jobs still running after timeout`);
|
|
12851
14176
|
}
|
|
12852
14177
|
}
|
|
12853
|
-
if (
|
|
14178
|
+
if (this._isTestEnvironment()) {
|
|
12854
14179
|
this.activeJobs.clear();
|
|
12855
14180
|
}
|
|
12856
14181
|
}
|
|
@@ -12871,14 +14196,14 @@ class StateMachinePlugin extends Plugin {
|
|
|
12871
14196
|
actions: options.actions || {},
|
|
12872
14197
|
guards: options.guards || {},
|
|
12873
14198
|
persistTransitions: options.persistTransitions !== false,
|
|
12874
|
-
transitionLogResource: options.transitionLogResource || "
|
|
12875
|
-
stateResource: options.stateResource || "
|
|
12876
|
-
|
|
12877
|
-
|
|
14199
|
+
transitionLogResource: options.transitionLogResource || "plg_state_transitions",
|
|
14200
|
+
stateResource: options.stateResource || "plg_entity_states",
|
|
14201
|
+
retryAttempts: options.retryAttempts || 3,
|
|
14202
|
+
retryDelay: options.retryDelay || 100,
|
|
14203
|
+
verbose: options.verbose || false
|
|
12878
14204
|
};
|
|
12879
14205
|
this.database = null;
|
|
12880
14206
|
this.machines = /* @__PURE__ */ new Map();
|
|
12881
|
-
this.stateStorage = /* @__PURE__ */ new Map();
|
|
12882
14207
|
this._validateConfiguration();
|
|
12883
14208
|
}
|
|
12884
14209
|
_validateConfiguration() {
|
|
@@ -13019,43 +14344,55 @@ class StateMachinePlugin extends Plugin {
|
|
|
13019
14344
|
machine.currentStates.set(entityId, toState);
|
|
13020
14345
|
if (this.config.persistTransitions) {
|
|
13021
14346
|
const transitionId = `${machineId}_${entityId}_${timestamp}`;
|
|
13022
|
-
|
|
13023
|
-
|
|
13024
|
-
|
|
13025
|
-
|
|
13026
|
-
|
|
13027
|
-
|
|
13028
|
-
|
|
13029
|
-
|
|
13030
|
-
|
|
13031
|
-
|
|
13032
|
-
|
|
13033
|
-
|
|
13034
|
-
|
|
13035
|
-
|
|
14347
|
+
let logOk = false;
|
|
14348
|
+
let lastLogErr;
|
|
14349
|
+
for (let attempt = 0; attempt < this.config.retryAttempts; attempt++) {
|
|
14350
|
+
const [ok, err] = await tryFn(
|
|
14351
|
+
() => this.database.resource(this.config.transitionLogResource).insert({
|
|
14352
|
+
id: transitionId,
|
|
14353
|
+
machineId,
|
|
14354
|
+
entityId,
|
|
14355
|
+
fromState,
|
|
14356
|
+
toState,
|
|
14357
|
+
event,
|
|
14358
|
+
context,
|
|
14359
|
+
timestamp,
|
|
14360
|
+
createdAt: now.slice(0, 10)
|
|
14361
|
+
// YYYY-MM-DD for partitioning
|
|
14362
|
+
})
|
|
14363
|
+
);
|
|
14364
|
+
if (ok) {
|
|
14365
|
+
logOk = true;
|
|
14366
|
+
break;
|
|
14367
|
+
}
|
|
14368
|
+
lastLogErr = err;
|
|
14369
|
+
if (attempt < this.config.retryAttempts - 1) {
|
|
14370
|
+
const delay = this.config.retryDelay * Math.pow(2, attempt);
|
|
14371
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
14372
|
+
}
|
|
14373
|
+
}
|
|
13036
14374
|
if (!logOk && this.config.verbose) {
|
|
13037
|
-
console.warn(`[StateMachinePlugin] Failed to log transition:`,
|
|
14375
|
+
console.warn(`[StateMachinePlugin] Failed to log transition after ${this.config.retryAttempts} attempts:`, lastLogErr.message);
|
|
13038
14376
|
}
|
|
13039
14377
|
const stateId = `${machineId}_${entityId}`;
|
|
13040
|
-
const
|
|
13041
|
-
|
|
13042
|
-
|
|
13043
|
-
|
|
13044
|
-
|
|
13045
|
-
|
|
13046
|
-
|
|
13047
|
-
|
|
13048
|
-
|
|
13049
|
-
|
|
13050
|
-
|
|
13051
|
-
|
|
13052
|
-
|
|
13053
|
-
|
|
13054
|
-
|
|
14378
|
+
const stateData = {
|
|
14379
|
+
machineId,
|
|
14380
|
+
entityId,
|
|
14381
|
+
currentState: toState,
|
|
14382
|
+
context,
|
|
14383
|
+
lastTransition: transitionId,
|
|
14384
|
+
updatedAt: now
|
|
14385
|
+
};
|
|
14386
|
+
const [updateOk] = await tryFn(
|
|
14387
|
+
() => this.database.resource(this.config.stateResource).update(stateId, stateData)
|
|
14388
|
+
);
|
|
14389
|
+
if (!updateOk) {
|
|
14390
|
+
const [insertOk, insertErr] = await tryFn(
|
|
14391
|
+
() => this.database.resource(this.config.stateResource).insert({ id: stateId, ...stateData })
|
|
14392
|
+
);
|
|
14393
|
+
if (!insertOk && this.config.verbose) {
|
|
14394
|
+
console.warn(`[StateMachinePlugin] Failed to upsert state:`, insertErr.message);
|
|
13055
14395
|
}
|
|
13056
|
-
});
|
|
13057
|
-
if (!stateOk && this.config.verbose) {
|
|
13058
|
-
console.warn(`[StateMachinePlugin] Failed to update state:`, stateErr.message);
|
|
13059
14396
|
}
|
|
13060
14397
|
}
|
|
13061
14398
|
}
|
|
@@ -13086,8 +14423,9 @@ class StateMachinePlugin extends Plugin {
|
|
|
13086
14423
|
}
|
|
13087
14424
|
/**
|
|
13088
14425
|
* Get valid events for current state
|
|
14426
|
+
* Can accept either a state name (sync) or entityId (async to fetch latest state)
|
|
13089
14427
|
*/
|
|
13090
|
-
getValidEvents(machineId, stateOrEntityId) {
|
|
14428
|
+
async getValidEvents(machineId, stateOrEntityId) {
|
|
13091
14429
|
const machine = this.machines.get(machineId);
|
|
13092
14430
|
if (!machine) {
|
|
13093
14431
|
throw new Error(`State machine '${machineId}' not found`);
|
|
@@ -13096,7 +14434,7 @@ class StateMachinePlugin extends Plugin {
|
|
|
13096
14434
|
if (machine.config.states[stateOrEntityId]) {
|
|
13097
14435
|
state = stateOrEntityId;
|
|
13098
14436
|
} else {
|
|
13099
|
-
state =
|
|
14437
|
+
state = await this.getState(machineId, stateOrEntityId);
|
|
13100
14438
|
}
|
|
13101
14439
|
const stateConfig = machine.config.states[state];
|
|
13102
14440
|
return stateConfig && stateConfig.on ? Object.keys(stateConfig.on) : [];
|
|
@@ -13110,9 +14448,10 @@ class StateMachinePlugin extends Plugin {
|
|
|
13110
14448
|
}
|
|
13111
14449
|
const { limit = 50, offset = 0 } = options;
|
|
13112
14450
|
const [ok, err, transitions] = await tryFn(
|
|
13113
|
-
() => this.database.resource(this.config.transitionLogResource).
|
|
13114
|
-
|
|
13115
|
-
|
|
14451
|
+
() => this.database.resource(this.config.transitionLogResource).query({
|
|
14452
|
+
machineId,
|
|
14453
|
+
entityId
|
|
14454
|
+
}, {
|
|
13116
14455
|
limit,
|
|
13117
14456
|
offset
|
|
13118
14457
|
})
|
|
@@ -13123,8 +14462,8 @@ class StateMachinePlugin extends Plugin {
|
|
|
13123
14462
|
}
|
|
13124
14463
|
return [];
|
|
13125
14464
|
}
|
|
13126
|
-
const
|
|
13127
|
-
return
|
|
14465
|
+
const sorted = (transitions || []).sort((a, b) => b.timestamp - a.timestamp);
|
|
14466
|
+
return sorted.map((t) => ({
|
|
13128
14467
|
from: t.fromState,
|
|
13129
14468
|
to: t.toState,
|
|
13130
14469
|
event: t.event,
|
|
@@ -13145,15 +14484,20 @@ class StateMachinePlugin extends Plugin {
|
|
|
13145
14484
|
if (this.config.persistTransitions) {
|
|
13146
14485
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
13147
14486
|
const stateId = `${machineId}_${entityId}`;
|
|
13148
|
-
await
|
|
13149
|
-
|
|
13150
|
-
|
|
13151
|
-
|
|
13152
|
-
|
|
13153
|
-
|
|
13154
|
-
|
|
13155
|
-
|
|
13156
|
-
|
|
14487
|
+
const [ok, err] = await tryFn(
|
|
14488
|
+
() => this.database.resource(this.config.stateResource).insert({
|
|
14489
|
+
id: stateId,
|
|
14490
|
+
machineId,
|
|
14491
|
+
entityId,
|
|
14492
|
+
currentState: initialState,
|
|
14493
|
+
context,
|
|
14494
|
+
lastTransition: null,
|
|
14495
|
+
updatedAt: now
|
|
14496
|
+
})
|
|
14497
|
+
);
|
|
14498
|
+
if (!ok && err && !err.message?.includes("already exists")) {
|
|
14499
|
+
throw new Error(`Failed to initialize entity state: ${err.message}`);
|
|
14500
|
+
}
|
|
13157
14501
|
}
|
|
13158
14502
|
const initialStateConfig = machine.config.states[initialState];
|
|
13159
14503
|
if (initialStateConfig && initialStateConfig.entry) {
|
|
@@ -13218,7 +14562,6 @@ class StateMachinePlugin extends Plugin {
|
|
|
13218
14562
|
}
|
|
13219
14563
|
async stop() {
|
|
13220
14564
|
this.machines.clear();
|
|
13221
|
-
this.stateStorage.clear();
|
|
13222
14565
|
}
|
|
13223
14566
|
async cleanup() {
|
|
13224
14567
|
await this.stop();
|
|
@@ -13262,6 +14605,7 @@ exports.ResourceIdsReader = ResourceIdsReader;
|
|
|
13262
14605
|
exports.ResourceNotFound = ResourceNotFound;
|
|
13263
14606
|
exports.ResourceReader = ResourceReader;
|
|
13264
14607
|
exports.ResourceWriter = ResourceWriter;
|
|
14608
|
+
exports.S3QueuePlugin = S3QueuePlugin;
|
|
13265
14609
|
exports.S3db = Database;
|
|
13266
14610
|
exports.S3dbError = S3dbError;
|
|
13267
14611
|
exports.SchedulerPlugin = SchedulerPlugin;
|