Skip to content

Commit c6a9930

Browse files
committed
✨ feat(triggers): add retention and capacity limits to digest and batch-retry buffers
- Track per-entry updatedAt timestamps alongside the buffered containers - Prune entries older than the 7-day retention window before each access - Enforce a 5000-entry cap by evicting the oldest entry when a new one is added - Flush digest cron skips dispatch when eviction empties the buffer before send
1 parent 95de9f0 commit c6a9930

File tree

2 files changed

+347
-12
lines changed

2 files changed

+347
-12
lines changed

app/triggers/providers/Trigger.test.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4012,6 +4012,92 @@ test('getBatchRetryContainers should match raw containers by fallback fullName w
40124012
expect(trigger.batchRetryBuffer.get('undefined_container1')).toBe(currentContainer);
40134013
});
40144014

4015+
test('getBatchRetryContainers should evict stale retry-buffer entries before reuse', () => {
4016+
trigger.configuration = {
4017+
threshold: 'all',
4018+
once: true,
4019+
mode: 'batch',
4020+
};
4021+
4022+
const currentContainer = {
4023+
id: 'stale-id',
4024+
watcher: 'local',
4025+
name: 'container1',
4026+
updateAvailable: true,
4027+
updateKind: { kind: 'tag', semverDiff: 'major' },
4028+
} as any;
4029+
4030+
trigger.batchRetryBuffer.set('stale-id', currentContainer);
4031+
(trigger as any).batchRetryBufferUpdatedAt = new Map([['stale-id', 1_000]]);
4032+
(trigger as any).bufferEntryRetentionMs = 100;
4033+
storeContainer.getContainersRaw.mockReturnValue([currentContainer]);
4034+
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1_101);
4035+
4036+
try {
4037+
const retryContainers = (trigger as any).getBatchRetryContainers([]);
4038+
4039+
expect(retryContainers).toEqual([]);
4040+
expect(trigger.batchRetryBuffer.size).toBe(0);
4041+
} finally {
4042+
nowSpy.mockRestore();
4043+
}
4044+
});
4045+
4046+
test('handleContainerReports should cap retry-buffer growth by evicting the oldest entries', async () => {
4047+
trigger.configuration = {
4048+
threshold: 'all',
4049+
once: true,
4050+
mode: 'batch',
4051+
};
4052+
(trigger as any).batchRetryBufferMaxEntries = 2;
4053+
trigger.triggerBatch = vi.fn().mockRejectedValue(new Error('SMTP timeout'));
4054+
4055+
const nowSpy = vi
4056+
.spyOn(Date, 'now')
4057+
.mockReturnValueOnce(1_000)
4058+
.mockReturnValueOnce(1_001)
4059+
.mockReturnValueOnce(1_002);
4060+
4061+
try {
4062+
await trigger.handleContainerReports([
4063+
{
4064+
changed: true,
4065+
container: {
4066+
id: 'c1',
4067+
name: 'c1',
4068+
watcher: 'local',
4069+
updateAvailable: true,
4070+
updateKind: { kind: 'tag', semverDiff: 'major' },
4071+
},
4072+
},
4073+
{
4074+
changed: true,
4075+
container: {
4076+
id: 'c2',
4077+
name: 'c2',
4078+
watcher: 'local',
4079+
updateAvailable: true,
4080+
updateKind: { kind: 'tag', semverDiff: 'major' },
4081+
},
4082+
},
4083+
{
4084+
changed: true,
4085+
container: {
4086+
id: 'c3',
4087+
name: 'c3',
4088+
watcher: 'local',
4089+
updateAvailable: true,
4090+
updateKind: { kind: 'tag', semverDiff: 'major' },
4091+
},
4092+
},
4093+
] as any);
4094+
4095+
expect([...trigger.batchRetryBuffer.keys()]).toEqual(['c2', 'c3']);
4096+
} finally {
4097+
nowSpy.mockRestore();
4098+
}
4099+
});
4100+
40154101
test('handleContainerReports should use fallback fullName keys for retry and digest cleanup when notification keys are missing', async () => {
40164102
trigger.configuration = {
40174103
threshold: 'all',
@@ -4370,6 +4456,37 @@ describe('digest mode', () => {
43704456
triggerBatchSpy.mockRestore();
43714457
});
43724458

4459+
test('bufferContainerForDigest should cap digest-buffer growth by evicting the oldest entries', () => {
4460+
(trigger as any).digestBufferMaxEntries = 2;
4461+
const nowSpy = vi
4462+
.spyOn(Date, 'now')
4463+
.mockReturnValueOnce(1_000)
4464+
.mockReturnValueOnce(1_001)
4465+
.mockReturnValueOnce(1_002);
4466+
4467+
try {
4468+
(trigger as any).bufferContainerForDigest({
4469+
id: 'c1',
4470+
name: 'app-1',
4471+
watcher: 'test',
4472+
});
4473+
(trigger as any).bufferContainerForDigest({
4474+
id: 'c2',
4475+
name: 'app-2',
4476+
watcher: 'test',
4477+
});
4478+
(trigger as any).bufferContainerForDigest({
4479+
id: 'c3',
4480+
name: 'app-3',
4481+
watcher: 'test',
4482+
});
4483+
4484+
expect([...trigger.digestBuffer.keys()]).toEqual(['c2', 'c3']);
4485+
} finally {
4486+
nowSpy.mockRestore();
4487+
}
4488+
});
4489+
43734490
test('handleContainerReportDigest should return early when report is not eligible for simple handling', async () => {
43744491
await trigger.register('trigger', 'test', 'digest-trigger', {
43754492
...configurationValid,
@@ -4560,6 +4677,36 @@ describe('digest mode', () => {
45604677
triggerBatchSpy.mockRestore();
45614678
});
45624679

4680+
test('flushDigestBuffer should evict stale buffered entries before dispatch', async () => {
4681+
await trigger.register('trigger', 'test', 'digest-trigger', {
4682+
...configurationValid,
4683+
mode: 'digest',
4684+
});
4685+
4686+
const staleContainer = {
4687+
id: 'c1',
4688+
name: 'app',
4689+
watcher: 'test',
4690+
updateAvailable: true,
4691+
updateKind: { kind: 'tag', localValue: '1.0', remoteValue: '2.0' },
4692+
} as any;
4693+
trigger.digestBuffer.set('c1', staleContainer);
4694+
(trigger as any).digestBufferUpdatedAt = new Map([['c1', 1_000]]);
4695+
(trigger as any).bufferEntryRetentionMs = 100;
4696+
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1_101);
4697+
4698+
try {
4699+
const triggerBatchSpy = vi.spyOn(trigger, 'triggerBatch').mockResolvedValue(undefined);
4700+
await trigger.flushDigestBuffer();
4701+
4702+
expect(trigger.digestBuffer.size).toBe(0);
4703+
expect(triggerBatchSpy).not.toHaveBeenCalled();
4704+
triggerBatchSpy.mockRestore();
4705+
} finally {
4706+
nowSpy.mockRestore();
4707+
}
4708+
});
4709+
45634710
test('flushDigestBuffer should return early when a digest flush is already in progress', async () => {
45644711
await trigger.register('trigger', 'test', 'digest-trigger', {
45654712
...configurationValid,

0 commit comments

Comments
 (0)