@panorama-industries/effect-vercel-queue 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +13 -2
- package/dist/contracts/message-handling.d.ts +128 -0
- package/dist/contracts/message-handling.d.ts.map +1 -0
- package/dist/contracts/message-handling.js +4 -0
- package/dist/contracts/message-handling.js.map +1 -0
- package/dist/contracts/queue-api.d.ts +255 -0
- package/dist/contracts/queue-api.d.ts.map +1 -0
- package/dist/contracts/queue-api.js +18 -0
- package/dist/contracts/queue-api.js.map +1 -0
- package/dist/contracts/queue-config.d.ts +87 -0
- package/dist/contracts/queue-config.d.ts.map +1 -0
- package/dist/contracts/queue-config.js +7 -0
- package/dist/contracts/queue-config.js.map +1 -0
- package/dist/contracts/queue-consumer.d.ts +30 -0
- package/dist/contracts/queue-consumer.d.ts.map +1 -0
- package/dist/contracts/queue-consumer.js +15 -0
- package/dist/contracts/queue-consumer.js.map +1 -0
- package/dist/contracts/queue-domain.d.ts +171 -0
- package/dist/contracts/queue-domain.d.ts.map +1 -0
- package/dist/contracts/queue-domain.js +137 -0
- package/dist/contracts/queue-domain.js.map +1 -0
- package/dist/contracts/queue-poller.d.ts +169 -0
- package/dist/contracts/queue-poller.d.ts.map +1 -0
- package/dist/contracts/queue-poller.js +10 -0
- package/dist/contracts/queue-poller.js.map +1 -0
- package/dist/contracts/queue-producer.d.ts +23 -0
- package/dist/contracts/queue-producer.d.ts.map +1 -0
- package/dist/contracts/queue-producer.js +5 -0
- package/dist/contracts/queue-producer.js.map +1 -0
- package/dist/contracts/topic.d.ts +47 -0
- package/dist/contracts/topic.d.ts.map +1 -0
- package/dist/contracts/topic.js +34 -0
- package/dist/contracts/topic.js.map +1 -0
- package/dist/implementation/queue-api-live.d.ts +16 -0
- package/dist/implementation/queue-api-live.d.ts.map +1 -0
- package/dist/implementation/queue-api-live.js +326 -0
- package/dist/implementation/queue-api-live.js.map +1 -0
- package/dist/implementation/queue-config-live.d.ts +37 -0
- package/dist/implementation/queue-config-live.d.ts.map +1 -0
- package/dist/implementation/queue-config-live.js +99 -0
- package/dist/implementation/queue-config-live.js.map +1 -0
- package/dist/implementation/queue-consumer-live.d.ts +14 -0
- package/dist/implementation/queue-consumer-live.d.ts.map +1 -0
- package/dist/implementation/queue-consumer-live.js +56 -0
- package/dist/implementation/queue-consumer-live.js.map +1 -0
- package/dist/implementation/queue-live.d.ts +15 -0
- package/dist/implementation/queue-live.d.ts.map +1 -0
- package/dist/implementation/queue-live.js +16 -0
- package/dist/implementation/queue-live.js.map +1 -0
- package/dist/implementation/queue-poller-live.d.ts +14 -0
- package/dist/implementation/queue-poller-live.d.ts.map +1 -0
- package/dist/implementation/queue-poller-live.js +66 -0
- package/dist/implementation/queue-poller-live.js.map +1 -0
- package/dist/implementation/queue-producer-live.d.ts +9 -0
- package/dist/implementation/queue-producer-live.d.ts.map +1 -0
- package/dist/implementation/queue-producer-live.js +54 -0
- package/dist/implementation/queue-producer-live.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/cloud-event.d.ts +46 -0
- package/dist/internal/cloud-event.d.ts.map +1 -0
- package/dist/internal/cloud-event.js +140 -0
- package/dist/internal/cloud-event.js.map +1 -0
- package/dist/internal/lease.d.ts +23 -0
- package/dist/internal/lease.d.ts.map +1 -0
- package/dist/internal/lease.js +44 -0
- package/dist/internal/lease.js.map +1 -0
- package/dist/internal/multipart.d.ts +23 -0
- package/dist/internal/multipart.d.ts.map +1 -0
- package/dist/internal/multipart.js +205 -0
- package/dist/internal/multipart.js.map +1 -0
- package/dist/internal/process.d.ts +25 -0
- package/dist/internal/process.d.ts.map +1 -0
- package/dist/internal/process.js +95 -0
- package/dist/internal/process.js.map +1 -0
- package/dist/internal/queue-protocol.d.ts +38 -0
- package/dist/internal/queue-protocol.d.ts.map +1 -0
- package/dist/internal/queue-protocol.js +119 -0
- package/dist/internal/queue-protocol.js.map +1 -0
- package/dist/internal/version.d.ts +4 -0
- package/dist/internal/version.d.ts.map +1 -0
- package/dist/internal/version.js +4 -0
- package/dist/internal/version.js.map +1 -0
- package/dist/retry-policy.d.ts +26 -0
- package/dist/retry-policy.d.ts.map +1 -0
- package/dist/retry-policy.js +34 -0
- package/dist/retry-policy.js.map +1 -0
- package/dist/testing/index.d.ts +2 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +2 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/test-queue.d.ts +74 -0
- package/dist/testing/test-queue.d.ts.map +1 -0
- package/dist/testing/test-queue.js +253 -0
- package/dist/testing/test-queue.js.map +1 -0
- package/package.json +59 -3
- package/src/contracts/message-handling.ts +68 -0
- package/src/contracts/queue-api.ts +199 -0
- package/src/contracts/queue-config.ts +47 -0
- package/src/contracts/queue-consumer.ts +40 -0
- package/src/contracts/queue-domain.ts +202 -0
- package/src/contracts/queue-poller.ts +76 -0
- package/src/contracts/queue-producer.ts +40 -0
- package/src/contracts/topic.ts +76 -0
- package/src/implementation/queue-api-live.ts +637 -0
- package/src/implementation/queue-config-live.ts +178 -0
- package/src/implementation/queue-consumer-live.ts +112 -0
- package/src/implementation/queue-live.ts +32 -0
- package/src/implementation/queue-poller-live.ts +130 -0
- package/src/implementation/queue-producer-live.ts +89 -0
- package/src/index.ts +19 -0
- package/src/internal/cloud-event.ts +244 -0
- package/src/internal/lease.ts +84 -0
- package/src/internal/multipart.ts +240 -0
- package/src/internal/process.ts +194 -0
- package/src/internal/queue-protocol.ts +169 -0
- package/src/internal/version.ts +5 -0
- package/src/retry-policy.ts +48 -0
- package/src/testing/index.ts +1 -0
- package/src/testing/test-queue.ts +469 -0
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
import { VercelRegion } from "@panorama-industries/effect-vercel-core";
|
|
2
|
+
import { Clock, Context, DateTime, Duration, Effect, Layer, Option } from "effect";
|
|
3
|
+
|
|
4
|
+
import type {
|
|
5
|
+
OutgoingMessage,
|
|
6
|
+
QueueLeaseError,
|
|
7
|
+
QueueReceiveByIdError,
|
|
8
|
+
QueueRequestError,
|
|
9
|
+
QueueSendError,
|
|
10
|
+
ReceiveByIdOptions,
|
|
11
|
+
ReceiveOptions,
|
|
12
|
+
} from "../contracts/queue-api.ts";
|
|
13
|
+
|
|
14
|
+
import { BatchSendResult, QueueApi, SendResult } from "../contracts/queue-api.ts";
|
|
15
|
+
import {
|
|
16
|
+
ConcurrencyCoalescedPending,
|
|
17
|
+
ConsumerGroupName,
|
|
18
|
+
CursorFallback,
|
|
19
|
+
MessageAlreadyProcessed,
|
|
20
|
+
MessageCorrupted,
|
|
21
|
+
MessageHandle,
|
|
22
|
+
MessageId,
|
|
23
|
+
MessageLocked,
|
|
24
|
+
MessageMetadata,
|
|
25
|
+
MessageNotAvailable,
|
|
26
|
+
MessageNotFound,
|
|
27
|
+
MessageUnavailable,
|
|
28
|
+
ReceiptHandle,
|
|
29
|
+
ReceivedMessage,
|
|
30
|
+
TopicName,
|
|
31
|
+
} from "../contracts/queue-domain.ts";
|
|
32
|
+
|
|
33
|
+
/** A `QueueApi` operation, as recorded by {@link TestQueue}. */
|
|
34
|
+
export type TestQueueOperation =
|
|
35
|
+
| "send"
|
|
36
|
+
| "sendBatch"
|
|
37
|
+
| "receive"
|
|
38
|
+
| "receiveById"
|
|
39
|
+
| "acknowledge"
|
|
40
|
+
| "changeVisibility";
|
|
41
|
+
|
|
42
|
+
/** Errors each operation can be scripted to fail with. */
|
|
43
|
+
export interface TestQueueOperationError {
|
|
44
|
+
readonly send: QueueSendError;
|
|
45
|
+
readonly sendBatch: QueueSendError;
|
|
46
|
+
readonly receive: QueueRequestError | MessageCorrupted;
|
|
47
|
+
readonly receiveById: QueueReceiveByIdError;
|
|
48
|
+
readonly acknowledge: QueueLeaseError;
|
|
49
|
+
readonly changeVisibility: QueueLeaseError;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** One call made to the test `QueueApi`, in call order. */
|
|
53
|
+
export interface TestQueueCall {
|
|
54
|
+
readonly operation: TestQueueOperation;
|
|
55
|
+
/** When the call was made, in epoch milliseconds. */
|
|
56
|
+
readonly at: number;
|
|
57
|
+
readonly topic: TopicName;
|
|
58
|
+
/** Region the call was routed to: the handle's region for lease calls. */
|
|
59
|
+
readonly region: VercelRegion;
|
|
60
|
+
readonly consumerGroup?: ConsumerGroupName;
|
|
61
|
+
readonly messageId?: MessageId;
|
|
62
|
+
readonly receiptHandle?: ReceiptHandle;
|
|
63
|
+
readonly visibilityTimeout?: Duration.Duration;
|
|
64
|
+
readonly limit?: number;
|
|
65
|
+
readonly cursorFallback?: CursorFallback;
|
|
66
|
+
readonly experimentalHandoff?: boolean;
|
|
67
|
+
readonly experimentalIfCoalesced?: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Where one message stands for one consumer group. */
|
|
71
|
+
export interface TestQueueDelivery {
|
|
72
|
+
readonly status: "pending" | "leased" | "acknowledged";
|
|
73
|
+
readonly deliveryCount: number;
|
|
74
|
+
readonly handoffCount: number;
|
|
75
|
+
/** When the message becomes receivable again, in epoch milliseconds. */
|
|
76
|
+
readonly visibleAt: number;
|
|
77
|
+
readonly receiptHandle: Option.Option<ReceiptHandle>;
|
|
78
|
+
readonly coalescedCount: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface StoredMessage {
|
|
82
|
+
readonly id: MessageId;
|
|
83
|
+
readonly body: Uint8Array;
|
|
84
|
+
readonly contentType: string;
|
|
85
|
+
readonly createdAt: number;
|
|
86
|
+
readonly expiresAt: number;
|
|
87
|
+
readonly availableAt: number;
|
|
88
|
+
readonly idempotencyKey: string | undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface MutableDelivery {
|
|
92
|
+
status: TestQueueDelivery["status"];
|
|
93
|
+
deliveryCount: number;
|
|
94
|
+
handoffCount: number;
|
|
95
|
+
visibleAt: number;
|
|
96
|
+
receiptHandle: Option.Option<ReceiptHandle>;
|
|
97
|
+
coalescedCount: number;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const DEFAULT_RETENTION = Duration.hours(24);
|
|
101
|
+
const DEFAULT_VISIBILITY_TIMEOUT = Duration.seconds(30);
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Inspects and scripts the in-memory queue behind {@link TestQueue.layer}.
|
|
105
|
+
*
|
|
106
|
+
* Time follows `Clock`, so `TestClock` drives lease expiry and delays.
|
|
107
|
+
*/
|
|
108
|
+
export class TestQueue extends Context.Service<
|
|
109
|
+
TestQueue,
|
|
110
|
+
{
|
|
111
|
+
/** Every `QueueApi` call so far, in order, including calls that failed. */
|
|
112
|
+
readonly calls: Effect.Effect<ReadonlyArray<TestQueueCall>>;
|
|
113
|
+
|
|
114
|
+
/** The state of one message for one consumer group; none before it was first leased. */
|
|
115
|
+
readonly delivery: (
|
|
116
|
+
topic: TopicName,
|
|
117
|
+
consumerGroup: ConsumerGroupName,
|
|
118
|
+
messageId: MessageId,
|
|
119
|
+
) => Effect.Effect<Option.Option<TestQueueDelivery>>;
|
|
120
|
+
|
|
121
|
+
/** Make the next call to `operation` fail with `error` instead of running. */
|
|
122
|
+
readonly failNext: <Op extends TestQueueOperation>(
|
|
123
|
+
operation: Op,
|
|
124
|
+
error: TestQueueOperationError[Op],
|
|
125
|
+
) => Effect.Effect<void>;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* **Experimental.** Coalesce `count` messages into the current delivery, as a concurrency key
|
|
129
|
+
* does: acknowledgments are refused with `ConcurrencyCoalescedPending` until they cover it.
|
|
130
|
+
*/
|
|
131
|
+
readonly coalesce: (
|
|
132
|
+
topic: TopicName,
|
|
133
|
+
consumerGroup: ConsumerGroupName,
|
|
134
|
+
messageId: MessageId,
|
|
135
|
+
count: number,
|
|
136
|
+
) => Effect.Effect<void>;
|
|
137
|
+
}
|
|
138
|
+
>()("@effect-vercel/queue/TestQueue") {
|
|
139
|
+
/**
|
|
140
|
+
* An in-memory `QueueApi` with real lease semantics, plus the {@link TestQueue} controls.
|
|
141
|
+
*
|
|
142
|
+
* Each consumer group sees every message of a topic. Leases expire after their visibility
|
|
143
|
+
* timeout; acknowledging or changing visibility needs the current receipt handle (409 otherwise,
|
|
144
|
+
* 404 once acknowledged). Leasing by identifier answers 404, 409 or 410 like the service.
|
|
145
|
+
* Calls without a region go to `iad1`.
|
|
146
|
+
*/
|
|
147
|
+
static readonly layer: Layer.Layer<QueueApi | TestQueue> = Layer.effectContext(
|
|
148
|
+
Effect.sync(() => {
|
|
149
|
+
const defaultRegion = VercelRegion.make("iad1");
|
|
150
|
+
const topics = new Map<TopicName, Array<StoredMessage>>();
|
|
151
|
+
const deliveries = new Map<string, MutableDelivery>();
|
|
152
|
+
const recorded: Array<TestQueueCall> = [];
|
|
153
|
+
const scripted = new Map<TestQueueOperation, Array<unknown>>();
|
|
154
|
+
let sequence = 0;
|
|
155
|
+
|
|
156
|
+
const key = (topic: string, group: string, id: string) => `${topic}\u0000${group}\u0000${id}`;
|
|
157
|
+
const nextId = (prefix: string) => `${prefix}_${++sequence}`;
|
|
158
|
+
|
|
159
|
+
const call = <Op extends TestQueueOperation, A>(
|
|
160
|
+
entry: Omit<TestQueueCall, "at"> & { readonly operation: Op },
|
|
161
|
+
run: (now: number) => Effect.Effect<A, TestQueueOperationError[Op]>,
|
|
162
|
+
): Effect.Effect<A, TestQueueOperationError[Op]> =>
|
|
163
|
+
Effect.flatMap(Clock.currentTimeMillis, (now) => {
|
|
164
|
+
recorded.push({ ...entry, at: now });
|
|
165
|
+
const failure = scripted.get(entry.operation)?.shift();
|
|
166
|
+
if (failure !== undefined) {
|
|
167
|
+
return Effect.fail(failure as TestQueueOperationError[Op]);
|
|
168
|
+
}
|
|
169
|
+
return run(now);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const findMessage = (topic: TopicName, id: string, now: number) =>
|
|
173
|
+
Option.fromNullishOr(
|
|
174
|
+
topics.get(topic)?.find((message) => message.id === id && message.expiresAt > now),
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
const deliveryOf = (topic: TopicName, group: ConsumerGroupName, id: MessageId) => {
|
|
178
|
+
const k = key(topic, group, id);
|
|
179
|
+
let state = deliveries.get(k);
|
|
180
|
+
if (state === undefined) {
|
|
181
|
+
state = {
|
|
182
|
+
status: "pending",
|
|
183
|
+
deliveryCount: 0,
|
|
184
|
+
handoffCount: 0,
|
|
185
|
+
visibleAt: 0,
|
|
186
|
+
receiptHandle: Option.none(),
|
|
187
|
+
coalescedCount: 0,
|
|
188
|
+
};
|
|
189
|
+
deliveries.set(k, state);
|
|
190
|
+
}
|
|
191
|
+
return state;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const isLeased = (state: MutableDelivery, now: number) =>
|
|
195
|
+
state.status === "leased" && state.visibleAt > now;
|
|
196
|
+
|
|
197
|
+
const lease = (
|
|
198
|
+
topic: TopicName,
|
|
199
|
+
group: ConsumerGroupName,
|
|
200
|
+
message: StoredMessage,
|
|
201
|
+
state: MutableDelivery,
|
|
202
|
+
now: number,
|
|
203
|
+
timeout: Duration.Duration,
|
|
204
|
+
region: VercelRegion,
|
|
205
|
+
) => {
|
|
206
|
+
const receiptHandle = ReceiptHandle.make(nextId("rh"));
|
|
207
|
+
state.status = "leased";
|
|
208
|
+
state.deliveryCount += 1;
|
|
209
|
+
state.visibleAt = now + Duration.toMillis(timeout);
|
|
210
|
+
state.receiptHandle = Option.some(receiptHandle);
|
|
211
|
+
return new ReceivedMessage({
|
|
212
|
+
handle: new MessageHandle({
|
|
213
|
+
topic,
|
|
214
|
+
consumerGroup: group,
|
|
215
|
+
messageId: message.id,
|
|
216
|
+
receiptHandle,
|
|
217
|
+
region,
|
|
218
|
+
}),
|
|
219
|
+
metadata: new MessageMetadata({
|
|
220
|
+
messageId: message.id,
|
|
221
|
+
deliveryCount: state.deliveryCount,
|
|
222
|
+
createdAt: DateTime.makeUnsafe(message.createdAt),
|
|
223
|
+
expiresAt: DateTime.makeUnsafe(message.expiresAt),
|
|
224
|
+
topic,
|
|
225
|
+
consumerGroup: group,
|
|
226
|
+
region,
|
|
227
|
+
...(state.handoffCount > 0 ? { experimentalHandoffCount: state.handoffCount } : {}),
|
|
228
|
+
}),
|
|
229
|
+
contentType: message.contentType,
|
|
230
|
+
body: message.body,
|
|
231
|
+
});
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const publish = (topic: TopicName, message: OutgoingMessage, now: number) => {
|
|
235
|
+
const stored = topics.get(topic) ?? [];
|
|
236
|
+
topics.set(topic, stored);
|
|
237
|
+
if (message.idempotencyKey !== undefined) {
|
|
238
|
+
const duplicate = stored.find(
|
|
239
|
+
(existing) =>
|
|
240
|
+
existing.idempotencyKey === message.idempotencyKey && existing.expiresAt > now,
|
|
241
|
+
);
|
|
242
|
+
if (duplicate !== undefined) return duplicate.id;
|
|
243
|
+
}
|
|
244
|
+
const id = MessageId.make(nextId("msg"));
|
|
245
|
+
const retention =
|
|
246
|
+
message.retention === undefined
|
|
247
|
+
? DEFAULT_RETENTION
|
|
248
|
+
: Duration.fromInputUnsafe(message.retention);
|
|
249
|
+
stored.push({
|
|
250
|
+
id,
|
|
251
|
+
body: message.body,
|
|
252
|
+
contentType: message.contentType,
|
|
253
|
+
createdAt: now,
|
|
254
|
+
expiresAt: now + Duration.toMillis(retention),
|
|
255
|
+
availableAt: now + (message.delay === undefined ? 0 : Duration.toMillis(message.delay)),
|
|
256
|
+
idempotencyKey: message.idempotencyKey,
|
|
257
|
+
});
|
|
258
|
+
return id;
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const validateLease = (
|
|
262
|
+
handle: MessageHandle,
|
|
263
|
+
now: number,
|
|
264
|
+
): Effect.Effect<MutableDelivery, MessageNotFound | MessageNotAvailable> => {
|
|
265
|
+
const message = findMessage(handle.topic, handle.messageId, now);
|
|
266
|
+
const state = deliveries.get(key(handle.topic, handle.consumerGroup, handle.messageId));
|
|
267
|
+
if (Option.isNone(message) || state === undefined || state.status === "acknowledged") {
|
|
268
|
+
return Effect.fail(new MessageNotFound({ messageId: handle.messageId }));
|
|
269
|
+
}
|
|
270
|
+
const held =
|
|
271
|
+
isLeased(state, now) &&
|
|
272
|
+
Option.getOrUndefined(state.receiptHandle) === handle.receiptHandle;
|
|
273
|
+
if (!held) {
|
|
274
|
+
return Effect.fail(
|
|
275
|
+
new MessageNotAvailable({
|
|
276
|
+
messageId: handle.messageId,
|
|
277
|
+
reason: new MessageUnavailable({ message: "The lease is no longer held" }),
|
|
278
|
+
}),
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
return Effect.succeed(state);
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const api = QueueApi.of({
|
|
285
|
+
send: (topic, message) =>
|
|
286
|
+
call({ operation: "send", topic, region: defaultRegion }, (now) =>
|
|
287
|
+
Effect.succeed(SendResult.Sent({ messageId: publish(topic, message, now) })),
|
|
288
|
+
),
|
|
289
|
+
|
|
290
|
+
sendBatch: (topic, messages) =>
|
|
291
|
+
call({ operation: "sendBatch", topic, region: defaultRegion }, (now) =>
|
|
292
|
+
Effect.succeed(
|
|
293
|
+
messages.map((message) =>
|
|
294
|
+
BatchSendResult.Sent({ messageId: publish(topic, message, now) }),
|
|
295
|
+
),
|
|
296
|
+
),
|
|
297
|
+
),
|
|
298
|
+
|
|
299
|
+
receive: (topic, consumerGroup, options?: ReceiveOptions) => {
|
|
300
|
+
const region = options?.region ?? defaultRegion;
|
|
301
|
+
const timeout = Duration.fromInputUnsafe(
|
|
302
|
+
options?.visibilityTimeout ?? DEFAULT_VISIBILITY_TIMEOUT,
|
|
303
|
+
);
|
|
304
|
+
const limit = options?.limit ?? 1;
|
|
305
|
+
return call(
|
|
306
|
+
{
|
|
307
|
+
operation: "receive",
|
|
308
|
+
topic,
|
|
309
|
+
region,
|
|
310
|
+
consumerGroup,
|
|
311
|
+
visibilityTimeout: timeout,
|
|
312
|
+
limit,
|
|
313
|
+
...(options?.cursorFallback === undefined
|
|
314
|
+
? {}
|
|
315
|
+
: { cursorFallback: options.cursorFallback }),
|
|
316
|
+
},
|
|
317
|
+
(now) =>
|
|
318
|
+
Effect.succeed(
|
|
319
|
+
(topics.get(topic) ?? [])
|
|
320
|
+
.filter((message) => message.expiresAt > now && message.availableAt <= now)
|
|
321
|
+
.map((message) => ({
|
|
322
|
+
message,
|
|
323
|
+
state: deliveryOf(topic, consumerGroup, message.id),
|
|
324
|
+
}))
|
|
325
|
+
.filter(({ state }) => state.status !== "acknowledged" && state.visibleAt <= now)
|
|
326
|
+
.slice(0, limit)
|
|
327
|
+
.map(({ message, state }) =>
|
|
328
|
+
lease(topic, consumerGroup, message, state, now, timeout, region),
|
|
329
|
+
),
|
|
330
|
+
),
|
|
331
|
+
);
|
|
332
|
+
},
|
|
333
|
+
|
|
334
|
+
receiveById: (topic, consumerGroup, messageId, options?: ReceiveByIdOptions) => {
|
|
335
|
+
const region = options?.region ?? defaultRegion;
|
|
336
|
+
const timeout = Duration.fromInputUnsafe(
|
|
337
|
+
options?.visibilityTimeout ?? DEFAULT_VISIBILITY_TIMEOUT,
|
|
338
|
+
);
|
|
339
|
+
return call(
|
|
340
|
+
{
|
|
341
|
+
operation: "receiveById",
|
|
342
|
+
topic,
|
|
343
|
+
region,
|
|
344
|
+
consumerGroup,
|
|
345
|
+
messageId,
|
|
346
|
+
visibilityTimeout: timeout,
|
|
347
|
+
},
|
|
348
|
+
(now) => {
|
|
349
|
+
const message = findMessage(topic, messageId, now);
|
|
350
|
+
if (Option.isNone(message)) {
|
|
351
|
+
return Effect.fail(new MessageNotFound({ messageId }));
|
|
352
|
+
}
|
|
353
|
+
const state = deliveryOf(topic, consumerGroup, messageId);
|
|
354
|
+
if (state.status === "acknowledged") {
|
|
355
|
+
return Effect.fail(new MessageAlreadyProcessed({ messageId }));
|
|
356
|
+
}
|
|
357
|
+
if (isLeased(state, now)) {
|
|
358
|
+
return Effect.fail(
|
|
359
|
+
new MessageNotAvailable({
|
|
360
|
+
messageId,
|
|
361
|
+
reason: new MessageLocked({
|
|
362
|
+
retryAfter: Option.some(Duration.millis(state.visibleAt - now)),
|
|
363
|
+
}),
|
|
364
|
+
}),
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
if (message.value.availableAt > now || state.visibleAt > now) {
|
|
368
|
+
return Effect.fail(
|
|
369
|
+
new MessageNotAvailable({
|
|
370
|
+
messageId,
|
|
371
|
+
reason: new MessageUnavailable({ message: "The message is not visible yet" }),
|
|
372
|
+
}),
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
return Effect.succeed(
|
|
376
|
+
lease(topic, consumerGroup, message.value, state, now, timeout, region),
|
|
377
|
+
);
|
|
378
|
+
},
|
|
379
|
+
);
|
|
380
|
+
},
|
|
381
|
+
|
|
382
|
+
acknowledge: (handle, options) =>
|
|
383
|
+
call(
|
|
384
|
+
{
|
|
385
|
+
operation: "acknowledge",
|
|
386
|
+
topic: handle.topic,
|
|
387
|
+
region: handle.region,
|
|
388
|
+
consumerGroup: handle.consumerGroup,
|
|
389
|
+
messageId: handle.messageId,
|
|
390
|
+
receiptHandle: handle.receiptHandle,
|
|
391
|
+
...(options?.experimentalIfCoalesced === undefined
|
|
392
|
+
? {}
|
|
393
|
+
: { experimentalIfCoalesced: options.experimentalIfCoalesced }),
|
|
394
|
+
},
|
|
395
|
+
(now) =>
|
|
396
|
+
Effect.flatMap(validateLease(handle, now), (state) => {
|
|
397
|
+
if (state.coalescedCount > (options?.experimentalIfCoalesced ?? 0)) {
|
|
398
|
+
return Effect.fail(
|
|
399
|
+
new MessageNotAvailable({
|
|
400
|
+
messageId: handle.messageId,
|
|
401
|
+
reason: new ConcurrencyCoalescedPending({
|
|
402
|
+
coalescedCount: state.coalescedCount,
|
|
403
|
+
}),
|
|
404
|
+
}),
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
state.status = "acknowledged";
|
|
408
|
+
state.receiptHandle = Option.none();
|
|
409
|
+
state.coalescedCount = 0;
|
|
410
|
+
return Effect.void;
|
|
411
|
+
}),
|
|
412
|
+
),
|
|
413
|
+
|
|
414
|
+
changeVisibility: (handle, visibilityTimeout, options) => {
|
|
415
|
+
const timeout = Duration.fromInputUnsafe(visibilityTimeout);
|
|
416
|
+
const handoff = options?.experimentalHandoff === true;
|
|
417
|
+
return call(
|
|
418
|
+
{
|
|
419
|
+
operation: "changeVisibility",
|
|
420
|
+
topic: handle.topic,
|
|
421
|
+
region: handle.region,
|
|
422
|
+
consumerGroup: handle.consumerGroup,
|
|
423
|
+
messageId: handle.messageId,
|
|
424
|
+
receiptHandle: handle.receiptHandle,
|
|
425
|
+
visibilityTimeout: timeout,
|
|
426
|
+
...(handoff ? { experimentalHandoff: true } : {}),
|
|
427
|
+
},
|
|
428
|
+
(now) =>
|
|
429
|
+
Effect.map(validateLease(handle, now), (state) => {
|
|
430
|
+
const releases = Duration.isZero(timeout);
|
|
431
|
+
state.visibleAt = now + Duration.toMillis(timeout);
|
|
432
|
+
if (releases) {
|
|
433
|
+
state.status = "pending";
|
|
434
|
+
state.receiptHandle = Option.none();
|
|
435
|
+
}
|
|
436
|
+
if (!handoff) return Option.none();
|
|
437
|
+
if (!releases) return Option.some("not_immediate" as const);
|
|
438
|
+
state.handoffCount += 1;
|
|
439
|
+
return Option.some("accepted" as const);
|
|
440
|
+
}),
|
|
441
|
+
);
|
|
442
|
+
},
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
const control = TestQueue.of({
|
|
446
|
+
calls: Effect.sync(() => [...recorded]),
|
|
447
|
+
delivery: (topic, consumerGroup, messageId) =>
|
|
448
|
+
Effect.sync(() =>
|
|
449
|
+
Option.map(
|
|
450
|
+
Option.fromNullishOr(deliveries.get(key(topic, consumerGroup, messageId))),
|
|
451
|
+
(state) => ({ ...state }),
|
|
452
|
+
),
|
|
453
|
+
),
|
|
454
|
+
failNext: (operation, error) =>
|
|
455
|
+
Effect.sync(() => {
|
|
456
|
+
const queue = scripted.get(operation) ?? [];
|
|
457
|
+
queue.push(error);
|
|
458
|
+
scripted.set(operation, queue);
|
|
459
|
+
}),
|
|
460
|
+
coalesce: (topic, consumerGroup, messageId, count) =>
|
|
461
|
+
Effect.sync(() => {
|
|
462
|
+
deliveryOf(topic, consumerGroup, messageId).coalescedCount = count;
|
|
463
|
+
}),
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
return Context.make(QueueApi, api).pipe(Context.add(TestQueue, control));
|
|
467
|
+
}),
|
|
468
|
+
);
|
|
469
|
+
}
|