queen-mq 2.1.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 CHANGED
@@ -229,7 +229,11 @@ const queen = new Queen({
229
229
  timeoutMillis: 30000,
230
230
  retryAttempts: 3,
231
231
  loadBalancingStrategy: 'affinity', // or 'round-robin', 'session'
232
- enableFailover: true
232
+ enableFailover: true,
233
+ // Opt-in wire forms, off by default (see Binary Push, Push Compression and Framed Pop)
234
+ binaryPush: false,
235
+ pushCompression: false,
236
+ framedPop: false
233
237
  })
234
238
  ```
235
239
 
@@ -515,6 +519,54 @@ rate, and a broker outage shows up as slow pushes with bounded memory rather
515
519
  than as messages that quietly disappeared. `close()` flushes with a 30 second
516
520
  deadline and logs how many messages were left unsent if it expires.
517
521
 
522
+ ### Binary Push, Push Compression and Framed Pop
523
+
524
+ Three opt-in forms of the push, pop and transaction requests. They change how
525
+ the messages travel between the client and the broker and nothing else: the
526
+ calls, their results, deduplication and the delivery guarantees stay the same.
527
+ All three are off by default, and with them off the client sends and reads the
528
+ JSON every broker knows.
529
+
530
+ ```javascript
531
+ const queen = new Queen({
532
+ url: 'http://localhost:6632',
533
+ binaryPush: true, // a push travels as a binary body
534
+ pushCompression: true, // with binaryPush: framed and compressed here
535
+ framedPop: true // a pop returns the stored records, unpacked here
536
+ })
537
+ ```
538
+
539
+ | Option | What it does |
540
+ | --- | --- |
541
+ | `binaryPush` | A push, direct or buffered, sends its items as one length-prefixed binary body instead of a JSON document. The answer is the same list, one entry per item. |
542
+ | `pushCompression` | Needs `binaryPush`. The client frames each partition's batch the way the broker stores it, mints the message ids (UUIDv7) and compresses the batch with zstd, once. The broker checks the frames and, when it has nothing to add to them, stores those bytes without compressing them again. A transaction that pushes sends its pushes the same way, with the rest of the bundle as JSON in front of them. |
543
+ | `framedPop` | `pop()` and `consume()` ask for the stored records (`frames=true`) and unpack them here into the same message objects, instead of a JSON answer that the broker renders message by message. |
544
+
545
+ Turn them on when the broker's CPU or the network limits a high message rate:
546
+ the framing, the compression and the unpacking then run in the clients, and the
547
+ batches cross the network compressed. zstd is the one of Node.js (`node:zlib`),
548
+ so there is nothing to install.
549
+
550
+ **What stays JSON**, with no action from you:
551
+
552
+ - A push with a `traceId` on one of its items.
553
+ - A transaction with no push, or with a `traceId` on one of its pushes.
554
+ - A pop the broker cannot answer in frames: a pop of a conflating group, and
555
+ every pop on a broker that runs with an encryption key
556
+ (`QUEEN_ENCRYPTION_KEY`). The client reads the JSON answer as always.
557
+ - Acks, KV, timers, locks and the admin calls. Ephemeral queues have their own
558
+ routes and do not change either.
559
+
560
+ A transaction's `results` come back in the order you added the operations, as
561
+ with JSON. The broker answers a binary transaction with the acks first and the
562
+ pushes after them, and the client puts them back.
563
+
564
+ **Requires broker 2.3.0 or later** for `binaryPush` and `pushCompression`.
565
+ A broker up to 2.2.0 answers 400 to the binary bodies and the client does not
566
+ fall back to JSON, so turn the two on after every broker of the cluster is
567
+ upgraded. `framedPop` is safe against any broker: an older one ignores the
568
+ parameter and answers JSON.
569
+
518
570
  ### Dead Letter Queue
519
571
 
520
572
  ```javascript
@@ -747,6 +799,48 @@ other than the lost precondition still throws.
747
799
 
748
800
  ---
749
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
+
750
844
  ## Examples
751
845
 
752
846
  ### Complete Pipeline with Consumer Groups
@@ -882,6 +976,16 @@ throws before any request when the builder has `.commitOnDelivery()`.
882
976
 
883
977
  `.autoAck()` is `consume()`'s ack after your handler and has no effect on a pop.
884
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
+
885
989
  ### Consume
886
990
 
