@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/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
- getPostHog()?.shutdown(),
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
- container.logger.info(
79
- `Hogsend worker started with ${journeyTasks.length} journey task(s)`,
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
@@ -16,7 +16,6 @@ export const checkAlertsTask = hatchet.task({
16
16
  await checkAlertRules({
17
17
  db,
18
18
  logger,
19
- resendApiKey: process.env.RESEND_API_KEY,
20
19
  });
21
20
 
22
21
  return { checked: true };
@@ -1,22 +1,16 @@
1
1
  import { NonRetryableError } from "@hatchet-dev/typescript-sdk/v1/index.js";
2
- import { createResendClient } from "@hogsend/plugin-resend";
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
- retries: 3,
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
- const { data, error } = await resend.emails.send({
32
- from:
33
- input.from ??
34
- process.env.RESEND_FROM_EMAIL ??
35
- "Hogsend <noreply@hogsend.com>",
36
- to: input.to,
37
- subject: input.subject,
38
- html: input.html,
39
- replyTo: input.replyTo,
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
- if (error) {
45
- const name = (error as { name?: string }).name ?? "";
46
- if (NON_RETRYABLE_CODES.has(name)) {
47
- throw new NonRetryableError(`${name}: ${error.message}`);
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 new Error(`Failed to send email: ${error.message}`);
53
+ throw error;
50
54
  }
51
-
52
- return { emailId: data?.id ?? "" };
53
55
  },
54
56
  });