@durable-streams/client-conformance-tests 0.1.6 → 0.1.7
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/dist/adapters/typescript-adapter.cjs +75 -3
- package/dist/adapters/typescript-adapter.js +76 -4
- package/dist/{benchmark-runner-D-YSAvRy.js → benchmark-runner-CrE6JkbX.js} +86 -8
- package/dist/{benchmark-runner-BlKqhoXE.cjs → benchmark-runner-Db4he452.cjs} +87 -8
- package/dist/cli.cjs +1 -1
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.d.cts +106 -6
- package/dist/index.d.ts +106 -6
- package/dist/index.js +1 -1
- package/dist/{protocol-3cf94Xyb.d.cts → protocol-D37G3c4e.d.cts} +80 -4
- package/dist/{protocol-DyEvTHPF.d.ts → protocol-Mcbiq3nQ.d.ts} +80 -4
- package/dist/protocol.d.cts +2 -2
- package/dist/protocol.d.ts +2 -2
- package/package.json +3 -3
- package/src/adapters/typescript-adapter.ts +127 -6
- package/src/protocol.ts +85 -1
- package/src/runner.ts +178 -13
- package/src/test-cases.ts +110 -3
- package/test-cases/consumer/error-handling.yaml +42 -0
- package/test-cases/consumer/offset-handling.yaml +209 -0
- package/test-cases/producer/idempotent/autoclaim.yaml +214 -0
- package/test-cases/producer/idempotent/batching.yaml +98 -0
- package/test-cases/producer/idempotent/concurrent-requests.yaml +100 -0
- package/test-cases/producer/idempotent/epoch-management.yaml +333 -0
- package/test-cases/producer/idempotent/error-handling.yaml +194 -0
- package/test-cases/producer/idempotent/multi-producer.yaml +322 -0
- package/test-cases/producer/idempotent/sequence-validation.yaml +339 -0
- package/test-cases/producer/idempotent-json-batching.yaml +134 -0
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
DurableStream,
|
|
15
15
|
DurableStreamError,
|
|
16
16
|
FetchError,
|
|
17
|
+
IdempotentProducer,
|
|
17
18
|
stream,
|
|
18
19
|
} from "@durable-streams/client"
|
|
19
20
|
import {
|
|
@@ -439,7 +440,7 @@ async function handleCommand(command: TestCommand): Promise<TestResult> {
|
|
|
439
440
|
return {
|
|
440
441
|
type: `read`,
|
|
441
442
|
success: true,
|
|
442
|
-
status:
|
|
443
|
+
status: response.status,
|
|
443
444
|
chunks,
|
|
444
445
|
offset: finalOffset,
|
|
445
446
|
upToDate,
|
|
@@ -543,6 +544,117 @@ async function handleCommand(command: TestCommand): Promise<TestResult> {
|
|
|
543
544
|
}
|
|
544
545
|
}
|
|
545
546
|
|
|
547
|
+
case `idempotent-append`: {
|
|
548
|
+
try {
|
|
549
|
+
const url = `${serverUrl}${command.path}`
|
|
550
|
+
|
|
551
|
+
// Get content-type from cache or use default
|
|
552
|
+
const contentType =
|
|
553
|
+
streamContentTypes.get(command.path) ?? `application/octet-stream`
|
|
554
|
+
|
|
555
|
+
const ds = new DurableStream({
|
|
556
|
+
url,
|
|
557
|
+
contentType,
|
|
558
|
+
})
|
|
559
|
+
|
|
560
|
+
const producer = new IdempotentProducer(ds, command.producerId, {
|
|
561
|
+
epoch: command.epoch,
|
|
562
|
+
autoClaim: command.autoClaim,
|
|
563
|
+
maxInFlight: 1,
|
|
564
|
+
lingerMs: 0, // Send immediately for testing
|
|
565
|
+
})
|
|
566
|
+
|
|
567
|
+
// For JSON streams, parse the string data into a native object
|
|
568
|
+
// (IdempotentProducer expects native objects for JSON streams)
|
|
569
|
+
const normalizedContentType = contentType
|
|
570
|
+
.split(`;`)[0]
|
|
571
|
+
?.trim()
|
|
572
|
+
.toLowerCase()
|
|
573
|
+
const isJson = normalizedContentType === `application/json`
|
|
574
|
+
const data = isJson ? JSON.parse(command.data) : command.data
|
|
575
|
+
|
|
576
|
+
try {
|
|
577
|
+
// append() is fire-and-forget (synchronous), then flush() sends the batch
|
|
578
|
+
producer.append(data)
|
|
579
|
+
await producer.flush()
|
|
580
|
+
await producer.close()
|
|
581
|
+
|
|
582
|
+
return {
|
|
583
|
+
type: `idempotent-append`,
|
|
584
|
+
success: true,
|
|
585
|
+
status: 200,
|
|
586
|
+
}
|
|
587
|
+
} catch (err) {
|
|
588
|
+
await producer.close()
|
|
589
|
+
throw err
|
|
590
|
+
}
|
|
591
|
+
} catch (err) {
|
|
592
|
+
return errorResult(`idempotent-append`, err)
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
case `idempotent-append-batch`: {
|
|
597
|
+
try {
|
|
598
|
+
const url = `${serverUrl}${command.path}`
|
|
599
|
+
|
|
600
|
+
// Get content-type from cache or use default
|
|
601
|
+
const contentType =
|
|
602
|
+
streamContentTypes.get(command.path) ?? `application/octet-stream`
|
|
603
|
+
|
|
604
|
+
const ds = new DurableStream({
|
|
605
|
+
url,
|
|
606
|
+
contentType,
|
|
607
|
+
})
|
|
608
|
+
|
|
609
|
+
// Use provided maxInFlight or default to 1 for compatibility
|
|
610
|
+
const maxInFlight = command.maxInFlight ?? 1
|
|
611
|
+
|
|
612
|
+
// When testing concurrency (maxInFlight > 1), use small batches to force
|
|
613
|
+
// multiple concurrent requests. Otherwise batch all items together.
|
|
614
|
+
const testingConcurrency = maxInFlight > 1
|
|
615
|
+
const producer = new IdempotentProducer(ds, command.producerId, {
|
|
616
|
+
epoch: command.epoch,
|
|
617
|
+
autoClaim: command.autoClaim,
|
|
618
|
+
maxInFlight,
|
|
619
|
+
lingerMs: testingConcurrency ? 0 : 1000,
|
|
620
|
+
maxBatchBytes: testingConcurrency ? 1 : 1024 * 1024,
|
|
621
|
+
})
|
|
622
|
+
|
|
623
|
+
// For JSON streams, parse string items into native objects
|
|
624
|
+
// (IdempotentProducer expects native objects for JSON streams)
|
|
625
|
+
const normalizedContentType = contentType
|
|
626
|
+
.split(`;`)[0]
|
|
627
|
+
?.trim()
|
|
628
|
+
.toLowerCase()
|
|
629
|
+
const isJson = normalizedContentType === `application/json`
|
|
630
|
+
const items = isJson
|
|
631
|
+
? command.items.map((item: string) => JSON.parse(item))
|
|
632
|
+
: command.items
|
|
633
|
+
|
|
634
|
+
try {
|
|
635
|
+
// append() is fire-and-forget (synchronous), adds to pending batch
|
|
636
|
+
for (const item of items) {
|
|
637
|
+
producer.append(item)
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// flush() sends the batch and waits for completion
|
|
641
|
+
await producer.flush()
|
|
642
|
+
await producer.close()
|
|
643
|
+
|
|
644
|
+
return {
|
|
645
|
+
type: `idempotent-append-batch`,
|
|
646
|
+
success: true,
|
|
647
|
+
status: 200,
|
|
648
|
+
}
|
|
649
|
+
} catch (err) {
|
|
650
|
+
await producer.close()
|
|
651
|
+
throw err
|
|
652
|
+
}
|
|
653
|
+
} catch (err) {
|
|
654
|
+
return errorResult(`idempotent-append-batch`, err)
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
546
658
|
default:
|
|
547
659
|
return {
|
|
548
660
|
type: `error`,
|
|
@@ -702,7 +814,7 @@ async function handleBenchmark(command: BenchmarkCommand): Promise<TestResult> {
|
|
|
702
814
|
|
|
703
815
|
// Wait for data
|
|
704
816
|
return new Promise<Uint8Array>((resolve) => {
|
|
705
|
-
const unsubscribe = res.subscribeBytes((chunk) => {
|
|
817
|
+
const unsubscribe = res.subscribeBytes(async (chunk) => {
|
|
706
818
|
if (chunk.data.length > 0) {
|
|
707
819
|
unsubscribe()
|
|
708
820
|
res.cancel()
|
|
@@ -749,10 +861,19 @@ async function handleBenchmark(command: BenchmarkCommand): Promise<TestResult> {
|
|
|
749
861
|
// Generate payload (using fill for speed - don't want to measure PRNG)
|
|
750
862
|
const payload = new Uint8Array(operation.size).fill(42)
|
|
751
863
|
|
|
752
|
-
//
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
864
|
+
// Use IdempotentProducer for automatic batching and pipelining
|
|
865
|
+
const producer = new IdempotentProducer(ds, `bench-producer`, {
|
|
866
|
+
lingerMs: 0, // No linger - send batches immediately when ready
|
|
867
|
+
onError: (err) => console.error(`Batch failed:`, err),
|
|
868
|
+
})
|
|
869
|
+
|
|
870
|
+
// Fire-and-forget: don't await individual appends, producer batches in background
|
|
871
|
+
for (let i = 0; i < operation.count; i++) {
|
|
872
|
+
producer.append(payload)
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// Wait for all messages to be delivered
|
|
876
|
+
await producer.flush()
|
|
756
877
|
|
|
757
878
|
metrics.bytesTransferred = operation.count * operation.size
|
|
758
879
|
metrics.messagesProcessed = operation.count
|
package/src/protocol.ts
CHANGED
|
@@ -62,10 +62,54 @@ export interface AppendCommand {
|
|
|
62
62
|
data: string
|
|
63
63
|
/** Whether data is base64 encoded binary */
|
|
64
64
|
binary?: boolean
|
|
65
|
-
/** Optional sequence number for ordering */
|
|
65
|
+
/** Optional sequence number for ordering (Stream-Seq header) */
|
|
66
66
|
seq?: number
|
|
67
67
|
/** Custom headers to include */
|
|
68
68
|
headers?: Record<string, string>
|
|
69
|
+
/** Producer ID for idempotent producers */
|
|
70
|
+
producerId?: string
|
|
71
|
+
/** Producer epoch for idempotent producers */
|
|
72
|
+
producerEpoch?: number
|
|
73
|
+
/** Producer sequence for idempotent producers */
|
|
74
|
+
producerSeq?: number
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Append via IdempotentProducer client (tests client-side exactly-once semantics).
|
|
79
|
+
*/
|
|
80
|
+
export interface IdempotentAppendCommand {
|
|
81
|
+
type: `idempotent-append`
|
|
82
|
+
path: string
|
|
83
|
+
/** Data to append (string - will be JSON parsed for JSON streams) */
|
|
84
|
+
data: string
|
|
85
|
+
/** Producer ID */
|
|
86
|
+
producerId: string
|
|
87
|
+
/** Producer epoch */
|
|
88
|
+
epoch: number
|
|
89
|
+
/** Auto-claim epoch on 403 */
|
|
90
|
+
autoClaim: boolean
|
|
91
|
+
/** Custom headers to include */
|
|
92
|
+
headers?: Record<string, string>
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Batch append via IdempotentProducer client (tests client-side JSON batching).
|
|
97
|
+
*/
|
|
98
|
+
export interface IdempotentAppendBatchCommand {
|
|
99
|
+
type: `idempotent-append-batch`
|
|
100
|
+
path: string
|
|
101
|
+
/** Items to append - will be batched by the client */
|
|
102
|
+
items: Array<string>
|
|
103
|
+
/** Producer ID */
|
|
104
|
+
producerId: string
|
|
105
|
+
/** Producer epoch */
|
|
106
|
+
epoch: number
|
|
107
|
+
/** Auto-claim epoch on 403 */
|
|
108
|
+
autoClaim: boolean
|
|
109
|
+
/** Max concurrent batches in flight (default 1, set higher to test 409 retry) */
|
|
110
|
+
maxInFlight?: number
|
|
111
|
+
/** Custom headers to include */
|
|
112
|
+
headers?: Record<string, string>
|
|
69
113
|
}
|
|
70
114
|
|
|
71
115
|
/**
|
|
@@ -235,6 +279,8 @@ export type TestCommand =
|
|
|
235
279
|
| CreateCommand
|
|
236
280
|
| ConnectCommand
|
|
237
281
|
| AppendCommand
|
|
282
|
+
| IdempotentAppendCommand
|
|
283
|
+
| IdempotentAppendBatchCommand
|
|
238
284
|
| ReadCommand
|
|
239
285
|
| HeadCommand
|
|
240
286
|
| DeleteCommand
|
|
@@ -313,6 +359,42 @@ export interface AppendResult {
|
|
|
313
359
|
headersSent?: Record<string, string>
|
|
314
360
|
/** Params that were sent in the request (for dynamic param testing) */
|
|
315
361
|
paramsSent?: Record<string, string>
|
|
362
|
+
/** Whether this was a duplicate (204 response) - for idempotent producers */
|
|
363
|
+
duplicate?: boolean
|
|
364
|
+
/** Current producer epoch from server (on 200 or 403) */
|
|
365
|
+
producerEpoch?: number
|
|
366
|
+
/** Server's highest accepted sequence for this (stream, producerId, epoch) - returned in Producer-Seq header on 200/204 */
|
|
367
|
+
producerSeq?: number
|
|
368
|
+
/** Expected producer sequence (on 409 sequence gap) */
|
|
369
|
+
producerExpectedSeq?: number
|
|
370
|
+
/** Received producer sequence (on 409 sequence gap) */
|
|
371
|
+
producerReceivedSeq?: number
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Successful idempotent-append result.
|
|
376
|
+
*/
|
|
377
|
+
export interface IdempotentAppendResult {
|
|
378
|
+
type: `idempotent-append`
|
|
379
|
+
success: true
|
|
380
|
+
status: number
|
|
381
|
+
/** New offset after append */
|
|
382
|
+
offset?: string
|
|
383
|
+
/** Whether this was a duplicate */
|
|
384
|
+
duplicate?: boolean
|
|
385
|
+
/** Server's highest accepted sequence for this (stream, producerId, epoch) - returned in Producer-Seq header */
|
|
386
|
+
producerSeq?: number
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Successful idempotent-append-batch result.
|
|
391
|
+
*/
|
|
392
|
+
export interface IdempotentAppendBatchResult {
|
|
393
|
+
type: `idempotent-append-batch`
|
|
394
|
+
success: true
|
|
395
|
+
status: number
|
|
396
|
+
/** Server's highest accepted sequence for this (stream, producerId, epoch) - returned in Producer-Seq header */
|
|
397
|
+
producerSeq?: number
|
|
316
398
|
}
|
|
317
399
|
|
|
318
400
|
/**
|
|
@@ -458,6 +540,8 @@ export type TestResult =
|
|
|
458
540
|
| CreateResult
|
|
459
541
|
| ConnectResult
|
|
460
542
|
| AppendResult
|
|
543
|
+
| IdempotentAppendResult
|
|
544
|
+
| IdempotentAppendBatchResult
|
|
461
545
|
| ReadResult
|
|
462
546
|
| HeadResult
|
|
463
547
|
| DeleteResult
|
package/src/runner.ts
CHANGED
|
@@ -366,6 +366,71 @@ async function executeOperation(
|
|
|
366
366
|
}
|
|
367
367
|
}
|
|
368
368
|
|
|
369
|
+
case `idempotent-append`: {
|
|
370
|
+
const path = resolveVariables(op.path, variables)
|
|
371
|
+
const data = resolveVariables(op.data, variables)
|
|
372
|
+
|
|
373
|
+
const result = await client.send(
|
|
374
|
+
{
|
|
375
|
+
type: `idempotent-append`,
|
|
376
|
+
path,
|
|
377
|
+
data,
|
|
378
|
+
producerId: op.producerId,
|
|
379
|
+
epoch: op.epoch ?? 0,
|
|
380
|
+
autoClaim: op.autoClaim ?? false,
|
|
381
|
+
headers: op.headers,
|
|
382
|
+
},
|
|
383
|
+
commandTimeout
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
if (verbose) {
|
|
387
|
+
console.log(
|
|
388
|
+
` idempotent-append ${path}: ${result.success ? `ok` : `failed`}`
|
|
389
|
+
)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (
|
|
393
|
+
result.success &&
|
|
394
|
+
result.type === `idempotent-append` &&
|
|
395
|
+
op.expect?.storeOffsetAs
|
|
396
|
+
) {
|
|
397
|
+
variables.set(op.expect.storeOffsetAs, result.offset ?? ``)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return { result }
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
case `idempotent-append-batch`: {
|
|
404
|
+
const path = resolveVariables(op.path, variables)
|
|
405
|
+
|
|
406
|
+
// Send items to client which will batch them internally
|
|
407
|
+
const items = op.items.map((item) =>
|
|
408
|
+
resolveVariables(item.data, variables)
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
const result = await client.send(
|
|
412
|
+
{
|
|
413
|
+
type: `idempotent-append-batch`,
|
|
414
|
+
path,
|
|
415
|
+
items,
|
|
416
|
+
producerId: op.producerId,
|
|
417
|
+
epoch: op.epoch ?? 0,
|
|
418
|
+
autoClaim: op.autoClaim ?? false,
|
|
419
|
+
maxInFlight: op.maxInFlight,
|
|
420
|
+
headers: op.headers,
|
|
421
|
+
},
|
|
422
|
+
commandTimeout
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
if (verbose) {
|
|
426
|
+
console.log(
|
|
427
|
+
` idempotent-append-batch ${path}: ${result.success ? `ok` : `failed`}`
|
|
428
|
+
)
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return { result }
|
|
432
|
+
}
|
|
433
|
+
|
|
369
434
|
case `read`: {
|
|
370
435
|
const path = resolveVariables(op.path, variables)
|
|
371
436
|
const offset = op.offset
|
|
@@ -538,7 +603,8 @@ async function executeOperation(
|
|
|
538
603
|
|
|
539
604
|
case `server-append`: {
|
|
540
605
|
// Direct HTTP append to server, bypassing client adapter
|
|
541
|
-
// Used for concurrent operations when adapter is blocked on a read
|
|
606
|
+
// Used for concurrent operations when adapter is blocked on a read,
|
|
607
|
+
// and for testing protocol-level behavior like idempotent producers
|
|
542
608
|
const path = resolveVariables(op.path, variables)
|
|
543
609
|
const data = resolveVariables(op.data, variables)
|
|
544
610
|
|
|
@@ -550,28 +616,72 @@ async function executeOperation(
|
|
|
550
616
|
const contentType =
|
|
551
617
|
headResponse.headers.get(`content-type`) ?? `application/octet-stream`
|
|
552
618
|
|
|
619
|
+
// Build headers, including producer headers if present
|
|
620
|
+
const headers: Record<string, string> = {
|
|
621
|
+
"content-type": contentType,
|
|
622
|
+
...op.headers,
|
|
623
|
+
}
|
|
624
|
+
if (op.producerId !== undefined) {
|
|
625
|
+
headers[`Producer-Id`] = op.producerId
|
|
626
|
+
}
|
|
627
|
+
if (op.producerEpoch !== undefined) {
|
|
628
|
+
headers[`Producer-Epoch`] = op.producerEpoch.toString()
|
|
629
|
+
}
|
|
630
|
+
if (op.producerSeq !== undefined) {
|
|
631
|
+
headers[`Producer-Seq`] = op.producerSeq.toString()
|
|
632
|
+
}
|
|
633
|
+
|
|
553
634
|
const response = await fetch(`${ctx.serverUrl}${path}`, {
|
|
554
635
|
method: `POST`,
|
|
555
636
|
body: data,
|
|
556
|
-
headers
|
|
557
|
-
"content-type": contentType,
|
|
558
|
-
...op.headers,
|
|
559
|
-
},
|
|
637
|
+
headers,
|
|
560
638
|
})
|
|
561
639
|
|
|
640
|
+
const status = response.status
|
|
641
|
+
const offset = response.headers.get(`Stream-Next-Offset`) ?? undefined
|
|
642
|
+
const duplicate = status === 204
|
|
643
|
+
const producerEpoch = response.headers.get(`Producer-Epoch`)
|
|
644
|
+
const producerSeq = response.headers.get(`Producer-Seq`)
|
|
645
|
+
const producerExpectedSeq = response.headers.get(
|
|
646
|
+
`Producer-Expected-Seq`
|
|
647
|
+
)
|
|
648
|
+
const producerReceivedSeq = response.headers.get(
|
|
649
|
+
`Producer-Received-Seq`
|
|
650
|
+
)
|
|
651
|
+
|
|
562
652
|
if (verbose) {
|
|
563
653
|
console.log(
|
|
564
|
-
` server-append ${path}: ${
|
|
654
|
+
` server-append ${path}: status=${status}${duplicate ? ` (duplicate)` : ``}`
|
|
565
655
|
)
|
|
566
656
|
}
|
|
567
657
|
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
658
|
+
// Build result for expectation verification
|
|
659
|
+
// success: true means we got a valid protocol response (even 403/409)
|
|
660
|
+
// The status field indicates the actual operation result
|
|
661
|
+
const result: TestResult = {
|
|
662
|
+
type: `append`,
|
|
663
|
+
success: true,
|
|
664
|
+
status,
|
|
665
|
+
offset,
|
|
666
|
+
duplicate,
|
|
667
|
+
producerEpoch: producerEpoch
|
|
668
|
+
? parseInt(producerEpoch, 10)
|
|
669
|
+
: undefined,
|
|
670
|
+
producerSeq: producerSeq ? parseInt(producerSeq, 10) : undefined,
|
|
671
|
+
producerExpectedSeq: producerExpectedSeq
|
|
672
|
+
? parseInt(producerExpectedSeq, 10)
|
|
673
|
+
: undefined,
|
|
674
|
+
producerReceivedSeq: producerReceivedSeq
|
|
675
|
+
? parseInt(producerReceivedSeq, 10)
|
|
676
|
+
: undefined,
|
|
572
677
|
}
|
|
573
678
|
|
|
574
|
-
|
|
679
|
+
// Store offset if requested
|
|
680
|
+
if (op.expect?.storeOffsetAs && offset) {
|
|
681
|
+
variables.set(op.expect.storeOffsetAs, offset)
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
return { result }
|
|
575
685
|
} catch (err) {
|
|
576
686
|
return {
|
|
577
687
|
error: `Server append failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
@@ -796,6 +906,22 @@ function validateExpectation(
|
|
|
796
906
|
}
|
|
797
907
|
}
|
|
798
908
|
|
|
909
|
+
// Check dataExact - verifies exact messages in order
|
|
910
|
+
if (expect.dataExact !== undefined && isReadResult(result)) {
|
|
911
|
+
const expectedMessages = expect.dataExact as Array<string>
|
|
912
|
+
const actualMessages = result.chunks.map((c) => c.data)
|
|
913
|
+
|
|
914
|
+
if (actualMessages.length !== expectedMessages.length) {
|
|
915
|
+
return `Expected ${expectedMessages.length} messages, got ${actualMessages.length}. Expected: [${expectedMessages.join(`, `)}], got: [${actualMessages.join(`, `)}]`
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
for (let i = 0; i < expectedMessages.length; i++) {
|
|
919
|
+
if (actualMessages[i] !== expectedMessages[i]) {
|
|
920
|
+
return `Message ${i} mismatch: expected "${expectedMessages[i]}", got "${actualMessages[i]}"`
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
799
925
|
// Check upToDate
|
|
800
926
|
if (expect.upToDate !== undefined && isReadResult(result)) {
|
|
801
927
|
if (result.upToDate !== expect.upToDate) {
|
|
@@ -878,6 +1004,41 @@ function validateExpectation(
|
|
|
878
1004
|
}
|
|
879
1005
|
}
|
|
880
1006
|
|
|
1007
|
+
// Check duplicate (for idempotent producer 204 responses)
|
|
1008
|
+
if (expect.duplicate !== undefined && isAppendResult(result)) {
|
|
1009
|
+
if (result.duplicate !== expect.duplicate) {
|
|
1010
|
+
return `Expected duplicate=${expect.duplicate}, got ${result.duplicate}`
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// Check producerEpoch (returned on 200/403)
|
|
1015
|
+
if (expect.producerEpoch !== undefined && isAppendResult(result)) {
|
|
1016
|
+
if (result.producerEpoch !== expect.producerEpoch) {
|
|
1017
|
+
return `Expected producerEpoch=${expect.producerEpoch}, got ${result.producerEpoch}`
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// Check producerSeq (returned on 200/204 - highest accepted sequence)
|
|
1022
|
+
if (expect.producerSeq !== undefined && isAppendResult(result)) {
|
|
1023
|
+
if (result.producerSeq !== expect.producerSeq) {
|
|
1024
|
+
return `Expected producerSeq=${expect.producerSeq}, got ${result.producerSeq}`
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// Check producerExpectedSeq (returned on 409 sequence gap)
|
|
1029
|
+
if (expect.producerExpectedSeq !== undefined && isAppendResult(result)) {
|
|
1030
|
+
if (result.producerExpectedSeq !== expect.producerExpectedSeq) {
|
|
1031
|
+
return `Expected producerExpectedSeq=${expect.producerExpectedSeq}, got ${result.producerExpectedSeq}`
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// Check producerReceivedSeq (returned on 409 sequence gap)
|
|
1036
|
+
if (expect.producerReceivedSeq !== undefined && isAppendResult(result)) {
|
|
1037
|
+
if (result.producerReceivedSeq !== expect.producerReceivedSeq) {
|
|
1038
|
+
return `Expected producerReceivedSeq=${expect.producerReceivedSeq}, got ${result.producerReceivedSeq}`
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
|
|
881
1042
|
return null
|
|
882
1043
|
}
|
|
883
1044
|
|
|
@@ -1061,8 +1222,12 @@ export async function runConformanceTests(
|
|
|
1061
1222
|
const totalTests = countTests(suites)
|
|
1062
1223
|
console.log(`\nRunning ${totalTests} client conformance tests...\n`)
|
|
1063
1224
|
|
|
1064
|
-
// Start reference server
|
|
1065
|
-
|
|
1225
|
+
// Start reference server with short long-poll timeout for testing
|
|
1226
|
+
// Tests use timeoutMs: 1000, so server timeout should be shorter
|
|
1227
|
+
const server = new DurableStreamTestServer({
|
|
1228
|
+
port: options.serverPort ?? 0,
|
|
1229
|
+
longPollTimeout: 500, // 500ms timeout for testing
|
|
1230
|
+
})
|
|
1066
1231
|
await server.start()
|
|
1067
1232
|
const serverUrl = server.url
|
|
1068
1233
|
|
package/src/test-cases.ts
CHANGED
|
@@ -118,10 +118,16 @@ export interface AppendOperation {
|
|
|
118
118
|
data?: string
|
|
119
119
|
/** Binary data (base64 encoded) */
|
|
120
120
|
binaryData?: string
|
|
121
|
-
/** Sequence number for ordering */
|
|
121
|
+
/** Sequence number for ordering (Stream-Seq header) */
|
|
122
122
|
seq?: number
|
|
123
123
|
headers?: Record<string, string>
|
|
124
124
|
expect?: AppendExpectation
|
|
125
|
+
/** Producer ID for idempotent producers */
|
|
126
|
+
producerId?: string
|
|
127
|
+
/** Producer epoch for idempotent producers */
|
|
128
|
+
producerEpoch?: number
|
|
129
|
+
/** Producer sequence for idempotent producers */
|
|
130
|
+
producerSeq?: number
|
|
125
131
|
}
|
|
126
132
|
|
|
127
133
|
/**
|
|
@@ -140,6 +146,64 @@ export interface AppendBatchOperation {
|
|
|
140
146
|
expect?: AppendBatchExpectation
|
|
141
147
|
}
|
|
142
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Append via IdempotentProducer client (tests client-side exactly-once semantics).
|
|
151
|
+
*/
|
|
152
|
+
export interface IdempotentAppendOperation {
|
|
153
|
+
action: `idempotent-append`
|
|
154
|
+
path: string
|
|
155
|
+
/** Producer ID */
|
|
156
|
+
producerId: string
|
|
157
|
+
/** Producer epoch */
|
|
158
|
+
epoch?: number
|
|
159
|
+
/** Data to append (string or JSON for JSON streams) */
|
|
160
|
+
data: string
|
|
161
|
+
/** Auto-claim epoch on 403 */
|
|
162
|
+
autoClaim?: boolean
|
|
163
|
+
headers?: Record<string, string>
|
|
164
|
+
expect?: IdempotentAppendExpectation
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Batch append via IdempotentProducer client (tests client-side JSON batching).
|
|
169
|
+
*/
|
|
170
|
+
export interface IdempotentAppendBatchOperation {
|
|
171
|
+
action: `idempotent-append-batch`
|
|
172
|
+
path: string
|
|
173
|
+
/** Producer ID */
|
|
174
|
+
producerId: string
|
|
175
|
+
/** Producer epoch */
|
|
176
|
+
epoch?: number
|
|
177
|
+
/** Items to append (will be batched by the client) */
|
|
178
|
+
items: Array<{
|
|
179
|
+
data: string
|
|
180
|
+
}>
|
|
181
|
+
/** Auto-claim epoch on 403 */
|
|
182
|
+
autoClaim?: boolean
|
|
183
|
+
/** Max concurrent batches in flight (default 1, set higher to test 409 retry) */
|
|
184
|
+
maxInFlight?: number
|
|
185
|
+
headers?: Record<string, string>
|
|
186
|
+
expect?: IdempotentAppendBatchExpectation
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Expectation for idempotent-append operation.
|
|
191
|
+
*/
|
|
192
|
+
export interface IdempotentAppendExpectation extends BaseExpectation {
|
|
193
|
+
/** Expected duplicate flag */
|
|
194
|
+
duplicate?: boolean
|
|
195
|
+
/** Store the returned offset */
|
|
196
|
+
storeOffsetAs?: string
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Expectation for idempotent-append-batch operation.
|
|
201
|
+
*/
|
|
202
|
+
export interface IdempotentAppendBatchExpectation extends BaseExpectation {
|
|
203
|
+
/** All items should succeed */
|
|
204
|
+
allSucceed?: boolean
|
|
205
|
+
}
|
|
206
|
+
|
|
143
207
|
/**
|
|
144
208
|
* Read from a stream.
|
|
145
209
|
*/
|
|
@@ -223,13 +287,42 @@ export interface AssertOperation {
|
|
|
223
287
|
|
|
224
288
|
/**
|
|
225
289
|
* Append to stream via direct server HTTP (bypasses client adapter).
|
|
226
|
-
* Used for concurrent operations when adapter is blocked on a read
|
|
290
|
+
* Used for concurrent operations when adapter is blocked on a read,
|
|
291
|
+
* and for testing protocol-level behavior like idempotent producers.
|
|
227
292
|
*/
|
|
228
293
|
export interface ServerAppendOperation {
|
|
229
294
|
action: `server-append`
|
|
230
295
|
path: string
|
|
231
296
|
data: string
|
|
232
297
|
headers?: Record<string, string>
|
|
298
|
+
/** Producer ID for idempotent producers */
|
|
299
|
+
producerId?: string
|
|
300
|
+
/** Producer epoch for idempotent producers */
|
|
301
|
+
producerEpoch?: number
|
|
302
|
+
/** Producer sequence for idempotent producers */
|
|
303
|
+
producerSeq?: number
|
|
304
|
+
/** Expected result */
|
|
305
|
+
expect?: ServerAppendExpectation
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Expectation for server-append operation.
|
|
310
|
+
*/
|
|
311
|
+
export interface ServerAppendExpectation {
|
|
312
|
+
/** Expected HTTP status code */
|
|
313
|
+
status?: number
|
|
314
|
+
/** Store the returned offset */
|
|
315
|
+
storeOffsetAs?: string
|
|
316
|
+
/** Expected duplicate flag (true for 204 idempotent success) */
|
|
317
|
+
duplicate?: boolean
|
|
318
|
+
/** Expected producer epoch in response */
|
|
319
|
+
producerEpoch?: number
|
|
320
|
+
/** Expected producer seq in response (highest accepted sequence) */
|
|
321
|
+
producerSeq?: number
|
|
322
|
+
/** Expected producer expected seq (on 409 sequence gap) */
|
|
323
|
+
producerExpectedSeq?: number
|
|
324
|
+
/** Expected producer received seq (on 409 sequence gap) */
|
|
325
|
+
producerReceivedSeq?: number
|
|
233
326
|
}
|
|
234
327
|
|
|
235
328
|
/**
|
|
@@ -320,6 +413,8 @@ export type TestOperation =
|
|
|
320
413
|
| ConnectOperation
|
|
321
414
|
| AppendOperation
|
|
322
415
|
| AppendBatchOperation
|
|
416
|
+
| IdempotentAppendOperation
|
|
417
|
+
| IdempotentAppendBatchOperation
|
|
323
418
|
| ReadOperation
|
|
324
419
|
| HeadOperation
|
|
325
420
|
| DeleteOperation
|
|
@@ -360,13 +455,23 @@ export interface ConnectExpectation extends BaseExpectation {
|
|
|
360
455
|
}
|
|
361
456
|
|
|
362
457
|
export interface AppendExpectation extends BaseExpectation {
|
|
363
|
-
status?: 200 | 404 | 409 | number
|
|
458
|
+
status?: 200 | 204 | 400 | 403 | 404 | 409 | number
|
|
364
459
|
/** Store the returned offset */
|
|
365
460
|
storeOffsetAs?: string
|
|
366
461
|
/** Expected headers that were sent (for dynamic header testing) */
|
|
367
462
|
headersSent?: Record<string, string>
|
|
368
463
|
/** Expected params that were sent (for dynamic param testing) */
|
|
369
464
|
paramsSent?: Record<string, string>
|
|
465
|
+
/** Expected duplicate flag (true for 204 idempotent success) */
|
|
466
|
+
duplicate?: boolean
|
|
467
|
+
/** Expected producer epoch in response */
|
|
468
|
+
producerEpoch?: number
|
|
469
|
+
/** Expected producer seq in response (highest accepted sequence) */
|
|
470
|
+
producerSeq?: number
|
|
471
|
+
/** Expected producer expected seq (on 409 sequence gap) */
|
|
472
|
+
producerExpectedSeq?: number
|
|
473
|
+
/** Expected producer received seq (on 409 sequence gap) */
|
|
474
|
+
producerReceivedSeq?: number
|
|
370
475
|
}
|
|
371
476
|
|
|
372
477
|
export interface AppendBatchExpectation extends BaseExpectation {
|
|
@@ -386,6 +491,8 @@ export interface ReadExpectation extends BaseExpectation {
|
|
|
386
491
|
dataContains?: string
|
|
387
492
|
/** Expected data to contain all of these substrings */
|
|
388
493
|
dataContainsAll?: Array<string>
|
|
494
|
+
/** Expected exact messages in order (for JSON streams, verifies each chunk) */
|
|
495
|
+
dataExact?: Array<string>
|
|
389
496
|
/** Expected number of chunks */
|
|
390
497
|
chunkCount?: number
|
|
391
498
|
/** Minimum number of chunks */
|