@remit/imap-worker 0.0.25 → 0.0.27
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/package.json +1 -1
- package/src/failure-kind.test.ts +42 -0
- package/src/failure-kind.ts +17 -0
- package/src/handlers/flag-push.ts +4 -7
- package/src/handlers/placement-move-push.ts +4 -7
- package/src/handlers/sync-message-body.ts +24 -28
- package/src/handlers/sync-messages.ts +4 -13
- package/src/index.ts +18 -10
- package/src/poller.ts +6 -1
package/package.json
CHANGED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { RefreshTokenError } from "@remit/mail-oauth-service";
|
|
4
|
+
import { MailConnectionError } from "@remit/mailbox-service";
|
|
5
|
+
import { imapFailureKind } from "./failure-kind.js";
|
|
6
|
+
|
|
7
|
+
describe("imapFailureKind", () => {
|
|
8
|
+
it("counts an IMAP authentication rejection as auth", () => {
|
|
9
|
+
assert.equal(
|
|
10
|
+
imapFailureKind(
|
|
11
|
+
new MailConnectionError("auth", "IMAP authentication failed"),
|
|
12
|
+
),
|
|
13
|
+
"auth",
|
|
14
|
+
);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("counts a token refresh that cannot mint credentials as auth", () => {
|
|
18
|
+
assert.equal(
|
|
19
|
+
imapFailureKind(
|
|
20
|
+
new RefreshTokenError({
|
|
21
|
+
kind: "reauth-required",
|
|
22
|
+
code: "invalid_grant",
|
|
23
|
+
}),
|
|
24
|
+
),
|
|
25
|
+
"auth",
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("keeps a network failure apart from an auth failure", () => {
|
|
30
|
+
assert.equal(
|
|
31
|
+
imapFailureKind(
|
|
32
|
+
new MailConnectionError("network", "IMAP connection failed: ETIMEDOUT"),
|
|
33
|
+
),
|
|
34
|
+
"network",
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("classifies anything else as other", () => {
|
|
39
|
+
assert.equal(imapFailureKind(new Error("bad cursor")), "other");
|
|
40
|
+
assert.equal(imapFailureKind(undefined), "other");
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { MailFailureKind } from "@remit/logger-lambda";
|
|
2
|
+
import { RefreshTokenError } from "@remit/mail-oauth-service";
|
|
3
|
+
import { MailConnectionError } from "@remit/mailbox-service";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Which class of failure ended an IMAP operation, for the exported failure
|
|
7
|
+
* counter (standalone-observability D3). `auth` is its own kind because it is
|
|
8
|
+
* the one class that never resolves itself — an expired OAuth grant or a
|
|
9
|
+
* changed password fails identically forever, and it is the most common way a
|
|
10
|
+
* self-hosted mailbox goes quiet. A refresh that cannot mint a token is the
|
|
11
|
+
* same condition arriving one layer earlier.
|
|
12
|
+
*/
|
|
13
|
+
export const imapFailureKind = (error: unknown): MailFailureKind => {
|
|
14
|
+
if (error instanceof RefreshTokenError) return "auth";
|
|
15
|
+
if (error instanceof MailConnectionError) return error.kind;
|
|
16
|
+
return "other";
|
|
17
|
+
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { getClient } from "@remit/backend/client";
|
|
2
2
|
import type { Logger } from "@remit/logger-lambda";
|
|
3
|
-
import {
|
|
3
|
+
import { recordImapFailure } from "@remit/logger-lambda";
|
|
4
4
|
import {
|
|
5
5
|
guardConnectionCursor,
|
|
6
6
|
isCursorRebuildNeeded,
|
|
@@ -222,15 +222,12 @@ export const handleFlagPush = async (
|
|
|
222
222
|
);
|
|
223
223
|
|
|
224
224
|
if (outcome === "reconciled") {
|
|
225
|
-
metrics.addMetric(
|
|
226
|
-
"flagPushStaleRowReconciled",
|
|
227
|
-
MetricUnit.Count,
|
|
228
|
-
1,
|
|
229
|
-
);
|
|
230
225
|
return;
|
|
231
226
|
}
|
|
232
227
|
|
|
233
|
-
|
|
228
|
+
// Terminal and never re-thrown, so the handler-outcome series
|
|
229
|
+
// records this record as a success. Counted here or it is invisible.
|
|
230
|
+
recordImapFailure("FLAG_PUSH_EXHAUSTED", "other");
|
|
234
231
|
log.error(
|
|
235
232
|
{ error: error instanceof Error ? error.message : String(error) },
|
|
236
233
|
"Flag push retry exhausted; message still exists at its mailbox",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { getClient } from "@remit/backend/client";
|
|
2
2
|
import type { Logger } from "@remit/logger-lambda";
|
|
3
|
-
import {
|
|
3
|
+
import { recordImapFailure } from "@remit/logger-lambda";
|
|
4
4
|
import {
|
|
5
5
|
guardConnectionCursor,
|
|
6
6
|
type IImapConnection,
|
|
@@ -428,11 +428,6 @@ export const handlePlacementMovePush = async (
|
|
|
428
428
|
);
|
|
429
429
|
|
|
430
430
|
if (outcome === "reconciled") {
|
|
431
|
-
metrics.addMetric(
|
|
432
|
-
"placementMoveStaleRowReconciled",
|
|
433
|
-
MetricUnit.Count,
|
|
434
|
-
1,
|
|
435
|
-
);
|
|
436
431
|
await emitMoveResync(emitEvent, {
|
|
437
432
|
accountId,
|
|
438
433
|
sourceMailboxId: marker.sourceMailboxId,
|
|
@@ -441,7 +436,9 @@ export const handlePlacementMovePush = async (
|
|
|
441
436
|
return;
|
|
442
437
|
}
|
|
443
438
|
|
|
444
|
-
|
|
439
|
+
// Terminal and never re-thrown, so the handler-outcome series
|
|
440
|
+
// records this record as a success. Counted here or it is invisible.
|
|
441
|
+
recordImapFailure("PLACEMENT_MOVE_EXHAUSTED", "other");
|
|
445
442
|
log.error(
|
|
446
443
|
{ error: error instanceof Error ? error.message : String(error) },
|
|
447
444
|
"Placement move retry exhausted; message still exists at its source",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { getClient } from "@remit/backend/client";
|
|
2
2
|
import type { Logger } from "@remit/logger-lambda";
|
|
3
|
-
import {
|
|
3
|
+
import { recordImapFailure } from "@remit/logger-lambda";
|
|
4
4
|
import {
|
|
5
5
|
BodySyncService,
|
|
6
6
|
guardConnectionCursor,
|
|
@@ -157,6 +157,7 @@ export const syncMessageBody = async (
|
|
|
157
157
|
secrets,
|
|
158
158
|
mailboxSpecialUse: mailboxSpecialUseRepository,
|
|
159
159
|
quarantine: quarantineRepository,
|
|
160
|
+
flagQueue: flagQueueService,
|
|
160
161
|
} = await getClient();
|
|
161
162
|
|
|
162
163
|
const account = await accountService.get(accountId);
|
|
@@ -270,6 +271,7 @@ export const syncMessageBody = async (
|
|
|
270
271
|
uidValidity: mailbox.uidValidity ?? 0,
|
|
271
272
|
attempts: receiveCount,
|
|
272
273
|
},
|
|
274
|
+
{ flagQueueService },
|
|
273
275
|
);
|
|
274
276
|
|
|
275
277
|
// Guard at the openBox choke point (epic #1281 invariants 3 & 5). The
|
|
@@ -320,35 +322,29 @@ export const syncMessageBody = async (
|
|
|
320
322
|
// never a steady state (epic #1281 invariant 3). Resolve every
|
|
321
323
|
// failed id into exactly one of the two terminal outcomes instead of
|
|
322
324
|
// letting the record dead-letter with no diagnosis.
|
|
323
|
-
const {
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
);
|
|
325
|
+
const { brokenMessageIds } = await resolveExhaustedBodySyncFailures(
|
|
326
|
+
{
|
|
327
|
+
messageService,
|
|
328
|
+
threadMessageService,
|
|
329
|
+
storageService: storage,
|
|
330
|
+
log,
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
accountId,
|
|
334
|
+
accountConfigId: account.accountConfigId,
|
|
335
|
+
mailboxId,
|
|
336
|
+
mailboxPath: mailbox.fullPath,
|
|
337
|
+
failedMessageIds: result.failedMessageIds,
|
|
338
|
+
getConnection: getConnectionChecked,
|
|
339
|
+
},
|
|
340
|
+
);
|
|
340
341
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
"bodySyncStaleRowReconciled",
|
|
344
|
-
MetricUnit.Count,
|
|
345
|
-
reconciledMessageIds.length,
|
|
346
|
-
);
|
|
347
|
-
}
|
|
342
|
+
// A broken body is terminal on a record the handler acks, so the
|
|
343
|
+
// handler-outcome series never sees it.
|
|
348
344
|
if (brokenMessageIds.length > 0) {
|
|
349
|
-
|
|
350
|
-
"
|
|
351
|
-
|
|
345
|
+
recordImapFailure(
|
|
346
|
+
"SYNC_MESSAGE_BODY_BROKEN",
|
|
347
|
+
"other",
|
|
352
348
|
brokenMessageIds.length,
|
|
353
349
|
);
|
|
354
350
|
}
|
|
@@ -14,7 +14,7 @@ import type {
|
|
|
14
14
|
IUnitOfWork,
|
|
15
15
|
} from "@remit/data-ports";
|
|
16
16
|
import { SyncPhase } from "@remit/domain-enums";
|
|
17
|
-
import { type Logger,
|
|
17
|
+
import { type Logger, recordImapFailure } from "@remit/logger-lambda";
|
|
18
18
|
import { RefreshTokenError } from "@remit/mail-oauth-service";
|
|
19
19
|
import {
|
|
20
20
|
createManagedConnectionFactory,
|
|
@@ -420,22 +420,13 @@ const syncMailboxMessages = async (
|
|
|
420
420
|
"Message sync batch complete",
|
|
421
421
|
);
|
|
422
422
|
|
|
423
|
-
if (result.syncedCount > 0) {
|
|
424
|
-
metrics.addMetric(
|
|
425
|
-
"imapMessagesSynced",
|
|
426
|
-
MetricUnit.Count,
|
|
427
|
-
result.syncedCount,
|
|
428
|
-
);
|
|
429
|
-
}
|
|
430
|
-
|
|
431
423
|
// A stalled cursor is the one failure on this path that produces no error:
|
|
432
424
|
// unapplicable messages are caught and held back, so the round returns
|
|
433
425
|
// normally, the SQS record is deleted, and neither redrive nor the DLQ ever
|
|
434
|
-
// engages. Without this
|
|
435
|
-
// that looks healthy.
|
|
436
|
-
// value alarms, per-mailbox detail being in the accompanying ERROR log.
|
|
426
|
+
// engages. Without this counter the mailbox stops syncing behind a worker
|
|
427
|
+
// that looks healthy. Per-mailbox detail is in the accompanying ERROR log.
|
|
437
428
|
if (result.cursorStalled) {
|
|
438
|
-
|
|
429
|
+
recordImapFailure("SYNC_MESSAGES_CURSOR_STALLED", "other");
|
|
439
430
|
}
|
|
440
431
|
|
|
441
432
|
// Emit body sync events for the messages we just synced. Each event carries
|
package/src/index.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import {
|
|
2
2
|
createLogger,
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
queueNameFromEventSource,
|
|
4
|
+
recordImapFailure,
|
|
5
|
+
recordQueueEvent,
|
|
5
6
|
withTelemetry,
|
|
6
7
|
} from "@remit/logger-lambda";
|
|
7
8
|
import type { SQSBatchResponse, SQSEvent, SQSHandler } from "aws-lambda";
|
|
8
9
|
import type { WorkerEvent } from "./events.js";
|
|
10
|
+
import { imapFailureKind } from "./failure-kind.js";
|
|
9
11
|
import { processEvent } from "./processor.js";
|
|
10
12
|
|
|
11
13
|
const log = createLogger();
|
|
@@ -39,15 +41,16 @@ export const handler: SQSHandler = withTelemetry(
|
|
|
39
41
|
"Processing event",
|
|
40
42
|
);
|
|
41
43
|
|
|
42
|
-
|
|
44
|
+
const queue = queueNameFromEventSource(record.eventSourceARN);
|
|
43
45
|
const opStart = Date.now();
|
|
44
46
|
const failed = await processEvent(imapEvent, log, receiveCount)
|
|
45
47
|
.then(() => {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
recordQueueEvent({
|
|
49
|
+
queue,
|
|
50
|
+
eventType: imapEvent.type,
|
|
51
|
+
outcome: "success",
|
|
52
|
+
durationMs: Date.now() - opStart,
|
|
53
|
+
});
|
|
51
54
|
return false;
|
|
52
55
|
})
|
|
53
56
|
.catch((error) => {
|
|
@@ -55,10 +58,15 @@ export const handler: SQSHandler = withTelemetry(
|
|
|
55
58
|
{ error, messageId: record.messageId },
|
|
56
59
|
"Event processing failed",
|
|
57
60
|
);
|
|
58
|
-
|
|
61
|
+
recordQueueEvent({
|
|
62
|
+
queue,
|
|
63
|
+
eventType: imapEvent.type,
|
|
64
|
+
outcome: "failure",
|
|
65
|
+
durationMs: Date.now() - opStart,
|
|
66
|
+
});
|
|
67
|
+
recordImapFailure(imapEvent.type, imapFailureKind(error));
|
|
59
68
|
return true;
|
|
60
69
|
});
|
|
61
|
-
metrics.clearDimensions();
|
|
62
70
|
|
|
63
71
|
if (failed) {
|
|
64
72
|
batchItemFailures.push({ itemIdentifier: record.messageId });
|
package/src/poller.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createLogger } from "@remit/logger-lambda";
|
|
1
|
+
import { createLogger, startMetricsServer } from "@remit/logger-lambda";
|
|
2
2
|
import { runQueuePoller } from "@remit/sqs-client/poller";
|
|
3
3
|
import { env } from "expect-env";
|
|
4
4
|
import { handler } from "./index.js";
|
|
@@ -12,6 +12,11 @@ import { handler } from "./index.js";
|
|
|
12
12
|
*/
|
|
13
13
|
const log = createLogger();
|
|
14
14
|
|
|
15
|
+
// /metrics and nothing else, on the compose network (standalone-observability
|
|
16
|
+
// D2). No health route on it: worker liveness is a heartbeat file, which keeps
|
|
17
|
+
// answering when this server does not.
|
|
18
|
+
startMetricsServer();
|
|
19
|
+
|
|
15
20
|
await runQueuePoller({
|
|
16
21
|
log,
|
|
17
22
|
targets: [
|