887
991
  ```javascript
@@ -997,7 +1101,10 @@ await queen.close() // Flush buffers and close connections
997
1101
  loadBalancingStrategy: 'affinity', // 'affinity' | 'round-robin' | 'session'
998
1102
  affinityHashRing: 128,
999
1103
  enableFailover: true,
1000
- healthRetryAfterMillis: 5000
1104
+ healthRetryAfterMillis: 5000,
1105
+ binaryPush: false, // a push as a binary body
1106
+ pushCompression: false, // with binaryPush: frames compressed in the client
1107
+ framedPop: false // a pop as the stored records
1001
1108
  }
1002
1109
  ```
1003
1110
 
@@ -179,7 +179,7 @@ export class Queen {
179
179
  }
180
180
 
181
181
  #createHttpClient() {
182
- const { urls, timeoutMillis, retryAttempts, retryDelayMillis, loadBalancingStrategy, affinityHashRing, healthRetryAfterMillis, enableFailover, bearerToken, headers, hostHeader, retry429 } = this.#config
182
+ const { urls, timeoutMillis, retryAttempts, retryDelayMillis, loadBalancingStrategy, affinityHashRing, healthRetryAfterMillis, enableFailover, bearerToken, headers, hostHeader, retry429, binaryPush, pushCompression, framedPop } = this.#config
183
183
 
184
184
  if (urls.length === 1) {
185
185
  // Single server
@@ -191,7 +191,10 @@ export class Queen {
191
191
  bearerToken,
192
192
  headers,
193
193
  hostHeader,
194
- retry429
194
+ retry429,
195
+ binaryPush,
196
+ pushCompression,
197
+ framedPop
195
198
  })
196
199
  }
197
200
 
@@ -209,7 +212,10 @@ export class Queen {
209
212
  bearerToken,
210
213
  headers,
211
214
  hostHeader,
212
- retry429
215
+ retry429,
216
+ binaryPush,
217
+ pushCompression,
218
+ framedPop
213
219
  })
214
220
  }
215
221
 
