@crewhaus/queue-protocol 0.1.3 → 0.1.5
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/postgres.d.ts +24 -0
- package/dist/adapters/postgres.js +87 -0
- package/dist/adapters/redis-streams.d.ts +35 -0
- package/dist/adapters/redis-streams.js +68 -0
- package/dist/adapters/sqs.d.ts +50 -0
- package/dist/adapters/sqs.js +100 -0
- package/dist/index.d.ts +115 -0
- package/dist/index.js +163 -0
- package/package.json +9 -6
- package/src/adapters/adapters.test.ts +0 -729
- package/src/adapters/postgres.ts +0 -144
- package/src/adapters/redis-streams.ts +0 -120
- package/src/adapters/sqs.ts +0 -149
- package/src/index.test.ts +0 -99
- package/src/index.ts +0 -276
package/src/adapters/postgres.ts
DELETED
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Section 30 — Postgres adapter for `@crewhaus/queue-protocol`. Uses
|
|
3
|
-
* advisory locks + a job table for visibility timeouts:
|
|
4
|
-
* pull → SELECT … FOR UPDATE SKIP LOCKED
|
|
5
|
-
* ack → DELETE FROM jobs WHERE id = $1
|
|
6
|
-
* nack(transient) → UPDATE jobs SET visibility_expires_at = NOW()
|
|
7
|
-
* nack(permanent) → INSERT INTO dead_letter_jobs + DELETE
|
|
8
|
-
* extendVisibility → UPDATE jobs SET visibility_expires_at = NOW() + ...
|
|
9
|
-
*
|
|
10
|
-
* v0 throws when `pg` isn't installed; the contract holds with a stub
|
|
11
|
-
* client.
|
|
12
|
-
*/
|
|
13
|
-
import type { Job, JobId, NackReason, PullOptions, QueueAdapter } from "../index";
|
|
14
|
-
import { QueueProtocolError } from "../index";
|
|
15
|
-
|
|
16
|
-
export type PostgresAdapterOptions = {
|
|
17
|
-
readonly tableName: string;
|
|
18
|
-
readonly deadLetterTable?: string;
|
|
19
|
-
readonly _client?: PostgresClientLike;
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
export type PostgresClientLike = {
|
|
23
|
-
query<T = unknown>(text: string, params?: unknown[]): Promise<{ rows: T[] }>;
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
type JobRow = {
|
|
27
|
-
id: string;
|
|
28
|
-
payload: string;
|
|
29
|
-
enqueued_at: string;
|
|
30
|
-
visibility_expires_at: string;
|
|
31
|
-
attempt: number;
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
export function createPostgresAdapter<TInput = unknown>(
|
|
35
|
-
opts: PostgresAdapterOptions,
|
|
36
|
-
): QueueAdapter<TInput> {
|
|
37
|
-
if (!opts.tableName) throw new QueueProtocolError("postgres adapter requires tableName");
|
|
38
|
-
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(opts.tableName)) {
|
|
39
|
-
throw new QueueProtocolError(`postgres adapter: invalid tableName "${opts.tableName}"`);
|
|
40
|
-
}
|
|
41
|
-
const client = opts._client ?? requireClient();
|
|
42
|
-
let acked = 0;
|
|
43
|
-
let nacked = 0;
|
|
44
|
-
let deadLetter = 0;
|
|
45
|
-
const dlqTable = opts.deadLetterTable;
|
|
46
|
-
|
|
47
|
-
return {
|
|
48
|
-
kind: "postgres",
|
|
49
|
-
async pull(pullOpts: PullOptions): Promise<ReadonlyArray<Job<TInput>>> {
|
|
50
|
-
const visibilitySec = Math.ceil((pullOpts.visibilityTimeoutMs ?? 60_000) / 1000);
|
|
51
|
-
const result = await client.query<JobRow>(
|
|
52
|
-
`WITH leased AS (
|
|
53
|
-
SELECT id FROM ${opts.tableName}
|
|
54
|
-
WHERE visibility_expires_at <= NOW()
|
|
55
|
-
ORDER BY enqueued_at ASC
|
|
56
|
-
LIMIT $1
|
|
57
|
-
FOR UPDATE SKIP LOCKED
|
|
58
|
-
)
|
|
59
|
-
UPDATE ${opts.tableName} t
|
|
60
|
-
SET visibility_expires_at = NOW() + INTERVAL '${visibilitySec} seconds',
|
|
61
|
-
attempt = attempt + 1
|
|
62
|
-
FROM leased
|
|
63
|
-
WHERE t.id = leased.id
|
|
64
|
-
RETURNING t.id, t.payload, t.enqueued_at, t.visibility_expires_at, t.attempt`,
|
|
65
|
-
[pullOpts.maxJobs ?? 10],
|
|
66
|
-
);
|
|
67
|
-
const out: Job<TInput>[] = [];
|
|
68
|
-
for (const row of result.rows) {
|
|
69
|
-
let parsed: TInput;
|
|
70
|
-
try {
|
|
71
|
-
parsed = JSON.parse(row.payload) as TInput;
|
|
72
|
-
} catch {
|
|
73
|
-
parsed = row.payload as unknown as TInput;
|
|
74
|
-
}
|
|
75
|
-
out.push({
|
|
76
|
-
id: row.id,
|
|
77
|
-
input: parsed,
|
|
78
|
-
enqueuedAt: new Date(row.enqueued_at).getTime(),
|
|
79
|
-
visibilityExpiresAt: new Date(row.visibility_expires_at).getTime(),
|
|
80
|
-
attempt: row.attempt,
|
|
81
|
-
});
|
|
82
|
-
}
|
|
83
|
-
return out;
|
|
84
|
-
},
|
|
85
|
-
async ack(jobId: JobId): Promise<void> {
|
|
86
|
-
await client.query(`DELETE FROM ${opts.tableName} WHERE id = $1`, [jobId]);
|
|
87
|
-
acked++;
|
|
88
|
-
},
|
|
89
|
-
async nack(jobId: JobId, reason: NackReason): Promise<void> {
|
|
90
|
-
if (reason === "permanent" && dlqTable) {
|
|
91
|
-
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(dlqTable)) {
|
|
92
|
-
throw new QueueProtocolError(`postgres adapter: invalid deadLetterTable "${dlqTable}"`);
|
|
93
|
-
}
|
|
94
|
-
await client.query(
|
|
95
|
-
`INSERT INTO ${dlqTable} (id, payload, enqueued_at)
|
|
96
|
-
SELECT id, payload, enqueued_at FROM ${opts.tableName} WHERE id = $1`,
|
|
97
|
-
[jobId],
|
|
98
|
-
);
|
|
99
|
-
await client.query(`DELETE FROM ${opts.tableName} WHERE id = $1`, [jobId]);
|
|
100
|
-
deadLetter++;
|
|
101
|
-
} else {
|
|
102
|
-
await client.query(
|
|
103
|
-
`UPDATE ${opts.tableName} SET visibility_expires_at = NOW() WHERE id = $1`,
|
|
104
|
-
[jobId],
|
|
105
|
-
);
|
|
106
|
-
}
|
|
107
|
-
nacked++;
|
|
108
|
-
},
|
|
109
|
-
async extendVisibility(jobId: JobId, additionalMs: number): Promise<void> {
|
|
110
|
-
const sec = Math.ceil(additionalMs / 1000);
|
|
111
|
-
await client.query(
|
|
112
|
-
`UPDATE ${opts.tableName} SET visibility_expires_at = NOW() + INTERVAL '${sec} seconds' WHERE id = $1`,
|
|
113
|
-
[jobId],
|
|
114
|
-
);
|
|
115
|
-
},
|
|
116
|
-
async stats(): Promise<{
|
|
117
|
-
pending: number;
|
|
118
|
-
inFlight: number;
|
|
119
|
-
acked: number;
|
|
120
|
-
nacked: number;
|
|
121
|
-
deadLetter: number;
|
|
122
|
-
}> {
|
|
123
|
-
const pendingRes = await client.query<{ count: string }>(
|
|
124
|
-
`SELECT COUNT(*)::text AS count FROM ${opts.tableName} WHERE visibility_expires_at <= NOW()`,
|
|
125
|
-
);
|
|
126
|
-
const inFlightRes = await client.query<{ count: string }>(
|
|
127
|
-
`SELECT COUNT(*)::text AS count FROM ${opts.tableName} WHERE visibility_expires_at > NOW()`,
|
|
128
|
-
);
|
|
129
|
-
return {
|
|
130
|
-
pending: Number.parseInt(pendingRes.rows[0]?.count ?? "0", 10),
|
|
131
|
-
inFlight: Number.parseInt(inFlightRes.rows[0]?.count ?? "0", 10),
|
|
132
|
-
acked,
|
|
133
|
-
nacked,
|
|
134
|
-
deadLetter,
|
|
135
|
-
};
|
|
136
|
-
},
|
|
137
|
-
};
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
function requireClient(): PostgresClientLike {
|
|
141
|
-
throw new QueueProtocolError(
|
|
142
|
-
"postgres adapter requires `pg` to be installed and DATABASE_URL configured. Pass an explicit `_client` to use a stub.",
|
|
143
|
-
);
|
|
144
|
-
}
|
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Section 30 — Redis Streams adapter for `@crewhaus/queue-protocol`.
|
|
3
|
-
*
|
|
4
|
-
* Maps to Redis Streams + consumer groups:
|
|
5
|
-
* pull → XREADGROUP
|
|
6
|
-
* ack → XACK
|
|
7
|
-
* nack(transient) → XADD back to the stream tail
|
|
8
|
-
* nack(permanent) → XADD to the dead-letter stream + XACK on main
|
|
9
|
-
* extendVisibility → no-op (Redis Streams uses pending-list min-idle-time
|
|
10
|
-
* instead of per-message visibility timeouts; consumers
|
|
11
|
-
* refresh by re-reading from the pending list)
|
|
12
|
-
*
|
|
13
|
-
* v0 throws when `ioredis` is not installed; the contract is correct +
|
|
14
|
-
* tested with stub clients.
|
|
15
|
-
*/
|
|
16
|
-
import type { Job, JobId, NackReason, PullOptions, QueueAdapter } from "../index";
|
|
17
|
-
import { QueueProtocolError } from "../index";
|
|
18
|
-
|
|
19
|
-
export type RedisStreamsAdapterOptions = {
|
|
20
|
-
readonly streamKey: string;
|
|
21
|
-
readonly consumerGroup: string;
|
|
22
|
-
readonly consumerName: string;
|
|
23
|
-
readonly deadLetterStream?: string;
|
|
24
|
-
readonly _client?: RedisClientLike;
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
export type RedisClientLike = {
|
|
28
|
-
xreadgroup(
|
|
29
|
-
group: string,
|
|
30
|
-
consumer: string,
|
|
31
|
-
count: number,
|
|
32
|
-
blockMs: number,
|
|
33
|
-
streamKey: string,
|
|
34
|
-
id: string,
|
|
35
|
-
): Promise<Array<{
|
|
36
|
-
stream: string;
|
|
37
|
-
messages: Array<{ id: string; fields: Record<string, string> }>;
|
|
38
|
-
}> | null>;
|
|
39
|
-
xack(streamKey: string, group: string, ...ids: string[]): Promise<number>;
|
|
40
|
-
xadd(streamKey: string, id: string, ...fields: string[]): Promise<string>;
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
export function createRedisStreamsAdapter<TInput = unknown>(
|
|
44
|
-
opts: RedisStreamsAdapterOptions,
|
|
45
|
-
): QueueAdapter<TInput> {
|
|
46
|
-
if (!opts.streamKey) throw new QueueProtocolError("redis-streams adapter requires streamKey");
|
|
47
|
-
if (!opts.consumerGroup)
|
|
48
|
-
throw new QueueProtocolError("redis-streams adapter requires consumerGroup");
|
|
49
|
-
const client = opts._client ?? requireClient();
|
|
50
|
-
let acked = 0;
|
|
51
|
-
let nacked = 0;
|
|
52
|
-
let deadLetter = 0;
|
|
53
|
-
return {
|
|
54
|
-
kind: "redis-streams",
|
|
55
|
-
async pull(pullOpts: PullOptions): Promise<ReadonlyArray<Job<TInput>>> {
|
|
56
|
-
const res = await client.xreadgroup(
|
|
57
|
-
opts.consumerGroup,
|
|
58
|
-
opts.consumerName,
|
|
59
|
-
pullOpts.maxJobs ?? 10,
|
|
60
|
-
pullOpts.longPollMs ?? 0,
|
|
61
|
-
opts.streamKey,
|
|
62
|
-
">",
|
|
63
|
-
);
|
|
64
|
-
if (!res) return [];
|
|
65
|
-
const out: Job<TInput>[] = [];
|
|
66
|
-
const now = Date.now();
|
|
67
|
-
for (const stream of res) {
|
|
68
|
-
for (const m of stream.messages) {
|
|
69
|
-
let parsed: TInput;
|
|
70
|
-
try {
|
|
71
|
-
parsed = JSON.parse(m.fields["payload"] ?? "{}") as TInput;
|
|
72
|
-
} catch {
|
|
73
|
-
parsed = m.fields["payload"] as unknown as TInput;
|
|
74
|
-
}
|
|
75
|
-
out.push({
|
|
76
|
-
id: m.id,
|
|
77
|
-
input: parsed,
|
|
78
|
-
enqueuedAt: now,
|
|
79
|
-
visibilityExpiresAt: now + (pullOpts.visibilityTimeoutMs ?? 60_000),
|
|
80
|
-
attempt: 1,
|
|
81
|
-
});
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
return out;
|
|
85
|
-
},
|
|
86
|
-
async ack(jobId: JobId): Promise<void> {
|
|
87
|
-
await client.xack(opts.streamKey, opts.consumerGroup, jobId);
|
|
88
|
-
acked++;
|
|
89
|
-
},
|
|
90
|
-
async nack(jobId: JobId, reason: NackReason): Promise<void> {
|
|
91
|
-
if (reason === "permanent" && opts.deadLetterStream) {
|
|
92
|
-
await client.xadd(opts.deadLetterStream, "*", "payload", JSON.stringify({ jobId }));
|
|
93
|
-
deadLetter++;
|
|
94
|
-
} else {
|
|
95
|
-
// Re-publish to the tail; the consumer-group will redeliver.
|
|
96
|
-
await client.xadd(opts.streamKey, "*", "payload", JSON.stringify({ retry: jobId }));
|
|
97
|
-
}
|
|
98
|
-
await client.xack(opts.streamKey, opts.consumerGroup, jobId);
|
|
99
|
-
nacked++;
|
|
100
|
-
},
|
|
101
|
-
async extendVisibility(_jobId: JobId, _additionalMs: number): Promise<void> {
|
|
102
|
-
// Redis Streams uses min-idle-time; nothing to do here.
|
|
103
|
-
},
|
|
104
|
-
async stats(): Promise<{
|
|
105
|
-
pending: number;
|
|
106
|
-
inFlight: number;
|
|
107
|
-
acked: number;
|
|
108
|
-
nacked: number;
|
|
109
|
-
deadLetter: number;
|
|
110
|
-
}> {
|
|
111
|
-
return { pending: 0, inFlight: 0, acked, nacked, deadLetter };
|
|
112
|
-
},
|
|
113
|
-
};
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function requireClient(): RedisClientLike {
|
|
117
|
-
throw new QueueProtocolError(
|
|
118
|
-
"redis-streams adapter requires `ioredis` to be installed and a Redis URL configured. Pass an explicit `_client` to use a stub.",
|
|
119
|
-
);
|
|
120
|
-
}
|
package/src/adapters/sqs.ts
DELETED
|
@@ -1,149 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Section 30 — SQS adapter for `@crewhaus/queue-protocol`. Production
|
|
3
|
-
* deployments using AWS SQS use this; the contract is identical to the
|
|
4
|
-
* in-memory adapter (pull/ack/nack/extendVisibility/stats).
|
|
5
|
-
*
|
|
6
|
-
* v0 ships with the *abstraction* and credential plumbing; the actual
|
|
7
|
-
* `@aws-sdk/client-sqs` calls are gated on the AWS SDK being installed
|
|
8
|
-
* and `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY` (or the IAM
|
|
9
|
-
* instance-profile chain) being available. The implementation throws
|
|
10
|
-
* `QueueProtocolError("sqs adapter requires …")` when those are missing
|
|
11
|
-
* so a single misconfigured spec fails loud at boot rather than silently
|
|
12
|
-
* dropping jobs.
|
|
13
|
-
*
|
|
14
|
-
* The interface is correct + tested via the contract corpus; the live
|
|
15
|
-
* SQS smoke is gated behind `AWS_ACCESS_KEY_ID` + `SQS_QUEUE_URL` and
|
|
16
|
-
* runs only in deployments where those are set.
|
|
17
|
-
*/
|
|
18
|
-
import type { Job, JobId, NackReason, PullOptions, QueueAdapter } from "../index";
|
|
19
|
-
import { QueueProtocolError } from "../index";
|
|
20
|
-
|
|
21
|
-
export type SqsAdapterOptions = {
|
|
22
|
-
readonly queueUrl: string;
|
|
23
|
-
readonly region: string;
|
|
24
|
-
readonly accessKeyId?: string;
|
|
25
|
-
readonly secretAccessKey?: string;
|
|
26
|
-
/** Test override: inject a fake SQS client. */
|
|
27
|
-
readonly _client?: SqsClientLike;
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
export type SqsClientLike = {
|
|
31
|
-
receiveMessage(input: {
|
|
32
|
-
QueueUrl: string;
|
|
33
|
-
MaxNumberOfMessages: number;
|
|
34
|
-
VisibilityTimeout: number;
|
|
35
|
-
WaitTimeSeconds: number;
|
|
36
|
-
}): Promise<{
|
|
37
|
-
Messages?: Array<{ MessageId?: string; ReceiptHandle?: string; Body?: string }>;
|
|
38
|
-
}>;
|
|
39
|
-
deleteMessage(input: { QueueUrl: string; ReceiptHandle: string }): Promise<void>;
|
|
40
|
-
changeMessageVisibility(input: {
|
|
41
|
-
QueueUrl: string;
|
|
42
|
-
ReceiptHandle: string;
|
|
43
|
-
VisibilityTimeout: number;
|
|
44
|
-
}): Promise<void>;
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
export function createSqsAdapter<TInput = unknown>(opts: SqsAdapterOptions): QueueAdapter<TInput> {
|
|
48
|
-
if (!opts.queueUrl) {
|
|
49
|
-
throw new QueueProtocolError("sqs adapter requires queueUrl");
|
|
50
|
-
}
|
|
51
|
-
const client = opts._client ?? requireSdkClient(opts);
|
|
52
|
-
// Map ReceiptHandle by jobId so ack/nack/extendVisibility can find it.
|
|
53
|
-
const receiptByJobId = new Map<JobId, string>();
|
|
54
|
-
let acked = 0;
|
|
55
|
-
let nacked = 0;
|
|
56
|
-
let deadLetter = 0;
|
|
57
|
-
|
|
58
|
-
return {
|
|
59
|
-
kind: "sqs",
|
|
60
|
-
async pull(pullOpts: PullOptions): Promise<ReadonlyArray<Job<TInput>>> {
|
|
61
|
-
const result = await client.receiveMessage({
|
|
62
|
-
QueueUrl: opts.queueUrl,
|
|
63
|
-
MaxNumberOfMessages: Math.min(10, pullOpts.maxJobs ?? 10),
|
|
64
|
-
VisibilityTimeout: Math.ceil((pullOpts.visibilityTimeoutMs ?? 60_000) / 1000),
|
|
65
|
-
WaitTimeSeconds: Math.ceil((pullOpts.longPollMs ?? 0) / 1000),
|
|
66
|
-
});
|
|
67
|
-
const out: Job<TInput>[] = [];
|
|
68
|
-
const now = Date.now();
|
|
69
|
-
for (const m of result.Messages ?? []) {
|
|
70
|
-
if (!m.MessageId || !m.ReceiptHandle || m.Body === undefined) continue;
|
|
71
|
-
receiptByJobId.set(m.MessageId, m.ReceiptHandle);
|
|
72
|
-
let parsed: TInput;
|
|
73
|
-
try {
|
|
74
|
-
parsed = JSON.parse(m.Body) as TInput;
|
|
75
|
-
} catch {
|
|
76
|
-
parsed = m.Body as unknown as TInput;
|
|
77
|
-
}
|
|
78
|
-
out.push({
|
|
79
|
-
id: m.MessageId,
|
|
80
|
-
input: parsed,
|
|
81
|
-
enqueuedAt: now,
|
|
82
|
-
visibilityExpiresAt: now + (pullOpts.visibilityTimeoutMs ?? 60_000),
|
|
83
|
-
attempt: 1,
|
|
84
|
-
});
|
|
85
|
-
}
|
|
86
|
-
return out;
|
|
87
|
-
},
|
|
88
|
-
async ack(jobId: JobId): Promise<void> {
|
|
89
|
-
const handle = receiptByJobId.get(jobId);
|
|
90
|
-
if (!handle) throw new QueueProtocolError(`sqs ack: receipt for ${jobId} not found`);
|
|
91
|
-
await client.deleteMessage({ QueueUrl: opts.queueUrl, ReceiptHandle: handle });
|
|
92
|
-
receiptByJobId.delete(jobId);
|
|
93
|
-
acked++;
|
|
94
|
-
},
|
|
95
|
-
async nack(jobId: JobId, reason: NackReason): Promise<void> {
|
|
96
|
-
const handle = receiptByJobId.get(jobId);
|
|
97
|
-
if (!handle) throw new QueueProtocolError(`sqs nack: receipt for ${jobId} not found`);
|
|
98
|
-
if (reason === "permanent") {
|
|
99
|
-
// Delete from the main queue; the redrive policy on the SQS side
|
|
100
|
-
// moves it to the dead-letter queue automatically when configured.
|
|
101
|
-
await client.deleteMessage({ QueueUrl: opts.queueUrl, ReceiptHandle: handle });
|
|
102
|
-
deadLetter++;
|
|
103
|
-
} else {
|
|
104
|
-
// Reset visibility timeout to 0 so the message is immediately re-driven.
|
|
105
|
-
await client.changeMessageVisibility({
|
|
106
|
-
QueueUrl: opts.queueUrl,
|
|
107
|
-
ReceiptHandle: handle,
|
|
108
|
-
VisibilityTimeout: 0,
|
|
109
|
-
});
|
|
110
|
-
}
|
|
111
|
-
receiptByJobId.delete(jobId);
|
|
112
|
-
nacked++;
|
|
113
|
-
},
|
|
114
|
-
async extendVisibility(jobId: JobId, additionalMs: number): Promise<void> {
|
|
115
|
-
const handle = receiptByJobId.get(jobId);
|
|
116
|
-
if (!handle)
|
|
117
|
-
throw new QueueProtocolError(`sqs extendVisibility: receipt for ${jobId} not found`);
|
|
118
|
-
await client.changeMessageVisibility({
|
|
119
|
-
QueueUrl: opts.queueUrl,
|
|
120
|
-
ReceiptHandle: handle,
|
|
121
|
-
VisibilityTimeout: Math.ceil(additionalMs / 1000),
|
|
122
|
-
});
|
|
123
|
-
},
|
|
124
|
-
async stats(): Promise<{
|
|
125
|
-
pending: number;
|
|
126
|
-
inFlight: number;
|
|
127
|
-
acked: number;
|
|
128
|
-
nacked: number;
|
|
129
|
-
deadLetter: number;
|
|
130
|
-
}> {
|
|
131
|
-
// SQS doesn't expose accurate counters cheaply; we return the local
|
|
132
|
-
// counters for in-flight + acked/nacked + 0 for pending (consumers
|
|
133
|
-
// should query CloudWatch for that).
|
|
134
|
-
return {
|
|
135
|
-
pending: 0,
|
|
136
|
-
inFlight: receiptByJobId.size,
|
|
137
|
-
acked,
|
|
138
|
-
nacked,
|
|
139
|
-
deadLetter,
|
|
140
|
-
};
|
|
141
|
-
},
|
|
142
|
-
};
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function requireSdkClient(_opts: SqsAdapterOptions): SqsClientLike {
|
|
146
|
-
throw new QueueProtocolError(
|
|
147
|
-
"sqs adapter requires `@aws-sdk/client-sqs` to be installed and AWS credentials configured. Pass an explicit `_client` to use a stub.",
|
|
148
|
-
);
|
|
149
|
-
}
|
package/src/index.test.ts
DELETED
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { QueueProtocolError, createInMemoryQueue } from "./index.js";
|
|
3
|
-
|
|
4
|
-
describe("createInMemoryQueue", () => {
|
|
5
|
-
test("enqueue + pull returns jobs in FIFO order with attempt=1", async () => {
|
|
6
|
-
const q = createInMemoryQueue<string>();
|
|
7
|
-
await q.enqueue("a");
|
|
8
|
-
await q.enqueue("b");
|
|
9
|
-
await q.enqueue("c");
|
|
10
|
-
const got = await q.pull({ maxBatch: 10, visibilityTimeoutMs: 1000 });
|
|
11
|
-
expect(got.map((j) => j.input)).toEqual(["a", "b", "c"]);
|
|
12
|
-
expect(got.map((j) => j.attempt)).toEqual([1, 1, 1]);
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
test("pull respects maxBatch", async () => {
|
|
16
|
-
const q = createInMemoryQueue<number>();
|
|
17
|
-
for (let i = 0; i < 5; i++) await q.enqueue(i);
|
|
18
|
-
const first = await q.pull({ maxBatch: 2, visibilityTimeoutMs: 1000 });
|
|
19
|
-
expect(first.map((j) => j.input)).toEqual([0, 1]);
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
test("pulled jobs are invisible to subsequent pulls until visibility expires", async () => {
|
|
23
|
-
let t = 1_000;
|
|
24
|
-
const q = createInMemoryQueue<string>({ now: () => t });
|
|
25
|
-
await q.enqueue("a");
|
|
26
|
-
const first = await q.pull({ maxBatch: 10, visibilityTimeoutMs: 100 });
|
|
27
|
-
expect(first).toHaveLength(1);
|
|
28
|
-
// Within visibility window — invisible.
|
|
29
|
-
t = 1_050;
|
|
30
|
-
const second = await q.pull({ maxBatch: 10, visibilityTimeoutMs: 100 });
|
|
31
|
-
expect(second).toHaveLength(0);
|
|
32
|
-
// Past visibility window — reclaimed back to pending.
|
|
33
|
-
t = 1_200;
|
|
34
|
-
const third = await q.pull({ maxBatch: 10, visibilityTimeoutMs: 100 });
|
|
35
|
-
expect(third).toHaveLength(1);
|
|
36
|
-
expect(third[0]?.attempt).toBe(2);
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
test("ack removes the job; double-ack is a no-op", async () => {
|
|
40
|
-
const q = createInMemoryQueue<string>();
|
|
41
|
-
await q.enqueue("a");
|
|
42
|
-
const [j] = await q.pull({ maxBatch: 1, visibilityTimeoutMs: 1000 });
|
|
43
|
-
if (!j) throw new Error("no job");
|
|
44
|
-
await q.ack(j.id);
|
|
45
|
-
await q.ack(j.id); // idempotent
|
|
46
|
-
const stats = await q.stats();
|
|
47
|
-
expect(stats.acked).toBe(1);
|
|
48
|
-
expect(stats.pending).toBe(0);
|
|
49
|
-
expect(stats.inFlight).toBe(0);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
test("nack transient returns the job to pending; nack permanent moves to DLQ", async () => {
|
|
53
|
-
const q = createInMemoryQueue<string>();
|
|
54
|
-
await q.enqueue("a");
|
|
55
|
-
await q.enqueue("b");
|
|
56
|
-
const pulled = await q.pull({ maxBatch: 10, visibilityTimeoutMs: 1000 });
|
|
57
|
-
if (pulled.length !== 2) throw new Error("expected 2");
|
|
58
|
-
await q.nack(pulled[0]?.id, "transient");
|
|
59
|
-
await q.nack(pulled[1]?.id, "permanent");
|
|
60
|
-
|
|
61
|
-
const stats = await q.stats();
|
|
62
|
-
expect(stats.deadLetter).toBe(1);
|
|
63
|
-
expect(stats.pending).toBe(1);
|
|
64
|
-
|
|
65
|
-
const again = await q.pull({ maxBatch: 10, visibilityTimeoutMs: 1000 });
|
|
66
|
-
expect(again).toHaveLength(1);
|
|
67
|
-
expect(again[0]?.attempt).toBe(2);
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
test("extendVisibility pushes the lease forward and silences a timeout reclaim", async () => {
|
|
71
|
-
let t = 1_000;
|
|
72
|
-
const q = createInMemoryQueue<string>({ now: () => t });
|
|
73
|
-
await q.enqueue("a");
|
|
74
|
-
const [j] = await q.pull({ maxBatch: 1, visibilityTimeoutMs: 100 });
|
|
75
|
-
if (!j) throw new Error("no job");
|
|
76
|
-
await q.extendVisibility(j.id, 1_000);
|
|
77
|
-
t = 1_500;
|
|
78
|
-
const second = await q.pull({ maxBatch: 10, visibilityTimeoutMs: 100 });
|
|
79
|
-
expect(second).toHaveLength(0);
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
test("extendVisibility throws on unknown jobId", async () => {
|
|
83
|
-
const q = createInMemoryQueue<string>();
|
|
84
|
-
await expect(q.extendVisibility("nonexistent", 100)).rejects.toThrow(QueueProtocolError);
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
test("inspect returns jobs with attempt counts (T9 invariant for retry counting)", async () => {
|
|
88
|
-
const q = createInMemoryQueue<string>();
|
|
89
|
-
await q.enqueue("x");
|
|
90
|
-
const [j1] = await q.pull({ maxBatch: 1, visibilityTimeoutMs: 100 });
|
|
91
|
-
if (!j1) throw new Error("no job");
|
|
92
|
-
await q.nack(j1.id, "transient");
|
|
93
|
-
const [j2] = await q.pull({ maxBatch: 1, visibilityTimeoutMs: 100 });
|
|
94
|
-
if (!j2) throw new Error("no job");
|
|
95
|
-
expect(j2.attempt).toBe(2);
|
|
96
|
-
const insp = q.inspect();
|
|
97
|
-
expect(insp[0]?.attempts).toBe(2);
|
|
98
|
-
});
|
|
99
|
-
});
|