@delma/fylo 1.0.0 → 1.1.0
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/.github/workflows/publish.yml +4 -14
- package/README.md +66 -7
- package/package.json +4 -2
- package/src/adapters/redis.ts +346 -0
- package/src/core/write-queue.ts +56 -0
- package/src/index.ts +292 -29
- package/src/types/fylo.d.ts +37 -3
- package/src/types/write-queue.ts +42 -0
- package/src/worker.ts +12 -0
- package/src/workers/write-worker.ts +119 -0
- package/tests/integration/queue.test.ts +99 -0
- package/tests/mocks/redis.ts +156 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { test, expect, describe, beforeAll, afterAll, mock } from 'bun:test'
|
|
2
|
+
import Fylo from '../../src'
|
|
3
|
+
import S3Mock from '../mocks/s3'
|
|
4
|
+
import RedisMock from '../mocks/redis'
|
|
5
|
+
|
|
6
|
+
const POSTS = 'queued-post'
|
|
7
|
+
|
|
8
|
+
mock.module('../../src/adapters/s3', () => ({ S3: S3Mock }))
|
|
9
|
+
mock.module('../../src/adapters/redis', () => ({ Redis: RedisMock }))
|
|
10
|
+
|
|
11
|
+
const fylo = new Fylo()
|
|
12
|
+
|
|
13
|
+
beforeAll(async () => {
|
|
14
|
+
await Fylo.createCollection(POSTS)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
afterAll(async () => {
|
|
18
|
+
await Fylo.dropCollection(POSTS)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
describe('queue writes', () => {
|
|
22
|
+
test('queuePutData enqueues and worker commits insert', async () => {
|
|
23
|
+
const queued = await fylo.queuePutData(POSTS, {
|
|
24
|
+
title: 'Queued Title',
|
|
25
|
+
body: 'Queued Body'
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const queuedStatus = await fylo.getJobStatus(queued.jobId)
|
|
29
|
+
expect(queuedStatus?.status).toBe('queued')
|
|
30
|
+
|
|
31
|
+
const processed = await fylo.processQueuedWrites(1)
|
|
32
|
+
expect(processed).toBe(1)
|
|
33
|
+
|
|
34
|
+
const doc = await Fylo.getDoc<_post>(POSTS, queued.docId, false).once()
|
|
35
|
+
expect(Object.keys(doc).length).toBe(1)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('putData can return immediately when wait is false', async () => {
|
|
39
|
+
const queued = await fylo.putData(POSTS, {
|
|
40
|
+
title: 'Async Title',
|
|
41
|
+
body: 'Async Body'
|
|
42
|
+
} as _post, { wait: false })
|
|
43
|
+
|
|
44
|
+
expect(typeof queued).toBe('object')
|
|
45
|
+
expect('jobId' in queued).toBe(true)
|
|
46
|
+
|
|
47
|
+
const before = await Fylo.getDoc<_post>(POSTS, queued.docId, false).once()
|
|
48
|
+
expect(Object.keys(before).length).toBe(0)
|
|
49
|
+
|
|
50
|
+
const processed = await fylo.processQueuedWrites(1)
|
|
51
|
+
expect(processed).toBe(1)
|
|
52
|
+
|
|
53
|
+
const stats = await fylo.getQueueStats()
|
|
54
|
+
expect(stats.deadLetters).toBe(0)
|
|
55
|
+
|
|
56
|
+
const after = await Fylo.getDoc<_post>(POSTS, queued.docId, false).once()
|
|
57
|
+
expect(Object.keys(after).length).toBe(1)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('failed jobs can be recovered and eventually dead-lettered', async () => {
|
|
61
|
+
const originalExecute = fylo.executeQueuedWrite.bind(fylo)
|
|
62
|
+
;(fylo as any).executeQueuedWrite = async () => {
|
|
63
|
+
throw new Error('simulated write failure')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const queued = await fylo.putData(POSTS, {
|
|
68
|
+
title: 'Broken Title',
|
|
69
|
+
body: 'Broken Body'
|
|
70
|
+
} as _post, { wait: false })
|
|
71
|
+
|
|
72
|
+
expect(await fylo.processQueuedWrites(1)).toBe(0)
|
|
73
|
+
expect((await fylo.getJobStatus(queued.jobId))?.status).toBe('failed')
|
|
74
|
+
|
|
75
|
+
await Bun.sleep(15)
|
|
76
|
+
expect(await fylo.processQueuedWrites(1, true)).toBe(0)
|
|
77
|
+
expect((await fylo.getJobStatus(queued.jobId))?.status).toBe('failed')
|
|
78
|
+
|
|
79
|
+
await Bun.sleep(25)
|
|
80
|
+
expect(await fylo.processQueuedWrites(1, true)).toBe(0)
|
|
81
|
+
expect((await fylo.getJobStatus(queued.jobId))?.status).toBe('dead-letter')
|
|
82
|
+
|
|
83
|
+
const deadLetters = await fylo.getDeadLetters()
|
|
84
|
+
expect((await fylo.getQueueStats()).deadLetters).toBeGreaterThan(0)
|
|
85
|
+
expect(deadLetters.some(item => item.job.jobId === queued.jobId)).toBe(true)
|
|
86
|
+
|
|
87
|
+
;(fylo as any).executeQueuedWrite = originalExecute
|
|
88
|
+
|
|
89
|
+
const replayed = await fylo.replayDeadLetter(deadLetters[0]!.streamId)
|
|
90
|
+
expect(replayed?.jobId).toBe(queued.jobId)
|
|
91
|
+
expect((await fylo.getQueueStats()).deadLetters).toBe(0)
|
|
92
|
+
|
|
93
|
+
expect(await fylo.processQueuedWrites(1)).toBe(1)
|
|
94
|
+
expect((await fylo.getJobStatus(queued.jobId))?.status).toBe('committed')
|
|
95
|
+
} finally {
|
|
96
|
+
;(fylo as any).executeQueuedWrite = originalExecute
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
})
|
package/tests/mocks/redis.ts
CHANGED
|
@@ -5,9 +5,165 @@
|
|
|
5
5
|
*/
|
|
6
6
|
export default class RedisMock {
|
|
7
7
|
|
|
8
|
+
private static stream: Array<{
|
|
9
|
+
streamId: string
|
|
10
|
+
jobId: string
|
|
11
|
+
collection: string
|
|
12
|
+
docId: _ttid
|
|
13
|
+
operation: string
|
|
14
|
+
claimedBy?: string
|
|
15
|
+
}> = []
|
|
16
|
+
|
|
17
|
+
private static jobs = new Map<string, Record<string, any>>()
|
|
18
|
+
|
|
19
|
+
private static docs = new Map<string, Record<string, string>>()
|
|
20
|
+
|
|
21
|
+
private static locks = new Map<string, string>()
|
|
22
|
+
|
|
23
|
+
private static deadLetters: Array<{ streamId: string, jobId: string, reason?: string, failedAt: number }> = []
|
|
24
|
+
|
|
25
|
+
private static nextId = 0
|
|
26
|
+
|
|
8
27
|
async publish(_collection: string, _action: 'insert' | 'delete', _keyId: string | _ttid): Promise<void> {}
|
|
9
28
|
|
|
10
29
|
async claimTTID(_id: _ttid, _ttlSeconds: number = 10): Promise<boolean> { return true }
|
|
11
30
|
|
|
31
|
+
async enqueueWrite(job: Record<string, any>) {
|
|
32
|
+
RedisMock.jobs.set(job.jobId, { ...job, nextAttemptAt: job.nextAttemptAt ?? Date.now() })
|
|
33
|
+
RedisMock.docs.set(`fylo:doc:${job.collection}:${job.docId}`, {
|
|
34
|
+
status: 'queued',
|
|
35
|
+
lastJobId: job.jobId,
|
|
36
|
+
updatedAt: String(Date.now())
|
|
37
|
+
})
|
|
38
|
+
const streamId = String(++RedisMock.nextId)
|
|
39
|
+
RedisMock.stream.push({
|
|
40
|
+
streamId,
|
|
41
|
+
jobId: job.jobId,
|
|
42
|
+
collection: job.collection,
|
|
43
|
+
docId: job.docId,
|
|
44
|
+
operation: job.operation
|
|
45
|
+
})
|
|
46
|
+
return streamId
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async readWriteJobs(workerId: string, count: number = 1): Promise<Array<{ streamId: string, job: Record<string, any> }>> {
|
|
50
|
+
const available = RedisMock.stream
|
|
51
|
+
.filter(entry => !entry.claimedBy)
|
|
52
|
+
.slice(0, count)
|
|
53
|
+
|
|
54
|
+
for(const entry of available) entry.claimedBy = workerId
|
|
55
|
+
|
|
56
|
+
return available.map(entry => ({
|
|
57
|
+
streamId: entry.streamId,
|
|
58
|
+
job: { ...RedisMock.jobs.get(entry.jobId)! }
|
|
59
|
+
}))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async ackWriteJob(streamId: string) {
|
|
63
|
+
RedisMock.stream = RedisMock.stream.filter(item => item.streamId !== streamId)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async deadLetterWriteJob(streamId: string, job: Record<string, any>, reason?: string) {
|
|
67
|
+
RedisMock.deadLetters.push({
|
|
68
|
+
streamId: String(RedisMock.deadLetters.length + 1),
|
|
69
|
+
jobId: job.jobId,
|
|
70
|
+
reason,
|
|
71
|
+
failedAt: Date.now()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
await this.ackWriteJob(streamId)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async claimPendingJobs(workerId: string, _minIdleMs: number = 30_000, count: number = 10) {
|
|
78
|
+
const pending = RedisMock.stream
|
|
79
|
+
.filter(entry => entry.claimedBy)
|
|
80
|
+
.slice(0, count)
|
|
81
|
+
|
|
82
|
+
for(const entry of pending) entry.claimedBy = workerId
|
|
83
|
+
|
|
84
|
+
return pending.map(entry => ({
|
|
85
|
+
streamId: entry.streamId,
|
|
86
|
+
job: { ...RedisMock.jobs.get(entry.jobId)! }
|
|
87
|
+
}))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async setJobStatus(jobId: string, status: string, extra: Record<string, any> = {}) {
|
|
91
|
+
const job = RedisMock.jobs.get(jobId)
|
|
92
|
+
if(job) Object.assign(job, extra, { status, updatedAt: Date.now() })
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async setDocStatus(collection: string, docId: _ttid, status: string, jobId?: string) {
|
|
96
|
+
const key = `fylo:doc:${collection}:${docId}`
|
|
97
|
+
const curr = RedisMock.docs.get(key) ?? {}
|
|
98
|
+
RedisMock.docs.set(key, {
|
|
99
|
+
...curr,
|
|
100
|
+
status,
|
|
101
|
+
updatedAt: String(Date.now()),
|
|
102
|
+
...(jobId ? { lastJobId: jobId } : {})
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async getJob(jobId: string): Promise<Record<string, any> | null> {
|
|
107
|
+
const job = RedisMock.jobs.get(jobId)
|
|
108
|
+
return job ? { ...job } : null
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async getDocStatus(collection: string, docId: _ttid): Promise<Record<string, string> | null> {
|
|
112
|
+
return RedisMock.docs.get(`fylo:doc:${collection}:${docId}`) ?? null
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async readDeadLetters(count: number = 10) {
|
|
116
|
+
return RedisMock.deadLetters.slice(0, count).map(item => ({
|
|
117
|
+
streamId: item.streamId,
|
|
118
|
+
job: { ...RedisMock.jobs.get(item.jobId)! },
|
|
119
|
+
reason: item.reason,
|
|
120
|
+
failedAt: item.failedAt
|
|
121
|
+
}))
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async replayDeadLetter(streamId: string) {
|
|
125
|
+
const item = RedisMock.deadLetters.find(entry => entry.streamId === streamId)
|
|
126
|
+
if(!item) return null
|
|
127
|
+
|
|
128
|
+
const job = RedisMock.jobs.get(item.jobId)
|
|
129
|
+
if(!job) return null
|
|
130
|
+
|
|
131
|
+
const replayed = {
|
|
132
|
+
...job,
|
|
133
|
+
status: 'queued',
|
|
134
|
+
error: undefined,
|
|
135
|
+
workerId: undefined,
|
|
136
|
+
attempts: 0,
|
|
137
|
+
updatedAt: Date.now(),
|
|
138
|
+
nextAttemptAt: Date.now()
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
RedisMock.jobs.set(item.jobId, replayed)
|
|
142
|
+
await this.enqueueWrite(replayed)
|
|
143
|
+
RedisMock.deadLetters = RedisMock.deadLetters.filter(entry => entry.streamId !== streamId)
|
|
144
|
+
|
|
145
|
+
return { ...replayed }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async getQueueStats() {
|
|
149
|
+
return {
|
|
150
|
+
queued: RedisMock.stream.length,
|
|
151
|
+
pending: RedisMock.stream.filter(entry => entry.claimedBy).length,
|
|
152
|
+
deadLetters: RedisMock.deadLetters.length
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async acquireDocLock(collection: string, docId: _ttid, jobId: string) {
|
|
157
|
+
const key = `fylo:lock:${collection}:${docId}`
|
|
158
|
+
if(RedisMock.locks.has(key)) return false
|
|
159
|
+
RedisMock.locks.set(key, jobId)
|
|
160
|
+
return true
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async releaseDocLock(collection: string, docId: _ttid, jobId: string) {
|
|
164
|
+
const key = `fylo:lock:${collection}:${docId}`
|
|
165
|
+
if(RedisMock.locks.get(key) === jobId) RedisMock.locks.delete(key)
|
|
166
|
+
}
|
|
167
|
+
|
|
12
168
|
async *subscribe(_collection: string): AsyncGenerator<never, void, unknown> {}
|
|
13
169
|
}
|