@@ -20,6 +20,8 @@ Welcome to Queen client! This is your friendly guide to mastering message queues
20
20
  - [Part 12: Message Tracing - Debug Your Workflows](#part-12-message-tracing---debug-your-workflows)
21
21
  - [Part 13: Callbacks & Error Handling](#part-13-callbacks--error-handling)
22
22
  - [Part 14: Graceful Shutdown](#part-14-graceful-shutdown)
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)
23
25
  - [Cheat Sheet](#cheat-sheet)
24
26
 
25
27
  ---
@@ -1607,6 +1609,116 @@ await queen.close()
1607
1609
 
1608
1610
  ---
1609
1611
 
1612
+ ## Part 15: Binary Push, Compression & Framed Pop
1613
+
1614
+ Everything so far travels as JSON. For a high message rate, the client has three
1615
+ opt-in forms of the push, pop and transaction requests. They change how the
1616
+ messages travel and nothing else: your calls, their results, deduplication and
1617
+ the delivery guarantees stay the same. All three are off by default.
1618
+
1619
+ ```javascript
1620
+ const queen = new Queen({
1621
+ url: 'http://localhost:6632',
1622
+ binaryPush: true, // a push travels as a binary body
1623
+ pushCompression: true, // with binaryPush: framed and compressed here
1624
+ framedPop: true // a pop returns the stored records, unpacked here
1625
+ })
1626
+
1627
+ // The rest of your code does not change.
1628
+ await queen.queue('events').partition('device-7').push([{ data: { n: 1 } }])
1629
+ await queen.queue('events').group('workers').consume(async (message) => {
1630
+ console.log(message.data)
1631
+ })
1632
+ ```
1633
+
1634
+ ### What Each Option Does
1635
+
1636
+ | Option | What it does |
1637
+ | --- | --- |
1638
+ | `binaryPush` | A push, direct or buffered, sends its items as one length-prefixed binary body instead of a JSON document. The answer is the same list, one entry per item. |
1639
+ | `pushCompression` | Needs `binaryPush`. The client frames each partition's batch the way the broker stores it, mints the message ids (UUIDv7) and compresses the batch with zstd, once. The broker checks the frames and, when it has nothing to add to them, stores those bytes without compressing them again. A transaction that pushes sends its pushes the same way, with the rest of the bundle as JSON in front of them. |
1640
+ | `framedPop` | `pop()` and `consume()` ask for the stored records (`frames=true`) and unpack them here into the same message objects, instead of a JSON answer that the broker renders message by message. |
1641
+
1642
+ Turn them on when the broker's CPU or the network limits a high message rate:
1643
+ the framing, the compression and the unpacking then run in the clients, and the
1644
+ batches cross the network compressed. zstd is the one of Node.js (`node:zlib`),
1645
+ so there is nothing to install.
1646
+
1647
+ ### What Stays JSON
1648
+
1649
+ The client falls back by itself in these cases:
1650
+
1651
+ - A push with a `traceId` on one of its items.
1652
+ - A transaction with no push, or with a `traceId` on one of its pushes.
1653
+ - A pop the broker cannot answer in frames: a pop of a conflating group, and
1654
+ every pop on a broker that runs with an encryption key
1655
+ (`QUEEN_ENCRYPTION_KEY`). The client reads the JSON answer as always.
1656
+ - Acks, KV, timers, locks and the admin calls. Ephemeral queues have their own
1657
+ routes and do not change either.
1658
+
1659
+ A transaction's `results` come back in the order you added the operations, as
1660
+ with JSON. The broker answers a binary transaction with the acks first and the
1661
+ pushes after them, and the client puts them back.
1662
+
1663
+ ### Which Broker
1664
+
1665
+ `binaryPush` and `pushCompression` need broker 2.3.0 or later. A broker up
1666
+ to 2.2.0 answers 400 to the binary bodies and the client does not fall back to
1667
+ JSON, so turn the two on after every broker of the cluster is upgraded.
1668
+ `framedPop` is safe against any broker: an older one ignores the parameter and
1669
+ answers JSON.
1670
+
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
+
1610
1722
  ## Cheat Sheet
1611
1723
 
1612
1724
  ### Connection
@@ -1883,7 +1995,10 @@ await queen.close()
1883
1995
  loadBalancingStrategy: 'affinity', // 'affinity' | 'round-robin' | 'session'
1884
1996
  affinityHashRing: 128,
1885
1997
  enableFailover: true,
1886
- healthRetryAfterMillis: 5000
1998
+ healthRetryAfterMillis: 5000,
1999
+ binaryPush: false, // a push as a binary body
2000
+ pushCompression: false, // with binaryPush: frames compressed in the client
2001
+ framedPop: false // a pop as the stored records
1887
2002
  }
1888
2003
  ```
1889
2004
 
@@ -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
  // ===========================
@@ -3,6 +3,8 @@
3
3
  */
4
4
 
5
5
  import * as logger from '../utils/logger.js'
6
+ import { binaryPushBody, transactionFrames, restoreResultOrder, PUSH_BINARY_CONTENT_TYPE } from './pushBinary.js'
7
+ import { decodePopFrames, isPopFrames } from './popFrames.js'
6
8
 
7
9
  /**
8
10
  * The error a request ends with when its caller aborted it (a consumer being
@@ -142,6 +144,12 @@ export class HttpClient {
142
144
  // carries the same virtual Host.
143
145
  #hostOverride = null
144
146
  #pinnedDispatchers = new Map()
147
+ // The binary push body and the framed pop answer (pushBinary.js,
148
+ // popFrames.js). All three off by default: the client then sends and reads
149
+ // the JSON every broker knows.
150
+ #binaryPush
151
+ #pushCompression
152
+ #framedPop
145
153
 
146
154
  constructor(options = {}) {
147
155
  const {
@@ -169,7 +177,21 @@ export class HttpClient {
169
177
  // unbounded (retry forever, paced by backoff) unless maxAttempts is
170
178
  // explicitly set here, in which case it applies to both.
171
179
  // See PLAN_QUEEN_PROXY_CLOUD.md §4/§9 (client 429 backoff, blocker B4).
172
- retry429 = {}
180
+ retry429 = {},
181
+ // A push to /api/v1/push goes out as the binary push body instead of
182
+ // JSON. It needs a broker that accepts that body. Items with a trace id
183
+ // stay JSON.
184
+ binaryPush = false,
185
+ // With binaryPush: the messages are framed as the broker stores them and
186
+ // each partition's batch is zstd-compressed once, here; the broker keeps
187
+ // those bytes as they are. The client then mints the message ids. A
188
+ // transaction that pushes sends its pushes the same way (the binary
189
+ // transaction body), and the rest of the bundle as JSON in front of them.
190
+ pushCompression = false,
191
+ // Pops ask the broker for the messages as the stored records
192
+ // (frames=true) and unpack them here. A broker that does not know the
193
+ // parameter answers JSON, which is read as always.
194
+ framedPop = false
173
195
  } = options
174
196
 
175
197
  this.#baseUrl = baseUrl
@@ -180,6 +202,9 @@ export class HttpClient {
180
202
  this.#enableFailover = enableFailover
181
203
  this.#bearerToken = bearerToken
182
204
  this.#retry429 = retry429 || {}
205
+ this.#binaryPush = binaryPush === true
206
+ this.#pushCompression = pushCompression === true
207
+ this.#framedPop = framedPop === true
183
208
 
184
209
  // A Host in `headers` can never go out over fetch(); rather than let it be
185
210
  // dropped, map it onto the supported mechanism and say so out loud.
@@ -408,11 +433,15 @@ export class HttpClient {
408
433
  }
409
434
  }
410
435
 
411
- if (body) {
436
+ if (body instanceof Uint8Array) {
437
+ // Already encoded (the binary push body): sent as it is.
438
+ headers['Content-Type'] = PUSH_BINARY_CONTENT_TYPE
439
+ options.body = body
440
+ } else if (body) {
412
441
  options.body = JSON.stringify(body)
413
442
  }
414
443
 
415
- const response = await fetch(requestUrl, options)
444
+ let response = await fetch(requestUrl, options)
416
445
 
417
446
  // The broker granted the leases of a pop when it sent these headers. From
418
447
  // here the caller's abort no longer cuts the read: the body says which
@@ -460,6 +489,19 @@ export class HttpClient {
460
489
  throw error
461
490
  }
462
491
 
492
+ // A pop that asked for frames: its first bytes say which answer came
493
+ // back, whatever a proxy made of the Content-Type on the way. The framed
494
+ // one is unpacked into the object the JSON answer parses to, so its
495
+ // readers do not tell the two apart; a JSON one (a broker that does not
496
+ // know the parameter) goes on to be read as always.
497
+ if (this.#framedPop && isPop(method, url)) {
498
+ const bytes = new Uint8Array(await response.arrayBuffer())
499
+ if (isPopFrames(bytes)) {
500
+ return await decodePopFrames(bytes)
501
+ }
502
+ response = new Response(bytes, { status: response.status, headers: response.headers })
503
+ }
504
+
463
505
  // Parse successful response
464
506
  const contentType = response.headers.get('content-type')
465
507
  const contentLength = response.headers.get('content-length')
@@ -608,10 +650,32 @@ export class HttpClient {
608
650
  // `signal`: aborting it closes the request, which then rejects with an
609
651
  // error carrying `aborted: true` -- never retried and never failed over.
610
652
  async get(path, requestTimeoutMillis = null, affinityKey = null, retryKind = null, signal = null) {
653
+ if (this.#framedPop && path.startsWith('/api/v1/pop')) {
654
+ path += `${path.includes('?') ? '&' : '?'}frames=true`
655
+ }
611
656
  return this.#requestWithFailover('GET', path, null, requestTimeoutMillis, affinityKey, retryKind, signal)
612
657
  }
613
658
 
614
659
  async post(path, body = null, requestTimeoutMillis = null, affinityKey = null, retryKind = null, signal = null) {
660
+ if (this.#binaryPush && path === '/api/v1/push' && body && Array.isArray(body.items)) {
661
+ // Encoded once, before any retry: every attempt sends the same bytes
662
+ // (with pushCompression, the same message ids). null = an item the
663
+ // binary body does not carry, and the push stays JSON.
664
+ body = (await binaryPushBody(body.items, this.#pushCompression)) ?? body
665
+ }
666
+ if (this.#binaryPush && this.#pushCompression && path === '/api/v1/transaction') {
667
+ // The pushes of a bundle as frames built and compressed here, the rest
668
+ // of it as the JSON head in front of them. null = no push, or one the
669
+ // binary body does not carry: the bundle stays JSON.
670
+ const framed = await transactionFrames(body)
671
+ if (framed) {
672
+ const answer = await this.#requestWithFailover('POST', path, framed.body, requestTimeoutMillis, affinityKey, retryKind, signal)
673
+ // The broker answers the head's operations before the pushes: the
674
+ // caller gets the results in the order of its own operations.
675
+ restoreResultOrder(answer, framed.order)
676
+ return answer
677
+ }
678
+ }
615
679
  return this.#requestWithFailover('POST', path, body, requestTimeoutMillis, affinityKey, retryKind, signal)
616
680
  }
617
681