queen-mq 0.16.0 → 1.0.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/client-v2/Queen.js +71 -15
- package/client-v2/README.md +37 -0
- package/client-v2/builders/QueueBuilder.js +10 -4
- package/client-v2/builders/TransactionBuilder.js +14 -2
- package/client-v2/consumer/ConsumerManager.js +64 -12
- package/client-v2/http/HttpClient.js +382 -32
- package/client-v2/streams/runtime/Runner.js +45 -0
- package/client-v2/utils/defaults.js +20 -1
- package/package.json +4 -3
- package/test-v2/ackwindow.js +258 -0
- package/test-v2/auth.js +65 -149
- package/test-v2/consume.js +2 -0
- package/test-v2/http-unit/hostHeader.test.js +411 -0
- package/test-v2/http-unit/retry429.test.js +314 -0
- package/test-v2/load.js +37 -41
- package/test-v2/maintenance.js +2 -2
- package/test-v2/pop.js +2 -0
- package/test-v2/push.js +29 -35
- package/test-v2/run.js +37 -5
- package/test-v2/semantics.js +792 -0
- package/test-v2/stream/_helpers.js +1 -1
- package/test-v2/stream/tumbling.js +10 -6
- package/test-v2/streams-unit/ack.test.js +203 -0
- package/test-v2/streams-unit/e2e.test.js +4 -1
- package/test-v2/transaction.js +2 -0
- package/test-v2/watermark.js +78 -62
package/client-v2/Queen.js
CHANGED
|
@@ -15,6 +15,42 @@ import { CLIENT_DEFAULTS } from './utils/defaults.js'
|
|
|
15
15
|
import { validateUrl, validateUrls } from './utils/validation.js'
|
|
16
16
|
import * as logger from './utils/logger.js'
|
|
17
17
|
|
|
18
|
+
// Both /api/v1/ack and /api/v1/ack/batch respond with a top-level JSON array,
|
|
19
|
+
// one item per acknowledgment in request order:
|
|
20
|
+
// [{index, transactionId, success, error, queueName, partitionName, leaseReleased, dlq}]
|
|
21
|
+
// (see queen.ack_messages_v2 / routes/ack.cpp). A rejected ack/nack (e.g.
|
|
22
|
+
// "Invalid or expired lease") still arrives as HTTP 200 with success=false on
|
|
23
|
+
// the item, so the per-item flag is the only signal that the broker accepted it.
|
|
24
|
+
|
|
25
|
+
function normalizeAckItem(item, index) {
|
|
26
|
+
if (item === null || typeof item !== 'object') {
|
|
27
|
+
throw new Error(`Unexpected ack result item at index ${index}`)
|
|
28
|
+
}
|
|
29
|
+
let success = typeof item.success === 'boolean' ? item.success : true
|
|
30
|
+
let error = typeof item.error === 'string' && item.error.length > 0 ? item.error : null
|
|
31
|
+
if (error) success = false
|
|
32
|
+
if (!success && !error) error = 'Acknowledgment rejected by server'
|
|
33
|
+
return { ...item, success, error }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// expected is the number of acknowledgments sent, so a truncated or misaligned
|
|
37
|
+
// response fails loudly instead of being misattributed to the wrong message.
|
|
38
|
+
function parseAckResults(result, expected) {
|
|
39
|
+
if (Array.isArray(result)) {
|
|
40
|
+
if (result.length !== expected) {
|
|
41
|
+
throw new Error(`Ack response has ${result.length} results, expected ${expected}`)
|
|
42
|
+
}
|
|
43
|
+
return result.map(normalizeAckItem)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Top-level error envelope: the whole request was rejected.
|
|
47
|
+
if (result && typeof result === 'object' && typeof result.error === 'string' && result.error.length > 0) {
|
|
48
|
+
return Array.from({ length: expected }, () => ({ success: false, error: result.error }))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
throw new Error('Unexpected ack response format: missing per-item result array')
|
|
52
|
+
}
|
|
53
|
+
|
|
18
54
|
export class Queen {
|
|
19
55
|
#httpClient
|
|
20
56
|
#bufferManager
|
|
@@ -87,7 +123,7 @@ export class Queen {
|
|
|
87
123
|
}
|
|
88
124
|
|
|
89
125
|
#createHttpClient() {
|
|
90
|
-
const { urls, timeoutMillis, retryAttempts, retryDelayMillis, loadBalancingStrategy, affinityHashRing, healthRetryAfterMillis, enableFailover, bearerToken, headers } = this.#config
|
|
126
|
+
const { urls, timeoutMillis, retryAttempts, retryDelayMillis, loadBalancingStrategy, affinityHashRing, healthRetryAfterMillis, enableFailover, bearerToken, headers, hostHeader, retry429 } = this.#config
|
|
91
127
|
|
|
92
128
|
if (urls.length === 1) {
|
|
93
129
|
// Single server
|
|
@@ -97,7 +133,9 @@ export class Queen {
|
|
|
97
133
|
retryAttempts,
|
|
98
134
|
retryDelayMillis,
|
|
99
135
|
bearerToken,
|
|
100
|
-
headers
|
|
136
|
+
headers,
|
|
137
|
+
hostHeader,
|
|
138
|
+
retry429
|
|
101
139
|
})
|
|
102
140
|
}
|
|
103
141
|
|
|
@@ -113,7 +151,9 @@ export class Queen {
|
|
|
113
151
|
retryDelayMillis,
|
|
114
152
|
enableFailover,
|
|
115
153
|
bearerToken,
|
|
116
|
-
headers
|
|
154
|
+
headers,
|
|
155
|
+
hostHeader,
|
|
156
|
+
retry429
|
|
117
157
|
})
|
|
118
158
|
}
|
|
119
159
|
|
|
@@ -195,7 +235,7 @@ export class Queen {
|
|
|
195
235
|
// Handle batch acknowledgment
|
|
196
236
|
if (Array.isArray(message)) {
|
|
197
237
|
if (message.length === 0) {
|
|
198
|
-
return { processed: 0, results: [] }
|
|
238
|
+
return { success: true, processed: 0, results: [] }
|
|
199
239
|
}
|
|
200
240
|
|
|
201
241
|
// Check if messages have individual status
|
|
@@ -277,13 +317,19 @@ export class Queen {
|
|
|
277
317
|
consumerGroup: context.group || null
|
|
278
318
|
})
|
|
279
319
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
320
|
+
const results = parseAckResults(result, acknowledgments.length)
|
|
321
|
+
const failed = results.filter(r => !r.success)
|
|
322
|
+
|
|
323
|
+
if (failed.length > 0) {
|
|
324
|
+
const error = failed.length === 1
|
|
325
|
+
? failed[0].error
|
|
326
|
+
: `${failed.length} of ${results.length} acknowledgments rejected: ${failed[0].error}`
|
|
327
|
+
logger.error('Queen.ack', { type: 'batch', error, failed: failed.length, count: results.length })
|
|
328
|
+
return { success: false, error, results }
|
|
283
329
|
}
|
|
284
330
|
|
|
285
|
-
logger.log('Queen.ack', { type: 'batch', success: true, count:
|
|
286
|
-
return { success: true,
|
|
331
|
+
logger.log('Queen.ack', { type: 'batch', success: true, count: results.length })
|
|
332
|
+
return { success: true, processed: results.length, results }
|
|
287
333
|
} catch (error) {
|
|
288
334
|
logger.error('Queen.ack', { type: 'batch', error: error.message })
|
|
289
335
|
return { success: false, error: error.message }
|
|
@@ -321,13 +367,14 @@ export class Queen {
|
|
|
321
367
|
try {
|
|
322
368
|
const result = await this.#httpClient.post('/api/v1/ack', body)
|
|
323
369
|
|
|
324
|
-
|
|
325
|
-
logger.error('Queen.ack', { type: 'single', transactionId, error: result.error })
|
|
326
|
-
return { success: false, error: result.error }
|
|
327
|
-
}
|
|
370
|
+
const [ackResult] = parseAckResults(result, 1)
|
|
328
371
|
|
|
329
|
-
|
|
330
|
-
|
|
372
|
+
if (!ackResult.success) {
|
|
373
|
+
logger.error('Queen.ack', { type: 'single', transactionId, error: ackResult.error })
|
|
374
|
+
} else {
|
|
375
|
+
logger.log('Queen.ack', { type: 'single', transactionId, success: true })
|
|
376
|
+
}
|
|
377
|
+
return ackResult
|
|
331
378
|
} catch (error) {
|
|
332
379
|
logger.error('Queen.ack', { type: 'single', transactionId, error: error.message })
|
|
333
380
|
return { success: false, error: error.message }
|
|
@@ -512,6 +559,15 @@ export class Queen {
|
|
|
512
559
|
}
|
|
513
560
|
this.#shutdownHandlers = []
|
|
514
561
|
|
|
562
|
+
// Release the HTTP agent's keep-alive sockets — without this the Node
|
|
563
|
+
// event loop stays pinned by open sockets and the process never exits
|
|
564
|
+
// naturally after close().
|
|
565
|
+
try {
|
|
566
|
+
await this.#httpClient.destroy()
|
|
567
|
+
} catch (error) {
|
|
568
|
+
logger.warn('Queen.close', { error: error.message, phase: 'http-destroy' })
|
|
569
|
+
}
|
|
570
|
+
|
|
515
571
|
logger.log('Queen.close', 'Client closed successfully')
|
|
516
572
|
}
|
|
517
573
|
}
|
package/client-v2/README.md
CHANGED
|
@@ -104,6 +104,43 @@ const queen = new Queen({
|
|
|
104
104
|
})
|
|
105
105
|
```
|
|
106
106
|
|
|
107
|
+
### Connecting Through a Hosted Proxy (Cluster Selection)
|
|
108
|
+
|
|
109
|
+
A Queen proxy deployment routes each request to a tenant cluster using the
|
|
110
|
+
**Host header** (its first DNS label is the cluster slug). Normally you don't
|
|
111
|
+
have to think about it — every cluster has its own hostname, so pointing the
|
|
112
|
+
client at that hostname is all it takes:
|
|
113
|
+
|
|
114
|
+
```javascript
|
|
115
|
+
const queen = new Queen({
|
|
116
|
+
url: 'https://acme.eu1.queenmq.cloud', // Host follows from the URL
|
|
117
|
+
bearerToken: process.env.QUEEN_API_KEY
|
|
118
|
+
})
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
When the base URL can't be the cluster's hostname — an IP or a shared cell
|
|
122
|
+
endpoint, a local rig, a staging box, split-horizon DNS — use `hostHeader` to
|
|
123
|
+
advertise the cluster host while the connection still goes to the configured
|
|
124
|
+
address (the same thing `curl --resolve` does; TLS SNI and certificate
|
|
125
|
+
validation follow `hostHeader`, not the address):
|
|
126
|
+
|
|
127
|
+
```javascript
|
|
128
|
+
const queen = new Queen({
|
|
129
|
+
url: 'https://10.0.0.7:6711', // where we connect
|
|
130
|
+
hostHeader: 'acme.eu1.queenmq.cloud', // which cluster we ask for
|
|
131
|
+
bearerToken: process.env.QUEEN_API_KEY
|
|
132
|
+
})
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
`hostHeader` takes a bare authority (`'acme'`, `'acme.eu1.queenmq.cloud'`,
|
|
136
|
+
`'acme.local:6711'`) — not a URL. It works with `urls: [...]` too: every
|
|
137
|
+
backend is dialed as usual, and all of them get the same Host.
|
|
138
|
+
|
|
139
|
+
> ⚠️ `headers: { Host: '...' }` **cannot** work in JavaScript: `Host` is a
|
|
140
|
+
> WHATWG forbidden header name and `fetch()` drops it. Rather than let that
|
|
141
|
+
> silently route you to the wrong cluster, the client maps it onto
|
|
142
|
+
> `hostHeader` and warns once. Set `hostHeader` directly.
|
|
143
|
+
|
|
107
144
|
---
|
|
108
145
|
|
|
109
146
|
## Part 1: Hello Queue!
|
|
@@ -335,8 +335,10 @@ export class QueueBuilder {
|
|
|
335
335
|
|
|
336
336
|
// Generate affinity key for consistent routing to same backend
|
|
337
337
|
const affinityKey = this.#getAffinityKey()
|
|
338
|
-
|
|
339
|
-
|
|
338
|
+
|
|
339
|
+
// wait=true is a long-poll: on 429 it should back off and keep waiting
|
|
340
|
+
// rather than give up after a handful of tries (retryKind: 'pop').
|
|
341
|
+
const result = await this.#httpClient.get(`${path}?${params}`, this.#timeoutMillis + 5000, affinityKey, this.#wait ? 'pop' : null)
|
|
340
342
|
|
|
341
343
|
if (!result || !result.messages) {
|
|
342
344
|
logger.log('QueueBuilder.pop', { status: 'no-messages' })
|
|
@@ -347,8 +349,12 @@ export class QueueBuilder {
|
|
|
347
349
|
logger.log('QueueBuilder.pop', { status: 'success', count: messages.length })
|
|
348
350
|
return messages
|
|
349
351
|
} catch (error) {
|
|
350
|
-
// Return empty array on error instead of throwing
|
|
351
|
-
|
|
352
|
+
// Return empty array on error instead of throwing. This also covers a
|
|
353
|
+
// 429 whose retry429 policy was exhausted (bounded pop, or an explicit
|
|
354
|
+
// maxAttempts override) and a terminal 403 (e.g. cluster_suspended) --
|
|
355
|
+
// both are logged with their `.code` rather than raising, matching this
|
|
356
|
+
// method's existing swallow-to-[] contract.
|
|
357
|
+
logger.error('QueueBuilder.pop', { error: error.message, status: error.status, code: error.code })
|
|
352
358
|
return []
|
|
353
359
|
}
|
|
354
360
|
}
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import * as logger from '../utils/logger.js'
|
|
6
|
+
import { generateUUID } from './QueueBuilder.js'
|
|
7
|
+
import { isValidUUID } from '../utils/validation.js'
|
|
6
8
|
|
|
7
9
|
export class TransactionBuilder {
|
|
8
10
|
#httpClient
|
|
@@ -82,16 +84,26 @@ export class TransactionBuilder {
|
|
|
82
84
|
payloadValue = item
|
|
83
85
|
}
|
|
84
86
|
|
|
87
|
+
// Same contract as QueueBuilder.push: the caller's transactionId is
|
|
88
|
+
// what makes a retried transaction idempotent inside the dedup
|
|
89
|
+
// window, so it has to reach the wire. Absent, mint one here rather
|
|
90
|
+
// than leaving the broker to do it, so the id is knowable client
|
|
91
|
+
// side either way.
|
|
85
92
|
const result = {
|
|
86
93
|
queue: queueName,
|
|
87
|
-
payload: payloadValue
|
|
94
|
+
payload: payloadValue,
|
|
95
|
+
transactionId: item.transactionId || generateUUID()
|
|
88
96
|
}
|
|
89
|
-
|
|
97
|
+
|
|
90
98
|
// Add partition if set
|
|
91
99
|
if (partition !== null) {
|
|
92
100
|
result.partition = partition
|
|
93
101
|
}
|
|
94
102
|
|
|
103
|
+
if (item.traceId && isValidUUID(item.traceId)) {
|
|
104
|
+
result.traceId = item.traceId
|
|
105
|
+
}
|
|
106
|
+
|
|
95
107
|
return result
|
|
96
108
|
})
|
|
97
109
|
})
|
|
@@ -144,9 +144,11 @@ export class ConsumerManager {
|
|
|
144
144
|
}
|
|
145
145
|
|
|
146
146
|
try {
|
|
147
|
-
// Pop messages with affinity key for consistent routing
|
|
147
|
+
// Pop messages with affinity key for consistent routing. wait=true is
|
|
148
|
+
// a long-poll: mark it 'pop' so a 429 backs off and keeps waiting
|
|
149
|
+
// instead of giving up after the bounded push-like attempt budget.
|
|
148
150
|
const clientTimeout = wait ? timeoutMillis + 5000 : timeoutMillis
|
|
149
|
-
const result = await this.#httpClient.get(`${path}?${baseParams}`, clientTimeout, affinityKey)
|
|
151
|
+
const result = await this.#httpClient.get(`${path}?${baseParams}`, clientTimeout, affinityKey, wait ? 'pop' : null)
|
|
150
152
|
|
|
151
153
|
// Handle empty response
|
|
152
154
|
if (!result || !result.messages || result.messages.length === 0) {
|
|
@@ -188,9 +190,18 @@ export class ConsumerManager {
|
|
|
188
190
|
for (const message of messages) {
|
|
189
191
|
if (signal && signal.aborted) break
|
|
190
192
|
|
|
191
|
-
await this.#processMessage(message, handler, autoAck, group)
|
|
193
|
+
const ok = await this.#processMessage(message, handler, autoAck, group)
|
|
192
194
|
processedCount++
|
|
193
195
|
|
|
196
|
+
// A nack releases the lease and clamps the server cursor at the
|
|
197
|
+
// failed message: everything after it in this popped batch WILL
|
|
198
|
+
// be redelivered. Processing it now would only produce duplicates
|
|
199
|
+
// and rejected acks — abandon the rest of the batch.
|
|
200
|
+
if (autoAck && !ok) {
|
|
201
|
+
logger.warn('ConsumerManager.worker', { workerId, status: 'batch-abandoned-after-nack', remaining: messages.length - messages.indexOf(message) - 1 })
|
|
202
|
+
break
|
|
203
|
+
}
|
|
204
|
+
|
|
194
205
|
if (limit && processedCount >= limit) break
|
|
195
206
|
}
|
|
196
207
|
} else {
|
|
@@ -216,6 +227,20 @@ export class ConsumerManager {
|
|
|
216
227
|
continue // Retry on timeout
|
|
217
228
|
}
|
|
218
229
|
|
|
230
|
+
// 429 (rate limited): HttpClient already retries this internally
|
|
231
|
+
// with backoff (unbounded for wait=true pop, per retry429 policy) --
|
|
232
|
+
// this branch is a defensive fallback for the case where an explicit
|
|
233
|
+
// retry429.maxAttempts override got exhausted. Back off and keep
|
|
234
|
+
// polling instead of hot-looping or rethrowing/dying.
|
|
235
|
+
if (error.status === 429) {
|
|
236
|
+
const retryAfterMs = typeof error.retryAfterSeconds === 'number' && error.retryAfterSeconds >= 0
|
|
237
|
+
? error.retryAfterSeconds * 1000
|
|
238
|
+
: 1000
|
|
239
|
+
logger.warn('ConsumerManager.worker', { workerId, status: 'rate-limited', code: error.code, retryAfterMs })
|
|
240
|
+
await new Promise(resolve => setTimeout(resolve, retryAfterMs))
|
|
241
|
+
continue
|
|
242
|
+
}
|
|
243
|
+
|
|
219
244
|
// Check if network error
|
|
220
245
|
const isNetworkError = error.message?.includes('fetch failed') ||
|
|
221
246
|
error.message?.includes('ECONNREFUSED') ||
|
|
@@ -228,8 +253,18 @@ export class ConsumerManager {
|
|
|
228
253
|
continue
|
|
229
254
|
}
|
|
230
255
|
|
|
256
|
+
// 403 (forbidden): terminal. cluster_suspended in particular can
|
|
257
|
+
// never resolve itself, and none of the other proxy codes
|
|
258
|
+
// (storage_quota_exceeded / feature_gated / forbidden) are worth
|
|
259
|
+
// hot-looping either -- stop this worker and surface the error
|
|
260
|
+
// (with .code) to the caller instead of retrying.
|
|
261
|
+
if (error.status === 403) {
|
|
262
|
+
logger.error('ConsumerManager.worker', { workerId, status: 'forbidden', code: error.code, error: error.message })
|
|
263
|
+
throw error
|
|
264
|
+
}
|
|
265
|
+
|
|
231
266
|
// Other errors - rethrow
|
|
232
|
-
logger.error('ConsumerManager.worker', { workerId, error: error.message })
|
|
267
|
+
logger.error('ConsumerManager.worker', { workerId, error: error.message, code: error.code })
|
|
233
268
|
throw error
|
|
234
269
|
}
|
|
235
270
|
}
|
|
@@ -237,6 +272,11 @@ export class ConsumerManager {
|
|
|
237
272
|
logger.log('ConsumerManager.worker', { workerId, status: 'stopped', processedCount })
|
|
238
273
|
}
|
|
239
274
|
|
|
275
|
+
/**
|
|
276
|
+
* Returns true when the message was handled (and acked) successfully,
|
|
277
|
+
* false when it was nacked — the caller must abandon the rest of the
|
|
278
|
+
* popped batch (the nack released the lease server-side).
|
|
279
|
+
*/
|
|
240
280
|
async #processMessage(message, handler, autoAck, group) {
|
|
241
281
|
try {
|
|
242
282
|
await handler(message)
|
|
@@ -244,18 +284,26 @@ export class ConsumerManager {
|
|
|
244
284
|
// Auto-ack on success if enabled
|
|
245
285
|
if (autoAck) {
|
|
246
286
|
const context = group ? { group } : {}
|
|
247
|
-
await this.#queen.ack(message, true, context)
|
|
248
|
-
|
|
287
|
+
const res = await this.#queen.ack(message, true, context)
|
|
288
|
+
if (res && res.success === false) {
|
|
289
|
+
logger.error('ConsumerManager.processMessage', { transactionId: message.transactionId, status: 'ack-rejected', error: res.error })
|
|
290
|
+
} else {
|
|
291
|
+
logger.log('ConsumerManager.processMessage', { transactionId: message.transactionId, status: 'acked' })
|
|
292
|
+
}
|
|
249
293
|
}
|
|
294
|
+
return true
|
|
250
295
|
} catch (error) {
|
|
251
296
|
// Auto-nack on error if enabled
|
|
252
297
|
if (autoAck) {
|
|
253
|
-
const context = group ? { group } : {}
|
|
254
|
-
await this.#queen.ack(message, false, context)
|
|
298
|
+
const context = group ? { group, error: error.message } : { error: error.message }
|
|
299
|
+
const res = await this.#queen.ack(message, false, context)
|
|
300
|
+
if (res && res.success === false) {
|
|
301
|
+
logger.error('ConsumerManager.processMessage', { transactionId: message.transactionId, status: 'nack-rejected', error: res.error })
|
|
302
|
+
}
|
|
255
303
|
logger.error('ConsumerManager.processMessage', { transactionId: message.transactionId, error: error.message, status: 'nacked' })
|
|
256
304
|
// Don't rethrow when autoAck is enabled - NACK was already sent
|
|
257
305
|
// This allows the consumer to continue and retry
|
|
258
|
-
return
|
|
306
|
+
return false
|
|
259
307
|
}
|
|
260
308
|
logger.error('ConsumerManager.processMessage', { transactionId: message.transactionId, error: error.message })
|
|
261
309
|
throw error
|
|
@@ -269,13 +317,17 @@ export class ConsumerManager {
|
|
|
269
317
|
// Auto-ack on success if enabled
|
|
270
318
|
if (autoAck) {
|
|
271
319
|
const context = group ? { group } : {}
|
|
272
|
-
await this.#queen.ack(messages, true, context)
|
|
273
|
-
|
|
320
|
+
const res = await this.#queen.ack(messages, true, context)
|
|
321
|
+
if (res && res.success === false) {
|
|
322
|
+
logger.error('ConsumerManager.processBatch', { count: messages.length, status: 'ack-rejected', error: res.error })
|
|
323
|
+
} else {
|
|
324
|
+
logger.log('ConsumerManager.processBatch', { count: messages.length, status: 'acked' })
|
|
325
|
+
}
|
|
274
326
|
}
|
|
275
327
|
} catch (error) {
|
|
276
328
|
// Auto-nack on error if enabled
|
|
277
329
|
if (autoAck) {
|
|
278
|
-
const context = group ? { group } : {}
|
|
330
|
+
const context = group ? { group, error: error.message } : { error: error.message }
|
|
279
331
|
await this.#queen.ack(messages, false, context)
|
|
280
332
|
logger.error('ConsumerManager.processBatch', { count: messages.length, error: error.message, status: 'nacked' })
|
|
281
333
|
// Don't rethrow when autoAck is enabled - NACK was already sent
|