@alt-javascript/camel-lite-component-seda 1.0.2
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 +63 -0
- package/package.json +43 -0
- package/src/SedaComponent.js +24 -0
- package/src/SedaConsumer.js +86 -0
- package/src/SedaEndpoint.js +52 -0
- package/src/SedaProducer.js +25 -0
- package/src/SedaQueue.js +56 -0
- package/src/index.js +8 -0
- package/test/concurrent.test.js +155 -0
- package/test/import.test.js +43 -0
- package/test/seda-queue.test.js +98 -0
- package/test/seda.test.js +194 -0
package/README.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
[](https://opensource.org/licenses/MIT)
|
|
2
|
+
|
|
3
|
+
## What
|
|
4
|
+
|
|
5
|
+
Async in-process queuing via a blocking queue. `seda:` (Staged Event-Driven Architecture) decouples producer and consumer threads — the producer returns immediately after enqueuing, and the consumer processes independently.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install camel-lite-component-seda
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## URI Syntax
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
seda:name[?size=0&concurrentConsumers=1]
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
| Parameter | Default | Description |
|
|
20
|
+
|----------------------|---------|-------------|
|
|
21
|
+
| `size` | `0` | Maximum queue depth. `0` = unlimited. |
|
|
22
|
+
| `concurrentConsumers`| `1` | Number of concurrent consumer workers draining the queue. |
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
```js
|
|
27
|
+
import { CamelContext } from 'camel-lite-core';
|
|
28
|
+
import { SedaComponent } from 'camel-lite-component-seda';
|
|
29
|
+
|
|
30
|
+
const context = new CamelContext();
|
|
31
|
+
context.addComponent('seda', new SedaComponent());
|
|
32
|
+
|
|
33
|
+
context.addRoutes({
|
|
34
|
+
configure(ctx) {
|
|
35
|
+
// Consumer route — runs async
|
|
36
|
+
ctx.from('seda:work')
|
|
37
|
+
.process(exchange => {
|
|
38
|
+
console.log('Processing:', exchange.in.body);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Producer route — fire-and-forget
|
|
42
|
+
ctx.from('direct:submit')
|
|
43
|
+
.to('seda:work');
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
await context.start();
|
|
48
|
+
|
|
49
|
+
// Fire-and-forget: returns before seda:work processes the exchange
|
|
50
|
+
const template = context.createProducerTemplate();
|
|
51
|
+
await template.sendBody('seda:work', 'task payload');
|
|
52
|
+
|
|
53
|
+
// Receive a single body (blocks until one is available)
|
|
54
|
+
const consumer = context.createConsumerTemplate();
|
|
55
|
+
const body = await consumer.receiveBody('seda:work');
|
|
56
|
+
console.log(body);
|
|
57
|
+
|
|
58
|
+
await context.stop();
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## See Also
|
|
62
|
+
|
|
63
|
+
[camel-lite — root README](../../README.md)
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@alt-javascript/camel-lite-component-seda",
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": "./src/index.js"
|
|
7
|
+
},
|
|
8
|
+
"dependencies": {
|
|
9
|
+
"@alt-javascript/logger": "^3.0.7",
|
|
10
|
+
"@alt-javascript/config": "^3.0.7",
|
|
11
|
+
"@alt-javascript/common": "^3.0.7",
|
|
12
|
+
"@alt-javascript/camel-lite-core": "1.0.2"
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"test": "node --test"
|
|
16
|
+
},
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "https://github.com/alt-javascript/camel-lite"
|
|
20
|
+
},
|
|
21
|
+
"author": "Craig Parravicini",
|
|
22
|
+
"contributors": [
|
|
23
|
+
"Claude (Anthropic)",
|
|
24
|
+
"Apache Camel — design inspiration and pattern source"
|
|
25
|
+
],
|
|
26
|
+
"keywords": [
|
|
27
|
+
"alt-javascript",
|
|
28
|
+
"camel",
|
|
29
|
+
"camel-lite",
|
|
30
|
+
"eai",
|
|
31
|
+
"eip",
|
|
32
|
+
"integration",
|
|
33
|
+
"seda",
|
|
34
|
+
"async",
|
|
35
|
+
"messaging",
|
|
36
|
+
"queuing",
|
|
37
|
+
"component"
|
|
38
|
+
],
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"registry": "https://registry.npmjs.org/",
|
|
41
|
+
"access": "public"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Component } from '@alt-javascript/camel-lite-core';
|
|
2
|
+
import SedaEndpoint from './SedaEndpoint.js';
|
|
3
|
+
|
|
4
|
+
class SedaComponent extends Component {
|
|
5
|
+
// Endpoint cache — ensures producer and consumer for the same URI share one queue
|
|
6
|
+
#endpoints = new Map();
|
|
7
|
+
|
|
8
|
+
createEndpoint(uri, remaining, parameters, context) {
|
|
9
|
+
if (this.#endpoints.has(uri)) {
|
|
10
|
+
return this.#endpoints.get(uri);
|
|
11
|
+
}
|
|
12
|
+
const endpoint = new SedaEndpoint(uri, remaining, parameters, context);
|
|
13
|
+
this.#endpoints.set(uri, endpoint);
|
|
14
|
+
return endpoint;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Called by CamelContext.stop() to allow cleanup if needed
|
|
18
|
+
clearEndpoints() {
|
|
19
|
+
this.#endpoints.clear();
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export { SedaComponent };
|
|
24
|
+
export default SedaComponent;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { Consumer } from '@alt-javascript/camel-lite-core';
|
|
2
|
+
import { LoggerFactory } from '@alt-javascript/logger';
|
|
3
|
+
|
|
4
|
+
const log = LoggerFactory.getLogger('@alt-javascript/camel-lite/SedaConsumer');
|
|
5
|
+
|
|
6
|
+
class SedaConsumer extends Consumer {
|
|
7
|
+
#uri;
|
|
8
|
+
#context;
|
|
9
|
+
#pipeline;
|
|
10
|
+
#queue;
|
|
11
|
+
#concurrentConsumers;
|
|
12
|
+
#workerPromises = [];
|
|
13
|
+
|
|
14
|
+
constructor(uri, context, pipeline, queue, concurrentConsumers = 1) {
|
|
15
|
+
super();
|
|
16
|
+
this.#uri = uri;
|
|
17
|
+
this.#context = context;
|
|
18
|
+
this.#pipeline = pipeline;
|
|
19
|
+
this.#queue = queue;
|
|
20
|
+
this.#concurrentConsumers = concurrentConsumers;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
get uri() {
|
|
24
|
+
return this.#uri;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async start() {
|
|
28
|
+
this.#context.registerConsumer(this.#uri, this);
|
|
29
|
+
log.info(`SEDA consumer started: ${this.#uri} (concurrentConsumers: ${this.#concurrentConsumers})`);
|
|
30
|
+
|
|
31
|
+
for (let i = 0; i < this.#concurrentConsumers; i++) {
|
|
32
|
+
const workerId = i;
|
|
33
|
+
const workerPromise = this.#runWorker(workerId);
|
|
34
|
+
this.#workerPromises.push(workerPromise);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async #runWorker(workerId) {
|
|
39
|
+
log.debug(`Worker ${workerId} started for ${this.#uri}`);
|
|
40
|
+
while (true) {
|
|
41
|
+
const exchange = await this.#queue.dequeue();
|
|
42
|
+
if (exchange === null) {
|
|
43
|
+
// Queue closed — drain complete
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
log.debug(`Worker ${workerId} dequeued exchange ${exchange.in.messageId} from ${this.#uri}`);
|
|
47
|
+
try {
|
|
48
|
+
await this.#pipeline.run(exchange);
|
|
49
|
+
} catch (err) {
|
|
50
|
+
// Pipeline.run() captures errors into exchange.exception — this catch is a safety net
|
|
51
|
+
log.error(`Worker ${workerId} error processing exchange ${exchange.in.messageId}: ${err.message}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
log.debug(`Worker ${workerId} exiting for ${this.#uri}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Poll the queue for an exchange, waiting at most timeoutMs milliseconds.
|
|
59
|
+
* Returns the Exchange if one is available, or null on timeout.
|
|
60
|
+
* Used by ConsumerTemplate to drain a message from outside the route pipeline.
|
|
61
|
+
* @param {number} [timeoutMs=5000]
|
|
62
|
+
* @returns {Promise<Exchange|null>}
|
|
63
|
+
*/
|
|
64
|
+
async poll(timeoutMs = 5000) {
|
|
65
|
+
let timer;
|
|
66
|
+
const timeout = new Promise(resolve => {
|
|
67
|
+
timer = setTimeout(() => resolve(null), timeoutMs);
|
|
68
|
+
});
|
|
69
|
+
const item = await Promise.race([this.#queue.dequeue(), timeout]);
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
return item;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async stop() {
|
|
75
|
+
// Close the queue — unblocks all waiting dequeues with null
|
|
76
|
+
this.#queue.close();
|
|
77
|
+
// Drain all worker loops
|
|
78
|
+
await Promise.all(this.#workerPromises);
|
|
79
|
+
this.#workerPromises = [];
|
|
80
|
+
this.#context.registerConsumer(this.#uri, null);
|
|
81
|
+
log.info(`SEDA consumer stopped: ${this.#uri}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export { SedaConsumer };
|
|
86
|
+
export default SedaConsumer;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Endpoint } from '@alt-javascript/camel-lite-core';
|
|
2
|
+
import SedaProducer from './SedaProducer.js';
|
|
3
|
+
import SedaConsumer from './SedaConsumer.js';
|
|
4
|
+
import SedaQueue from './SedaQueue.js';
|
|
5
|
+
|
|
6
|
+
class SedaEndpoint extends Endpoint {
|
|
7
|
+
#uri;
|
|
8
|
+
#context;
|
|
9
|
+
#concurrentConsumers;
|
|
10
|
+
#size;
|
|
11
|
+
#queue;
|
|
12
|
+
|
|
13
|
+
constructor(uri, remaining, parameters, context) {
|
|
14
|
+
super();
|
|
15
|
+
this.#uri = uri;
|
|
16
|
+
this.#context = context;
|
|
17
|
+
|
|
18
|
+
// Parse URI params — parameters is a URLSearchParams instance
|
|
19
|
+
const params = parameters instanceof URLSearchParams
|
|
20
|
+
? parameters
|
|
21
|
+
: new URLSearchParams(typeof parameters === 'string' ? parameters : '');
|
|
22
|
+
|
|
23
|
+
this.#concurrentConsumers = Math.max(1, parseInt(params.get('concurrentConsumers') ?? '1', 10) || 1);
|
|
24
|
+
this.#size = Math.max(0, parseInt(params.get('size') ?? '0', 10) || 0);
|
|
25
|
+
|
|
26
|
+
// One queue per endpoint, shared by producer and all consumer workers
|
|
27
|
+
this.#queue = new SedaQueue(this.#size);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
get uri() {
|
|
31
|
+
return this.#uri;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
get concurrentConsumers() {
|
|
35
|
+
return this.#concurrentConsumers;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
get size() {
|
|
39
|
+
return this.#size;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
createProducer() {
|
|
43
|
+
return new SedaProducer(this.#uri, this.#queue);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
createConsumer(pipeline) {
|
|
47
|
+
return new SedaConsumer(this.#uri, this.#context, pipeline, this.#queue, this.#concurrentConsumers);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export { SedaEndpoint };
|
|
52
|
+
export default SedaEndpoint;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Producer } from '@alt-javascript/camel-lite-core';
|
|
2
|
+
|
|
3
|
+
class SedaProducer extends Producer {
|
|
4
|
+
#uri;
|
|
5
|
+
#queue;
|
|
6
|
+
|
|
7
|
+
constructor(uri, queue) {
|
|
8
|
+
super();
|
|
9
|
+
this.#uri = uri;
|
|
10
|
+
this.#queue = queue;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
get uri() {
|
|
14
|
+
return this.#uri;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async send(exchange) {
|
|
18
|
+
// Fire-and-forget: enqueue and return immediately.
|
|
19
|
+
// SedaQueueFullError propagates to the caller if the queue is at capacity.
|
|
20
|
+
this.#queue.enqueue(exchange);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export { SedaProducer };
|
|
25
|
+
export default SedaProducer;
|
package/src/SedaQueue.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { SedaQueueFullError } from '@alt-javascript/camel-lite-core';
|
|
2
|
+
|
|
3
|
+
class SedaQueue {
|
|
4
|
+
#items = [];
|
|
5
|
+
#waiters = [];
|
|
6
|
+
#closed = false;
|
|
7
|
+
#maxSize;
|
|
8
|
+
|
|
9
|
+
constructor(maxSize = 0) {
|
|
10
|
+
this.#maxSize = maxSize;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
enqueue(item) {
|
|
14
|
+
if (this.#closed) {
|
|
15
|
+
throw new Error('SedaQueue is closed');
|
|
16
|
+
}
|
|
17
|
+
if (this.#maxSize > 0 && this.#items.length >= this.#maxSize) {
|
|
18
|
+
throw new SedaQueueFullError(this.#maxSize);
|
|
19
|
+
}
|
|
20
|
+
if (this.#waiters.length > 0) {
|
|
21
|
+
// A consumer is already waiting — deliver directly without touching the queue
|
|
22
|
+
this.#waiters.shift().resolve(item);
|
|
23
|
+
} else {
|
|
24
|
+
this.#items.push(item);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
dequeue() {
|
|
29
|
+
if (this.#items.length > 0) {
|
|
30
|
+
return Promise.resolve(this.#items.shift());
|
|
31
|
+
}
|
|
32
|
+
if (this.#closed) {
|
|
33
|
+
return Promise.resolve(null);
|
|
34
|
+
}
|
|
35
|
+
return new Promise(resolve => this.#waiters.push({ resolve }));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
close() {
|
|
39
|
+
this.#closed = true;
|
|
40
|
+
for (const waiter of this.#waiters) {
|
|
41
|
+
waiter.resolve(null);
|
|
42
|
+
}
|
|
43
|
+
this.#waiters = [];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
get size() {
|
|
47
|
+
return this.#items.length;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
get closed() {
|
|
51
|
+
return this.#closed;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export { SedaQueue };
|
|
56
|
+
export default SedaQueue;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { SedaComponent } from './SedaComponent.js';
|
|
2
|
+
export { SedaEndpoint } from './SedaEndpoint.js';
|
|
3
|
+
export { SedaProducer } from './SedaProducer.js';
|
|
4
|
+
export { SedaConsumer } from './SedaConsumer.js';
|
|
5
|
+
export { SedaQueue } from './SedaQueue.js';
|
|
6
|
+
|
|
7
|
+
import SedaComponent from './SedaComponent.js';
|
|
8
|
+
export default SedaComponent;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { CamelContext, Exchange, RouteDefinition, SedaQueueFullError } from '@alt-javascript/camel-lite-core';
|
|
4
|
+
import { DirectComponent } from '@alt-javascript/camel-lite-component-direct';
|
|
5
|
+
import { SedaComponent, SedaQueue, SedaProducer, SedaConsumer, SedaEndpoint } from '@alt-javascript/camel-lite-component-seda';
|
|
6
|
+
|
|
7
|
+
function makeLatch(count) {
|
|
8
|
+
let resolve;
|
|
9
|
+
let remaining = count;
|
|
10
|
+
const promise = new Promise(r => { resolve = r; });
|
|
11
|
+
const tick = () => { if (--remaining <= 0) resolve(); };
|
|
12
|
+
return { promise, tick };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('Concurrent consumers', () => {
|
|
16
|
+
it('4 concurrent workers process 100 exchanges — all processed, none lost', async () => {
|
|
17
|
+
const { promise: latch, tick } = makeLatch(100);
|
|
18
|
+
let counter = 0;
|
|
19
|
+
|
|
20
|
+
const context = new CamelContext();
|
|
21
|
+
context.addComponent('direct', new DirectComponent());
|
|
22
|
+
context.addComponent('seda', new SedaComponent());
|
|
23
|
+
|
|
24
|
+
const routeA = new RouteDefinition('direct:entry');
|
|
25
|
+
routeA.to('seda:work?concurrentConsumers=4');
|
|
26
|
+
|
|
27
|
+
const routeB = new RouteDefinition('seda:work?concurrentConsumers=4');
|
|
28
|
+
routeB.process((exchange) => {
|
|
29
|
+
counter++;
|
|
30
|
+
tick();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
context.addRoutes({ configure() {}, getRoutes() { return [routeA, routeB]; } });
|
|
34
|
+
await context.start();
|
|
35
|
+
|
|
36
|
+
const entryConsumer = context.getConsumer('direct:entry');
|
|
37
|
+
for (let i = 0; i < 100; i++) {
|
|
38
|
+
const exchange = new Exchange();
|
|
39
|
+
exchange.in.body = i;
|
|
40
|
+
await entryConsumer.process(exchange);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
await latch;
|
|
44
|
+
await context.stop();
|
|
45
|
+
|
|
46
|
+
assert.equal(counter, 100, `expected 100 processed, got ${counter}`);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('stop() with 4 workers in-flight drains all workers cleanly', async () => {
|
|
50
|
+
const TOTAL = 40;
|
|
51
|
+
const { promise: latch, tick } = makeLatch(TOTAL);
|
|
52
|
+
let counter = 0;
|
|
53
|
+
|
|
54
|
+
const context = new CamelContext();
|
|
55
|
+
context.addComponent('direct', new DirectComponent());
|
|
56
|
+
context.addComponent('seda', new SedaComponent());
|
|
57
|
+
|
|
58
|
+
const routeA = new RouteDefinition('direct:entry');
|
|
59
|
+
routeA.to('seda:drain?concurrentConsumers=4');
|
|
60
|
+
|
|
61
|
+
const routeB = new RouteDefinition('seda:drain?concurrentConsumers=4');
|
|
62
|
+
routeB.process(async (exchange) => {
|
|
63
|
+
// Simulate a tiny bit of async work
|
|
64
|
+
await new Promise(r => setTimeout(r, 5));
|
|
65
|
+
counter++;
|
|
66
|
+
tick();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
context.addRoutes({ configure() {}, getRoutes() { return [routeA, routeB]; } });
|
|
70
|
+
await context.start();
|
|
71
|
+
|
|
72
|
+
const entryConsumer = context.getConsumer('direct:entry');
|
|
73
|
+
for (let i = 0; i < TOTAL; i++) {
|
|
74
|
+
const exchange = new Exchange();
|
|
75
|
+
exchange.in.body = i;
|
|
76
|
+
await entryConsumer.process(exchange);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Wait for all to be processed, then stop
|
|
80
|
+
await latch;
|
|
81
|
+
const stopStart = Date.now();
|
|
82
|
+
await context.stop();
|
|
83
|
+
const stopDuration = Date.now() - stopStart;
|
|
84
|
+
|
|
85
|
+
assert.equal(counter, TOTAL, `expected ${TOTAL} processed, got ${counter}`);
|
|
86
|
+
assert.ok(stopDuration < 2000, `stop() should be fast after drain, took ${stopDuration}ms`);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('Backpressure', () => {
|
|
91
|
+
it('size=2: 3rd enqueue throws SedaQueueFullError', async () => {
|
|
92
|
+
const context = new CamelContext();
|
|
93
|
+
context.addComponent('direct', new DirectComponent());
|
|
94
|
+
context.addComponent('seda', new SedaComponent());
|
|
95
|
+
|
|
96
|
+
// Use a very slow consumer so items stay in queue long enough to hit the limit
|
|
97
|
+
const { promise: latch, tick } = makeLatch(2);
|
|
98
|
+
|
|
99
|
+
const routeA = new RouteDefinition('direct:entry');
|
|
100
|
+
routeA.to('seda:bounded?size=2');
|
|
101
|
+
|
|
102
|
+
const routeB = new RouteDefinition('seda:bounded?size=2');
|
|
103
|
+
routeB.process(async (exchange) => {
|
|
104
|
+
// Slow enough that 3 items could pile up before processing starts
|
|
105
|
+
await new Promise(r => setTimeout(r, 50));
|
|
106
|
+
tick();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
context.addRoutes({ configure() {}, getRoutes() { return [routeA, routeB]; } });
|
|
110
|
+
await context.start();
|
|
111
|
+
|
|
112
|
+
// To reliably test backpressure, enqueue directly to a queue with no running consumer
|
|
113
|
+
const queue = new SedaQueue(2);
|
|
114
|
+
queue.enqueue(new Exchange());
|
|
115
|
+
queue.enqueue(new Exchange());
|
|
116
|
+
|
|
117
|
+
let threw = false;
|
|
118
|
+
try {
|
|
119
|
+
queue.enqueue(new Exchange());
|
|
120
|
+
} catch (err) {
|
|
121
|
+
threw = true;
|
|
122
|
+
assert.ok(err instanceof SedaQueueFullError, `expected SedaQueueFullError, got ${err.constructor.name}`);
|
|
123
|
+
assert.equal(err.maxSize, 2);
|
|
124
|
+
assert.ok(err.message.includes('2'));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
assert.ok(threw, 'should have thrown SedaQueueFullError on 3rd enqueue');
|
|
128
|
+
await context.stop();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('SedaQueueFullError propagates through send() to caller', async () => {
|
|
132
|
+
const queue = new SedaQueue(1);
|
|
133
|
+
const producer = new SedaProducer('seda:bounded', queue);
|
|
134
|
+
|
|
135
|
+
const ex1 = new Exchange();
|
|
136
|
+
const ex2 = new Exchange();
|
|
137
|
+
|
|
138
|
+
await producer.send(ex1); // succeeds — queue has 1 item
|
|
139
|
+
|
|
140
|
+
await assert.rejects(
|
|
141
|
+
() => producer.send(ex2),
|
|
142
|
+
(err) => {
|
|
143
|
+
assert.ok(err instanceof SedaQueueFullError);
|
|
144
|
+
assert.equal(err.maxSize, 1);
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('SedaEndpoint with size param creates bounded queue', () => {
|
|
151
|
+
const ctx = new CamelContext();
|
|
152
|
+
const ep = new SedaEndpoint('seda:bounded', 'bounded', new URLSearchParams('size=5'), ctx);
|
|
153
|
+
assert.equal(ep.size, 5);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { Exchange, Message, CamelContext, Component, Pipeline, SedaQueueFullError } from '@alt-javascript/camel-lite-core';
|
|
4
|
+
import { SedaComponent, SedaEndpoint, SedaProducer, SedaConsumer, SedaQueue } from '@alt-javascript/camel-lite-component-seda';
|
|
5
|
+
|
|
6
|
+
describe('cross-package import integration', () => {
|
|
7
|
+
it('Exchange imported from camel-lite-core constructs correctly', () => {
|
|
8
|
+
const ex = new Exchange();
|
|
9
|
+
assert.equal(ex.pattern, 'InOnly');
|
|
10
|
+
assert.ok(ex.in instanceof Message);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('SedaComponent is a subclass of Component', () => {
|
|
14
|
+
const c = new SedaComponent();
|
|
15
|
+
assert.ok(c instanceof Component);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('SedaQueueFullError imported from camel-lite-core', () => {
|
|
19
|
+
const err = new SedaQueueFullError(5);
|
|
20
|
+
assert.ok(err instanceof Error);
|
|
21
|
+
assert.equal(err.name, 'SedaQueueFullError');
|
|
22
|
+
assert.equal(err.maxSize, 5);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('SedaComponent.createEndpoint returns a SedaEndpoint', () => {
|
|
26
|
+
const c = new SedaComponent();
|
|
27
|
+
const ctx = new CamelContext();
|
|
28
|
+
const ep = c.createEndpoint('seda:test', 'test', new URLSearchParams(), ctx);
|
|
29
|
+
assert.ok(ep instanceof SedaEndpoint);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('SedaEndpoint.createProducer returns SedaProducer', () => {
|
|
33
|
+
const ctx = new CamelContext();
|
|
34
|
+
const ep = new SedaEndpoint('seda:test', 'test', new URLSearchParams(), ctx);
|
|
35
|
+
assert.ok(ep.createProducer() instanceof SedaProducer);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('SedaEndpoint.createConsumer returns SedaConsumer', () => {
|
|
39
|
+
const ctx = new CamelContext();
|
|
40
|
+
const ep = new SedaEndpoint('seda:test', 'test', new URLSearchParams(), ctx);
|
|
41
|
+
assert.ok(ep.createConsumer(new Pipeline([])) instanceof SedaConsumer);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { SedaQueue } from '../src/SedaQueue.js';
|
|
4
|
+
import { SedaQueueFullError } from '@alt-javascript/camel-lite-core';
|
|
5
|
+
|
|
6
|
+
describe('SedaQueue', () => {
|
|
7
|
+
it('enqueue then dequeue returns the item', async () => {
|
|
8
|
+
const q = new SedaQueue();
|
|
9
|
+
q.enqueue('hello');
|
|
10
|
+
const item = await q.dequeue();
|
|
11
|
+
assert.equal(item, 'hello');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('dequeue before enqueue — resolves when item arrives', async () => {
|
|
15
|
+
const q = new SedaQueue();
|
|
16
|
+
const deqPromise = q.dequeue();
|
|
17
|
+
q.enqueue('late');
|
|
18
|
+
const item = await deqPromise;
|
|
19
|
+
assert.equal(item, 'late');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('multiple items dequeued in FIFO order', async () => {
|
|
23
|
+
const q = new SedaQueue();
|
|
24
|
+
q.enqueue(1);
|
|
25
|
+
q.enqueue(2);
|
|
26
|
+
q.enqueue(3);
|
|
27
|
+
assert.equal(await q.dequeue(), 1);
|
|
28
|
+
assert.equal(await q.dequeue(), 2);
|
|
29
|
+
assert.equal(await q.dequeue(), 3);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('close() resolves all waiting consumers with null', async () => {
|
|
33
|
+
const q = new SedaQueue();
|
|
34
|
+
const p1 = q.dequeue();
|
|
35
|
+
const p2 = q.dequeue();
|
|
36
|
+
q.close();
|
|
37
|
+
assert.equal(await p1, null);
|
|
38
|
+
assert.equal(await p2, null);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('dequeue on a closed empty queue returns null immediately', async () => {
|
|
42
|
+
const q = new SedaQueue();
|
|
43
|
+
q.close();
|
|
44
|
+
const item = await q.dequeue();
|
|
45
|
+
assert.equal(item, null);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('enqueue after close throws', () => {
|
|
49
|
+
const q = new SedaQueue();
|
|
50
|
+
q.close();
|
|
51
|
+
assert.throws(() => q.enqueue('x'), { message: 'SedaQueue is closed' });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('size reflects queued items', () => {
|
|
55
|
+
const q = new SedaQueue();
|
|
56
|
+
assert.equal(q.size, 0);
|
|
57
|
+
q.enqueue('a');
|
|
58
|
+
q.enqueue('b');
|
|
59
|
+
assert.equal(q.size, 2);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('closed reflects queue state', () => {
|
|
63
|
+
const q = new SedaQueue();
|
|
64
|
+
assert.equal(q.closed, false);
|
|
65
|
+
q.close();
|
|
66
|
+
assert.equal(q.closed, true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('maxSize=2: enqueue 2 succeeds, 3rd throws SedaQueueFullError', () => {
|
|
70
|
+
const q = new SedaQueue(2);
|
|
71
|
+
q.enqueue('a');
|
|
72
|
+
q.enqueue('b');
|
|
73
|
+
assert.throws(
|
|
74
|
+
() => q.enqueue('c'),
|
|
75
|
+
(err) => {
|
|
76
|
+
assert.ok(err instanceof SedaQueueFullError);
|
|
77
|
+
assert.equal(err.name, 'SedaQueueFullError');
|
|
78
|
+
assert.equal(err.maxSize, 2);
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('maxSize=0 (unlimited): accepts many items without throwing', () => {
|
|
85
|
+
const q = new SedaQueue(0);
|
|
86
|
+
for (let i = 0; i < 1000; i++) q.enqueue(i);
|
|
87
|
+
assert.equal(q.size, 1000);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('direct delivery to waiting consumer bypasses items array (size stays 0)', async () => {
|
|
91
|
+
const q = new SedaQueue();
|
|
92
|
+
const p = q.dequeue(); // waiter registered
|
|
93
|
+
assert.equal(q.size, 0);
|
|
94
|
+
q.enqueue('direct');
|
|
95
|
+
assert.equal(q.size, 0); // delivered directly, not buffered
|
|
96
|
+
assert.equal(await p, 'direct');
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { CamelContext, Exchange, Pipeline, RouteDefinition } from '@alt-javascript/camel-lite-core';
|
|
4
|
+
import { DirectComponent } from '@alt-javascript/camel-lite-component-direct';
|
|
5
|
+
import { SedaComponent, SedaEndpoint, SedaProducer, SedaConsumer, SedaQueue } from '@alt-javascript/camel-lite-component-seda';
|
|
6
|
+
|
|
7
|
+
// Promise latch — resolves once `count` items have been processed
|
|
8
|
+
function makeLatch(count) {
|
|
9
|
+
let resolve;
|
|
10
|
+
let remaining = count;
|
|
11
|
+
const promise = new Promise(r => { resolve = r; });
|
|
12
|
+
const tick = () => { if (--remaining <= 0) resolve(); };
|
|
13
|
+
return { promise, tick };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('SedaComponent', () => {
|
|
17
|
+
it('can be constructed', () => {
|
|
18
|
+
const c = new SedaComponent();
|
|
19
|
+
assert.ok(c instanceof SedaComponent);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('createEndpoint returns SedaEndpoint', () => {
|
|
23
|
+
const c = new SedaComponent();
|
|
24
|
+
const ctx = new CamelContext();
|
|
25
|
+
const ep = c.createEndpoint('seda:work', 'work', new URLSearchParams(), ctx);
|
|
26
|
+
assert.ok(ep instanceof SedaEndpoint);
|
|
27
|
+
assert.equal(ep.uri, 'seda:work');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('createEndpoint returns same endpoint for same URI (endpoint cache)', () => {
|
|
31
|
+
const c = new SedaComponent();
|
|
32
|
+
const ctx = new CamelContext();
|
|
33
|
+
const ep1 = c.createEndpoint('seda:work', 'work', new URLSearchParams(), ctx);
|
|
34
|
+
const ep2 = c.createEndpoint('seda:work', 'work', new URLSearchParams(), ctx);
|
|
35
|
+
assert.strictEqual(ep1, ep2, 'same URI must return same endpoint (shared queue)');
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('SedaEndpoint', () => {
|
|
40
|
+
it('createProducer returns SedaProducer', () => {
|
|
41
|
+
const ctx = new CamelContext();
|
|
42
|
+
const ep = new SedaEndpoint('seda:work', 'work', new URLSearchParams(), ctx);
|
|
43
|
+
const producer = ep.createProducer();
|
|
44
|
+
assert.ok(producer instanceof SedaProducer);
|
|
45
|
+
assert.equal(producer.uri, 'seda:work');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('createConsumer returns SedaConsumer', () => {
|
|
49
|
+
const ctx = new CamelContext();
|
|
50
|
+
const ep = new SedaEndpoint('seda:work', 'work', new URLSearchParams(), ctx);
|
|
51
|
+
const pipeline = new Pipeline([]);
|
|
52
|
+
const consumer = ep.createConsumer(pipeline);
|
|
53
|
+
assert.ok(consumer instanceof SedaConsumer);
|
|
54
|
+
assert.equal(consumer.uri, 'seda:work');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('concurrentConsumers defaults to 1', () => {
|
|
58
|
+
const ctx = new CamelContext();
|
|
59
|
+
const ep = new SedaEndpoint('seda:work', 'work', new URLSearchParams(), ctx);
|
|
60
|
+
assert.equal(ep.concurrentConsumers, 1);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('concurrentConsumers parsed from URI param', () => {
|
|
64
|
+
const ctx = new CamelContext();
|
|
65
|
+
const ep = new SedaEndpoint('seda:work', 'work', new URLSearchParams('concurrentConsumers=4'), ctx);
|
|
66
|
+
assert.equal(ep.concurrentConsumers, 4);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('size defaults to 0 (unlimited)', () => {
|
|
70
|
+
const ctx = new CamelContext();
|
|
71
|
+
const ep = new SedaEndpoint('seda:work', 'work', new URLSearchParams(), ctx);
|
|
72
|
+
assert.equal(ep.size, 0);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('SedaProducer', () => {
|
|
77
|
+
it('send() enqueues without awaiting downstream processing', async () => {
|
|
78
|
+
const queue = new SedaQueue();
|
|
79
|
+
const producer = new SedaProducer('seda:work', queue);
|
|
80
|
+
const exchange = new Exchange();
|
|
81
|
+
|
|
82
|
+
// Consumer loop waits for item
|
|
83
|
+
const dequeuePromise = queue.dequeue();
|
|
84
|
+
await producer.send(exchange);
|
|
85
|
+
|
|
86
|
+
// send() returned — now await the dequeue
|
|
87
|
+
const dequeued = await dequeuePromise;
|
|
88
|
+
assert.strictEqual(dequeued, exchange);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('SedaConsumer lifecycle', () => {
|
|
93
|
+
it('start() registers consumer with context', async () => {
|
|
94
|
+
const ctx = new CamelContext();
|
|
95
|
+
const queue = new SedaQueue();
|
|
96
|
+
const pipeline = new Pipeline([]);
|
|
97
|
+
const consumer = new SedaConsumer('seda:test', ctx, pipeline, queue, 1);
|
|
98
|
+
|
|
99
|
+
await consumer.start();
|
|
100
|
+
assert.strictEqual(ctx.getConsumer('seda:test'), consumer);
|
|
101
|
+
await consumer.stop();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('stop() deregisters consumer from context', async () => {
|
|
105
|
+
const ctx = new CamelContext();
|
|
106
|
+
const queue = new SedaQueue();
|
|
107
|
+
const pipeline = new Pipeline([]);
|
|
108
|
+
const consumer = new SedaConsumer('seda:test', ctx, pipeline, queue, 1);
|
|
109
|
+
|
|
110
|
+
await consumer.start();
|
|
111
|
+
await consumer.stop();
|
|
112
|
+
assert.equal(ctx.getConsumer('seda:test'), null);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('SEDA integration: single consumer, 10 exchanges', () => {
|
|
117
|
+
it('all 10 exchanges processed and drained by stop()', async () => {
|
|
118
|
+
const { promise: latch, tick } = makeLatch(10);
|
|
119
|
+
const processed = [];
|
|
120
|
+
|
|
121
|
+
const context = new CamelContext();
|
|
122
|
+
context.addComponent('direct', new DirectComponent());
|
|
123
|
+
context.addComponent('seda', new SedaComponent());
|
|
124
|
+
|
|
125
|
+
// Entry route: direct:entry → seda:work (fire-and-forget)
|
|
126
|
+
const routeA = new RouteDefinition('direct:entry');
|
|
127
|
+
routeA.process((exchange) => { exchange.in.body = `msg-${exchange.in.body}`; });
|
|
128
|
+
routeA.to('seda:work');
|
|
129
|
+
|
|
130
|
+
// Worker route: seda:work → process
|
|
131
|
+
const routeB = new RouteDefinition('seda:work');
|
|
132
|
+
routeB.process((exchange) => {
|
|
133
|
+
processed.push(exchange.in.body);
|
|
134
|
+
tick();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
context.addRoutes({ configure() {}, getRoutes() { return [routeA, routeB]; } });
|
|
138
|
+
await context.start();
|
|
139
|
+
|
|
140
|
+
const entryConsumer = context.getConsumer('direct:entry');
|
|
141
|
+
|
|
142
|
+
// Enqueue 10 exchanges via direct:entry → seda:work
|
|
143
|
+
for (let i = 0; i < 10; i++) {
|
|
144
|
+
const exchange = new Exchange();
|
|
145
|
+
exchange.in.body = i;
|
|
146
|
+
await entryConsumer.process(exchange);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Wait for all 10 to be processed by the seda worker
|
|
150
|
+
await latch;
|
|
151
|
+
// Drain workers
|
|
152
|
+
await context.stop();
|
|
153
|
+
|
|
154
|
+
assert.equal(processed.length, 10, `expected 10 processed, got ${processed.length}`);
|
|
155
|
+
assert.ok(!context.getConsumer('seda:work'), 'consumer deregistered after stop');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('pipeline error in worker does not stop the worker — subsequent exchanges still processed', async () => {
|
|
159
|
+
const { promise: latch, tick } = makeLatch(5);
|
|
160
|
+
const processed = [];
|
|
161
|
+
|
|
162
|
+
const context = new CamelContext();
|
|
163
|
+
context.addComponent('direct', new DirectComponent());
|
|
164
|
+
context.addComponent('seda', new SedaComponent());
|
|
165
|
+
|
|
166
|
+
const routeA = new RouteDefinition('direct:entry');
|
|
167
|
+
routeA.to('seda:work');
|
|
168
|
+
|
|
169
|
+
let callCount = 0;
|
|
170
|
+
const routeB = new RouteDefinition('seda:work');
|
|
171
|
+
routeB.process((exchange) => {
|
|
172
|
+
callCount++;
|
|
173
|
+
if (callCount === 2) throw new Error('deliberate worker error');
|
|
174
|
+
processed.push(exchange.in.body);
|
|
175
|
+
tick();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
context.addRoutes({ configure() {}, getRoutes() { return [routeA, routeB]; } });
|
|
179
|
+
await context.start();
|
|
180
|
+
|
|
181
|
+
const entryConsumer = context.getConsumer('direct:entry');
|
|
182
|
+
for (let i = 0; i < 6; i++) {
|
|
183
|
+
const exchange = new Exchange();
|
|
184
|
+
exchange.in.body = `item-${i}`;
|
|
185
|
+
await entryConsumer.process(exchange);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
await latch;
|
|
189
|
+
await context.stop();
|
|
190
|
+
|
|
191
|
+
// 1 of 6 failed mid-pipeline (exception captured in exchange); 5 completed
|
|
192
|
+
assert.equal(processed.length, 5);
|
|
193
|
+
});
|
|
194
|
+
});
|