queen-mq 2.3.0 → 2.6.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/README.md +52 -0
- package/client-v2/README.md +50 -0
- package/client-v2/builders/QueueBuilder.js +34 -0
- package/client-v2/reads/offsetReads.js +252 -0
- package/package.json +2 -2
- package/test-v2/reads-unit/offsetReads.test.js +380 -0
- package/test-v2/reads.js +109 -0
- package/test-v2/run.js +2 -0
package/README.md
CHANGED
|
@@ -799,6 +799,48 @@ other than the lost precondition still throws.
|
|
|
799
799
|
|
|
800
800
|
---
|
|
801
801
|
|
|
802
|
+
## Reads by Offset
|
|
803
|
+
|
|
804
|
+
Two queue methods read a partition's log by position, over `POST /api/v1/fetch` and
|
|
805
|
+
`POST /api/v1/partitions/changed`. Neither takes a lease, moves a cursor or needs an ack: two
|
|
806
|
+
readers asking the same question get the same answer, and no consumer group notices them. They
|
|
807
|
+
suit a replay, an audit, a sink that keeps its own position, and a service that rebuilds an entity
|
|
808
|
+
from its partition.
|
|
809
|
+
|
|
810
|
+
```javascript
|
|
811
|
+
// Every record of these partitions, from the oldest one still kept.
|
|
812
|
+
const read = await queen.queue('orders').fetch(['customer-42', 'customer-7'])
|
|
813
|
+
const part = read.get('customer-42')
|
|
814
|
+
part.records // [{ offset, transactionId, payload, ts }, ...] in offset order
|
|
815
|
+
part.nextOffset // where to start next time to read only what is new
|
|
816
|
+
part.highWatermark // the next offset the partition will assign
|
|
817
|
+
part.logStartOffset // the oldest offset retention kept
|
|
818
|
+
|
|
819
|
+
// Only what came after an earlier read.
|
|
820
|
+
const later = await queen.queue('orders').fetch(['customer-42'], {
|
|
821
|
+
offsets: { 'customer-42': part.nextOffset }
|
|
822
|
+
})
|
|
823
|
+
|
|
824
|
+
// The partitions written at or after a time, every page of them, in creation order.
|
|
825
|
+
const { partitions, safeTime } = await queen.queue('orders').changedSince(new Date(Date.now() - 600_000))
|
|
826
|
+
partitions.map(p => p.name) // each is { name, id, lastOffset, logStart, lastWriteAt }
|
|
827
|
+
|
|
828
|
+
// Next time, only what changed after that answer.
|
|
829
|
+
const next = await queen.queue('orders').changedSince(safeTime)
|
|
830
|
+
```
|
|
831
|
+
|
|
832
|
+
`fetch` reads each partition up to where it ended when the call first read it: it asks for 1000
|
|
833
|
+
partitions per request, and asks again until each one is read. A partition whose start retention
|
|
834
|
+
deleted is read from its oldest kept record. It answers a `Map` in the order the partitions were
|
|
835
|
+
named; a partition nobody wrote, or of a queue that does not exist, has no records.
|
|
836
|
+
`options.maxBytes` caps the payload bytes of one request (default 8 MiB).
|
|
837
|
+
|
|
838
|
+
`changedSince` takes a `Date`, epoch milliseconds or an ISO-8601 string; without one it lists every
|
|
839
|
+
partition of the queue. `safeTime` is the broker's time up to which every write is counted in the
|
|
840
|
+
answer: pass it as the next `since`, and never compare it with your own clock.
|
|
841
|
+
|
|
842
|
+
---
|
|
843
|
+
|
|
802
844
|
## Examples
|
|
803
845
|
|
|
804
846
|
### Complete Pipeline with Consumer Groups
|
|
@@ -934,6 +976,16 @@ throws before any request when the builder has `.commitOnDelivery()`.
|
|
|
934
976
|
|
|
935
977
|
`.autoAck()` is `consume()`'s ack after your handler and has no effect on a pop.
|
|
936
978
|
|
|
979
|
+
### Reads by Offset
|
|
980
|
+
|
|
981
|
+
```javascript
|
|
982
|
+
const read = await queen.queue('q').fetch(['p1', 'p2']) // Map: partition -> { records, nextOffset, ... }
|
|
983
|
+
const later = await queen.queue('q').fetch(['p1'], { offsets: { p1: 42 }, maxBytes: 1 << 20 })
|
|
984
|
+
const { partitions, safeTime } = await queen.queue('q').changedSince(since) // Date, epoch ms, ISO string; null = all
|
|
985
|
+
```
|
|
986
|
+
|
|
987
|
+
No lease, no cursor, nothing to ack. See [Reads by Offset](#reads-by-offset).
|
|
988
|
+
|
|
937
989
|
### Consume
|
|
938
990
|
|
|
939
991
|
```javascript
|
package/client-v2/README.md
CHANGED
|
@@ -21,6 +21,7 @@ Welcome to Queen client! This is your friendly guide to mastering message queues
|
|
|
21
21
|
- [Part 13: Callbacks & Error Handling](#part-13-callbacks--error-handling)
|
|
22
22
|
- [Part 14: Graceful Shutdown](#part-14-graceful-shutdown)
|
|
23
23
|
- [Part 15: Binary Push, Compression & Framed Pop](#part-15-binary-push-compression--framed-pop)
|
|
24
|
+
- [Part 16: Reads by Offset - Replay Without a Lease](#part-16-reads-by-offset---replay-without-a-lease)
|
|
24
25
|
- [Cheat Sheet](#cheat-sheet)
|
|
25
26
|
|
|
26
27
|
---
|
|
@@ -1669,6 +1670,55 @@ answers JSON.
|
|
|
1669
1670
|
|
|
1670
1671
|
---
|
|
1671
1672
|
|
|
1673
|
+
## Part 16: Reads by Offset - Replay Without a Lease
|
|
1674
|
+
|
|
1675
|
+
A pop hands messages to one consumer and waits for its ack. Sometimes you only
|
|
1676
|
+
want to read: rebuild an entity from its partition, replay a day for an audit,
|
|
1677
|
+
copy a queue somewhere else. Two queue methods do that, and neither takes a
|
|
1678
|
+
lease, moves a cursor or needs an ack. Two readers asking the same question get
|
|
1679
|
+
the same answer, and no consumer group notices them.
|
|
1680
|
+
|
|
1681
|
+
```javascript
|
|
1682
|
+
// Every record of these partitions, from the oldest one still kept
|
|
1683
|
+
const read = await queen.queue('orders').fetch(['customer-42', 'customer-7'])
|
|
1684
|
+
|
|
1685
|
+
for (const [partition, part] of read) {
|
|
1686
|
+
console.log(partition, part.records.map(r => r.payload))
|
|
1687
|
+
// part.records: [{ offset, transactionId, payload, ts }, ...]
|
|
1688
|
+
// part.nextOffset: where to start next time to read only what is new
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
// Only what came after that read
|
|
1692
|
+
const later = await queen.queue('orders').fetch(['customer-42'], {
|
|
1693
|
+
offsets: { 'customer-42': read.get('customer-42').nextOffset }
|
|
1694
|
+
})
|
|
1695
|
+
```
|
|
1696
|
+
|
|
1697
|
+
`fetch` reads each partition up to where it ended when the call first read it.
|
|
1698
|
+
It asks for 1000 partitions per request and asks again until each one is read,
|
|
1699
|
+
and a partition whose start retention deleted is read from its oldest kept
|
|
1700
|
+
record. A partition nobody wrote, or of a queue that does not exist, has no
|
|
1701
|
+
records. `maxBytes` caps the payload bytes of one request (default 8 MiB).
|
|
1702
|
+
|
|
1703
|
+
To find out which partitions to read, ask which ones were written since a time:
|
|
1704
|
+
|
|
1705
|
+
```javascript
|
|
1706
|
+
const { partitions, safeTime } = await queen.queue('orders')
|
|
1707
|
+
.changedSince(new Date(Date.now() - 10 * 60_000))
|
|
1708
|
+
|
|
1709
|
+
partitions.map(p => p.name) // each { name, id, lastOffset, logStart, lastWriteAt }
|
|
1710
|
+
|
|
1711
|
+
// Next time, only what changed after that answer
|
|
1712
|
+
const next = await queen.queue('orders').changedSince(safeTime)
|
|
1713
|
+
```
|
|
1714
|
+
|
|
1715
|
+
`changedSince` follows every page of the answer and returns the partitions in
|
|
1716
|
+
the order they were created; without a time it lists all of them. `safeTime` is
|
|
1717
|
+
the broker's time up to which every write is counted in the answer: pass it as
|
|
1718
|
+
the next `since`, and never compare it with your own clock.
|
|
1719
|
+
|
|
1720
|
+
---
|
|
1721
|
+
|
|
1672
1722
|
## Cheat Sheet
|
|
1673
1723
|
|
|
1674
1724
|
### Connection
|
|
@@ -14,6 +14,7 @@ import { durableAddress } from '../buffer/sinks.js'
|
|
|
14
14
|
import { QUEUE_DEFAULTS, CONSUME_DEFAULTS, POP_DEFAULTS } from '../utils/defaults.js'
|
|
15
15
|
import { checkConflationResponse, CONFLATION_UNSUPPORTED } from '../utils/conflation.js'
|
|
16
16
|
import { popSizing, parseAutopilotDecision } from '../utils/autopilot.js'
|
|
17
|
+
import { fetchPartitions, partitionsChangedSince } from '../reads/offsetReads.js'
|
|
17
18
|
import * as logger from '../utils/logger.js'
|
|
18
19
|
|
|
19
20
|
// Per-item push statuses that mean the broker took the message: `queued`
|
|
@@ -632,6 +633,39 @@ export class QueueBuilder {
|
|
|
632
633
|
await this.#bufferManager.flushBuffer(queueAddress)
|
|
633
634
|
}
|
|
634
635
|
|
|
636
|
+
// ===========================
|
|
637
|
+
// Reads by offset
|
|
638
|
+
// ===========================
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Every record of these partitions, read by offset: no lease, no cursor,
|
|
642
|
+
* nothing to ack. Answers a Map of partition name to
|
|
643
|
+
* `{ partition, records, highWatermark, logStartOffset, nextOffset }`.
|
|
644
|
+
*
|
|
645
|
+
* const read = await queen.queue('orders').fetch(['customer-42', 'customer-7'])
|
|
646
|
+
* read.get('customer-42').records.map(r => r.payload)
|
|
647
|
+
*
|
|
648
|
+
* `options.offsets` ({ name: offset } or a Map) starts a partition later than
|
|
649
|
+
* its oldest kept record, e.g. at the `nextOffset` of an earlier read.
|
|
650
|
+
* `options.maxBytes` caps the payload bytes of one call (default 8 MiB).
|
|
651
|
+
* See reads/offsetReads.js.
|
|
652
|
+
*/
|
|
653
|
+
async fetch(partitions, options = {}) {
|
|
654
|
+
return fetchPartitions(this.#httpClient, this.#queueName, partitions, options)
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* The partitions written at or after `since` (a Date, epoch milliseconds or
|
|
659
|
+
* an ISO-8601 string; null lists them all): `{ partitions, safeTime }`.
|
|
660
|
+
* Pass `safeTime` as the next `since`.
|
|
661
|
+
*
|
|
662
|
+
* const { partitions } = await queen.queue('orders').changedSince(new Date(Date.now() - 600_000))
|
|
663
|
+
* partitions.map(p => p.name)
|
|
664
|
+
*/
|
|
665
|
+
async changedSince(since = null) {
|
|
666
|
+
return partitionsChangedSince(this.#httpClient, this.#queueName, since)
|
|
667
|
+
}
|
|
668
|
+
|
|
635
669
|
// ===========================
|
|
636
670
|
// Dead Letter Queue Methods
|
|
637
671
|
// ===========================
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads by offset and partition discovery, as two queue methods:
|
|
3
|
+
*
|
|
4
|
+
* const read = await queen.queue('orders').fetch(['customer-42', 'customer-7'])
|
|
5
|
+
* read.get('customer-42').records // [{ offset, transactionId, payload, ts }, ...]
|
|
6
|
+
*
|
|
7
|
+
* const { partitions, safeTime } = await queen.queue('orders').changedSince(since)
|
|
8
|
+
*
|
|
9
|
+
* `fetch` is `POST /api/v1/fetch` and `changedSince` is
|
|
10
|
+
* `POST /api/v1/partitions/changed`. Neither takes a lease, moves a cursor or
|
|
11
|
+
* needs an ack: two readers asking the same question get the same answer, and
|
|
12
|
+
* no consumer group notices them. Each is served from the answering node's own
|
|
13
|
+
* applied state.
|
|
14
|
+
*
|
|
15
|
+
* Both do the loops a caller of the raw routes has to write:
|
|
16
|
+
*
|
|
17
|
+
* - fetch asks for at most 1000 partitions per call, and asks again from one
|
|
18
|
+
* past the last record until each partition reaches the high watermark its
|
|
19
|
+
* first answer reported. A partition whose start was deleted by retention is
|
|
20
|
+
* read from its oldest kept record (an offset below `logStartOffset` answers
|
|
21
|
+
* OFFSET_OUT_OF_RANGE and no records, so a loop that does not restart there
|
|
22
|
+
* silently reads nothing).
|
|
23
|
+
* - changedSince follows the page cursor (`next`) until the broker says the
|
|
24
|
+
* pass is over, and answers the smallest `safeTime` of its pages.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
// The broker refuses more than 1024 entries in one call; 1000 leaves room.
|
|
28
|
+
const ENTRIES_PER_CALL = 1000
|
|
29
|
+
|
|
30
|
+
// Bytes of payload one call may carry: each entry's budget and the ceiling of
|
|
31
|
+
// the whole call. 8 MiB is the most the broker reads for one entry.
|
|
32
|
+
const DEFAULT_MAX_BYTES = 8 * 1024 * 1024
|
|
33
|
+
|
|
34
|
+
// Partitions per discovery page: the broker's default and its ceiling.
|
|
35
|
+
const CHANGED_PAGE = 1000
|
|
36
|
+
|
|
37
|
+
const OFFSET_OUT_OF_RANGE = 'OFFSET_OUT_OF_RANGE'
|
|
38
|
+
const UNKNOWN_TOPIC_OR_PARTITION = 'UNKNOWN_TOPIC_OR_PARTITION'
|
|
39
|
+
|
|
40
|
+
// Every call of one read goes to the same node: a follower answers from what it
|
|
41
|
+
// has applied, so two nodes can disagree about where a partition ends.
|
|
42
|
+
const readAffinity = (queue) => `${queue}:*:__READ__`
|
|
43
|
+
|
|
44
|
+
function partitionNames(partitions) {
|
|
45
|
+
const list = typeof partitions === 'string' ? [partitions] : partitions
|
|
46
|
+
if (!Array.isArray(list)) {
|
|
47
|
+
throw new Error('fetch: partitions must be a partition name or an array of names')
|
|
48
|
+
}
|
|
49
|
+
const names = []
|
|
50
|
+
const seen = new Set()
|
|
51
|
+
for (const p of list) {
|
|
52
|
+
const name = typeof p === 'number' && Number.isInteger(p) ? String(p) : p
|
|
53
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
54
|
+
throw new Error(`fetch: a partition name must be a non-empty string, got ${JSON.stringify(p)}`)
|
|
55
|
+
}
|
|
56
|
+
if (!seen.has(name)) {
|
|
57
|
+
seen.add(name)
|
|
58
|
+
names.push(name)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return names
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function startOffsets(offsets) {
|
|
65
|
+
if (offsets == null) return new Map()
|
|
66
|
+
const entries = offsets instanceof Map ? [...offsets.entries()] : Object.entries(offsets)
|
|
67
|
+
const out = new Map()
|
|
68
|
+
for (const [name, offset] of entries) {
|
|
69
|
+
if (!Number.isSafeInteger(offset) || offset < 0) {
|
|
70
|
+
throw new Error(`fetch: offsets['${name}'] must be a non-negative integer, got ${JSON.stringify(offset)}`)
|
|
71
|
+
}
|
|
72
|
+
out.set(String(name), offset)
|
|
73
|
+
}
|
|
74
|
+
return out
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function positiveInteger(value, what) {
|
|
78
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
79
|
+
throw new Error(`${what} must be a positive integer, got ${JSON.stringify(value)}`)
|
|
80
|
+
}
|
|
81
|
+
return value
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Every record of `partitions`, read by offset: from each partition's offset in
|
|
86
|
+
* `options.offsets` (default 0, the oldest record still kept) up to where the
|
|
87
|
+
* partition ended when it was first read.
|
|
88
|
+
*
|
|
89
|
+
* Answers a Map, in the order the partitions were named, of
|
|
90
|
+
* `{ partition, records, highWatermark, logStartOffset, nextOffset }`:
|
|
91
|
+
* - `records`: `{ offset, transactionId, payload, ts }` in offset order. `ts` is
|
|
92
|
+
* the time the push that wrote the record was stored.
|
|
93
|
+
* - `highWatermark`: the next offset the partition will assign;
|
|
94
|
+
* `logStartOffset`: the oldest one retention kept.
|
|
95
|
+
* - `nextOffset`: where to start next time to read only what is new.
|
|
96
|
+
*
|
|
97
|
+
* A partition nobody has written, or of a queue that does not exist, answers no
|
|
98
|
+
* records. So does an offset past the end of the partition (`nextOffset` above
|
|
99
|
+
* `highWatermark` says so: the partition was deleted and written again).
|
|
100
|
+
*/
|
|
101
|
+
export async function fetchPartitions(httpClient, queue, partitions, options = {}) {
|
|
102
|
+
if (!queue) throw new Error('Queue name is required for fetch')
|
|
103
|
+
const names = partitionNames(partitions)
|
|
104
|
+
const offsets = startOffsets(options.offsets)
|
|
105
|
+
const maxBytes = positiveInteger(options.maxBytes ?? DEFAULT_MAX_BYTES, 'fetch: maxBytes')
|
|
106
|
+
|
|
107
|
+
const state = new Map()
|
|
108
|
+
for (const name of names) {
|
|
109
|
+
state.set(name, {
|
|
110
|
+
partition: name,
|
|
111
|
+
records: [],
|
|
112
|
+
highWatermark: 0,
|
|
113
|
+
logStartOffset: 0,
|
|
114
|
+
nextOffset: offsets.get(name) ?? 0,
|
|
115
|
+
// The high watermark of the first answer: the read ends there.
|
|
116
|
+
end: null
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let pending = names
|
|
121
|
+
while (pending.length > 0) {
|
|
122
|
+
const again = []
|
|
123
|
+
for (let i = 0; i < pending.length; i += ENTRIES_PER_CALL) {
|
|
124
|
+
const chunk = pending.slice(i, i + ENTRIES_PER_CALL)
|
|
125
|
+
const body = {
|
|
126
|
+
entries: chunk.map(name => ({
|
|
127
|
+
queue,
|
|
128
|
+
partition: name,
|
|
129
|
+
offset: state.get(name).nextOffset,
|
|
130
|
+
maxBytes
|
|
131
|
+
})),
|
|
132
|
+
maxBytes
|
|
133
|
+
}
|
|
134
|
+
const answer = await httpClient.post('/api/v1/fetch', body, null, readAffinity(queue))
|
|
135
|
+
const entries = answer?.entries
|
|
136
|
+
if (!Array.isArray(entries) || entries.length !== chunk.length) {
|
|
137
|
+
throw new Error(`fetch ${queue}: the broker answered ${Array.isArray(entries) ? entries.length : 'no'} entries for ${chunk.length} asked`)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let delivered = 0
|
|
141
|
+
// Entries that came back empty while short of their end: the call's byte
|
|
142
|
+
// ceiling was spent by the entries before them.
|
|
143
|
+
const starved = []
|
|
144
|
+
chunk.forEach((name, j) => {
|
|
145
|
+
const entry = entries[j]
|
|
146
|
+
const s = state.get(name)
|
|
147
|
+
s.highWatermark = entry.highWatermark ?? s.highWatermark
|
|
148
|
+
s.logStartOffset = entry.logStartOffset ?? s.logStartOffset
|
|
149
|
+
if (entry.error) {
|
|
150
|
+
if (entry.error === OFFSET_OUT_OF_RANGE && s.nextOffset < s.logStartOffset) {
|
|
151
|
+
// Retention deleted what was asked for: go on from the oldest kept.
|
|
152
|
+
s.nextOffset = s.logStartOffset
|
|
153
|
+
if (s.end === null) s.end = s.highWatermark
|
|
154
|
+
if (s.nextOffset < s.end) again.push(name)
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
if (entry.error === OFFSET_OUT_OF_RANGE || entry.error === UNKNOWN_TOPIC_OR_PARTITION) {
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
throw new Error(`fetch ${queue}/${name}: the broker answered ${entry.error}`)
|
|
161
|
+
}
|
|
162
|
+
if (s.end === null) s.end = s.highWatermark
|
|
163
|
+
const records = Array.isArray(entry.records) ? entry.records : []
|
|
164
|
+
if (records.length > 0) {
|
|
165
|
+
delivered += records.length
|
|
166
|
+
for (const r of records) {
|
|
167
|
+
s.records.push({ offset: r.offset, transactionId: r.transactionId, payload: r.payload, ts: r.ts })
|
|
168
|
+
}
|
|
169
|
+
s.nextOffset = records[records.length - 1].offset + 1
|
|
170
|
+
if (s.nextOffset < s.end) again.push(name)
|
|
171
|
+
} else if (s.nextOffset < s.end) {
|
|
172
|
+
starved.push(name)
|
|
173
|
+
}
|
|
174
|
+
})
|
|
175
|
+
// An empty entry is asked again only when its call read something: a
|
|
176
|
+
// call that read nothing at all would answer the same next time.
|
|
177
|
+
if (delivered > 0) again.push(...starved)
|
|
178
|
+
}
|
|
179
|
+
pending = again
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const out = new Map()
|
|
183
|
+
for (const name of names) {
|
|
184
|
+
const { partition, records, highWatermark, logStartOffset, nextOffset } = state.get(name)
|
|
185
|
+
out.set(name, { partition, records, highWatermark, logStartOffset, nextOffset })
|
|
186
|
+
}
|
|
187
|
+
return out
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function sinceIso(since) {
|
|
191
|
+
if (since == null) return null
|
|
192
|
+
if (since instanceof Date) {
|
|
193
|
+
if (Number.isNaN(since.getTime())) throw new Error('changedSince: since is an invalid Date')
|
|
194
|
+
return since.toISOString()
|
|
195
|
+
}
|
|
196
|
+
if (typeof since === 'number') {
|
|
197
|
+
const d = new Date(since)
|
|
198
|
+
if (Number.isNaN(d.getTime())) throw new Error(`changedSince: since ${since} is not a time`)
|
|
199
|
+
return d.toISOString()
|
|
200
|
+
}
|
|
201
|
+
if (typeof since === 'string' && since.length > 0) return since
|
|
202
|
+
throw new Error(`changedSince: since must be a Date, epoch milliseconds or an ISO-8601 string, got ${JSON.stringify(since)}`)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The partitions of the queue written at or after `since`, in the order they
|
|
207
|
+
* were created: `{ partitions, safeTime }`. Each partition is
|
|
208
|
+
* `{ name, id, lastOffset, logStart, lastWriteAt }`; `lastOffset` is the offset
|
|
209
|
+
* of its last record (-1 when none).
|
|
210
|
+
*
|
|
211
|
+
* `since` is a Date, epoch milliseconds or an ISO-8601 string; null or absent
|
|
212
|
+
* lists every partition of the queue. A queue that does not exist has none.
|
|
213
|
+
*
|
|
214
|
+
* `safeTime` is the broker's time up to which every write is counted in this
|
|
215
|
+
* answer: pass it as the next `since` to see only what changed after this
|
|
216
|
+
* call. It comes from the broker's clock, never compare it with the local one.
|
|
217
|
+
*/
|
|
218
|
+
export async function partitionsChangedSince(httpClient, queue, since) {
|
|
219
|
+
if (!queue) throw new Error('Queue name is required for changedSince')
|
|
220
|
+
const from = sinceIso(since)
|
|
221
|
+
const partitions = []
|
|
222
|
+
let safeTime = null
|
|
223
|
+
let after = null
|
|
224
|
+
for (;;) {
|
|
225
|
+
const entry = { queue, limit: CHANGED_PAGE }
|
|
226
|
+
if (from !== null) entry.since = from
|
|
227
|
+
if (after !== null) entry.after = after
|
|
228
|
+
const answer = await httpClient.post('/api/v1/partitions/changed', { entries: [entry] }, null, readAffinity(queue))
|
|
229
|
+
// Pages served by different nodes: the smallest is the one that holds for
|
|
230
|
+
// all of them. The broker spells every one the same way, so the strings
|
|
231
|
+
// compare in time order.
|
|
232
|
+
if (typeof answer?.safeTime === 'string' && (safeTime === null || answer.safeTime < safeTime)) {
|
|
233
|
+
safeTime = answer.safeTime
|
|
234
|
+
}
|
|
235
|
+
const result = answer?.entries?.[0]
|
|
236
|
+
if (!result) throw new Error(`changedSince ${queue}: the broker answered no entry`)
|
|
237
|
+
if (result.error === UNKNOWN_TOPIC_OR_PARTITION) break
|
|
238
|
+
if (result.error) throw new Error(`changedSince ${queue}: the broker answered ${result.error}`)
|
|
239
|
+
for (const p of result.partitions || []) {
|
|
240
|
+
partitions.push({
|
|
241
|
+
name: p.name,
|
|
242
|
+
id: p.id ?? null,
|
|
243
|
+
lastOffset: p.lastOffset,
|
|
244
|
+
logStart: p.logStart,
|
|
245
|
+
lastWriteAt: p.lastWriteAt
|
|
246
|
+
})
|
|
247
|
+
}
|
|
248
|
+
if (!result.next) break
|
|
249
|
+
after = result.next
|
|
250
|
+
}
|
|
251
|
+
return { partitions, safeTime }
|
|
252
|
+
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "queen-mq",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Partitioned message queue on a replicated broker log — broker client + fluent streaming SDK (windows, joins, gates) in one package",
|
|
6
6
|
"main": "client-v2/index.js",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"test": "npm run test:unit && node test-v2/run.js human",
|
|
9
|
-
"test:unit": "node --test test-v2/streams-unit/configHash.test.js test-v2/streams-unit/operators.test.js test-v2/streams-unit/cycle.test.js test-v2/streams-unit/eventTime.test.js test-v2/streams-unit/ack.test.js test-v2/streams-unit/gate.test.js test-v2/http-unit/retry429.test.js test-v2/http-unit/hostHeader.test.js test-v2/http-unit/pushStatus.test.js test-v2/http-unit/renew.test.js test-v2/consumer-unit/handlerError.test.js test-v2/consumer-unit/nackScope.test.js test-v2/consumer-unit/stopOnAbort.test.js test-v2/kv-unit/kvWire.test.js test-v2/kv-unit/timerWire.test.js test-v2/kv-unit/txnWire.test.js test-v2/kv-unit/locksWire.test.js test-v2/buffer-unit/buffer.test.js test-v2/conflation-unit/conflationWire.test.js test-v2/autopilot-unit/autopilotWire.test.js test-v2/ephemeral-unit/ephemeralWire.test.js test-v2/ephemeral-unit/ephemeralBuffer.test.js test-v2/ephemeral-unit/durableSinkPin.test.js test-v2/runner-unit/fatalExit.test.js test-v2/pop-unit/popDefaults.test.js test-v2/admin-unit/removedRoutes.test.js test-v2/consumer-unit/supervision.test.js test-v2/wire-unit/pushBinary.test.js test-v2/wire-unit/popFrames.test.js test-v2/wire-unit/transactionFrames.test.js",
|
|
9
|
+
"test:unit": "node --test test-v2/streams-unit/configHash.test.js test-v2/streams-unit/operators.test.js test-v2/streams-unit/cycle.test.js test-v2/streams-unit/eventTime.test.js test-v2/streams-unit/ack.test.js test-v2/streams-unit/gate.test.js test-v2/http-unit/retry429.test.js test-v2/http-unit/hostHeader.test.js test-v2/http-unit/pushStatus.test.js test-v2/http-unit/renew.test.js test-v2/consumer-unit/handlerError.test.js test-v2/consumer-unit/nackScope.test.js test-v2/consumer-unit/stopOnAbort.test.js test-v2/kv-unit/kvWire.test.js test-v2/kv-unit/timerWire.test.js test-v2/kv-unit/txnWire.test.js test-v2/kv-unit/locksWire.test.js test-v2/buffer-unit/buffer.test.js test-v2/conflation-unit/conflationWire.test.js test-v2/autopilot-unit/autopilotWire.test.js test-v2/ephemeral-unit/ephemeralWire.test.js test-v2/ephemeral-unit/ephemeralBuffer.test.js test-v2/ephemeral-unit/durableSinkPin.test.js test-v2/runner-unit/fatalExit.test.js test-v2/pop-unit/popDefaults.test.js test-v2/admin-unit/removedRoutes.test.js test-v2/consumer-unit/supervision.test.js test-v2/wire-unit/pushBinary.test.js test-v2/wire-unit/popFrames.test.js test-v2/wire-unit/transactionFrames.test.js test-v2/reads-unit/offsetReads.test.js",
|
|
10
10
|
"test:unit:e2e": "node --test test-v2/streams-unit/e2e.test.js",
|
|
11
11
|
"test:integration": "node test-v2/run.js human",
|
|
12
12
|
"test:streams": "node test-v2/run.js stream",
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* queue.fetch() and queue.changedSince() against a fake broker that answers
|
|
3
|
+
* POST /api/v1/fetch and POST /api/v1/partitions/changed the way
|
|
4
|
+
* server/src/rsm/facade/real/phase2/reads.rs does: entries answered in order,
|
|
5
|
+
* each entry's byte budget and the whole call's ceiling (the first record of an
|
|
6
|
+
* entry read whatever it weighs), a record cap per entry, OFFSET_OUT_OF_RANGE
|
|
7
|
+
* below logStartOffset and above the high watermark, UNKNOWN_TOPIC_OR_PARTITION
|
|
8
|
+
* for a queue that does not exist, discovery pages with a `next` cursor exactly
|
|
9
|
+
* when a page is full, BAD_CURSOR for a cursor it did not issue.
|
|
10
|
+
*
|
|
11
|
+
* A real node:http server records every request, so the bodies the client sends
|
|
12
|
+
* are asserted on the socket.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, it } from 'node:test'
|
|
16
|
+
import assert from 'node:assert/strict'
|
|
17
|
+
import { createServer } from 'node:http'
|
|
18
|
+
|
|
19
|
+
import { Queen } from '../../client-v2/index.js'
|
|
20
|
+
|
|
21
|
+
const MiB = 1024 * 1024
|
|
22
|
+
|
|
23
|
+
class FakeBroker {
|
|
24
|
+
constructor({ recordsPerEntry = 10_000 } = {}) {
|
|
25
|
+
this.queues = new Map() // queue -> Map(partition -> { records, logStart, created })
|
|
26
|
+
this.recordsPerEntry = recordsPerEntry
|
|
27
|
+
this.hits = []
|
|
28
|
+
this.safeTimes = [] // answered in turn by partitions/changed, then the last one
|
|
29
|
+
this.afterFetch = null // (broker) => void, run after every fetch answer
|
|
30
|
+
this.created = 0
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
queue(name) {
|
|
34
|
+
if (!this.queues.has(name)) this.queues.set(name, new Map())
|
|
35
|
+
return this.queues.get(name)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
write(queue, partition, payloads, { lastWriteAt = '2026-10-20T10:00:00.000000Z' } = {}) {
|
|
39
|
+
const q = this.queue(queue)
|
|
40
|
+
if (!q.has(partition)) q.set(partition, { records: [], logStart: 0, created: this.created++, lastWriteAt })
|
|
41
|
+
const p = q.get(partition)
|
|
42
|
+
for (const payload of payloads) {
|
|
43
|
+
const offset = p.records.length === 0 ? p.logStart : p.records[p.records.length - 1].offset + 1
|
|
44
|
+
p.records.push({ offset, transactionId: `${partition}-${offset}`, payload, ts: '2026-10-20T10:00:00.000000Z' })
|
|
45
|
+
}
|
|
46
|
+
p.lastWriteAt = lastWriteAt
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Retention: everything below `offset` is gone. */
|
|
50
|
+
trim(queue, partition, offset) {
|
|
51
|
+
const p = this.queue(queue).get(partition)
|
|
52
|
+
p.records = p.records.filter(r => r.offset >= offset)
|
|
53
|
+
p.logStart = offset
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
fetch(body) {
|
|
57
|
+
if (body.entries.length > 1024) return [400, { error: 'too many entries' }]
|
|
58
|
+
const ceiling = body.maxBytes ?? null
|
|
59
|
+
let used = 0
|
|
60
|
+
const entries = body.entries.map(e => {
|
|
61
|
+
const out = { queue: e.queue, partition: e.partition ?? 'Default', records: [], highWatermark: 0, logStartOffset: 0 }
|
|
62
|
+
const q = this.queues.get(e.queue)
|
|
63
|
+
if (!q) return { ...out, error: 'UNKNOWN_TOPIC_OR_PARTITION' }
|
|
64
|
+
const p = q.get(out.partition)
|
|
65
|
+
if (!p) return out
|
|
66
|
+
const last = p.records.length ? p.records[p.records.length - 1].offset : p.logStart - 1
|
|
67
|
+
out.highWatermark = last + 1
|
|
68
|
+
out.logStartOffset = p.logStart
|
|
69
|
+
if (e.offset < p.logStart || e.offset > out.highWatermark) return { ...out, error: 'OFFSET_OUT_OF_RANGE' }
|
|
70
|
+
if (ceiling !== null && used >= ceiling) return out
|
|
71
|
+
const budget = Math.min(e.maxBytes ?? MiB, ceiling === null ? Infinity : ceiling - used)
|
|
72
|
+
let spent = 0
|
|
73
|
+
for (const r of p.records) {
|
|
74
|
+
if (r.offset < e.offset) continue
|
|
75
|
+
if (out.records.length >= this.recordsPerEntry) break
|
|
76
|
+
const weight = Math.max(JSON.stringify(r.payload).length, 1)
|
|
77
|
+
if (out.records.length > 0 && spent + weight > budget) break
|
|
78
|
+
spent += weight
|
|
79
|
+
out.records.push(r)
|
|
80
|
+
}
|
|
81
|
+
used += spent
|
|
82
|
+
return out
|
|
83
|
+
})
|
|
84
|
+
return [200, { entries }]
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
changed(body) {
|
|
88
|
+
if (body.entries.length > 1024) return [400, { error: 'too many entries' }]
|
|
89
|
+
const safeTime = this.safeTimes.length > 1 ? this.safeTimes.shift() : (this.safeTimes[0] ?? '2026-10-20T12:00:00.000000Z')
|
|
90
|
+
const entries = body.entries.map(e => {
|
|
91
|
+
const q = this.queues.get(e.queue)
|
|
92
|
+
if (!q) return { queue: e.queue, error: 'UNKNOWN_TOPIC_OR_PARTITION' }
|
|
93
|
+
let from = 0
|
|
94
|
+
if (e.after) {
|
|
95
|
+
const m = /^p\|(\d+)$/.exec(e.after)
|
|
96
|
+
if (!m) return { queue: e.queue, error: 'BAD_CURSOR' }
|
|
97
|
+
from = Number(m[1]) + 1
|
|
98
|
+
}
|
|
99
|
+
const limit = Math.min(Math.max(e.limit ?? 1000, 1), 1000)
|
|
100
|
+
const all = [...q.entries()].sort((a, b) => a[1].created - b[1].created)
|
|
101
|
+
const partitions = []
|
|
102
|
+
let last = null
|
|
103
|
+
for (const [name, p] of all) {
|
|
104
|
+
if (p.created < from) continue
|
|
105
|
+
if (e.since && p.lastWriteAt < e.since) continue
|
|
106
|
+
const lastOffset = p.records.length ? p.records[p.records.length - 1].offset : p.logStart - 1
|
|
107
|
+
partitions.push({ name, id: `id-${name}`, lastOffset, logStart: p.logStart, lastWriteAt: p.lastWriteAt })
|
|
108
|
+
last = p.created
|
|
109
|
+
if (partitions.length >= limit) break
|
|
110
|
+
}
|
|
111
|
+
return { queue: e.queue, partitions, next: partitions.length >= limit ? `p|${last}` : null }
|
|
112
|
+
})
|
|
113
|
+
return [200, { safeTime, safeTimeDegraded: false, entries }]
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async run(test) {
|
|
117
|
+
const server = createServer((req, res) => {
|
|
118
|
+
let raw = ''
|
|
119
|
+
req.on('data', c => { raw += c })
|
|
120
|
+
req.on('end', () => {
|
|
121
|
+
const body = raw ? JSON.parse(raw) : null
|
|
122
|
+
this.hits.push({ method: req.method, url: req.url, body })
|
|
123
|
+
let answer
|
|
124
|
+
if (req.method === 'POST' && req.url === '/api/v1/fetch') {
|
|
125
|
+
answer = this.fetch(body)
|
|
126
|
+
this.afterFetch?.(this)
|
|
127
|
+
} else if (req.method === 'POST' && req.url === '/api/v1/partitions/changed') {
|
|
128
|
+
answer = this.changed(body)
|
|
129
|
+
} else {
|
|
130
|
+
answer = [404, { error: 'not found' }]
|
|
131
|
+
}
|
|
132
|
+
res.writeHead(answer[0], { 'Content-Type': 'application/json' })
|
|
133
|
+
res.end(JSON.stringify(answer[1]))
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
await new Promise(r => server.listen(0, '127.0.0.1', r))
|
|
137
|
+
const queen = new Queen({ url: `http://127.0.0.1:${server.address().port}`, handleSignals: false })
|
|
138
|
+
try {
|
|
139
|
+
await test(queen)
|
|
140
|
+
} finally {
|
|
141
|
+
await queen.close()
|
|
142
|
+
await new Promise(r => server.close(r))
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
fetches() {
|
|
147
|
+
return this.hits.filter(h => h.url === '/api/v1/fetch')
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const payloads = (n, from = 0) => Array.from({ length: n }, (_, i) => ({ n: from + i }))
|
|
152
|
+
const offsetsOf = (part) => part.records.map(r => r.offset)
|
|
153
|
+
|
|
154
|
+
describe('queue.fetch()', () => {
|
|
155
|
+
it('reads every record of each partition, from offset 0, in one call when they fit', async () => {
|
|
156
|
+
const broker = new FakeBroker()
|
|
157
|
+
broker.write('tf.events', 'trace-1', payloads(3))
|
|
158
|
+
broker.write('tf.events', 'trace-2', [{ a: 1 }])
|
|
159
|
+
await broker.run(async (queen) => {
|
|
160
|
+
const read = await queen.queue('tf.events').fetch(['trace-1', 'trace-2'])
|
|
161
|
+
assert.deepEqual([...read.keys()], ['trace-1', 'trace-2'])
|
|
162
|
+
const t1 = read.get('trace-1')
|
|
163
|
+
assert.deepEqual(t1.records.map(r => r.payload), payloads(3))
|
|
164
|
+
assert.deepEqual(t1.records[0], { offset: 0, transactionId: 'trace-1-0', payload: { n: 0 }, ts: '2026-10-20T10:00:00.000000Z' })
|
|
165
|
+
assert.equal(t1.partition, 'trace-1')
|
|
166
|
+
assert.equal(t1.highWatermark, 3)
|
|
167
|
+
assert.equal(t1.logStartOffset, 0)
|
|
168
|
+
assert.equal(t1.nextOffset, 3)
|
|
169
|
+
assert.deepEqual(read.get('trace-2').records.map(r => r.payload), [{ a: 1 }])
|
|
170
|
+
|
|
171
|
+
const fetches = broker.fetches()
|
|
172
|
+
assert.equal(fetches.length, 1)
|
|
173
|
+
assert.deepEqual(fetches[0].body, {
|
|
174
|
+
entries: [
|
|
175
|
+
{ queue: 'tf.events', partition: 'trace-1', offset: 0, maxBytes: 8 * MiB },
|
|
176
|
+
{ queue: 'tf.events', partition: 'trace-2', offset: 0, maxBytes: 8 * MiB }
|
|
177
|
+
],
|
|
178
|
+
maxBytes: 8 * MiB
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('asks again from one past the last record until the high watermark of the first answer', async () => {
|
|
184
|
+
const broker = new FakeBroker({ recordsPerEntry: 3 })
|
|
185
|
+
broker.write('q', 'p', payloads(10))
|
|
186
|
+
await broker.run(async (queen) => {
|
|
187
|
+
const read = await queen.queue('q').fetch('p')
|
|
188
|
+
assert.deepEqual(offsetsOf(read.get('p')), [0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
|
|
189
|
+
assert.deepEqual(broker.fetches().map(h => h.body.entries[0].offset), [0, 3, 6, 9])
|
|
190
|
+
assert.equal(read.get('p').nextOffset, 10)
|
|
191
|
+
})
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('does not chase a partition written during the read', async () => {
|
|
195
|
+
const broker = new FakeBroker({ recordsPerEntry: 2 })
|
|
196
|
+
broker.write('q', 'p', payloads(4))
|
|
197
|
+
broker.afterFetch = (b) => b.write('q', 'p', [{ late: true }])
|
|
198
|
+
await broker.run(async (queen) => {
|
|
199
|
+
const read = await queen.queue('q').fetch(['p'])
|
|
200
|
+
// The first answer said the partition ended at 4: offsets 0..3 are read,
|
|
201
|
+
// and the call stops there although the partition kept growing.
|
|
202
|
+
assert.deepEqual(offsetsOf(read.get('p')), [0, 1, 2, 3])
|
|
203
|
+
assert.equal(broker.fetches().length, 2)
|
|
204
|
+
assert.equal(read.get('p').nextOffset, 4)
|
|
205
|
+
})
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('asks for at most 1000 partitions per call', async () => {
|
|
209
|
+
const broker = new FakeBroker()
|
|
210
|
+
const names = Array.from({ length: 2500 }, (_, i) => `p-${i}`)
|
|
211
|
+
for (const name of names) broker.write('q', name, [{ name }])
|
|
212
|
+
await broker.run(async (queen) => {
|
|
213
|
+
const read = await queen.queue('q').fetch(names)
|
|
214
|
+
assert.deepEqual(broker.fetches().map(h => h.body.entries.length), [1000, 1000, 500])
|
|
215
|
+
assert.equal(read.size, 2500)
|
|
216
|
+
for (const name of names) assert.deepEqual(read.get(name).records.map(r => r.payload), [{ name }])
|
|
217
|
+
})
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('asks again for the partitions the call ceiling left empty', async () => {
|
|
221
|
+
const broker = new FakeBroker()
|
|
222
|
+
const fat = 'x'.repeat(100)
|
|
223
|
+
for (const name of ['a', 'b', 'c']) broker.write('q', name, [fat, fat])
|
|
224
|
+
await broker.run(async (queen) => {
|
|
225
|
+
// Each record weighs 102 bytes: one call carries two of them at most.
|
|
226
|
+
const read = await queen.queue('q').fetch(['a', 'b', 'c'], { maxBytes: 250 })
|
|
227
|
+
for (const name of ['a', 'b', 'c']) assert.deepEqual(offsetsOf(read.get(name)), [0, 1])
|
|
228
|
+
assert.ok(broker.fetches().length > 1)
|
|
229
|
+
assert.ok(broker.fetches().every(h => h.body.maxBytes === 250))
|
|
230
|
+
})
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('reads from logStartOffset when retention deleted the start', async () => {
|
|
234
|
+
const broker = new FakeBroker()
|
|
235
|
+
broker.write('q', 'p', payloads(10))
|
|
236
|
+
broker.trim('q', 'p', 6)
|
|
237
|
+
await broker.run(async (queen) => {
|
|
238
|
+
const read = await queen.queue('q').fetch(['p'])
|
|
239
|
+
assert.deepEqual(offsetsOf(read.get('p')), [6, 7, 8, 9])
|
|
240
|
+
assert.equal(read.get('p').logStartOffset, 6)
|
|
241
|
+
assert.deepEqual(broker.fetches().map(h => h.body.entries[0].offset), [0, 6])
|
|
242
|
+
})
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('starts each partition at its offset in options.offsets', async () => {
|
|
246
|
+
const broker = new FakeBroker()
|
|
247
|
+
broker.write('q', 'a', payloads(5))
|
|
248
|
+
broker.write('q', 'b', payloads(5))
|
|
249
|
+
await broker.run(async (queen) => {
|
|
250
|
+
const read = await queen.queue('q').fetch(['a', 'b'], { offsets: { a: 3 } })
|
|
251
|
+
assert.deepEqual(offsetsOf(read.get('a')), [3, 4])
|
|
252
|
+
assert.deepEqual(offsetsOf(read.get('b')), [0, 1, 2, 3, 4])
|
|
253
|
+
const again = await queen.queue('q').fetch(['a'], { offsets: new Map([['a', read.get('a').nextOffset]]) })
|
|
254
|
+
assert.deepEqual(again.get('a').records, [])
|
|
255
|
+
assert.equal(again.get('a').nextOffset, 5)
|
|
256
|
+
})
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it('answers no records for a missing queue, an unwritten partition and an offset past the end', async () => {
|
|
260
|
+
const broker = new FakeBroker()
|
|
261
|
+
broker.write('q', 'p', payloads(2))
|
|
262
|
+
await broker.run(async (queen) => {
|
|
263
|
+
const missing = await queen.queue('nope').fetch(['p'])
|
|
264
|
+
assert.deepEqual(missing.get('p').records, [])
|
|
265
|
+
const read = await queen.queue('q').fetch(['never', 'p'], { offsets: { p: 7 } })
|
|
266
|
+
assert.deepEqual(read.get('never').records, [])
|
|
267
|
+
assert.deepEqual(read.get('p').records, [])
|
|
268
|
+
assert.equal(read.get('p').nextOffset, 7)
|
|
269
|
+
assert.equal(read.get('p').highWatermark, 2)
|
|
270
|
+
})
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
it('throws on an error marker it does not know', async () => {
|
|
274
|
+
const broker = new FakeBroker()
|
|
275
|
+
broker.write('q', 'p', payloads(1))
|
|
276
|
+
broker.fetch = () => [200, { entries: [{ queue: 'q', partition: 'p', records: [], highWatermark: 1, logStartOffset: 0, error: 'SOMETHING_NEW' }] }]
|
|
277
|
+
await broker.run(async (queen) => {
|
|
278
|
+
await assert.rejects(queen.queue('q').fetch(['p']), /SOMETHING_NEW/)
|
|
279
|
+
})
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
it('stops when a call reads nothing although the partition is not at its end', async () => {
|
|
283
|
+
const broker = new FakeBroker()
|
|
284
|
+
broker.fetch = () => [200, { entries: [{ queue: 'q', partition: 'p', records: [], highWatermark: 5, logStartOffset: 0 }] }]
|
|
285
|
+
await broker.run(async (queen) => {
|
|
286
|
+
const read = await queen.queue('q').fetch(['p'])
|
|
287
|
+
assert.deepEqual(read.get('p').records, [])
|
|
288
|
+
assert.equal(broker.fetches().length, 1)
|
|
289
|
+
})
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
it('asks once for a partition named twice and refuses a bad name or offset before any call', async () => {
|
|
293
|
+
const broker = new FakeBroker()
|
|
294
|
+
broker.write('q', 'p', payloads(1))
|
|
295
|
+
await broker.run(async (queen) => {
|
|
296
|
+
const read = await queen.queue('q').fetch(['p', 'p'])
|
|
297
|
+
assert.equal(read.size, 1)
|
|
298
|
+
assert.equal(broker.fetches()[0].body.entries.length, 1)
|
|
299
|
+
await assert.rejects(queen.queue('q').fetch(['']), /non-empty string/)
|
|
300
|
+
await assert.rejects(queen.queue('q').fetch(['p'], { offsets: { p: -1 } }), /non-negative integer/)
|
|
301
|
+
await assert.rejects(queen.queue('q').fetch(['p'], { maxBytes: 0 }), /positive integer/)
|
|
302
|
+
assert.equal(broker.fetches().length, 1)
|
|
303
|
+
})
|
|
304
|
+
})
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
describe('queue.changedSince()', () => {
|
|
308
|
+
it('follows the page cursor to the end of the pass, in creation order', async () => {
|
|
309
|
+
const broker = new FakeBroker()
|
|
310
|
+
for (let i = 0; i < 2300; i++) broker.write('tf.events', `t-${i}`, [{ i }])
|
|
311
|
+
await broker.run(async (queen) => {
|
|
312
|
+
const { partitions, safeTime } = await queen.queue('tf.events').changedSince('2026-10-20T09:00:00Z')
|
|
313
|
+
assert.equal(partitions.length, 2300)
|
|
314
|
+
assert.equal(partitions[0].name, 't-0')
|
|
315
|
+
assert.equal(partitions[2299].name, 't-2299')
|
|
316
|
+
assert.deepEqual(partitions[0], { name: 't-0', id: 'id-t-0', lastOffset: 0, logStart: 0, lastWriteAt: '2026-10-20T10:00:00.000000Z' })
|
|
317
|
+
assert.equal(safeTime, '2026-10-20T12:00:00.000000Z')
|
|
318
|
+
const bodies = broker.hits.map(h => h.body.entries[0])
|
|
319
|
+
assert.deepEqual(bodies.map(b => b.after ?? null), [null, 'p|999', 'p|1999'])
|
|
320
|
+
assert.ok(bodies.every(b => b.since === '2026-10-20T09:00:00Z' && b.limit === 1000 && b.queue === 'tf.events'))
|
|
321
|
+
})
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
it('lists only the partitions written at or after since', async () => {
|
|
325
|
+
const broker = new FakeBroker()
|
|
326
|
+
broker.write('q', 'old', [1], { lastWriteAt: '2026-10-20T08:00:00.000000Z' })
|
|
327
|
+
broker.write('q', 'new', [1], { lastWriteAt: '2026-10-20T10:30:00.000000Z' })
|
|
328
|
+
await broker.run(async (queen) => {
|
|
329
|
+
const since = new Date('2026-10-20T10:00:00Z')
|
|
330
|
+
const { partitions } = await queen.queue('q').changedSince(since)
|
|
331
|
+
assert.deepEqual(partitions.map(p => p.name), ['new'])
|
|
332
|
+
assert.equal(broker.hits[0].body.entries[0].since, '2026-10-20T10:00:00.000Z')
|
|
333
|
+
await queen.queue('q').changedSince(since.getTime())
|
|
334
|
+
assert.equal(broker.hits[1].body.entries[0].since, '2026-10-20T10:00:00.000Z')
|
|
335
|
+
})
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
it('without since lists every partition, and sends no since', async () => {
|
|
339
|
+
const broker = new FakeBroker()
|
|
340
|
+
broker.write('q', 'a', [1], { lastWriteAt: '2020-01-01T00:00:00.000000Z' })
|
|
341
|
+
await broker.run(async (queen) => {
|
|
342
|
+
const { partitions } = await queen.queue('q').changedSince()
|
|
343
|
+
assert.deepEqual(partitions.map(p => p.name), ['a'])
|
|
344
|
+
assert.equal('since' in broker.hits[0].body.entries[0], false)
|
|
345
|
+
})
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
it('answers the smallest safeTime of its pages', async () => {
|
|
349
|
+
const broker = new FakeBroker()
|
|
350
|
+
for (let i = 0; i < 1500; i++) broker.write('q', `p-${i}`, [1])
|
|
351
|
+
broker.safeTimes = ['2026-10-20T12:00:05.000000Z', '2026-10-20T12:00:01.000000Z']
|
|
352
|
+
await broker.run(async (queen) => {
|
|
353
|
+
const { safeTime } = await queen.queue('q').changedSince(null)
|
|
354
|
+
assert.equal(safeTime, '2026-10-20T12:00:01.000000Z')
|
|
355
|
+
})
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
it('answers no partitions for a queue that does not exist, and throws on BAD_CURSOR', async () => {
|
|
359
|
+
const broker = new FakeBroker()
|
|
360
|
+
await broker.run(async (queen) => {
|
|
361
|
+
const { partitions, safeTime } = await queen.queue('ghost').changedSince('2026-10-20T00:00:00Z')
|
|
362
|
+
assert.deepEqual(partitions, [])
|
|
363
|
+
assert.equal(safeTime, '2026-10-20T12:00:00.000000Z')
|
|
364
|
+
})
|
|
365
|
+
const bad = new FakeBroker()
|
|
366
|
+
bad.changed = () => [200, { safeTime: '2026-10-20T12:00:00.000000Z', safeTimeDegraded: false, entries: [{ queue: 'q', error: 'BAD_CURSOR' }] }]
|
|
367
|
+
await bad.run(async (queen) => {
|
|
368
|
+
await assert.rejects(queen.queue('q').changedSince(null), /BAD_CURSOR/)
|
|
369
|
+
})
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
it('refuses a since that is not a time before any call', async () => {
|
|
373
|
+
const broker = new FakeBroker()
|
|
374
|
+
await broker.run(async (queen) => {
|
|
375
|
+
await assert.rejects(queen.queue('q').changedSince(new Date('nope')), /invalid Date/)
|
|
376
|
+
await assert.rejects(queen.queue('q').changedSince({}), /ISO-8601/)
|
|
377
|
+
assert.equal(broker.hits.length, 0)
|
|
378
|
+
})
|
|
379
|
+
})
|
|
380
|
+
})
|
package/test-v2/reads.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads by offset (queue.fetch) and partition discovery (queue.changedSince)
|
|
3
|
+
* against a live broker.
|
|
4
|
+
*
|
|
5
|
+
* Run: node run.js fetchReadsEveryRecordByOffset
|
|
6
|
+
* node run.js changedSinceListsTheWrittenPartitions
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const QUEUE_FETCH = 'test-queue-v2-reads-fetch'
|
|
10
|
+
const QUEUE_CHANGED = 'test-queue-v2-reads-changed'
|
|
11
|
+
|
|
12
|
+
export async function fetchReadsEveryRecordByOffset(client) {
|
|
13
|
+
// 1,200 partitions: more than one fetch call (1000 entries each).
|
|
14
|
+
const partitions = Array.from({ length: 1200 }, (_, i) => `entity-${i}`)
|
|
15
|
+
const perPartition = 3
|
|
16
|
+
for (let n = 0; n < perPartition; n++) {
|
|
17
|
+
const items = partitions.map(p => ({ partition: p, transactionId: `${p}-${n}`, data: { p, n } }))
|
|
18
|
+
for (let i = 0; i < items.length; i += 500) {
|
|
19
|
+
const chunk = items.slice(i, i + 500)
|
|
20
|
+
// One push per partition group keeps the per-partition order of n.
|
|
21
|
+
await Promise.all(chunk.map(x => client.queue(QUEUE_FETCH).partition(x.partition).push([{ transactionId: x.transactionId, data: x.data }])))
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const read = await client.queue(QUEUE_FETCH).fetch(partitions)
|
|
26
|
+
if (read.size !== partitions.length) {
|
|
27
|
+
return { success: false, message: `fetch answered ${read.size} partitions for ${partitions.length}` }
|
|
28
|
+
}
|
|
29
|
+
for (const p of partitions) {
|
|
30
|
+
const part = read.get(p)
|
|
31
|
+
const got = part.records.map(r => `${r.offset}:${r.payload.p}:${r.payload.n}:${r.transactionId}`)
|
|
32
|
+
const want = Array.from({ length: perPartition }, (_, n) => `${n}:${p}:${n}:${p}-${n}`)
|
|
33
|
+
if (got.join(',') !== want.join(',')) {
|
|
34
|
+
return { success: false, message: `${p}: read ${got.join(',')}, expected ${want.join(',')}` }
|
|
35
|
+
}
|
|
36
|
+
if (part.highWatermark !== perPartition || part.nextOffset !== perPartition || part.logStartOffset !== 0) {
|
|
37
|
+
return { success: false, message: `${p}: bounds ${JSON.stringify(part)}` }
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// A later read from nextOffset answers only what came after.
|
|
42
|
+
await client.queue(QUEUE_FETCH).partition('entity-7').push([{ transactionId: 'entity-7-late', data: { late: true } }])
|
|
43
|
+
const later = await client.queue(QUEUE_FETCH).fetch(['entity-7', 'entity-8'], {
|
|
44
|
+
offsets: { 'entity-7': read.get('entity-7').nextOffset, 'entity-8': read.get('entity-8').nextOffset }
|
|
45
|
+
})
|
|
46
|
+
const late = later.get('entity-7').records
|
|
47
|
+
if (late.length !== 1 || late[0].offset !== perPartition || late[0].payload.late !== true) {
|
|
48
|
+
return { success: false, message: `read from nextOffset answered ${JSON.stringify(late)}` }
|
|
49
|
+
}
|
|
50
|
+
if (later.get('entity-8').records.length !== 0) {
|
|
51
|
+
return { success: false, message: 'a partition with nothing new answered records' }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// The reads took no lease and moved no cursor: a pop still gets the message.
|
|
55
|
+
const popped = await client.queue(QUEUE_FETCH).partition('entity-9').batch(10).wait(false).pop()
|
|
56
|
+
if (popped.length !== perPartition) {
|
|
57
|
+
return { success: false, message: `a pop after the fetch got ${popped.length} messages, expected ${perPartition}` }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const missing = await client.queue('test-queue-v2-reads-does-not-exist').fetch(['x'])
|
|
61
|
+
if (missing.get('x').records.length !== 0) {
|
|
62
|
+
return { success: false, message: 'a queue that does not exist answered records' }
|
|
63
|
+
}
|
|
64
|
+
return { success: true, message: `read ${partitions.length} partitions by offset` }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function changedSinceListsTheWrittenPartitions(client) {
|
|
68
|
+
const before = new Date(Date.now() - 60_000)
|
|
69
|
+
const names = Array.from({ length: 1100 }, (_, i) => `c-${i}`)
|
|
70
|
+
for (let i = 0; i < names.length; i += 100) {
|
|
71
|
+
await Promise.all(names.slice(i, i + 100).map(p => client.queue(QUEUE_CHANGED).partition(p).push([{ data: { p } }])))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 1,100 partitions: two pages.
|
|
75
|
+
const first = await client.queue(QUEUE_CHANGED).changedSince(before)
|
|
76
|
+
const listed = new Set(first.partitions.map(p => p.name))
|
|
77
|
+
const missing = names.filter(n => !listed.has(n))
|
|
78
|
+
if (missing.length > 0) {
|
|
79
|
+
return { success: false, message: `changedSince missed ${missing.length} partitions, e.g. ${missing.slice(0, 3)}` }
|
|
80
|
+
}
|
|
81
|
+
if (first.partitions.length !== names.length || typeof first.safeTime !== 'string') {
|
|
82
|
+
return { success: false, message: `changedSince answered ${first.partitions.length} partitions, safeTime ${first.safeTime}` }
|
|
83
|
+
}
|
|
84
|
+
const one = first.partitions.find(p => p.name === 'c-5')
|
|
85
|
+
if (one.lastOffset !== 0 || one.logStart !== 0 || typeof one.lastWriteAt !== 'string' || typeof one.id !== 'string') {
|
|
86
|
+
return { success: false, message: `partition fields ${JSON.stringify(one)}` }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// From the safeTime of that pass, a write to one partition lists it, and
|
|
90
|
+
// nothing written before that safeTime comes back.
|
|
91
|
+
await client.queue(QUEUE_CHANGED).partition('c-42').push([{ data: { again: true } }])
|
|
92
|
+
const next = await client.queue(QUEUE_CHANGED).changedSince(first.safeTime)
|
|
93
|
+
if (!next.partitions.some(p => p.name === 'c-42' && p.lastOffset === 1)) {
|
|
94
|
+
return { success: false, message: `c-42 not listed after its write: ${JSON.stringify(next.partitions.slice(0, 5))}` }
|
|
95
|
+
}
|
|
96
|
+
if (next.partitions.some(p => p.lastWriteAt < first.safeTime)) {
|
|
97
|
+
return { success: false, message: 'a partition written before safeTime was listed' }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const all = await client.queue(QUEUE_CHANGED).changedSince()
|
|
101
|
+
if (all.partitions.length !== names.length) {
|
|
102
|
+
return { success: false, message: `changedSince() without since answered ${all.partitions.length}` }
|
|
103
|
+
}
|
|
104
|
+
const none = await client.queue('test-queue-v2-reads-does-not-exist').changedSince(before)
|
|
105
|
+
if (none.partitions.length !== 0) {
|
|
106
|
+
return { success: false, message: 'a queue that does not exist listed partitions' }
|
|
107
|
+
}
|
|
108
|
+
return { success: true, message: `listed ${names.length} partitions over two pages` }
|
|
109
|
+
}
|
package/test-v2/run.js
CHANGED
|
@@ -20,6 +20,7 @@ import * as timerTests from './timers.js'
|
|
|
20
20
|
import * as lockTests from './locks.js'
|
|
21
21
|
import * as streamTests from './stream/index.js'
|
|
22
22
|
import * as docsTests from './docs.js'
|
|
23
|
+
import * as readsTests from './reads.js'
|
|
23
24
|
import { LoadBalancer } from '../client-v2/http/LoadBalancer.js';
|
|
24
25
|
|
|
25
26
|
|
|
@@ -117,6 +118,7 @@ async function main() {
|
|
|
117
118
|
kvTests,
|
|
118
119
|
timerTests,
|
|
119
120
|
lockTests,
|
|
121
|
+
readsTests,
|
|
120
122
|
docsTests
|
|
121
123
|
]
|
|
122
124
|
|