s3db.js 11.0.2 → 11.0.4

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.
@@ -44,14 +44,16 @@ export async function updateAnalytics(transactions, analyticsResource, config) {
44
44
  if (config.verbose) {
45
45
  console.log(
46
46
  `[EventualConsistency] ${config.resource}.${config.field} - ` +
47
- `Updating ${cohortCount} hourly analytics cohorts...`
47
+ `Updating ${cohortCount} hourly analytics cohorts IN PARALLEL...`
48
48
  );
49
49
  }
50
50
 
51
- // Update hourly analytics
52
- for (const [cohort, txns] of Object.entries(byHour)) {
53
- await upsertAnalytics('hour', cohort, txns, analyticsResource, config);
54
- }
51
+ // ✅ OTIMIZAÇÃO: Update hourly analytics EM PARALELO
52
+ await Promise.all(
53
+ Object.entries(byHour).map(([cohort, txns]) =>
54
+ upsertAnalytics('hour', cohort, txns, analyticsResource, config)
55
+ )
56
+ );
55
57
 
56
58
  // Roll up to daily and monthly if configured
57
59
  if (config.analyticsConfig.rollupStrategy === 'incremental') {
@@ -60,13 +62,16 @@ export async function updateAnalytics(transactions, analyticsResource, config) {
60
62
  if (config.verbose) {
61
63
  console.log(
62
64
  `[EventualConsistency] ${config.resource}.${config.field} - ` +
63
- `Rolling up ${uniqueHours.length} hours to daily/monthly analytics...`
65
+ `Rolling up ${uniqueHours.length} hours to daily/weekly/monthly analytics IN PARALLEL...`
64
66
  );
65
67
  }
66
68
 
67
- for (const cohortHour of uniqueHours) {
68
- await rollupAnalytics(cohortHour, analyticsResource, config);
69
- }
69
+ // OTIMIZAÇÃO: Rollup analytics EM PARALELO
70
+ await Promise.all(
71
+ uniqueHours.map(cohortHour =>
72
+ rollupAnalytics(cohortHour, analyticsResource, config)
73
+ )
74
+ );
70
75
  }
71
76
 
72
77
  if (config.verbose) {
@@ -206,7 +211,7 @@ function calculateOperationBreakdown(transactions) {
206
211
  }
207
212
 
208
213
  /**
209
- * Roll up hourly analytics to daily and monthly
214
+ * Roll up hourly analytics to daily, weekly, and monthly
210
215
  * @private
211
216
  */
212
217
  async function rollupAnalytics(cohortHour, analyticsResource, config) {
@@ -214,20 +219,58 @@ async function rollupAnalytics(cohortHour, analyticsResource, config) {
214
219
  const cohortDate = cohortHour.substring(0, 10); // '2025-10-09'
215
220
  const cohortMonth = cohortHour.substring(0, 7); // '2025-10'
216
221
 
222
+ // Calculate week cohort (ISO 8601 format)
223
+ const date = new Date(cohortDate);
224
+ const cohortWeek = getCohortWeekFromDate(date);
225
+
217
226
  // Roll up to day
218
227
  await rollupPeriod('day', cohortDate, cohortDate, analyticsResource, config);
219
228
 
229
+ // Roll up to week
230
+ await rollupPeriod('week', cohortWeek, cohortWeek, analyticsResource, config);
231
+
220
232
  // Roll up to month
221
233
  await rollupPeriod('month', cohortMonth, cohortMonth, analyticsResource, config);
222
234
  }
223
235
 
236
+ /**
237
+ * Get cohort week string from a date
238
+ * @private
239
+ */
240
+ function getCohortWeekFromDate(date) {
241
+ // ISO week calculation (use UTC methods)
242
+ const target = new Date(date.valueOf());
243
+ const dayNr = (date.getUTCDay() + 6) % 7;
244
+ target.setUTCDate(target.getUTCDate() - dayNr + 3);
245
+
246
+ const yearStart = new Date(Date.UTC(target.getUTCFullYear(), 0, 1));
247
+ const firstThursday = new Date(yearStart.valueOf());
248
+ if (yearStart.getUTCDay() !== 4) {
249
+ firstThursday.setUTCDate(yearStart.getUTCDate() + ((4 - yearStart.getUTCDay()) + 7) % 7);
250
+ }
251
+
252
+ const weekNumber = 1 + Math.round((target - firstThursday) / 604800000);
253
+ const weekYear = target.getUTCFullYear();
254
+
255
+ return `${weekYear}-W${String(weekNumber).padStart(2, '0')}`;
256
+ }
257
+
224
258
  /**
225
259
  * Roll up analytics for a specific period
226
260
  * @private
227
261
  */
228
262
  async function rollupPeriod(period, cohort, sourcePrefix, analyticsResource, config) {
229
- // Get all source analytics (e.g., all hours for a day)
230
- const sourcePeriod = period === 'day' ? 'hour' : 'day';
263
+ // Get all source analytics (e.g., all hours for a day, all days for a week)
264
+ let sourcePeriod;
265
+ if (period === 'day') {
266
+ sourcePeriod = 'hour';
267
+ } else if (period === 'week') {
268
+ sourcePeriod = 'day'; // Week aggregates from days
269
+ } else if (period === 'month') {
270
+ sourcePeriod = 'day'; // ✅ Month aggregates from days AND hours (like week)
271
+ } else {
272
+ sourcePeriod = 'day'; // Fallback
273
+ }
231
274
 
232
275
  const [ok, err, allAnalytics] = await tryFn(() =>
233
276
  analyticsResource.list()
@@ -236,9 +279,24 @@ async function rollupPeriod(period, cohort, sourcePrefix, analyticsResource, con
236
279
  if (!ok || !allAnalytics) return;
237
280
 
238
281
  // Filter to matching cohorts
239
- const sourceAnalytics = allAnalytics.filter(a =>
240
- a.period === sourcePeriod && a.cohort.startsWith(sourcePrefix)
241
- );
282
+ let sourceAnalytics;
283
+ if (period === 'week') {
284
+ // For week, we need to find all days that belong to this week
285
+ sourceAnalytics = allAnalytics.filter(a => {
286
+ if (a.period !== sourcePeriod) return false;
287
+ // Check if this day's cohort belongs to the target week
288
+ const dayDate = new Date(a.cohort);
289
+ const dayWeek = getCohortWeekFromDate(dayDate);
290
+ return dayWeek === cohort;
291
+ });
292
+ } else {
293
+ // For day and month, simple prefix matching works
294
+ // day: aggregates from hours (cohort '2025-10-09' matches '2025-10-09T14', '2025-10-09T15', etc)
295
+ // month: aggregates from days (cohort '2025-10' matches '2025-10-01', '2025-10-02', etc)
296
+ sourceAnalytics = allAnalytics.filter(a =>
297
+ a.period === sourcePeriod && a.cohort.startsWith(sourcePrefix)
298
+ );
299
+ }
242
300
 
243
301
  if (sourceAnalytics.length === 0) return;
244
302
 
@@ -567,6 +625,66 @@ export async function getYearByMonth(resourceName, field, year, options, fieldHa
567
625
  return data;
568
626
  }
569
627
 
628
+ /**
629
+ * Get analytics for entire year, broken down by weeks
630
+ *
631
+ * @param {string} resourceName - Resource name
632
+ * @param {string} field - Field name
633
+ * @param {number} year - Year (e.g., 2025)
634
+ * @param {Object} options - Options
635
+ * @param {Object} fieldHandlers - Field handlers map
636
+ * @returns {Promise<Array>} Weekly analytics for the year (up to 53 weeks)
637
+ */
638
+ export async function getYearByWeek(resourceName, field, year, options, fieldHandlers) {
639
+ const data = await getAnalytics(resourceName, field, {
640
+ period: 'week',
641
+ year
642
+ }, fieldHandlers);
643
+
644
+ // Week data doesn't need gap filling as much as daily/hourly
645
+ // But we can still provide it if requested
646
+ if (options.fillGaps) {
647
+ // ISO weeks: typically 52-53 weeks per year
648
+ const startWeek = `${year}-W01`;
649
+ const endWeek = `${year}-W53`;
650
+ return fillGaps(data, 'week', startWeek, endWeek);
651
+ }
652
+
653
+ return data;
654
+ }
655
+
656
+ /**
657
+ * Get analytics for entire month, broken down by weeks
658
+ *
659
+ * @param {string} resourceName - Resource name
660
+ * @param {string} field - Field name
661
+ * @param {string} month - Month in YYYY-MM format
662
+ * @param {Object} options - Options
663
+ * @param {Object} fieldHandlers - Field handlers map
664
+ * @returns {Promise<Array>} Weekly analytics for the month
665
+ */
666
+ export async function getMonthByWeek(resourceName, field, month, options, fieldHandlers) {
667
+ // month format: '2025-10'
668
+ const year = parseInt(month.substring(0, 4));
669
+ const monthNum = parseInt(month.substring(5, 7));
670
+
671
+ // Get first and last day of month
672
+ const firstDay = new Date(year, monthNum - 1, 1);
673
+ const lastDay = new Date(year, monthNum, 0);
674
+
675
+ // Find which weeks this month spans
676
+ const firstWeek = getCohortWeekFromDate(firstDay);
677
+ const lastWeek = getCohortWeekFromDate(lastDay);
678
+
679
+ const data = await getAnalytics(resourceName, field, {
680
+ period: 'week',
681
+ startDate: firstWeek,
682
+ endDate: lastWeek
683
+ }, fieldHandlers);
684
+
685
+ return data;
686
+ }
687
+
570
688
  /**
571
689
  * Get analytics for entire month, broken down by hours
572
690
  *
@@ -690,3 +808,220 @@ export async function getTopRecords(resourceName, field, options, fieldHandlers)
690
808
  // Limit results
691
809
  return records.slice(0, limit);
692
810
  }
811
+
812
+ /**
813
+ * Get analytics for entire year, broken down by days
814
+ *
815
+ * @param {string} resourceName - Resource name
816
+ * @param {string} field - Field name
817
+ * @param {number} year - Year (e.g., 2025)
818
+ * @param {Object} options - Options
819
+ * @param {Object} fieldHandlers - Field handlers map
820
+ * @returns {Promise<Array>} Daily analytics for the year (up to 365/366 records)
821
+ */
822
+ export async function getYearByDay(resourceName, field, year, options, fieldHandlers) {
823
+ const startDate = `${year}-01-01`;
824
+ const endDate = `${year}-12-31`;
825
+
826
+ const data = await getAnalytics(resourceName, field, {
827
+ period: 'day',
828
+ startDate,
829
+ endDate
830
+ }, fieldHandlers);
831
+
832
+ if (options.fillGaps) {
833
+ return fillGaps(data, 'day', startDate, endDate);
834
+ }
835
+
836
+ return data;
837
+ }
838
+
839
+ /**
840
+ * Get analytics for entire week, broken down by days
841
+ *
842
+ * @param {string} resourceName - Resource name
843
+ * @param {string} field - Field name
844
+ * @param {string} week - Week in YYYY-Www format (e.g., '2025-W42')
845
+ * @param {Object} options - Options
846
+ * @param {Object} fieldHandlers - Field handlers map
847
+ * @returns {Promise<Array>} Daily analytics for the week (7 records)
848
+ */
849
+ export async function getWeekByDay(resourceName, field, week, options, fieldHandlers) {
850
+ // week format: '2025-W42'
851
+ const year = parseInt(week.substring(0, 4));
852
+ const weekNum = parseInt(week.substring(6, 8));
853
+
854
+ // Calculate the first day of the week (Monday)
855
+ const jan4 = new Date(year, 0, 4);
856
+ const jan4Day = jan4.getDay() || 7; // Sunday = 7
857
+ const firstMonday = new Date(year, 0, 4 - jan4Day + 1);
858
+ const weekStart = new Date(firstMonday);
859
+ weekStart.setDate(weekStart.getDate() + (weekNum - 1) * 7);
860
+
861
+ // Get all 7 days of the week
862
+ const days = [];
863
+ for (let i = 0; i < 7; i++) {
864
+ const day = new Date(weekStart);
865
+ day.setDate(weekStart.getDate() + i);
866
+ days.push(day.toISOString().substring(0, 10));
867
+ }
868
+
869
+ const startDate = days[0];
870
+ const endDate = days[6];
871
+
872
+ const data = await getAnalytics(resourceName, field, {
873
+ period: 'day',
874
+ startDate,
875
+ endDate
876
+ }, fieldHandlers);
877
+
878
+ if (options.fillGaps) {
879
+ return fillGaps(data, 'day', startDate, endDate);
880
+ }
881
+
882
+ return data;
883
+ }
884
+
885
+ /**
886
+ * Get analytics for entire week, broken down by hours
887
+ *
888
+ * @param {string} resourceName - Resource name
889
+ * @param {string} field - Field name
890
+ * @param {string} week - Week in YYYY-Www format (e.g., '2025-W42')
891
+ * @param {Object} options - Options
892
+ * @param {Object} fieldHandlers - Field handlers map
893
+ * @returns {Promise<Array>} Hourly analytics for the week (168 records)
894
+ */
895
+ export async function getWeekByHour(resourceName, field, week, options, fieldHandlers) {
896
+ // week format: '2025-W42'
897
+ const year = parseInt(week.substring(0, 4));
898
+ const weekNum = parseInt(week.substring(6, 8));
899
+
900
+ // Calculate the first day of the week (Monday)
901
+ const jan4 = new Date(year, 0, 4);
902
+ const jan4Day = jan4.getDay() || 7; // Sunday = 7
903
+ const firstMonday = new Date(year, 0, 4 - jan4Day + 1);
904
+ const weekStart = new Date(firstMonday);
905
+ weekStart.setDate(weekStart.getDate() + (weekNum - 1) * 7);
906
+
907
+ // Get first and last day of week
908
+ const weekEnd = new Date(weekStart);
909
+ weekEnd.setDate(weekEnd.getDate() + 6);
910
+
911
+ const startDate = weekStart.toISOString().substring(0, 10);
912
+ const endDate = weekEnd.toISOString().substring(0, 10);
913
+
914
+ const data = await getAnalytics(resourceName, field, {
915
+ period: 'hour',
916
+ startDate,
917
+ endDate
918
+ }, fieldHandlers);
919
+
920
+ if (options.fillGaps) {
921
+ return fillGaps(data, 'hour', startDate, endDate);
922
+ }
923
+
924
+ return data;
925
+ }
926
+
927
+ /**
928
+ * Get analytics for last N hours
929
+ *
930
+ * @param {string} resourceName - Resource name
931
+ * @param {string} field - Field name
932
+ * @param {number} hours - Number of hours to look back (default: 24)
933
+ * @param {Object} options - Options
934
+ * @param {Object} fieldHandlers - Field handlers map
935
+ * @returns {Promise<Array>} Hourly analytics
936
+ */
937
+ export async function getLastNHours(resourceName, field, hours = 24, options, fieldHandlers) {
938
+ const now = new Date();
939
+ const hoursAgo = new Date(now);
940
+ hoursAgo.setHours(hoursAgo.getHours() - hours);
941
+
942
+ const startDate = hoursAgo.toISOString().substring(0, 13); // YYYY-MM-DDTHH
943
+ const endDate = now.toISOString().substring(0, 13);
944
+
945
+ const data = await getAnalytics(resourceName, field, {
946
+ period: 'hour',
947
+ startDate,
948
+ endDate
949
+ }, fieldHandlers);
950
+
951
+ if (options.fillGaps) {
952
+ const startDay = hoursAgo.toISOString().substring(0, 10);
953
+ const endDay = now.toISOString().substring(0, 10);
954
+ return fillGaps(data, 'hour', startDay, endDay);
955
+ }
956
+
957
+ return data;
958
+ }
959
+
960
+ /**
961
+ * Get analytics for last N weeks
962
+ *
963
+ * @param {string} resourceName - Resource name
964
+ * @param {string} field - Field name
965
+ * @param {number} weeks - Number of weeks to look back (default: 4)
966
+ * @param {Object} options - Options
967
+ * @param {Object} fieldHandlers - Field handlers map
968
+ * @returns {Promise<Array>} Weekly analytics
969
+ */
970
+ export async function getLastNWeeks(resourceName, field, weeks = 4, options, fieldHandlers) {
971
+ const now = new Date();
972
+ const weeksAgo = new Date(now);
973
+ weeksAgo.setDate(weeksAgo.getDate() - (weeks * 7));
974
+
975
+ // Get week cohorts for the range
976
+ const weekCohorts = [];
977
+ const currentDate = new Date(weeksAgo);
978
+ while (currentDate <= now) {
979
+ const weekCohort = getCohortWeekFromDate(currentDate);
980
+ if (!weekCohorts.includes(weekCohort)) {
981
+ weekCohorts.push(weekCohort);
982
+ }
983
+ currentDate.setDate(currentDate.getDate() + 7);
984
+ }
985
+
986
+ const startWeek = weekCohorts[0];
987
+ const endWeek = weekCohorts[weekCohorts.length - 1];
988
+
989
+ const data = await getAnalytics(resourceName, field, {
990
+ period: 'week',
991
+ startDate: startWeek,
992
+ endDate: endWeek
993
+ }, fieldHandlers);
994
+
995
+ return data;
996
+ }
997
+
998
+ /**
999
+ * Get analytics for last N months
1000
+ *
1001
+ * @param {string} resourceName - Resource name
1002
+ * @param {string} field - Field name
1003
+ * @param {number} months - Number of months to look back (default: 12)
1004
+ * @param {Object} options - Options
1005
+ * @param {Object} fieldHandlers - Field handlers map
1006
+ * @returns {Promise<Array>} Monthly analytics
1007
+ */
1008
+ export async function getLastNMonths(resourceName, field, months = 12, options, fieldHandlers) {
1009
+ const now = new Date();
1010
+ const monthsAgo = new Date(now);
1011
+ monthsAgo.setMonth(monthsAgo.getMonth() - months);
1012
+
1013
+ const startDate = monthsAgo.toISOString().substring(0, 7); // YYYY-MM
1014
+ const endDate = now.toISOString().substring(0, 7);
1015
+
1016
+ const data = await getAnalytics(resourceName, field, {
1017
+ period: 'month',
1018
+ startDate,
1019
+ endDate
1020
+ }, fieldHandlers);
1021
+
1022
+ if (options.fillGaps) {
1023
+ return fillGaps(data, 'month', startDate, endDate);
1024
+ }
1025
+
1026
+ return data;
1027
+ }
@@ -47,6 +47,9 @@ export function createConfig(options, detectedTimezone) {
47
47
  autoConsolidate: consolidation.auto !== false,
48
48
  mode: consolidation.mode || 'async',
49
49
 
50
+ // ✅ NOVO: Performance tuning - Mark applied concurrency (default 50, antes era 10 hardcoded)
51
+ markAppliedConcurrency: consolidation.markAppliedConcurrency ?? 50,
52
+
50
53
  // Late arrivals
51
54
  lateArrivalStrategy: lateArrivals.strategy || 'warn',
52
55
 
@@ -7,7 +7,6 @@ import tryFn from "../../concerns/try-fn.js";
7
7
  import { PromisePool } from "@supercharge/promise-pool";
8
8
  import { idGenerator } from "../../concerns/id.js";
9
9
  import { getCohortInfo, createSyntheticSetTransaction } from "./utils.js";
10
- import { cleanupStaleLocks } from "./locks.js";
11
10
 
12
11
  /**
13
12
  * Start consolidation timer for a handler
@@ -169,7 +168,7 @@ export async function runConsolidation(transactionResource, consolidateRecordFn,
169
168
  * @param {string} originalId - ID of the record to consolidate
170
169
  * @param {Object} transactionResource - Transaction resource
171
170
  * @param {Object} targetResource - Target resource
172
- * @param {Object} lockResource - Lock resource
171
+ * @param {Object} storage - PluginStorage instance for locks
173
172
  * @param {Object} analyticsResource - Analytics resource (optional)
174
173
  * @param {Function} updateAnalyticsFn - Function to update analytics (optional)
175
174
  * @param {Object} config - Plugin configuration
@@ -179,26 +178,21 @@ export async function consolidateRecord(
179
178
  originalId,
180
179
  transactionResource,
181
180
  targetResource,
182
- lockResource,
181
+ storage,
183
182
  analyticsResource,
184
183
  updateAnalyticsFn,
185
184
  config
186
185
  ) {
187
- // Clean up stale locks before attempting to acquire
188
- await cleanupStaleLocks(lockResource, config);
189
-
190
- // Acquire distributed lock to prevent concurrent consolidation
191
- const lockId = `lock-${originalId}`;
192
- const [lockAcquired, lockErr, lock] = await tryFn(() =>
193
- lockResource.insert({
194
- id: lockId,
195
- lockedAt: Date.now(),
196
- workerId: process.pid ? String(process.pid) : 'unknown'
197
- })
198
- );
186
+ // Acquire distributed lock with TTL to prevent concurrent consolidation
187
+ const lockKey = `consolidation-${config.resource}-${config.field}-${originalId}`;
188
+ const lock = await storage.acquireLock(lockKey, {
189
+ ttl: config.lockTimeout || 30,
190
+ timeout: 0, // Don't wait if locked
191
+ workerId: process.pid ? String(process.pid) : 'unknown'
192
+ });
199
193
 
200
194
  // If lock couldn't be acquired, another worker is consolidating
201
- if (!lockAcquired) {
195
+ if (!lock) {
202
196
  if (config.verbose) {
203
197
  console.log(`[EventualConsistency] Lock for ${originalId} already held, skipping`);
204
198
  }
@@ -508,9 +502,12 @@ export async function consolidateRecord(
508
502
  // Mark transactions as applied (skip synthetic ones) - use PromisePool for controlled concurrency
509
503
  const transactionsToUpdate = transactions.filter(txn => txn.id !== '__synthetic__');
510
504
 
505
+ // ✅ OTIMIZAÇÃO: Usar concurrency do config (default aumentado de 10 para 50)
506
+ const markAppliedConcurrency = config.markAppliedConcurrency || 50;
507
+
511
508
  const { results, errors } = await PromisePool
512
509
  .for(transactionsToUpdate)
513
- .withConcurrency(10) // Limit parallel updates
510
+ .withConcurrency(markAppliedConcurrency) // Configurável e maior!
514
511
  .process(async (txn) => {
515
512
  const [ok, err] = await tryFn(() =>
516
513
  transactionResource.update(txn.id, { applied: true })
@@ -576,10 +573,12 @@ export async function consolidateRecord(
576
573
  return consolidatedValue;
577
574
  } finally {
578
575
  // Always release the lock
579
- const [lockReleased, lockReleaseErr] = await tryFn(() => lockResource.delete(lockId));
576
+ const [lockReleased, lockReleaseErr] = await tryFn(() =>
577
+ storage.releaseLock(lockKey)
578
+ );
580
579
 
581
580
  if (!lockReleased && config.verbose) {
582
- console.warn(`[EventualConsistency] Failed to release lock ${lockId}:`, lockReleaseErr?.message);
581
+ console.warn(`[EventualConsistency] Failed to release lock ${lockKey}:`, lockReleaseErr?.message);
583
582
  }
584
583
  }
585
584
  }
@@ -705,7 +704,7 @@ export async function getCohortStats(cohortDate, transactionResource) {
705
704
  * @param {string} originalId - ID of the record to recalculate
706
705
  * @param {Object} transactionResource - Transaction resource
707
706
  * @param {Object} targetResource - Target resource
708
- * @param {Object} lockResource - Lock resource
707
+ * @param {Object} storage - PluginStorage instance for locks
709
708
  * @param {Function} consolidateRecordFn - Function to consolidate the record
710
709
  * @param {Object} config - Plugin configuration
711
710
  * @returns {Promise<number>} Recalculated value
@@ -714,25 +713,20 @@ export async function recalculateRecord(
714
713
  originalId,
715
714
  transactionResource,
716
715
  targetResource,
717
- lockResource,
716
+ storage,
718
717
  consolidateRecordFn,
719
718
  config
720
719
  ) {
721
- // Clean up stale locks before attempting to acquire
722
- await cleanupStaleLocks(lockResource, config);
723
-
724
- // Acquire distributed lock to prevent concurrent operations
725
- const lockId = `lock-recalculate-${originalId}`;
726
- const [lockAcquired, lockErr, lock] = await tryFn(() =>
727
- lockResource.insert({
728
- id: lockId,
729
- lockedAt: Date.now(),
730
- workerId: process.pid ? String(process.pid) : 'unknown'
731
- })
732
- );
720
+ // Acquire distributed lock with TTL to prevent concurrent operations
721
+ const lockKey = `recalculate-${config.resource}-${config.field}-${originalId}`;
722
+ const lock = await storage.acquireLock(lockKey, {
723
+ ttl: config.lockTimeout || 30,
724
+ timeout: 0, // Don't wait if locked
725
+ workerId: process.pid ? String(process.pid) : 'unknown'
726
+ });
733
727
 
734
728
  // If lock couldn't be acquired, another worker is operating on this record
735
- if (!lockAcquired) {
729
+ if (!lock) {
736
730
  if (config.verbose) {
737
731
  console.log(`[EventualConsistency] Recalculate lock for ${originalId} already held, skipping`);
738
732
  }
@@ -832,10 +826,12 @@ export async function recalculateRecord(
832
826
  return consolidatedValue;
833
827
  } finally {
834
828
  // Always release the lock
835
- const [lockReleased, lockReleaseErr] = await tryFn(() => lockResource.delete(lockId));
829
+ const [lockReleased, lockReleaseErr] = await tryFn(() =>
830
+ storage.releaseLock(lockKey)
831
+ );
836
832
 
837
833
  if (!lockReleased && config.verbose) {
838
- console.warn(`[EventualConsistency] Failed to release recalculate lock ${lockId}:`, lockReleaseErr?.message);
834
+ console.warn(`[EventualConsistency] Failed to release recalculate lock ${lockKey}:`, lockReleaseErr?.message);
839
835
  }
840
836
  }
841
837
  }
@@ -31,24 +31,22 @@ export function startGarbageCollectionTimer(handler, resourceName, fieldName, ru
31
31
  * Uses distributed locking to prevent multiple containers from running GC simultaneously
32
32
  *
33
33
  * @param {Object} transactionResource - Transaction resource
34
- * @param {Object} lockResource - Lock resource
34
+ * @param {Object} storage - PluginStorage instance for locks
35
35
  * @param {Object} config - Plugin configuration
36
36
  * @param {Function} emitFn - Function to emit events
37
37
  * @returns {Promise<void>}
38
38
  */
39
- export async function runGarbageCollection(transactionResource, lockResource, config, emitFn) {
40
- // Acquire distributed lock for GC operation
41
- const gcLockId = `lock-gc-${config.resource}-${config.field}`;
42
- const [lockAcquired] = await tryFn(() =>
43
- lockResource.insert({
44
- id: gcLockId,
45
- lockedAt: Date.now(),
46
- workerId: process.pid ? String(process.pid) : 'unknown'
47
- })
48
- );
39
+ export async function runGarbageCollection(transactionResource, storage, config, emitFn) {
40
+ // Acquire distributed lock with TTL for GC operation
41
+ const lockKey = `gc-${config.resource}-${config.field}`;
42
+ const lock = await storage.acquireLock(lockKey, {
43
+ ttl: 300, // 5 minutes for GC
44
+ timeout: 0, // Don't wait if locked
45
+ workerId: process.pid ? String(process.pid) : 'unknown'
46
+ });
49
47
 
50
48
  // If another container is already running GC, skip
51
- if (!lockAcquired) {
49
+ if (!lock) {
52
50
  if (config.verbose) {
53
51
  console.log(`[EventualConsistency] GC already running in another container`);
54
52
  }
@@ -121,6 +119,6 @@ export async function runGarbageCollection(transactionResource, lockResource, co
121
119
  }
122
120
  } finally {
123
121
  // Always release GC lock
124
- await tryFn(() => lockResource.delete(gcLockId));
122
+ await tryFn(() => storage.releaseLock(lockKey));
125
123
  }
126
124
  }