@owlmeans/redis-resource 0.1.18-rc.2 → 0.1.18-rc.21

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/src/resource.ts CHANGED
@@ -1,33 +1,47 @@
1
- import type { ResourceMaker, ResourceRecord } from '@owlmeans/resource'
2
- import { MisshapedRecord, prepareListOptions, RecordExists, UnknownRecordError, UnsupportedArgumentError } from '@owlmeans/resource'
1
+ import type {
2
+ Criteria, FirstOptions, ListOptions, ListResult, ResourceRecord, SubscribeOptions, Ttl,
3
+ Unsubscribe, WriteOptions
4
+ } from '@owlmeans/resource'
5
+ import {
6
+ applyQuery, filterRecords, firstMatch, matchCriteria, MisshapedRecord, RecordExists,
7
+ UnknownRecordError, UnsupportedArgumentError
8
+ } from '@owlmeans/resource'
3
9
  import type { RedisClient, RedisDbService, RedisResource } from './types.js'
4
10
  import type { ServerConfig, ServerContext } from '@owlmeans/server-context'
5
- import { DEFAULT_DB_ALIAS, DEFAULT_PAGE_SIZE } from './consts.js'
11
+ import {
12
+ CONSUMER_ID_LENGTH, DEFAULT_DB_ALIAS, DEFAULT_STREAM_BLOCK, READ_BATCH, RECLAIM_COUNT,
13
+ RECLAIM_IDLE, SCAN_BATCH, STREAM_MAX_LENGTH
14
+ } from './consts.js'
6
15
  import { appendContextual, assertContext } from '@owlmeans/context'
7
- import type { BasicContext, Contextual } from '@owlmeans/context'
8
- import { prepareSubOptions } from './utils/options.js'
9
- import { createIdOfLength } from '@owlmeans/basic-ids'
16
+ import { createIdOfLength, uuid } from '@owlmeans/basic-ids'
10
17
 
11
18
  type Config = ServerConfig
12
19
  type Context<C extends Config = Config> = ServerContext<C>
13
20
 
21
+ /**
22
+ * A `Resource` over plain redis strings: one JSON document per namespaced key.
23
+ *
24
+ * Reads by id are O(1) GETs. Everything else — a criteria read, a listing, a count, a purge —
25
+ * walks this resource's OWN namespace with SCAN and evaluates the criteria in memory, because
26
+ * redis has no index to ask. That is affordable for a small namespaced set (a session cache, an
27
+ * adapter store) and wrong for anything unbounded or hot; a resource that needs real queries
28
+ * belongs in mongo or postgres.
29
+ */
14
30
  export const makeRedisResource = <
15
31
  R extends ResourceRecord, T extends RedisResource<R> = RedisResource<R>
