lopata 0.7.0 → 0.8.2
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/dist/dashboard/{chunk-pqnphvm2.css → chunk-csyd2tq2.css} +27 -0
- package/dist/dashboard/{chunk-5nxa3jfc.js → chunk-yxzrcvyh.js} +364 -3
- package/dist/dashboard/index.html +1 -1
- package/package.json +5 -3
- package/src/api/handlers/generations.ts +19 -5
- package/src/api/types.ts +14 -0
- package/src/bindings/cache.ts +14 -8
- package/src/bindings/durable-object.ts +80 -21
- package/src/bindings/kv.ts +12 -8
- package/src/bindings/queue.ts +22 -12
- package/src/bindings/workflow.ts +332 -25
- package/src/env.ts +3 -2
- package/src/file-watcher.ts +59 -32
- package/src/generation-manager.ts +6 -1
- package/src/generation.ts +15 -3
- package/src/plugin.ts +2 -90
- package/src/setup-globals.ts +23 -21
- package/src/testing/clock.ts +26 -0
- package/src/testing/durable-object.ts +325 -0
- package/src/testing/env-builder.ts +126 -0
- package/src/testing/fetch-mock.ts +145 -0
- package/src/testing/index.ts +288 -0
- package/src/testing/setup.ts +68 -0
- package/src/testing/types.ts +68 -0
- package/src/testing/workflow.ts +323 -0
- package/src/tracing/store.ts +6 -0
- package/src/tracing/types.ts +1 -0
- package/src/virtual-modules.ts +99 -0
- package/src/vite-plugin/config-plugin.ts +2 -0
- package/src/vite-plugin/dev-server-plugin.ts +159 -56
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { Database, type SQLQueryBindings } from 'bun:sqlite'
|
|
2
2
|
import { existsSync, mkdirSync, rmSync, statSync } from 'node:fs'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
|
+
import type { Clock } from '../testing/clock'
|
|
5
|
+
import { realClock } from '../testing/clock'
|
|
4
6
|
import { persistError, startSpan } from '../tracing/span'
|
|
5
7
|
import type { ContainerContext } from './container'
|
|
6
8
|
import type { ContainerConfig } from './container'
|
|
@@ -84,20 +86,23 @@ export class SqlStorageCursor implements Iterable<Record<string, unknown>> {
|
|
|
84
86
|
// --- SQL Storage API ---
|
|
85
87
|
|
|
86
88
|
export class SqlStorage {
|
|
87
|
-
private _dbPath: string
|
|
89
|
+
private _dbPath: string | null
|
|
88
90
|
private _db: Database | null = null
|
|
89
91
|
|
|
90
|
-
constructor(dbPath: string) {
|
|
92
|
+
constructor(dbPath: string | null) {
|
|
91
93
|
this._dbPath = dbPath
|
|
92
94
|
}
|
|
93
95
|
|
|
94
96
|
private _getDb(): Database {
|
|
95
97
|
if (!this._db) {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
98
|
+
if (this._dbPath) {
|
|
99
|
+
const dir = this._dbPath.substring(0, this._dbPath.lastIndexOf('/'))
|
|
100
|
+
mkdirSync(dir, { recursive: true })
|
|
101
|
+
this._db = new Database(this._dbPath, { create: true })
|
|
102
|
+
this._db.run('PRAGMA journal_mode=WAL')
|
|
103
|
+
} else {
|
|
104
|
+
this._db = new Database(':memory:')
|
|
105
|
+
}
|
|
101
106
|
}
|
|
102
107
|
return this._db
|
|
103
108
|
}
|
|
@@ -123,6 +128,11 @@ export class SqlStorage {
|
|
|
123
128
|
}
|
|
124
129
|
|
|
125
130
|
get databaseSize(): number {
|
|
131
|
+
if (!this._dbPath) {
|
|
132
|
+
const db = this._getDb()
|
|
133
|
+
const row = db.query('SELECT page_count * page_size as size FROM pragma_page_count(), pragma_page_size()').get() as { size: number } | null
|
|
134
|
+
return row?.size ?? 0
|
|
135
|
+
}
|
|
126
136
|
try {
|
|
127
137
|
return statSync(this._dbPath).size
|
|
128
138
|
} catch {
|
|
@@ -273,10 +283,9 @@ export class SqliteDurableObjectStorage {
|
|
|
273
283
|
|
|
274
284
|
get sql(): SqlStorage {
|
|
275
285
|
if (!this._sql) {
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
const dbPath = join(this._dataDir, 'do-sql', this.namespace, `${this.id}.sqlite`)
|
|
286
|
+
const dbPath = this._dataDir
|
|
287
|
+
? join(this._dataDir, 'do-sql', this.namespace, `${this.id}.sqlite`)
|
|
288
|
+
: null
|
|
280
289
|
this._sql = new SqlStorage(dbPath)
|
|
281
290
|
}
|
|
282
291
|
return this._sql
|
|
@@ -740,14 +749,17 @@ export class DurableObjectNamespaceImpl {
|
|
|
740
749
|
private _containerConfig?: ContainerConfig
|
|
741
750
|
private _factoryOverride?: DOExecutorFactory
|
|
742
751
|
private _defaultFactory?: DOExecutorFactory
|
|
752
|
+
private _generationId?: number
|
|
753
|
+
private clock: Clock
|
|
743
754
|
|
|
744
|
-
constructor(db: Database, namespaceName: string, dataDir?: string, limits?: DurableObjectLimits, factory?: DOExecutorFactory) {
|
|
755
|
+
constructor(db: Database, namespaceName: string, dataDir?: string, limits?: DurableObjectLimits, factory?: DOExecutorFactory, clock?: Clock) {
|
|
745
756
|
this.db = db
|
|
746
757
|
this.namespaceName = namespaceName
|
|
747
758
|
this.dataDir = dataDir
|
|
748
759
|
this.limits = limits
|
|
749
760
|
this._factoryOverride = factory
|
|
750
761
|
this._evictionTimeoutMs = limits?.evictionTimeoutMs ?? 120_000
|
|
762
|
+
this.clock = clock ?? realClock
|
|
751
763
|
if (this._evictionTimeoutMs > 0) {
|
|
752
764
|
this._evictionTimer = setInterval(() => this._evictIdle(), 30_000)
|
|
753
765
|
}
|
|
@@ -763,9 +775,19 @@ export class DurableObjectNamespaceImpl {
|
|
|
763
775
|
}
|
|
764
776
|
|
|
765
777
|
/** Called after worker module is loaded to wire the actual class */
|
|
766
|
-
_setClass(cls: new(ctx: DurableObjectStateImpl, env: unknown) => DurableObjectBase, env: unknown) {
|
|
778
|
+
_setClass(cls: new(ctx: DurableObjectStateImpl, env: unknown) => DurableObjectBase, env: unknown, generationId?: number) {
|
|
779
|
+
// Dispose existing executors so next request creates new ones with the new class
|
|
780
|
+
for (const [idStr, executor] of this._executors) {
|
|
781
|
+
if (executor.activeWebSocketCount() === 0) {
|
|
782
|
+
executor.dispose().catch(() => {})
|
|
783
|
+
this._executors.delete(idStr)
|
|
784
|
+
this._lastActivity.delete(idStr)
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
767
788
|
this._class = cls
|
|
768
789
|
this._env = env
|
|
790
|
+
this._generationId = generationId
|
|
769
791
|
// Restore persisted alarms on startup
|
|
770
792
|
this._restoreAlarms()
|
|
771
793
|
}
|
|
@@ -791,7 +813,7 @@ export class DurableObjectNamespaceImpl {
|
|
|
791
813
|
const existing = this.alarmTimers.get(idStr)
|
|
792
814
|
if (existing) clearTimeout(existing)
|
|
793
815
|
|
|
794
|
-
const delay = Math.max(0, scheduledTime -
|
|
816
|
+
const delay = Math.max(0, scheduledTime - this.clock.now())
|
|
795
817
|
const timer = setTimeout(() => {
|
|
796
818
|
this.alarmTimers.delete(idStr)
|
|
797
819
|
this._fireAlarm(idStr, 0)
|
|
@@ -804,7 +826,7 @@ export class DurableObjectNamespaceImpl {
|
|
|
804
826
|
const executor = this._getOrCreateExecutor(idStr)
|
|
805
827
|
if (!executor) return
|
|
806
828
|
|
|
807
|
-
this._lastActivity.set(idStr,
|
|
829
|
+
this._lastActivity.set(idStr, this.clock.now())
|
|
808
830
|
|
|
809
831
|
// Delete alarm from DB before calling handler (matching CF behavior)
|
|
810
832
|
this.db
|
|
@@ -819,6 +841,7 @@ export class DurableObjectNamespaceImpl {
|
|
|
819
841
|
'do.namespace': this.namespaceName,
|
|
820
842
|
'do.id': idStr,
|
|
821
843
|
'do.alarm.retryCount': retryCount,
|
|
844
|
+
...(this._generationId != null ? { 'lopata.generation_id': this._generationId } : {}),
|
|
822
845
|
},
|
|
823
846
|
}, () => executor.executeAlarm(retryCount))
|
|
824
847
|
} catch (e) {
|
|
@@ -826,7 +849,7 @@ export class DurableObjectNamespaceImpl {
|
|
|
826
849
|
if (retryCount < MAX_ALARM_RETRIES) {
|
|
827
850
|
// Exponential backoff: 1s, 2s, 4s, 8s, 16s, 32s
|
|
828
851
|
const backoffMs = Math.pow(2, retryCount) * 1000
|
|
829
|
-
const retryTime =
|
|
852
|
+
const retryTime = this.clock.now() + backoffMs
|
|
830
853
|
// Re-persist alarm for retry
|
|
831
854
|
this.db
|
|
832
855
|
.query('INSERT OR REPLACE INTO do_alarms (namespace, id, alarm_time) VALUES (?, ?, ?)')
|
|
@@ -876,13 +899,13 @@ export class DurableObjectNamespaceImpl {
|
|
|
876
899
|
})
|
|
877
900
|
|
|
878
901
|
this._executors.set(idStr, executor)
|
|
879
|
-
this._lastActivity.set(idStr,
|
|
902
|
+
this._lastActivity.set(idStr, this.clock.now())
|
|
880
903
|
return executor
|
|
881
904
|
}
|
|
882
905
|
|
|
883
906
|
/** @internal Evict idle executors */
|
|
884
907
|
private _evictIdle() {
|
|
885
|
-
const now =
|
|
908
|
+
const now = this.clock.now()
|
|
886
909
|
for (const [idStr, lastActivity] of this._lastActivity) {
|
|
887
910
|
const executor = this._executors.get(idStr)
|
|
888
911
|
if (!executor) continue
|
|
@@ -944,6 +967,23 @@ export class DurableObjectNamespaceImpl {
|
|
|
944
967
|
return this._executors.get(idStr) ?? null
|
|
945
968
|
}
|
|
946
969
|
|
|
970
|
+
/** @internal List active executors with their WebSocket counts */
|
|
971
|
+
_listActiveExecutors(): Array<{ id: string; wsCount: number }> {
|
|
972
|
+
const result: Array<{ id: string; wsCount: number }> = []
|
|
973
|
+
for (const [id, executor] of this._executors) {
|
|
974
|
+
result.push({ id, wsCount: executor.activeWebSocketCount() })
|
|
975
|
+
}
|
|
976
|
+
return result
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/** @internal List all instance IDs in this namespace (from DB). */
|
|
980
|
+
_listInstanceIds(): string[] {
|
|
981
|
+
const rows = this.db
|
|
982
|
+
.query('SELECT id FROM do_instances WHERE namespace = ? ORDER BY created_at ASC')
|
|
983
|
+
.all(this.namespaceName) as { id: string }[]
|
|
984
|
+
return rows.map(r => r.id)
|
|
985
|
+
}
|
|
986
|
+
|
|
947
987
|
/** Whether the DO class defines an alarm() handler */
|
|
948
988
|
hasAlarmHandler(): boolean {
|
|
949
989
|
return typeof this._class?.prototype?.alarm === 'function'
|
|
@@ -958,6 +998,25 @@ export class DurableObjectNamespaceImpl {
|
|
|
958
998
|
return this._fireAlarm(idStr, 0)
|
|
959
999
|
}
|
|
960
1000
|
|
|
1001
|
+
/** @internal Fire all alarms whose scheduled time is <= clock.now(). Returns an array of promises. */
|
|
1002
|
+
_fireReadyAlarms(): Promise<void>[] {
|
|
1003
|
+
const now = this.clock.now()
|
|
1004
|
+
const rows = this.db
|
|
1005
|
+
.query('SELECT id, alarm_time FROM do_alarms WHERE namespace = ?')
|
|
1006
|
+
.all(this.namespaceName) as { id: string; alarm_time: number }[]
|
|
1007
|
+
const results: Promise<void>[] = []
|
|
1008
|
+
for (const row of rows) {
|
|
1009
|
+
if (row.alarm_time <= now) {
|
|
1010
|
+
// Cancel the existing setTimeout timer
|
|
1011
|
+
const existing = this.alarmTimers.get(row.id)
|
|
1012
|
+
if (existing) clearTimeout(existing)
|
|
1013
|
+
this.alarmTimers.delete(row.id)
|
|
1014
|
+
results.push(this._fireAlarm(row.id, 0))
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
return results
|
|
1018
|
+
}
|
|
1019
|
+
|
|
961
1020
|
/** @internal Cancel a scheduled alarm without firing it */
|
|
962
1021
|
cancelAlarm(idStr: string): void {
|
|
963
1022
|
const timer = this.alarmTimers.get(idStr)
|
|
@@ -1058,7 +1117,7 @@ export class DurableObjectNamespaceImpl {
|
|
|
1058
1117
|
if (prop === 'fetch') {
|
|
1059
1118
|
return async (input: Request | string | URL, init?: RequestInit) => {
|
|
1060
1119
|
const executor = self._getOrCreateExecutor(idStr, id)!
|
|
1061
|
-
self._lastActivity.set(idStr,
|
|
1120
|
+
self._lastActivity.set(idStr, self.clock.now())
|
|
1062
1121
|
const request = input instanceof Request ? input : new Request(input instanceof URL ? input.href : input, init)
|
|
1063
1122
|
return await executor.executeFetch(request)
|
|
1064
1123
|
}
|
|
@@ -1067,7 +1126,7 @@ export class DurableObjectNamespaceImpl {
|
|
|
1067
1126
|
// RPC: return a callable that also acts as a thenable for property access
|
|
1068
1127
|
const rpcCallable = (...args: unknown[]) => {
|
|
1069
1128
|
const executor = self._getOrCreateExecutor(idStr, id)!
|
|
1070
|
-
self._lastActivity.set(idStr,
|
|
1129
|
+
self._lastActivity.set(idStr, self.clock.now())
|
|
1071
1130
|
return executor.executeRpc(String(prop), args)
|
|
1072
1131
|
}
|
|
1073
1132
|
|
|
@@ -1077,7 +1136,7 @@ export class DurableObjectNamespaceImpl {
|
|
|
1077
1136
|
onRejected?: ((reason: unknown) => unknown) | null,
|
|
1078
1137
|
) => {
|
|
1079
1138
|
const executor = self._getOrCreateExecutor(idStr, id)!
|
|
1080
|
-
self._lastActivity.set(idStr,
|
|
1139
|
+
self._lastActivity.set(idStr, self.clock.now())
|
|
1081
1140
|
const promise = executor.executeRpcGet(String(prop))
|
|
1082
1141
|
return promise.then(onFulfilled, onRejected)
|
|
1083
1142
|
}
|
package/src/bindings/kv.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { Database } from 'bun:sqlite'
|
|
2
|
+
import type { Clock } from '../testing/clock'
|
|
3
|
+
import { realClock } from '../testing/clock'
|
|
2
4
|
|
|
3
5
|
export interface KVLimits {
|
|
4
6
|
maxKeySize?: number // default 512 (bytes)
|
|
@@ -22,11 +24,13 @@ export class SqliteKVNamespace {
|
|
|
22
24
|
private db: Database
|
|
23
25
|
private namespace: string
|
|
24
26
|
private limits: Required<KVLimits>
|
|
27
|
+
private clock: Clock
|
|
25
28
|
|
|
26
|
-
constructor(db: Database, namespace: string, limits?: KVLimits) {
|
|
29
|
+
constructor(db: Database, namespace: string, limits?: KVLimits, clock?: Clock) {
|
|
27
30
|
this.db = db
|
|
28
31
|
this.namespace = namespace
|
|
29
32
|
this.limits = { ...KV_DEFAULTS, ...limits }
|
|
33
|
+
this.clock = clock ?? realClock
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
private validateKey(key: string): void {
|
|
@@ -76,7 +80,7 @@ export class SqliteKVNamespace {
|
|
|
76
80
|
|
|
77
81
|
if (!row) return null
|
|
78
82
|
|
|
79
|
-
if (row.expiration && row.expiration <
|
|
83
|
+
if (row.expiration && row.expiration < this.clock.now() / 1000) {
|
|
80
84
|
this.db.run('DELETE FROM kv WHERE namespace = ? AND key = ?', [this.namespace, keyOrKeys])
|
|
81
85
|
return null
|
|
82
86
|
}
|
|
@@ -101,7 +105,7 @@ export class SqliteKVNamespace {
|
|
|
101
105
|
if (type === 'arrayBuffer' || type === 'stream') {
|
|
102
106
|
throw new Error(`KV bulk get does not support type "${type}"`)
|
|
103
107
|
}
|
|
104
|
-
const now =
|
|
108
|
+
const now = this.clock.now() / 1000
|
|
105
109
|
|
|
106
110
|
if (keys.length === 0) return result
|
|
107
111
|
|
|
@@ -157,7 +161,7 @@ export class SqliteKVNamespace {
|
|
|
157
161
|
|
|
158
162
|
if (!row) return { value: null, metadata: null, cacheStatus: null }
|
|
159
163
|
|
|
160
|
-
if (row.expiration && row.expiration <
|
|
164
|
+
if (row.expiration && row.expiration < this.clock.now() / 1000) {
|
|
161
165
|
this.db.run('DELETE FROM kv WHERE namespace = ? AND key = ?', [this.namespace, keyOrKeys])
|
|
162
166
|
return { value: null, metadata: null, cacheStatus: null }
|
|
163
167
|
}
|
|
@@ -184,7 +188,7 @@ export class SqliteKVNamespace {
|
|
|
184
188
|
if (type === 'arrayBuffer' || type === 'stream') {
|
|
185
189
|
throw new Error(`KV bulk get does not support type "${type}"`)
|
|
186
190
|
}
|
|
187
|
-
const now =
|
|
191
|
+
const now = this.clock.now() / 1000
|
|
188
192
|
|
|
189
193
|
if (keys.length === 0) return result
|
|
190
194
|
|
|
@@ -233,7 +237,7 @@ export class SqliteKVNamespace {
|
|
|
233
237
|
this.validateTtl(options.expirationTtl)
|
|
234
238
|
}
|
|
235
239
|
if (options?.expiration !== undefined) {
|
|
236
|
-
const minExpiration =
|
|
240
|
+
const minExpiration = this.clock.now() / 1000 + this.limits.minTtlSeconds
|
|
237
241
|
if (options.expiration < minExpiration) {
|
|
238
242
|
throw new Error(`KV expiration must be at least ${this.limits.minTtlSeconds} seconds in the future`)
|
|
239
243
|
}
|
|
@@ -241,7 +245,7 @@ export class SqliteKVNamespace {
|
|
|
241
245
|
|
|
242
246
|
let expiration: number | null = null
|
|
243
247
|
if (options?.expiration) expiration = options.expiration
|
|
244
|
-
else if (options?.expirationTtl) expiration =
|
|
248
|
+
else if (options?.expirationTtl) expiration = this.clock.now() / 1000 + options.expirationTtl
|
|
245
249
|
|
|
246
250
|
let metadata: string | null = null
|
|
247
251
|
if (options?.metadata !== undefined) {
|
|
@@ -263,7 +267,7 @@ export class SqliteKVNamespace {
|
|
|
263
267
|
const limit = Math.min(options?.limit ?? 1000, 1000)
|
|
264
268
|
const cursor = options?.cursor ?? ''
|
|
265
269
|
|
|
266
|
-
const now =
|
|
270
|
+
const now = this.clock.now() / 1000
|
|
267
271
|
|
|
268
272
|
// Lazily delete expired entries for this namespace
|
|
269
273
|
this.db.run(
|
package/src/bindings/queue.ts
CHANGED
|
@@ -2,6 +2,8 @@ import { randomUUIDv7 } from 'bun'
|
|
|
2
2
|
import type { Database } from 'bun:sqlite'
|
|
3
3
|
import crypto from 'node:crypto'
|
|
4
4
|
import { ExecutionContext } from '../execution-context'
|
|
5
|
+
import type { Clock } from '../testing/clock'
|
|
6
|
+
import { realClock } from '../testing/clock'
|
|
5
7
|
import { persistError, startSpan } from '../tracing/span'
|
|
6
8
|
|
|
7
9
|
// --- Types ---
|
|
@@ -108,12 +110,14 @@ export class SqliteQueueProducer {
|
|
|
108
110
|
private queueName: string
|
|
109
111
|
private defaultDelay: number
|
|
110
112
|
private limits: Required<QueueLimits>
|
|
113
|
+
private clock: Clock
|
|
111
114
|
|
|
112
|
-
constructor(db: Database, queueName: string, defaultDelay: number = 0, limits?: QueueLimits) {
|
|
115
|
+
constructor(db: Database, queueName: string, defaultDelay: number = 0, limits?: QueueLimits, clock?: Clock) {
|
|
113
116
|
this.db = db
|
|
114
117
|
this.queueName = queueName
|
|
115
118
|
this.defaultDelay = defaultDelay
|
|
116
119
|
this.limits = { ...QUEUE_DEFAULTS, ...limits }
|
|
120
|
+
this.clock = clock ?? realClock
|
|
117
121
|
}
|
|
118
122
|
|
|
119
123
|
async send(message: unknown, options?: SendOptions): Promise<void> {
|
|
@@ -130,7 +134,7 @@ export class SqliteQueueProducer {
|
|
|
130
134
|
throw new Error(`Message exceeds max size of ${this.limits.maxMessageSize} bytes`)
|
|
131
135
|
}
|
|
132
136
|
|
|
133
|
-
const now =
|
|
137
|
+
const now = this.clock.now()
|
|
134
138
|
const visibleAt = now + delaySeconds * 1000
|
|
135
139
|
|
|
136
140
|
this.db.run(
|
|
@@ -147,7 +151,7 @@ export class SqliteQueueProducer {
|
|
|
147
151
|
const stmt = this.db.prepare(
|
|
148
152
|
'INSERT INTO queue_messages (id, queue, body, content_type, attempts, visible_at, created_at) VALUES (?, ?, ?, ?, 0, ?, ?)',
|
|
149
153
|
)
|
|
150
|
-
const now =
|
|
154
|
+
const now = this.clock.now()
|
|
151
155
|
|
|
152
156
|
// Pre-encode all messages and validate total size
|
|
153
157
|
const encoded: { data: Uint8Array; contentType: string; delaySeconds: number }[] = []
|
|
@@ -199,18 +203,22 @@ export class QueueConsumer {
|
|
|
199
203
|
private polling = false
|
|
200
204
|
private _activeDeliveries = 0
|
|
201
205
|
|
|
206
|
+
private clock: Clock
|
|
207
|
+
|
|
202
208
|
constructor(
|
|
203
209
|
db: Database,
|
|
204
210
|
config: ConsumerConfig,
|
|
205
211
|
handler: QueueHandler,
|
|
206
212
|
env: Record<string, unknown>,
|
|
207
213
|
workerName?: string,
|
|
214
|
+
clock?: Clock,
|
|
208
215
|
) {
|
|
209
216
|
this.db = db
|
|
210
217
|
this.config = config
|
|
211
218
|
this.handler = handler
|
|
212
219
|
this.env = env
|
|
213
220
|
this.workerName = workerName
|
|
221
|
+
this.clock = clock ?? realClock
|
|
214
222
|
}
|
|
215
223
|
|
|
216
224
|
start(intervalMs: number = 1000): void {
|
|
@@ -237,7 +245,7 @@ export class QueueConsumer {
|
|
|
237
245
|
if (this.config.maxConcurrency && this._activeDeliveries >= this.config.maxConcurrency) return
|
|
238
246
|
this.polling = true
|
|
239
247
|
try {
|
|
240
|
-
const now =
|
|
248
|
+
const now = this.clock.now()
|
|
241
249
|
|
|
242
250
|
// Periodically clean up completed messages beyond retention period
|
|
243
251
|
const retentionMs = (this.config.retentionPeriodSeconds ?? 345600) * 1000
|
|
@@ -343,7 +351,7 @@ export class QueueConsumer {
|
|
|
343
351
|
|
|
344
352
|
if (!decision || decision.type === 'ack') {
|
|
345
353
|
// Ack (explicit or default) — mark as acked
|
|
346
|
-
this.db.run("UPDATE queue_messages SET status = 'acked', completed_at = ? WHERE id = ?", [
|
|
354
|
+
this.db.run("UPDATE queue_messages SET status = 'acked', completed_at = ? WHERE id = ?", [this.clock.now(), row.id])
|
|
347
355
|
} else {
|
|
348
356
|
// Retry
|
|
349
357
|
const delay = decision.delaySeconds ?? this.config.retryDelay ?? 0
|
|
@@ -352,17 +360,17 @@ export class QueueConsumer {
|
|
|
352
360
|
if (this.config.deadLetterQueue) {
|
|
353
361
|
this.db.run(
|
|
354
362
|
"UPDATE queue_messages SET queue = ?, visible_at = ?, status = 'pending' WHERE id = ?",
|
|
355
|
-
[this.config.deadLetterQueue,
|
|
363
|
+
[this.config.deadLetterQueue, this.clock.now(), row.id],
|
|
356
364
|
)
|
|
357
365
|
} else {
|
|
358
366
|
console.warn(`[lopata] Queue message ${row.id} exceeded max retries (${this.config.maxRetries}), discarding`)
|
|
359
|
-
this.db.run("UPDATE queue_messages SET status = 'failed', completed_at = ? WHERE id = ?", [
|
|
367
|
+
this.db.run("UPDATE queue_messages SET status = 'failed', completed_at = ? WHERE id = ?", [this.clock.now(), row.id])
|
|
360
368
|
}
|
|
361
369
|
} else {
|
|
362
370
|
// Retry with delay
|
|
363
371
|
this.db.run(
|
|
364
372
|
'UPDATE queue_messages SET visible_at = ? WHERE id = ?',
|
|
365
|
-
[
|
|
373
|
+
[this.clock.now() + delay * 1000, row.id],
|
|
366
374
|
)
|
|
367
375
|
}
|
|
368
376
|
}
|
|
@@ -400,16 +408,18 @@ const DEFAULT_PULL_BATCH_SIZE = 10
|
|
|
400
408
|
export class QueuePullConsumer {
|
|
401
409
|
private db: Database
|
|
402
410
|
private queueName: string
|
|
411
|
+
private clock: Clock
|
|
403
412
|
|
|
404
|
-
constructor(db: Database, queueName: string) {
|
|
413
|
+
constructor(db: Database, queueName: string, clock?: Clock) {
|
|
405
414
|
this.db = db
|
|
406
415
|
this.queueName = queueName
|
|
416
|
+
this.clock = clock ?? realClock
|
|
407
417
|
}
|
|
408
418
|
|
|
409
419
|
pull(options?: PullRequest): PullResponse {
|
|
410
420
|
const batchSize = options?.batch_size ?? DEFAULT_PULL_BATCH_SIZE
|
|
411
421
|
const visibilityTimeoutMs = options?.visibility_timeout_ms ?? DEFAULT_VISIBILITY_TIMEOUT_MS
|
|
412
|
-
const now =
|
|
422
|
+
const now = this.clock.now()
|
|
413
423
|
|
|
414
424
|
// Clean up expired leases — make messages visible again
|
|
415
425
|
this.db.run(
|
|
@@ -472,7 +482,7 @@ export class QueuePullConsumer {
|
|
|
472
482
|
ack(request: AckRequest): { acked: number; retried: number } {
|
|
473
483
|
let acked = 0
|
|
474
484
|
let retried = 0
|
|
475
|
-
const now =
|
|
485
|
+
const now = this.clock.now()
|
|
476
486
|
|
|
477
487
|
const tx = this.db.transaction(() => {
|
|
478
488
|
// Process acks
|
|
@@ -487,7 +497,7 @@ export class QueuePullConsumer {
|
|
|
487
497
|
).get(lease_id, this.queueName, now)
|
|
488
498
|
|
|
489
499
|
if (lease) {
|
|
490
|
-
this.db.run("UPDATE queue_messages SET status = 'acked', completed_at = ? WHERE id = ?", [
|
|
500
|
+
this.db.run("UPDATE queue_messages SET status = 'acked', completed_at = ? WHERE id = ?", [this.clock.now(), lease.message_id])
|
|
491
501
|
this.db.run('DELETE FROM queue_leases WHERE lease_id = ?', [lease_id])
|
|
492
502
|
acked++
|
|
493
503
|
}
|