@powersync/service-core 1.20.2 → 1.20.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.
@@ -0,0 +1,86 @@
1
+ import { RollingBucketMax } from '../metrics/RollingBucketMax.js';
2
+
3
+ /**
4
+ * Tracks replication lag across the current in-flight transaction and a rolling
5
+ * max of recently observed lag values.
6
+ */
7
+ export class ReplicationLagTracker {
8
+ private readonly rollingReplicationLag = new RollingBucketMax();
9
+ private _oldestUncommittedChange: Date | null = null;
10
+ private _isStartingReplication = true;
11
+
12
+ /**
13
+ * The oldest source timestamp still part of the current in-flight work.
14
+ */
15
+ get oldestUncommittedChange(): Date | null {
16
+ return this._oldestUncommittedChange;
17
+ }
18
+
19
+ /**
20
+ * True until replication has seen its first completed commit or equivalent keepalive.
21
+ */
22
+ get isStartingReplication(): boolean {
23
+ return this._isStartingReplication;
24
+ }
25
+
26
+ /**
27
+ * Registers the first source timestamp for the current in-flight work,
28
+ * for example the start of a transaction
29
+ */
30
+ trackUncommittedChange(timestamp: Date | null | undefined): void {
31
+ if (this._oldestUncommittedChange == null && timestamp != null) {
32
+ this._oldestUncommittedChange = timestamp;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Clears the current in-flight timestamp without changing startup state.
38
+ */
39
+ clearUncommittedChange(): void {
40
+ this._oldestUncommittedChange = null;
41
+ }
42
+
43
+ /**
44
+ * Marks replication as started even if no committed transaction lag was recorded.
45
+ */
46
+ markStarted(): void {
47
+ this._isStartingReplication = false;
48
+ }
49
+
50
+ /**
51
+ * Mark the current pending changes as "committed".
52
+ *
53
+ * Records the current in-flight lag into the rolling window and clears it.
54
+ * The current lag is calculated as the differnence between current time and the oldest change,
55
+ * as marked by trackUncommittedChange.
56
+ */
57
+ markCommitted(timestampMs = Date.now()): void {
58
+ if (this._oldestUncommittedChange != null) {
59
+ this.rollingReplicationLag.report(timestampMs - this._oldestUncommittedChange.getTime(), timestampMs);
60
+ }
61
+ this.clearUncommittedChange();
62
+ this.markStarted();
63
+ }
64
+
65
+ /**
66
+ * Returns the lag for the current in-flight work.
67
+ *
68
+ * 0 if idle (no pending changes to replicate).
69
+ *
70
+ * undefined when replication is still starting up.
71
+ */
72
+ getCurrentLagMillis(timestampMs = Date.now()): number | undefined {
73
+ if (this._oldestUncommittedChange == null) {
74
+ return this._isStartingReplication ? undefined : 0;
75
+ }
76
+ return timestampMs - this._oldestUncommittedChange.getTime();
77
+ }
78
+
79
+ /**
80
+ * Returns the rolling lag metric value, including the current in-flight lag when present.
81
+ */
82
+ getLagMillis(timestampMs = Date.now()): number | undefined {
83
+ this.rollingReplicationLag.report(this.getCurrentLagMillis(timestampMs), timestampMs);
84
+ return this.rollingReplicationLag.getRollingMax(timestampMs);
85
+ }
86
+ }
@@ -1,6 +1,7 @@
1
1
  export * from './AbstractReplicationJob.js';
2
2
  export * from './AbstractReplicator.js';
3
3
  export * from './ErrorRateLimiter.js';
4
+ export * from './ReplicationLagTracker.js';
4
5
  export * from './ReplicationEngine.js';
5
6
  export * from './ReplicationModule.js';
6
7
  export * from './replication-metrics.js';
@@ -0,0 +1,53 @@
1
+ import { ReplicationLagTracker } from '@/replication/ReplicationLagTracker.js';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ describe('ReplicationLagTracker', () => {
5
+ it('returns undefined before replication has started', () => {
6
+ const tracker = new ReplicationLagTracker();
7
+
8
+ expect(tracker.getLagMillis(0)).toBeUndefined();
9
+ });
10
+
11
+ it('tracks the oldest in-flight change and returns current lag', () => {
12
+ const tracker = new ReplicationLagTracker();
13
+
14
+ tracker.trackUncommittedChange(new Date(1_000));
15
+ tracker.trackUncommittedChange(new Date(2_000));
16
+
17
+ expect(tracker.oldestUncommittedChange?.getTime()).toBe(1_000);
18
+ expect(tracker.getCurrentLagMillis(4_000)).toBe(3_000);
19
+ expect(tracker.getLagMillis(4_000)).toBe(3_000);
20
+ });
21
+
22
+ it('records commit lag into the rolling window and clears in-flight state', () => {
23
+ const tracker = new ReplicationLagTracker();
24
+
25
+ tracker.trackUncommittedChange(new Date(0));
26
+ tracker.markCommitted(5_000);
27
+
28
+ expect(tracker.oldestUncommittedChange).toBeNull();
29
+ expect(tracker.isStartingReplication).toBe(false);
30
+ expect(tracker.getLagMillis(5_000)).toBe(5_000);
31
+ expect(tracker.getLagMillis(35_000)).toBe(0);
32
+ });
33
+
34
+ it('can clear in-flight state without changing started state', () => {
35
+ const tracker = new ReplicationLagTracker();
36
+
37
+ tracker.trackUncommittedChange(new Date(0));
38
+ tracker.clearUncommittedChange();
39
+
40
+ expect(tracker.oldestUncommittedChange).toBeNull();
41
+ expect(tracker.isStartingReplication).toBe(true);
42
+ expect(tracker.getLagMillis(5_000)).toBeUndefined();
43
+ });
44
+
45
+ it('can mark replication as started without a committed transaction', () => {
46
+ const tracker = new ReplicationLagTracker();
47
+
48
+ tracker.markStarted();
49
+
50
+ expect(tracker.isStartingReplication).toBe(false);
51
+ expect(tracker.getLagMillis(0)).toBe(0);
52
+ });
53
+ });
@@ -0,0 +1,106 @@
1
+ import { RollingBucketMax } from '@/metrics/RollingBucketMax.js';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ describe('RollingBucketMax', () => {
5
+ it('returns undefined before any values are reported', () => {
6
+ const tracker = new RollingBucketMax();
7
+
8
+ expect(tracker.getRollingMax(0)).toBeUndefined();
9
+ });
10
+
11
+ it('tracks the maximum value within a single bucket', () => {
12
+ const tracker = new RollingBucketMax();
13
+
14
+ tracker.report(3, 100);
15
+ tracker.report(9, 1_000);
16
+ tracker.report(5, 4_999);
17
+
18
+ expect(tracker.getRollingMax(4_999)).toBe(9);
19
+ });
20
+
21
+ it('keeps the rolling max across the last six 5s buckets', () => {
22
+ const tracker = new RollingBucketMax();
23
+
24
+ tracker.report(20, 0);
25
+ tracker.report(11, 5_000);
26
+ tracker.report(12, 10_000);
27
+ tracker.report(13, 15_000);
28
+ tracker.report(14, 20_000);
29
+ tracker.report(15, 25_000);
30
+
31
+ expect(tracker.getRollingMax(29_999)).toBe(20);
32
+ expect(tracker.getRollingMax(30_000)).toBe(15);
33
+ });
34
+
35
+ it('slides the rolling max forward as older buckets age out', () => {
36
+ const tracker = new RollingBucketMax();
37
+
38
+ tracker.report(20, 0);
39
+ expect(tracker.getRollingMax(0)).toBe(20);
40
+
41
+ tracker.report(18, 5_000);
42
+ expect(tracker.getRollingMax(5_000)).toBe(20);
43
+
44
+ tracker.report(16, 10_000);
45
+ expect(tracker.getRollingMax(10_000)).toBe(20);
46
+
47
+ tracker.report(14, 15_000);
48
+ expect(tracker.getRollingMax(15_000)).toBe(20);
49
+
50
+ tracker.report(12, 20_000);
51
+ expect(tracker.getRollingMax(20_000)).toBe(20);
52
+
53
+ tracker.report(10, 25_000);
54
+ expect(tracker.getRollingMax(29_999)).toBe(20);
55
+
56
+ tracker.report(8, 30_000);
57
+ expect(tracker.getRollingMax(30_000)).toBe(18);
58
+
59
+ tracker.report(6, 35_000);
60
+ expect(tracker.getRollingMax(35_000)).toBe(16);
61
+
62
+ tracker.report(4, 40_000);
63
+ expect(tracker.getRollingMax(40_000)).toBe(14);
64
+ });
65
+
66
+ it('keeps newer buckets in the rolling window while older peaks fall out', () => {
67
+ const tracker = new RollingBucketMax();
68
+
69
+ tracker.report(50, 0);
70
+ tracker.report(11, 5_000);
71
+ tracker.report(12, 10_000);
72
+ tracker.report(13, 15_000);
73
+ tracker.report(14, 20_000);
74
+ tracker.report(15, 25_000);
75
+
76
+ expect(tracker.getRollingMax(29_999)).toBe(50);
77
+
78
+ tracker.report(40, 30_000);
79
+ expect(tracker.getRollingMax(30_000)).toBe(40);
80
+ expect(tracker.getRollingMax(34_999)).toBe(40);
81
+ });
82
+
83
+ it('expires values after the rolling window passes with no new reports', () => {
84
+ const tracker = new RollingBucketMax();
85
+
86
+ tracker.report(7, 0);
87
+
88
+ expect(tracker.getRollingMax(29_999)).toBe(7);
89
+ expect(tracker.getRollingMax(30_000)).toBeUndefined();
90
+ });
91
+
92
+ it('supports custom bucket and window sizes', () => {
93
+ const tracker = new RollingBucketMax({
94
+ bucketSizeMs: 1_000,
95
+ windowSizeMs: 3_000
96
+ });
97
+
98
+ tracker.report(4, 0);
99
+ tracker.report(6, 1_000);
100
+ tracker.report(5, 2_000);
101
+
102
+ expect(tracker.getRollingMax(2_999)).toBe(6);
103
+ expect(tracker.getRollingMax(3_000)).toBe(6);
104
+ expect(tracker.getRollingMax(4_000)).toBe(5);
105
+ });
106
+ });