16
32
  >(
17
- alias: string, dbAlias = DEFAULT_DB_ALIAS, serviceAlias = DEFAULT_DB_ALIAS,
18
- makeCustomResource?: ResourceMaker<R, T>
33
+ alias: string, dbAlias = DEFAULT_DB_ALIAS, serviceAlias = DEFAULT_DB_ALIAS
19
34
  ): T => {
20
35
  const location = `redis-resource:${alias}`
21
36
 
22
37
  /**
23
- * A number (or its string form) is seconds from now; a `Date` is the absolute instant to
24
- * expire at.
38
+ * A number is seconds from now; a `Date` is the absolute instant to expire at.
25
39
  *
26
40
  * The absolute form needs PEXPIREAT, not EXPIREAT: `Date.getTime()` is milliseconds while
27
41
  * EXPIREAT reads its argument as seconds, so the timestamp landed tens of thousands of years
28
42
  * out and the record never expired.
29
43
  */
30
- const applyTtl = async (key: string, ttl: number | string | Date): Promise<void> => {
44
+ const applyTtl = async (key: string, ttl: Ttl): Promise<void> => {
31
45
  if (ttl instanceof Date) {
32
46
  await resource.db.client.pexpireat(key, ttl.getTime())
33
47
  } else {
@@ -35,289 +49,341 @@ export const makeRedisResource = <
35
49
  }
36
50
  }
37
51
 
38
- const resource: T = appendContextual<T>(alias, {
39
- key: key => `${resource.db.prefix}:${key ?? '*'}`,
52
+ /** Close a subscription once its lifetime runs out — seconds from now, or an instant. */
53
+ const armTtl = (close: Unsubscribe, ttl?: Ttl): void => {
54
+ if (ttl == null) {
55
+ return
56
+ }
57
+ setTimeout(() => void close(), ttl instanceof Date ? ttl.getTime() - Date.now() : ttl * 1000)
58
+ }
40
59
 
41
- get: async (id, field, opts) => {
42
- const record = await resource.load(id, field, opts)
43
- if (record == null) {
44
- throw new UnknownRecordError(id)
45
- }
60
+ /** The record as it goes into redis: whatever was passed, under the id it is keyed by. */
61
+ const withId = (record: Partial<R>, id: string): R => ({ ...record, id } as unknown as R)
46
62
 
47
- return record
48
- },
63
+ /**
64
+ * Write the record under its key. SET both creates and replaces, which is what lets `save`
65
+ * upsert without reading first.
66
+ */
67
+ const put = async (id: string, record: Partial<R>, opts?: WriteOptions): Promise<R> => {
68
+ const key = resource.key(id)
69
+ const stored = withId(record, id)
70
+ await resource.db.client.set(key, JSON.stringify(stored))
71
+ if (opts?.ttl != null) {
72
+ await applyTtl(key, opts.ttl)
73
+ }
49
74
 
50
- load: async (id, field, opts) => {
51
- if (typeof field === 'object') {
52
- opts = field
53
- field = field.field
54
- }
55
- if (opts != null) {
56
- throw new UnsupportedArgumentError('load:opts')
57
- }
58
- if (field === 'id' || field == null) {
59
- const val = await resource.db.client.get(resource.key(id))
60
- if (val == null) {
61
- return null
62
- }
75
+ return stored
76
+ }
63
77
 
64
- return JSON.parse(val)
65
- }
66
- const list = await resource.list({ [field]: id }, { pager: { size: 1 } })
78
+ /**
79
+ * Every key under this resource's prefix, collected with SCAN.
80
+ *
81
+ * Never KEYS: KEYS blocks the server for the length of the whole sweep, while SCAN walks the
82
+ * keyspace in bounded bites. SCAN may hand the same key back twice across iterations, which is
83
+ * why the keys land in a Set.
84
+ */
85
+ const scanKeys = async (): Promise<string[]> => {
86
+ const found = new Set<string>()
87
+ let cursor = '0'
88
+ do {
89
+ const [next, batch] = await resource.db.client.scan(
90
+ cursor, 'MATCH', resource.key(), 'COUNT', SCAN_BATCH
91
+ )
92
+ cursor = next
93
+ batch.forEach(key => found.add(key))
94
+ } while (cursor !== '0')
67
95
 
68
- return list.items[0] ?? null
69
- },
96
+ return [...found]
97
+ }
70
98
 
71
- save: async (record, opts) => {
72
- if (record.id == null) {
73
- throw new MisshapedRecord('id')
74
- }
75
- const existing = await resource.load(record.id)
76
- if (existing != null) {
77
- return resource.update(record, opts)
78
- }
99
+ /**
100
+ * The namespace as key/record pairs, read in MGET batches.
101
+ *
102
+ * O(N) over the resource's own namespace — every criteria that is not a bare id is answered
103
+ * from this in memory. A key holding something other than a string, this resource's own streams
104
+ * included, reads back as `null` from MGET and is skipped.
105
+ */
106
+ const readNamespace = async (): Promise<Array<[string, R]>> => {
107
+ const keys = await scanKeys()
108
+ const entries: Array<[string, R]> = []
109
+ for (let from = 0; from < keys.length; from += READ_BATCH) {
110
+ const batch = keys.slice(from, from + READ_BATCH)
111
+ const values = await resource.db.client.mget(batch)
112
+ values.forEach((value, index) => {
113
+ if (typeof value === 'string') {
114
+ entries.push([batch[index], JSON.parse(value)])
115
+ }
116
+ })
117
+ }
79
118
 
80
- return resource.create(record, typeof opts !== 'string' ? opts : undefined)
81
- },
119
+ return entries
120
+ }
82
121
 
83
- update: async (record, opts) => {
84
- let field = 'id'
85
- if (typeof opts === 'string') {
86
- field = opts
87
- } else if (typeof opts === 'object') {
88
- field = opts.field ?? field
89
- }
90
- if (field !== 'id' && record[field as keyof typeof record] == null) {
91
- throw new MisshapedRecord('no-field-value')
92
- }
93
- const id = field == 'id' ? record.id : record[field as keyof typeof record] as string
94
- if (id == null) {
95
- throw new MisshapedRecord('id')
96
- }
97
- const _opts = typeof opts === 'object' ? opts : {}
98
- const existing = await resource.get(id, field)
99
- const updated = { ...existing, ...record }
100
- await resource.db.client.set(resource.key(id), JSON.stringify(updated))
101
- if (_opts.ttl != null) {
102
- await applyTtl(resource.key(id), _opts.ttl)
103
- }
122
+ const readRecords = async (): Promise<R[]> => (await readNamespace()).map(([, record]) => record)
104
123
 
105
- return updated
106
- },
124
+ /** `{ id: 'x' }` is the one criteria redis can answer by key; anything else needs the walk. */
125
+ const soleId = (where: Criteria<any>): string | null => {
126
+ const keys = Object.keys(where)
107
127
 
108
- create: async (record, opts) => {
109
- if (record.id == null) {
110
- throw new UnknownRecordError('id')
111
- }
112
- if (await resource.load(record.id) != null) {
113
- throw new RecordExists(record.id)
114
- }
128
+ return keys.length === 1 && keys[0] === 'id' && typeof where.id === 'string' ? where.id : null
129
+ }
115
130
 
116
- const id = record.id
117
- await resource.db.client.set(resource.key(id), JSON.stringify(record))
118
- if (opts?.ttl != null) {
119
- await applyTtl(resource.key(id), opts.ttl)
120
- }
131
+ const loadOne = async (
132
+ idOrWhere: string | Criteria<any>, opts?: FirstOptions<any>
133
+ ): Promise<R | null> => {
134
+ const id = typeof idOrWhere === 'string' ? idOrWhere : soleId(idOrWhere)
135
+ if (id != null) {
136
+ const value = await resource.db.client.get(resource.key(id))
121
137
 
122
- return record
123
- },
138
+ return value == null ? null : JSON.parse(value)
139
+ }
124
140
 
125
- delete: async (id, opts) => {
126
- let record: R | null = null
127
- if (typeof id === 'object') {
128
- if (id.id == null) {
129
- throw new MisshapedRecord('id')
130
- }
131
- record = await resource.load(id.id)
132
- } else {
133
- // `opts` is optional in the contract: without this branch `delete(id)` — the plain,
134
- // by-id form every caller uses — left `record` null and returned without touching redis.
135
- record = await resource.load(id, opts)
136
- }
141
+ return firstMatch(await readRecords(), idOrWhere as Criteria<any>, opts)
142
+ }
137
143
 
138
- if (record == null) {
139
- return null
144
+ /**
145
+ * One dedicated connection per subscription: a redis client in subscriber mode accepts nothing
146
+ * but (un)subscribe commands, so the resource's own client has to stay out of it.
147
+ */
148
+ const listen = async (
149
+ pattern: string, onMessage: (channel: string, message: string) => void,
150
+ opts?: { once?: boolean, ttl?: Ttl }
151
+ ): Promise<Unsubscribe> => {
152
+ const subscriber = (resource.db.client as RedisClient).duplicate()
153
+ let closed = false
154
+ const unsubscribe: Unsubscribe = async () => {
155
+ if (closed) {
156
+ return
157
+ }
158
+ closed = true
159
+ try {
160
+ await subscriber.punsubscribe(pattern)
161
+ await subscriber.quit()
162
+ } catch (e) {
163
+ console.error(`${location}: failed to unsubscribe ${pattern}`, e)
140
164
  }
165
+ }
141
166
 
142
- let field = 'id'
143
- if (typeof opts === 'string') {
144
- field = opts
145
- opts = undefined
146
- } else if (typeof opts === 'object') {
147
- field = opts.field ?? field
167
+ await subscriber.psubscribe(pattern)
168
+ subscriber.on('pmessage', (_pattern, channel, message) => {
169
+ /**
170
+ * `once` closes before the handler's own work finishes on purpose: the handler may await a
171
+ * read, and a second message arriving meanwhile would fire a subscription that has already
172
+ * delivered its one message.
173
+ */
174
+ if (opts?.once === true) {
175
+ void unsubscribe()
148
176
  }
177
+ onMessage(channel, message)
178
+ })
179
+ armTtl(unsubscribe, opts?.ttl)
149
180
 
150
- id = record.id ?? ''
181
+ return unsubscribe
182
+ }
151
183
 
152
- if (id === '') {
153
- throw new MisshapedRecord('id')
154
- }
184
+ const resource: T = appendContextual<T>(alias, {
185
+ key: (key?: string) => `${resource.db.prefix}:${key ?? '*'}`,
155
186
 
156
- if (await resource.db.client.del(resource.key(id)) > 0) {
157
- return record
187
+ /**
188
+ * @throws {UnknownRecordError}
189
+ */
190
+ get: async (idOrWhere: string | Criteria<any>, opts?: FirstOptions<any>): Promise<R> => {
191
+ const record = await loadOne(idOrWhere, opts)
192
+ if (record == null) {
193
+ throw new UnknownRecordError(
194
+ typeof idOrWhere === 'string' ? idOrWhere : JSON.stringify(idOrWhere)
195
+ )
158
196
  }
159
197
 
160
- return null
198
+ return record
161
199
  },
162
200
 
163
- pick: async (id, opts) => {
164
- let record: R | null = null
165
- if (typeof id === 'object') {
166
- if (id.id == null) {
167
- throw new MisshapedRecord('id')
168
- }
169
- record = await resource.load(id.id)
170
- } else {
171
- record = await resource.load(id, opts)
201
+ load: loadOne,
202
+
203
+ /**
204
+ * Unpaged by default: without a `size` every match comes back. A `page` on its own is a
205
+ * caller error rather than a window over an implied default — there is no default here to
206
+ * take a window of.
207
+ *
208
+ * @throws {UnsupportedArgumentError} on `page` without `size`.
209
+ */
210
+ list: async (where?: Criteria<any>, opts?: ListOptions<any>): Promise<ListResult<R>> => {
211
+ if (opts?.page != null && opts.size == null) {
212
+ throw new UnsupportedArgumentError('page-without-size')
172
213
  }
173
214
 
174
- if (record == null) {
175
- return null
176
- }
215
+ return applyQuery(await readRecords(), where, opts)
216
+ },
177
217
 
178
- let field = 'id'
179
- if (typeof opts === 'string') {
180
- field = opts
181
- opts = undefined
182
- } else if (typeof opts === 'object') {
183
- field = opts.field ?? field
218
+ count: async (where?: Criteria<any>): Promise<number> =>
219
+ filterRecords(await readRecords(), where).length,
220
+
221
+ /**
222
+ * SET NX rather than a read followed by a write: two callers racing for the same id both saw
223
+ * an empty key, both "created" the record, and the loser's write silently won.
224
+ *
225
+ * A record with no id gets one — redis needs a key, and `save` relies on this to create.
226
+ *
227
+ * @throws {RecordExists}
228
+ */
229
+ create: async (record: Partial<R>, opts?: WriteOptions): Promise<R> => {
230
+ const id = record.id ?? uuid()
231
+ const key = resource.key(id)
232
+ const stored = withId(record, id)
233
+ if (await resource.db.client.set(key, JSON.stringify(stored), 'NX') == null) {
234
+ throw new RecordExists(id)
235
+ }
236
+ if (opts?.ttl != null) {
237
+ await applyTtl(key, opts.ttl)
184
238
  }
185
239
 
186
- id = record.id ?? ''
240
+ return stored
241
+ },
187
242
 
188
- if (id === '') {
243
+ /**
244
+ * Replaces the whole record: what is stored afterwards is exactly what was passed, not a
245
+ * merge over what was there. A renewal has to pass its TTL again — SET drops the old expiry.
246
+ *
247
+ * @throws {MisshapedRecord} without an id, {@link UnknownRecordError} when the id is absent.
248
+ */
249
+ update: async (record: Partial<R>, opts?: WriteOptions): Promise<R> => {
250
+ if (record.id == null) {
189
251
  throw new MisshapedRecord('id')
190
252
  }
191
-
192
- if (await resource.db.client.del(resource.key(id)) > 0) {
193
- return record
253
+ if (await resource.db.client.exists(resource.key(record.id)) < 1) {
254
+ throw new UnknownRecordError(record.id)
194
255
  }
195
256
 
196
- throw new UnknownRecordError(id)
257
+ return put(record.id, record, opts)
197
258
  },
198
259
 
199
- list: async (criteria, opts) => {
200
- const options = prepareListOptions(DEFAULT_PAGE_SIZE, criteria, opts)
201
- criteria = options.criteria
202
- const pager = options.pager ?? {}
260
+ /** Creates when the record carries no id, replaces otherwise — SET does both. */
261
+ save: async (record: Partial<R>, opts?: WriteOptions): Promise<R> =>
262
+ record.id == null ? resource.create(record, opts) : put(record.id, record, opts),
203
263
 
204
- const keys = await resource.db.client.keys(resource.key())
205
- pager.total = keys.length
206
- pager.page = pager.page ?? 0
207
- pager.size = pager.size ?? DEFAULT_PAGE_SIZE
264
+ /**
265
+ * GETDEL — reading the record and dropping the key in one command, so two callers cannot both
266
+ * be handed the same record. Needs redis 6.2 or newer.
267
+ */
268
+ delete: async (id: string): Promise<R | null> => {
269
+ const value = await resource.db.client.getdel(resource.key(id))
208
270
 
209
- if (keys.length === 0) {
210
- return { items: [], pager }
211
- }
212
-
213
- const start = pager.page * pager.size
214
- const slice = keys.toSpliced(start, pager.size)
215
-
216
- return {
217
- pager, items: (await Promise.all(slice.map(
218
- async key => await resource.db.client.get(key)
219
- ))).filter(val => typeof val === 'string').map(
220
- val => JSON.parse(val as string)
221
- )
222
- }
271
+ return value == null ? null : JSON.parse(value)
223
272
  },
224
273
 
225
- subscribe: async (handler, opts) => {
226
- opts = prepareSubOptions(opts)
227
-
228
- let _unsubascribeCalled = false
229
- const unsusbscibe = async () => {
230
- if (_unsubascribeCalled) {
231
- return
232
- }
233
- _unsubascribeCalled = true
234
- try {
235
- await subscriber.punsubscribe(resource.key(opts.key)).then(() => subscriber.quit())
236
- } catch (e) {
237
- console.error('Try to unsubscribe redis resource', e)
238
- }
274
+ /**
275
+ * Delete-and-return where absence is an error — the consume-once read.
276
+ *
277
+ * @throws {UnknownRecordError}
278
+ */
279
+ take: async (id: string): Promise<R> => {
280
+ const record = await resource.delete(id)
281
+ if (record == null) {
282
+ throw new UnknownRecordError(id)
239
283
  }
240
284
 
241
- const listener = (value: R) => {
242
- handler(value as any)
243
- if (opts.once === true) {
244
- void unsusbscibe()
245
- }
246
- }
285
+ return record
286
+ },
247
287
 
248
- const subscriber = (resource.db.client as RedisClient).duplicate()
249
- if (opts.key != null && null != await resource.load(opts.key)) {
250
- await subscriber.psubscribe(`__keyspace@0__:${resource.key(opts.key)}`)
251
- subscriber.on('pmessage', async (_, channel) => {
252
- const [, , ...key] = (channel as string).split(':')
253
- const record = await resource.load(key.join(':'))
254
- listener(record ?? { id: key, __null: true } as any as R)
255
- })
256
- } else {
257
- await subscriber.psubscribe(resource.key(opts.key))
258
- subscriber.on('pmessage', (_, __, message) => listener(JSON.parse(message)))
288
+ /**
289
+ * Bulk delete over the namespace walk. An empty criteria object is refused: wiping a whole
290
+ * namespace is what FLUSHDB is for, not something a filter that came back empty should do.
291
+ *
292
+ * @throws {UnsupportedArgumentError} on empty criteria.
293
+ */
294
+ purge: async (where: Criteria<any>): Promise<number> => {
295
+ if (where == null || Object.keys(where).length < 1) {
296
+ throw new UnsupportedArgumentError('purge:empty-criteria')
259
297
  }
298
+ const keys = (await readNamespace())
299
+ .filter(([, record]) => matchCriteria(record, where))
300
+ .map(([key]) => key)
260
301
 
261
-
262
- if (opts.ttl != null) {
263
- const ttl = opts.ttl instanceof Date
264
- ? opts.ttl.getTime() - Date.now()
265
- : typeof opts.ttl === 'number'
266
- ? opts.ttl * 1000
267
- : null
268
- if (ttl == null) {
269
- throw new UnsupportedArgumentError(`subscribe:ttl:${typeof opts.ttl}`)
270
- }
271
- setTimeout(unsusbscibe, ttl)
302
+ let deleted = 0
303
+ for (let from = 0; from < keys.length; from += READ_BATCH) {
304
+ deleted += await resource.db.client.del(keys.slice(from, from + READ_BATCH))
272
305
  }
273
306
 
274
- return unsusbscibe
307
+ return deleted
275
308
  },
276
309
 
277
- publish: async (record, key) => {
278
- await resource.db.client.publish(resource.key(key), JSON.stringify(record))
310
+ publish: async (value: R, channel?: string): Promise<void> => {
311
+ await resource.db.client.publish(resource.key(channel), JSON.stringify(value))
279
312
  },
280
313
 
281
- stream: async (key, record) => {
314
+ /**
315
+ * Channel pub/sub. The channel name is namespaced like a key, and an omitted channel listens
316
+ * to the resource's whole namespace — `key()` yields the glob that PSUBSCRIBE matches on.
317
+ */
318
+ subscribe: async (
319
+ handler: (value: R) => void | Promise<void>, opts?: SubscribeOptions
320
+ ): Promise<Unsubscribe> => listen(
321
+ resource.key(opts?.channel),
322
+ (_channel, message) => void handler(JSON.parse(message)),
323
+ opts
324
+ ),
325
+
326
+ /**
327
+ * Keyspace notifications for ONE record: every write, expiry or delete on its key hands the
328
+ * handler the current value, or `null` once the key is gone.
329
+ *
330
+ * Keyspace events are published per database and this listens on db 0, so deployments sharing
331
+ * one instance isolate on the key prefix, never on the db index. The server also has to have
332
+ * `notify-keyspace-events` enabled — redis emits nothing otherwise.
333
+ */
334
+ watch: async (
335
+ id: string, handler: (value: R | null) => void | Promise<void>,
336
+ opts?: Omit<SubscribeOptions, 'channel'>
337
+ ): Promise<Unsubscribe> => listen(
338
+ `__keyspace@0__:${resource.key(id)}`,
339
+ () => void loadOne(id).then(record => handler(record)),
340
+ opts
341
+ ),
342
+
343
+ stream: async (key: string, value: R): Promise<void> => {
282
344
  await resource.db.client.xadd(
283
- resource.key(key), 'MAXLEN', '~', 10000, '*', 'payload', JSON.stringify(record)
345
+ resource.key(key), 'MAXLEN', '~', STREAM_MAX_LENGTH, '*', 'payload', JSON.stringify(value)
284
346
  )
285
347
  },
286
348
 
287
- consume: async function* (key, group, consumer = createIdOfLength(15)) {
288
- console.log(`Establish redis stream consumer: ${group ?? 'no-group'}:${consumer} for ${key}`)
349
+ consume: async function* (
350
+ key: string, opts?: { group?: string, consumer?: string, block?: number }
351
+ ): AsyncGenerator<R> {
289
352
  const streamKey = resource.key(key)
353
+ const group = opts?.group
354
+ const consumer = opts?.consumer ?? createIdOfLength(CONSUMER_ID_LENGTH)
355
+ const block = opts?.block ?? DEFAULT_STREAM_BLOCK
356
+
290
357
  if (group == null) {
291
358
  let id = '$'
292
359
  do {
293
- const resp = await resource.db.client.xread(
294
- 'BLOCK', 1000, 'STREAMS', streamKey, id
295
- )
360
+ const resp = await resource.db.client.xread('BLOCK', block, 'STREAMS', streamKey, id)
296
361
  if (!resp) continue
297
362
 
298
363
  const [, entries] = resp[0]
299
364
  for (const [entryId, fields] of entries) {
300
365
  id = entryId
301
- const line = fields[1]
302
366
  try {
303
- console.log('received', fields)
304
- yield JSON.parse(line)
367
+ yield JSON.parse(fields[1])
305
368
  } catch (e) {
306
369
  console.error('Cannot parse redis stream entry', e)
307
370
  }
308
371
  }
309
372
  } while (true)
310
373
  } else {
311
- const _reclaim = async function* () {
374
+ /**
375
+ * Entries a consumer took and never acknowledged, claimed under THIS consumer's name.
376
+ * Claiming them for a fixed name instead left them owned by a consumer that never reads,
377
+ * so a crashed worker's entries moved once and then stalled for good.
378
+ */
379
+ const reclaim = async function* (): AsyncGenerator<R> {
312
380
  try {
313
381
  const [, entries] = await resource.db.client.xautoclaim(
314
- streamKey, group, 'junitor', 60000, '0-0', 'COUNT', 10
382
+ streamKey, group, consumer, RECLAIM_IDLE, '0-0', 'COUNT', RECLAIM_COUNT
315
383
  )
316
384
  for (const [id, fields] of entries as [string, string[]][]) {
317
- console.log('Reclaimed', id)
318
- const line = fields[1]
319
385
  try {
320
- yield JSON.parse(line)
386
+ yield JSON.parse(fields[1])
321
387
  } catch (e) {
322
388
  console.error('Cannot parse reclaimed redis stream entry', e)
323
389
  }
@@ -333,18 +399,17 @@ export const makeRedisResource = <
333
399
  do {
334
400
  const resp = await resource.db.client.xreadgroup(
335
401
  'GROUP', group, consumer,
336
- 'BLOCK', 1000, 'STREAMS', streamKey, '>'
402
+ 'BLOCK', block, 'STREAMS', streamKey, '>'
337
403
  )
338
404
  if (!resp) {
339
- yield* _reclaim()
405
+ yield* reclaim()
340
406
  continue
341
407
  }
342
408
 
343
409
  const [, entries] = resp[0] as [string, Array<[string, string[]]>]
344
410
  for (const [entryId, fields] of entries) {
345
- const line = fields[1]
346
411
  try {
347
- yield JSON.parse(line)
412
+ yield JSON.parse(fields[1])
348
413
  await resource.db.client.xack(streamKey, group, entryId)
349
414
  } catch (e) {
350
415
  console.error('Cannot parse redis stream entry', e)
@@ -353,11 +418,11 @@ export const makeRedisResource = <
353
418
  } while (true)
354
419
  }
355
420
  }
356
- } as Partial<T>)
421
+ } as unknown as Partial<T>)
357
422
 
358
423
  resource.init = async () => {
359
424
  const context = assertContext<Config, Context>(resource.ctx as Context, location)
360
- const redis = context.service<RedisDbService>(serviceAlias ?? dbAlias)
425
+ const redis = context.service<RedisDbService>(serviceAlias)
361
426
  await redis.ready()
362
427
  const db = await redis.db(dbAlias)
363
428
  const _pref = (val: string) => val.replaceAll(/\W+/g, '_')
@@ -368,14 +433,5 @@ export const makeRedisResource = <
368
433
  }
369
434
  }
370
435
 
371
- resource.reinitializeContext = <Type extends Contextual>(context: BasicContext<Config>) => {
372
- const resource = (makeCustomResource?.(dbAlias, serviceAlias)
373
- ?? makeRedisResource<R, T>(alias, dbAlias, serviceAlias)) as unknown as Type
374
-
375
- resource.ctx = context
376
-
377
- return resource as Type
378
- }
379
-
380
436
  return resource
381
437
  }