@hogsend/engine 0.3.0 → 0.5.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/README.md +8 -0
- package/package.json +9 -7
- package/src/app.ts +1 -1
- package/src/buckets/registry-singleton.ts +5 -17
- package/src/container.ts +13 -5
- package/src/env.ts +7 -0
- package/src/index.ts +25 -0
- package/src/journeys/constants.ts +14 -0
- package/src/journeys/define-journey.ts +36 -7
- package/src/journeys/errors.ts +17 -0
- package/src/journeys/journey-context.ts +135 -19
- package/src/journeys/registry-singleton.ts +5 -17
- package/src/lib/alerting.ts +0 -4
- package/src/lib/analytics-singleton.ts +25 -0
- package/src/lib/boot.ts +166 -0
- package/src/lib/bucket-posthog-sync.ts +6 -4
- package/src/lib/email-service-types.ts +19 -10
- package/src/lib/email.ts +15 -13
- package/src/lib/ingestion.ts +22 -1
- package/src/lib/mailer.ts +9 -9
- package/src/lib/notifications.ts +7 -8
- package/src/lib/posthog.ts +2 -4
- package/src/lib/singleton.ts +56 -0
- package/src/lib/tracked.ts +1 -1
- package/src/lib/tracking-events.ts +1 -1
- package/src/lib/worker-heartbeat.ts +78 -0
- package/src/routes/health.ts +16 -1
- package/src/worker.ts +28 -5
- package/src/workflows/check-alerts.ts +0 -1
- package/src/workflows/send-email.ts +35 -33
package/src/worker.ts
CHANGED
|
@@ -3,9 +3,10 @@ import { selectBucketTasks } from "./buckets/registry.js";
|
|
|
3
3
|
import type { HogsendClient } from "./container.js";
|
|
4
4
|
import type { DefinedJourney } from "./journeys/define-journey.js";
|
|
5
5
|
import { selectJourneyTasks } from "./journeys/registry.js";
|
|
6
|
+
import { reportWorkerReady } from "./lib/boot.js";
|
|
6
7
|
import { hatchet } from "./lib/hatchet.js";
|
|
7
|
-
import { getPostHog } from "./lib/posthog.js";
|
|
8
8
|
import { getRedisIfConnected } from "./lib/redis.js";
|
|
9
|
+
import { startWorkerHeartbeat } from "./lib/worker-heartbeat.js";
|
|
9
10
|
import {
|
|
10
11
|
bucketBackfillTask,
|
|
11
12
|
enqueueBucketBackfills,
|
|
@@ -63,21 +64,43 @@ export function createWorker(opts: CreateWorkerOptions): Worker {
|
|
|
63
64
|
// Hatchet's worker is created lazily on start so signal wiring can own its
|
|
64
65
|
// lifecycle. `_worker` is captured for stop().
|
|
65
66
|
let _worker: Awaited<ReturnType<typeof hatchet.worker>> | undefined;
|
|
67
|
+
let _stopHeartbeat: (() => Promise<void>) | undefined;
|
|
66
68
|
|
|
67
69
|
async function stop(): Promise<void> {
|
|
70
|
+
// Delete the heartbeat first (an immediate "worker down" signal) before the
|
|
71
|
+
// Redis connection is torn down below.
|
|
72
|
+
await _stopHeartbeat?.();
|
|
68
73
|
await Promise.allSettled([
|
|
69
74
|
_worker?.stop(),
|
|
70
|
-
|
|
75
|
+
// Shut down the injected analytics instance (same object the worker's
|
|
76
|
+
// tasks use), not the module singleton. Undefined when no analytics is
|
|
77
|
+
// configured — the optional chain makes that a no-op.
|
|
78
|
+
container.analytics?.shutdown(),
|
|
71
79
|
getRedisIfConnected()?.quit(),
|
|
72
80
|
]);
|
|
73
81
|
}
|
|
74
82
|
|
|
75
83
|
async function start(): Promise<void> {
|
|
84
|
+
// Emit BEFORE the Hatchet handshake: proves the process booted past init
|
|
85
|
+
// even while the connection is still establishing (the worker banner /
|
|
86
|
+
// "ready" line only fires once `hatchet.worker()` resolves).
|
|
87
|
+
container.logger.info("Hogsend worker starting", {
|
|
88
|
+
hatchet: container.env.HATCHET_CLIENT_HOST_PORT,
|
|
89
|
+
});
|
|
90
|
+
|
|
76
91
|
_worker = await hatchet.worker("hogsend-worker", { workflows });
|
|
77
92
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
93
|
+
reportWorkerReady({
|
|
94
|
+
client: container,
|
|
95
|
+
journeyTasks: journeyTasks.length,
|
|
96
|
+
bucketTasks: bucketTasks.length,
|
|
97
|
+
builtinTasks:
|
|
98
|
+
baseWorkflows.length - journeyTasks.length - bucketTasks.length,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Publish liveness so the API + Studio can show "worker connected"
|
|
102
|
+
// (read via GET /v1/health). Best-effort; never blocks the listener.
|
|
103
|
+
_stopHeartbeat = startWorkerHeartbeat(container.logger);
|
|
81
104
|
|
|
82
105
|
// Boot-time backfill / criteria-change re-eval (Section 6.6 B): diff each
|
|
83
106
|
// enabled bucket's criteriaHash against bucket_configs and trigger a
|
|
@@ -1,22 +1,16 @@
|
|
|
1
1
|
import { NonRetryableError } from "@hatchet-dev/typescript-sdk/v1/index.js";
|
|
2
|
-
import {
|
|
2
|
+
import { EmailSendError } from "@hogsend/email";
|
|
3
|
+
import { getEmailService } from "../lib/email.js";
|
|
3
4
|
import { hatchet } from "../lib/hatchet.js";
|
|
4
5
|
|
|
5
|
-
const resend = createResendClient({
|
|
6
|
-
apiKey: process.env.RESEND_API_KEY ?? "",
|
|
7
|
-
});
|
|
8
|
-
|
|
9
|
-
const NON_RETRYABLE_CODES = new Set([
|
|
10
|
-
"validation_error",
|
|
11
|
-
"missing_required_field",
|
|
12
|
-
"invalid_api_key",
|
|
13
|
-
"not_found",
|
|
14
|
-
"restricted_api_key",
|
|
15
|
-
]);
|
|
16
|
-
|
|
17
6
|
export const sendEmailTask = hatchet.task({
|
|
18
7
|
name: "send-email",
|
|
19
|
-
|
|
8
|
+
// The EmailProvider owns transient-failure backoff internally (classified
|
|
9
|
+
// exponential retry in its `send`), and permanent failures fail fast below via
|
|
10
|
+
// NonRetryableError — so Hatchet's retry is just ONE durability re-attempt for a
|
|
11
|
+
// worker crash/timeout, not a second transient-retry loop layered on the
|
|
12
|
+
// provider's. (Previously 3, which multiplied with the provider's own retries.)
|
|
13
|
+
retries: 1,
|
|
20
14
|
executionTimeout: "30s",
|
|
21
15
|
backoff: { factor: 2, maxSeconds: 30 },
|
|
22
16
|
fn: async (input: {
|
|
@@ -28,27 +22,35 @@ export const sendEmailTask = hatchet.task({
|
|
|
28
22
|
tags?: Array<{ name: string; value: string }>;
|
|
29
23
|
headers?: Record<string, string>;
|
|
30
24
|
}) => {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
tags: input.tags,
|
|
41
|
-
headers: input.headers,
|
|
42
|
-
});
|
|
25
|
+
// Deliver through the injected, provider-backed mailer (set by
|
|
26
|
+
// createHogsendClient → setEmailService). `sendRaw` calls the swappable
|
|
27
|
+
// EmailProvider's `send`, resolving the default `from` from the mailer
|
|
28
|
+
// config — so a swapped provider is honored and this task no longer
|
|
29
|
+
// constructs a raw Resend client of its own. The provider already retries
|
|
30
|
+
// transient failures internally and surfaces a classified EmailSendError;
|
|
31
|
+
// map a non-retryable one to Hatchet's NonRetryableError so the task's own
|
|
32
|
+
// retry/backoff doesn't re-attempt a permanent failure.
|
|
33
|
+
const emailService = getEmailService();
|
|
43
34
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
35
|
+
try {
|
|
36
|
+
// `from` is optional: when absent the mailer's `resolveFrom` falls back to
|
|
37
|
+
// its configured defaultFrom (env.RESEND_FROM_EMAIL).
|
|
38
|
+
const result = await emailService.sendRaw({
|
|
39
|
+
from: input.from,
|
|
40
|
+
to: input.to,
|
|
41
|
+
subject: input.subject,
|
|
42
|
+
html: input.html,
|
|
43
|
+
replyTo: input.replyTo,
|
|
44
|
+
tags: input.tags,
|
|
45
|
+
headers: input.headers,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
return { emailId: result.id };
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (error instanceof EmailSendError && !error.retryable) {
|
|
51
|
+
throw new NonRetryableError(error.message);
|
|
48
52
|
}
|
|
49
|
-
throw
|
|
53
|
+
throw error;
|
|
50
54
|
}
|
|
51
|
-
|
|
52
|
-
return { emailId: data?.id ?? "" };
|
|
53
55
|
},
|
|
54
56
|
});
|