forked from Talenttrust/Talenttrust-Backend
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdlqStore.ts
More file actions
228 lines (203 loc) · 7.18 KB
/
Copy pathdlqStore.ts
File metadata and controls
228 lines (203 loc) · 7.18 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
/**
* @module dlqStore
*
* Dead Letter Queue (DLQ) storage abstraction for failed webhook deliveries.
*
* ## Purpose
* When a webhook delivery fails after all retries, the event is pushed to the
* DLQ for manual inspection or delayed retry. This module provides an
* in-memory implementation suitable for single-process deployments, and a
* SQLite-backed implementation for durable, restart-safe persistence.
*
* ## Production Considerations
* For multi-process or persistent DLQ storage, use `SqliteDlqStore` which
* backs entries in a SQLite database via the existing connection from
* `src/db/database.ts`. The in-memory store is suitable for development
* and testing only.
*
* ## Security
* DLQ entries may contain sensitive payload data. Ensure that:
* - Payloads are redacted before storage via `redactPayload` from
* `src/utils/redact.ts` — raw signing secrets are never persisted.
* - Provider IDs are sanitized before use as metric labels.
* - The database file is excluded from version control and has
* restricted filesystem permissions (chmod 600).
*/
import { getDb } from '../db/database';
import { redactPayload } from '../utils/redact';
import type Database from '../db/betterSqlite3';
import type * as BetterSqlite3 from 'better-sqlite3';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/**
* A single DLQ entry representing a failed webhook delivery.
*/
export interface DlqEntry {
/** Opaque provider identifier. Must NOT contain secrets. */
providerId: string;
/** Globally unique delivery identifier. */
deliveryId: string;
/** Destination URL that failed. */
targetUrl: string;
/** Arbitrary JSON-serialisable payload body. Stored redacted —
* raw signing secrets are stripped by {@link redactPayload}. */
payload: unknown;
/** Timestamp (ms since epoch) when the entry was added to the DLQ. */
timestamp: number;
/** Number of enqueue / replay attempts accumulated for this entry. */
attemptCount: number;
}
/**
* Options accepted by {@link SqliteDlqStore}.
*
* @interface SqliteDlqStoreOptions
* @property {number} [capacity] - Optional maximum number of entries the
* store will hold. When the store is at capacity, the oldest pending
* entry is evicted before a new one is inserted (oldest-evict policy).
* Defaults to `0` (unbounded).
* @property {BetterSqlite3.Database} [db] - Optional explicit database
* handle. When omitted, the singleton from {@link getDb} is used.
* Tests typically pass a `:memory:` database for isolation.
*/
export interface SqliteDlqStoreOptions {
capacity?: number;
db?: BetterSqlite3.Database;
}
/**
* DLQ store interface.
*
* Implementations must be **synchronous** (non-blocking) for metrics sampling.
* Async operations (e.g., Redis calls) should be batched or cached.
*/
export interface DlqStore {
/**
* Add a failed delivery to the DLQ.
*
* @param entry - The DLQ entry to store.
*/
push(entry: DlqEntry): void;
/**
* Return the current DLQ depth (number of entries) per provider.
*
* @returns Map of provider ID → entry count.
*/
getDepthByProvider(): Map<string, number>;
/**
* Return the age (in seconds) of the oldest entry per provider.
*
* @returns Map of provider ID → age in seconds. Providers with empty queues
* are omitted from the map.
*/
getOldestAgeByProvider(): Map<string, number>;
/**
* Remove up to `count` entries from the DLQ for a given provider.
* Used for testing drainage and manual retry workflows.
*
* @param providerId - Provider whose entries to drain.
* @param count - Maximum number of entries to remove.
* @returns Array of removed entries.
*/
drain(providerId: string, count: number): DlqEntry[];
/**
* Remove all entries from the DLQ.
* Intended for use in tests only.
*
* @internal
*/
clear(): void;
}
// ---------------------------------------------------------------------------
// InMemoryDlqStore
// ---------------------------------------------------------------------------
/**
* In-memory DLQ store implementation.
*
* Entries are held in a `Map<providerId, Array<DlqEntry>>`. This is suitable
* for single-process deployments or development/testing. For production
* multi-process deployments, use a Redis-backed or database-backed store.
*/
export class InMemoryDlqStore implements DlqStore {
private readonly entries: Map<string, DlqEntry[]> = new Map();
/**
* Add a failed delivery to the DLQ.
*/
public push(entry: DlqEntry): void {
const queue = this.entries.get(entry.providerId) ?? [];
queue.push(entry);
this.entries.set(entry.providerId, queue);
}
/**
* Return the current DLQ depth per provider.
*/
public getDepthByProvider(): Map<string, number> {
const result = new Map<string, number>();
for (const [providerId, queue] of this.entries.entries()) {
result.set(providerId, queue.length);
}
return result;
}
/**
* Return the age (in seconds) of the oldest entry per provider.
*/
public getOldestAgeByProvider(): Map<string, number> {
const result = new Map<string, number>();
const nowMs = Date.now();
for (const [providerId, queue] of this.entries.entries()) {
if (queue.length === 0) {
continue; // Skip providers with empty queues
}
// Oldest entry is the first one (FIFO order)
const oldest = queue[0];
const ageSeconds = (nowMs - oldest.timestamp) / 1_000;
result.set(providerId, ageSeconds);
}
return result;
}
/**
* Remove up to `count` entries from the DLQ for a given provider.
*/
public drain(providerId: string, count: number): DlqEntry[] {
const queue = this.entries.get(providerId);
if (!queue || queue.length === 0) {
return [];
}
const drained = queue.splice(0, count);
// Remove the provider entry entirely if the queue is now empty
if (queue.length === 0) {
this.entries.delete(providerId);
}
return drained;
}
/**
* Atomically remove up to `replayCap` entries and return them.
* If the queue has more than `replayCap` entries, only the oldest `replayCap`
* are removed. The removal and cap check happen in a single synchronous step so
* concurrent callers (in the same process) cannot observe an intermediate state
* where entries are removed but the cap has not yet been enforced.
*
* @param providerId - Provider whose entries to drain.
* @param replayCap - Maximum number of entries to remove in this batch.
* @returns Array of removed entries (at most `replayCap`).
*/
public drainWithCap(providerId: string, replayCap: number): DlqEntry[] {
if (replayCap <= 0) return [];
const queue = this.entries.get(providerId);
if (!queue || queue.length === 0) return [];
// Splice is synchronous — read + remove in one operation
const batch = queue.splice(0, replayCap);
if (queue.length === 0) {
this.entries.delete(providerId);
}
return batch;
}
/**
* Remove all entries from the DLQ.
* Intended for use in tests only.
*
* @internal
*/
public clear(): void {
this.entries.clear();
}
}