@hogsend/engine 0.4.0 → 0.6.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/bucket-access.ts +213 -0
- package/src/buckets/bucket-reactions.ts +225 -0
- package/src/buckets/check-membership.ts +1 -0
- package/src/buckets/define-bucket.ts +79 -8
- package/src/buckets/registry-singleton.ts +5 -17
- package/src/buckets/registry.ts +81 -0
- package/src/container.ts +50 -10
- package/src/env.ts +7 -0
- package/src/index.ts +38 -0
- package/src/journeys/define-journey.ts +4 -2
- package/src/journeys/journey-context.ts +1 -2
- 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 +176 -0
- package/src/lib/bucket-emit.ts +47 -5
- 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/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/admin/buckets.ts +39 -9
- package/src/routes/health.ts +16 -1
- package/src/worker.ts +46 -6
- package/src/workflows/bucket-backfill.ts +90 -1
- package/src/workflows/bucket-reconcile.ts +205 -7
- package/src/workflows/check-alerts.ts +0 -1
- package/src/workflows/send-email.ts +35 -33
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
importJobs,
|
|
16
16
|
userEvents,
|
|
17
17
|
} from "@hogsend/db";
|
|
18
|
-
import { and, eq, gt, gte, inArray, isNull, sql } from "drizzle-orm";
|
|
18
|
+
import { and, eq, gt, gte, inArray, isNull, max, sql } from "drizzle-orm";
|
|
19
19
|
import {
|
|
20
20
|
computeExpiresAt,
|
|
21
21
|
computeMaxDwellAt,
|
|
@@ -209,6 +209,18 @@ async function backfillJoins(opts: {
|
|
|
209
209
|
// unset value would never be force-left.
|
|
210
210
|
const maxDwellAt = computeMaxDwellAt(bucket);
|
|
211
211
|
|
|
212
|
+
// Historical dwell anchor (Section 6.3 / LOCKED DECISION 1). For a
|
|
213
|
+
// windowed/event criterion the anchor is `max(occurredAt)` of the qualifying
|
|
214
|
+
// event = "when they became dormant" (e.g. went-dormant = the last
|
|
215
|
+
// `app_opened`). The dwell gate reads `coalesce(dwellAnchorAt, enteredAt)`, so
|
|
216
|
+
// backfilled members start the dwell clock at their real historical instant
|
|
217
|
+
// rather than the deploy-time `enteredAt`. Shapes with no cheap per-matcher
|
|
218
|
+
// timestamp leave the anchor NULL (fall back to enteredAt). The live join path
|
|
219
|
+
// (handleJoin) never sets dwellAnchorAt, so post-deploy joins clock from their
|
|
220
|
+
// real enteredAt. Computed batched per chunk (one GROUP BY max(occurredAt),
|
|
221
|
+
// mirroring the priorCounts GROUP BY) — never per-user serial queries.
|
|
222
|
+
const anchorEvent = resolveDwellAnchorEvent(criteria);
|
|
223
|
+
|
|
212
224
|
// Fix C (DEFERRED): backfilled fastExpiry rows are NOT armed with a
|
|
213
225
|
// bucket:arm-expiry durable timer here — they are picked up by the next cron
|
|
214
226
|
// sweep instead (reconcileBucketLeaves / reconcileBucketTtlLeaves are the
|
|
@@ -253,6 +265,35 @@ async function backfillJoins(opts: {
|
|
|
253
265
|
priorCounts.map((r) => [r.userId, Number(r.cnt)]),
|
|
254
266
|
);
|
|
255
267
|
|
|
268
|
+
// Batched dwell-anchor derivation (LOCKED DECISION 1): one GROUP BY
|
|
269
|
+
// max(occurredAt) over the qualifying event for THIS chunk, mirroring the
|
|
270
|
+
// priorCounts GROUP BY above (never per-user serial queries). Only computed
|
|
271
|
+
// when the criteria shape exposes a cheap per-matcher anchor event; an empty
|
|
272
|
+
// map leaves dwellAnchorAt NULL → the dwell gate falls back to enteredAt.
|
|
273
|
+
let anchorByUser = new Map<string, Date>();
|
|
274
|
+
if (anchorEvent != null) {
|
|
275
|
+
const anchors = await db
|
|
276
|
+
.select({
|
|
277
|
+
userId: userEvents.userId,
|
|
278
|
+
lastAt: max(userEvents.occurredAt),
|
|
279
|
+
})
|
|
280
|
+
.from(userEvents)
|
|
281
|
+
.where(
|
|
282
|
+
and(
|
|
283
|
+
eq(userEvents.event, anchorEvent),
|
|
284
|
+
inArray(userEvents.userId, chunk),
|
|
285
|
+
),
|
|
286
|
+
)
|
|
287
|
+
.groupBy(userEvents.userId);
|
|
288
|
+
anchorByUser = new Map(
|
|
289
|
+
anchors
|
|
290
|
+
.filter(
|
|
291
|
+
(r): r is { userId: string; lastAt: Date } => r.lastAt != null,
|
|
292
|
+
)
|
|
293
|
+
.map((r) => [r.userId, r.lastAt]),
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
256
297
|
const rows = chunk.map((userId) => ({
|
|
257
298
|
userId,
|
|
258
299
|
userEmail: emailByUser.get(userId) ?? null,
|
|
@@ -262,6 +303,8 @@ async function backfillJoins(opts: {
|
|
|
262
303
|
entryCount: 1 + (priorByUser.get(userId) ?? 0),
|
|
263
304
|
expiresAt: computeExpiresAt(bucket),
|
|
264
305
|
maxDwellAt,
|
|
306
|
+
// Historical dwell anchor where derivable; NULL otherwise (→ enteredAt).
|
|
307
|
+
dwellAnchorAt: anchorByUser.get(userId) ?? null,
|
|
265
308
|
lastEvaluatedAt: new Date(),
|
|
266
309
|
}));
|
|
267
310
|
|
|
@@ -365,6 +408,7 @@ async function reevalLeaves(opts: {
|
|
|
365
408
|
userEmail: row.userEmail,
|
|
366
409
|
epoch: row.entryCount,
|
|
367
410
|
source: "backfill",
|
|
411
|
+
reason: "criteria",
|
|
368
412
|
});
|
|
369
413
|
}
|
|
370
414
|
leftCount += flipped.length;
|
|
@@ -504,6 +548,51 @@ async function selectCompositeMatchers(
|
|
|
504
548
|
return matchers;
|
|
505
549
|
}
|
|
506
550
|
|
|
551
|
+
/**
|
|
552
|
+
* Resolve the event whose `max(occurredAt)` is the historical dwell anchor for a
|
|
553
|
+
* backfilled member (LOCKED DECISION 1 / Section 6.3) — "when they became
|
|
554
|
+
* dormant". Returns an event name only for the windowed/event shapes that expose
|
|
555
|
+
* a cheap per-matcher timestamp; `null` for everything else (the anchor stays
|
|
556
|
+
* NULL and the dwell gate falls back to `enteredAt`):
|
|
557
|
+
*
|
|
558
|
+
* - a single windowed `event` criterion → its `eventName` (the last qualifying
|
|
559
|
+
* occurrence is the window boundary, e.g. the last `app_opened`).
|
|
560
|
+
* - the lapsed-active composite `all(event(X).exists(),
|
|
561
|
+
* event(X).within(W).not_exists())` → event X (the flagship went-dormant
|
|
562
|
+
* shape; the last X is when they lapsed).
|
|
563
|
+
*
|
|
564
|
+
* Other shapes (property/count composites, OR-of-absence, multi-event) have no
|
|
565
|
+
* single cheap per-matcher timestamp, so they keep a NULL anchor.
|
|
566
|
+
*/
|
|
567
|
+
function resolveDwellAnchorEvent(criteria: ConditionEval): string | null {
|
|
568
|
+
if (criteria.type === "event") {
|
|
569
|
+
return criteria.within != null ? criteria.eventName : null;
|
|
570
|
+
}
|
|
571
|
+
// Lapsed-active composite — two legs on the SAME event X: an unwindowed
|
|
572
|
+
// exists() anchor and a windowed not_exists() leg. Mirrors
|
|
573
|
+
// isLapsedActiveComposite in bucket-reconcile.ts.
|
|
574
|
+
if (
|
|
575
|
+
criteria.type === "composite" &&
|
|
576
|
+
criteria.operator === "and" &&
|
|
577
|
+
criteria.conditions.length === 2
|
|
578
|
+
) {
|
|
579
|
+
const existsLeg = criteria.conditions.find(
|
|
580
|
+
(c) => c.type === "event" && c.check === "exists" && c.within == null,
|
|
581
|
+
);
|
|
582
|
+
const notExistsLeg = criteria.conditions.find(
|
|
583
|
+
(c) => c.type === "event" && c.check === "not_exists" && c.within != null,
|
|
584
|
+
);
|
|
585
|
+
if (
|
|
586
|
+
existsLeg?.type === "event" &&
|
|
587
|
+
notExistsLeg?.type === "event" &&
|
|
588
|
+
existsLeg.eventName === notExistsLeg.eventName
|
|
589
|
+
) {
|
|
590
|
+
return notExistsLeg.eventName;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
return null;
|
|
594
|
+
}
|
|
595
|
+
|
|
507
596
|
/**
|
|
508
597
|
* Upsert the bucket's current criteria fingerprint onto `bucket_configs` (Section
|
|
509
598
|
* 6.6 B). Mirrors the admin enable/disable onConflictDoUpdate target.
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
durationToMs,
|
|
8
8
|
evaluateCondition,
|
|
9
9
|
} from "@hogsend/core";
|
|
10
|
+
import type { JourneyMeta } from "@hogsend/core/types";
|
|
10
11
|
import {
|
|
11
12
|
bucketConfigs,
|
|
12
13
|
bucketMemberships,
|
|
@@ -27,6 +28,7 @@ import {
|
|
|
27
28
|
or,
|
|
28
29
|
sql,
|
|
29
30
|
} from "drizzle-orm";
|
|
31
|
+
import type { BucketLeaveReason } from "../buckets/bucket-reactions.js";
|
|
30
32
|
import { shouldEmitJoin } from "../buckets/check-membership.js";
|
|
31
33
|
import {
|
|
32
34
|
BUCKET_EVENT_PREFIX,
|
|
@@ -97,12 +99,21 @@ export const bucketReconcileTask = hatchet.task({
|
|
|
97
99
|
// kind:"manual" buckets are NEVER auto-recomputed (early-continue).
|
|
98
100
|
if (bucket.kind === "manual" || !bucket.criteria) continue;
|
|
99
101
|
|
|
100
|
-
// Process a bucket here iff a clock can flip its membership
|
|
101
|
-
// criteria window (criteria-driven
|
|
102
|
-
// `maxDwell` TTL (membership-age-driven
|
|
103
|
-
//
|
|
102
|
+
// Process a bucket here iff a clock can flip its membership OR fire a
|
|
103
|
+
// membership-age dwell: a TIME-BASED criteria window (criteria-driven
|
|
104
|
+
// leaves/joins), an unconditional `maxDwell` TTL (membership-age-driven
|
|
105
|
+
// leaves), OR a `dwell` reaction (membership-age-driven fire). timeBased is
|
|
106
|
+
// honoured explicitly OR inferred from a `within` window. The dwell-only
|
|
107
|
+
// bucket falls through and runs ONLY the dwell pass (the criteria pass is
|
|
108
|
+
// behind `if (timeBased)`, the TTL pass behind `if (bucket.maxDwell)`).
|
|
104
109
|
const timeBased = isTimeBased(bucket);
|
|
105
|
-
|
|
110
|
+
const dwellReactions = journeyRegistry
|
|
111
|
+
.getAll()
|
|
112
|
+
.filter(
|
|
113
|
+
(j) => j.sourceBucketId === bucket.id && j.reactionKind === "dwell",
|
|
114
|
+
);
|
|
115
|
+
const hasDwell = dwellReactions.length > 0;
|
|
116
|
+
if (!timeBased && !bucket.maxDwell && !hasDwell) continue;
|
|
106
117
|
|
|
107
118
|
try {
|
|
108
119
|
if (timeBased) {
|
|
@@ -145,6 +156,21 @@ export const bucketReconcileTask = hatchet.task({
|
|
|
145
156
|
bucket,
|
|
146
157
|
});
|
|
147
158
|
}
|
|
159
|
+
|
|
160
|
+
// Dwell pass — runs AFTER the TTL pass (ordering is load-bearing: a
|
|
161
|
+
// member force-left by maxDwell earlier this iteration is status='left'
|
|
162
|
+
// here, so the dwell scan's status='active' filter excludes it). Fires
|
|
163
|
+
// `bucket:dwell:<id>:<label>` over the continuously-dwelling active
|
|
164
|
+
// population at cron resolution (Section 6.4–6.6).
|
|
165
|
+
if (hasDwell) {
|
|
166
|
+
reconciled += await reconcileBucketDwell({
|
|
167
|
+
db,
|
|
168
|
+
logger,
|
|
169
|
+
journeyRegistry,
|
|
170
|
+
bucket,
|
|
171
|
+
dwellReactions,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
148
174
|
} catch (err) {
|
|
149
175
|
logger.error("Bucket reconcile failed", {
|
|
150
176
|
bucketId: bucket.id,
|
|
@@ -255,6 +281,8 @@ export const bucketExpiryTask = hatchet.durableTask({
|
|
|
255
281
|
userEmail: input.userEmail,
|
|
256
282
|
epoch: flipped.entryCount,
|
|
257
283
|
source: "reconcile",
|
|
284
|
+
// Fast-expiry is a criteria re-confirm leave (Section 6.7).
|
|
285
|
+
reason: "criteria",
|
|
258
286
|
});
|
|
259
287
|
|
|
260
288
|
return { status: "left", rowId: flipped.id };
|
|
@@ -287,6 +315,7 @@ async function reconcileBucketLeaves(opts: {
|
|
|
287
315
|
journeyRegistry,
|
|
288
316
|
bucket,
|
|
289
317
|
userIds: leaverIds,
|
|
318
|
+
reason: "criteria",
|
|
290
319
|
});
|
|
291
320
|
}
|
|
292
321
|
|
|
@@ -421,7 +450,14 @@ async function reconcileCompositeLeaves(opts: {
|
|
|
421
450
|
}
|
|
422
451
|
|
|
423
452
|
if (leaverIds.length === 0) return 0;
|
|
424
|
-
return bulkLeave({
|
|
453
|
+
return bulkLeave({
|
|
454
|
+
db,
|
|
455
|
+
logger,
|
|
456
|
+
journeyRegistry,
|
|
457
|
+
bucket,
|
|
458
|
+
userIds: leaverIds,
|
|
459
|
+
reason: "criteria",
|
|
460
|
+
});
|
|
425
461
|
}
|
|
426
462
|
|
|
427
463
|
/**
|
|
@@ -462,6 +498,7 @@ async function reconcileBucketTtlLeaves(opts: {
|
|
|
462
498
|
journeyRegistry,
|
|
463
499
|
bucket,
|
|
464
500
|
userIds: expired.map((r) => r.userId),
|
|
501
|
+
reason: "maxDwell",
|
|
465
502
|
});
|
|
466
503
|
}
|
|
467
504
|
|
|
@@ -478,8 +515,10 @@ async function bulkLeave(opts: {
|
|
|
478
515
|
journeyRegistry: ReturnType<typeof getJourneyRegistrySingleton>;
|
|
479
516
|
bucket: BucketMeta;
|
|
480
517
|
userIds: string[];
|
|
518
|
+
/** Why these members leave — TTL passes "maxDwell", criteria passes "criteria". */
|
|
519
|
+
reason: BucketLeaveReason;
|
|
481
520
|
}): Promise<number> {
|
|
482
|
-
const { db, logger, journeyRegistry, bucket, userIds } = opts;
|
|
521
|
+
const { db, logger, journeyRegistry, bucket, userIds, reason } = opts;
|
|
483
522
|
|
|
484
523
|
const dwellMs = bucket.minDwell ? durationToMs(bucket.minDwell) : 0;
|
|
485
524
|
const dwellCutoff = dwellMs > 0 ? new Date(Date.now() - dwellMs) : null;
|
|
@@ -524,12 +563,171 @@ async function bulkLeave(opts: {
|
|
|
524
563
|
userEmail: row.userEmail,
|
|
525
564
|
epoch: row.entryCount,
|
|
526
565
|
source: "reconcile",
|
|
566
|
+
reason,
|
|
527
567
|
});
|
|
528
568
|
}
|
|
529
569
|
|
|
530
570
|
return flipped.length;
|
|
531
571
|
}
|
|
532
572
|
|
|
573
|
+
/**
|
|
574
|
+
* Dwell pass for one bucket (Section 6.4–6.6). Fires `bucket:dwell:<id>:<label>`
|
|
575
|
+
* over the EXISTING continuously-dwelling active population at cron resolution —
|
|
576
|
+
* its unique value over `on("enter") + ctx.sleep`. Idempotent across sweeps,
|
|
577
|
+
* interoperable with maxDwell/fastExpiry, and routed through
|
|
578
|
+
* `emitBucketTransition` (NOT a raw push) for `userEvents`/exitOn/history/analytics
|
|
579
|
+
* parity (Section 6.1):
|
|
580
|
+
*
|
|
581
|
+
* - PUSH FIRST (at-least-once; the deterministic idempotencyKey + the userEvents
|
|
582
|
+
* dedup absorb a same-sweep retry), THEN stamp `dwellState` (the inter-sweep
|
|
583
|
+
* "already fired this membership" gate). The stamp's `status='active'` clause
|
|
584
|
+
* makes the leave/fastExpiry interop correct (a row flipped to `left` between
|
|
585
|
+
* SELECT and UPDATE no-ops).
|
|
586
|
+
* - The dwell clock is `coalesce(dwellAnchorAt, enteredAt)` — backfilled members
|
|
587
|
+
* use their derived historical anchor (LOCKED DECISION 1), live joins use
|
|
588
|
+
* enteredAt (anchor NULL).
|
|
589
|
+
* - Candidates are ordered `lastEvaluatedAt asc nulls first` (oldest-served-first)
|
|
590
|
+
* so a busy `every` bucket cannot starve members past BATCH_SIZE; the stamp
|
|
591
|
+
* bumps `lastEvaluatedAt`, advancing the cursor. Hitting BATCH_SIZE is logged
|
|
592
|
+
* once per sweep (visibility, not silent).
|
|
593
|
+
* - First-deploy quiet window: reuse `firstTimeBackfillIncomplete` so the
|
|
594
|
+
* pre-existing/backfilled population is not blasted before the first-time
|
|
595
|
+
* backfill has settled.
|
|
596
|
+
*
|
|
597
|
+
* `every` is fires-at-most-once-per-sweep, coalescing (one catch-up fire after a
|
|
598
|
+
* multi-interval outage); `dwellCount` is the deterministic interval ordinal
|
|
599
|
+
* `floor((sweepInstant - anchor) / offsetMs)` (gap-stable, NOT a fire count). For
|
|
600
|
+
* `after` the ordinal is always 1 (one-shot).
|
|
601
|
+
*/
|
|
602
|
+
async function reconcileBucketDwell(opts: {
|
|
603
|
+
db: Database;
|
|
604
|
+
logger: Logger;
|
|
605
|
+
journeyRegistry: ReturnType<typeof getJourneyRegistrySingleton>;
|
|
606
|
+
bucket: BucketMeta;
|
|
607
|
+
dwellReactions: JourneyMeta[];
|
|
608
|
+
}): Promise<number> {
|
|
609
|
+
const { db, logger, journeyRegistry, bucket, dwellReactions } = opts;
|
|
610
|
+
|
|
611
|
+
// First-deploy quiet window: do not blast the pre-existing/backfilled
|
|
612
|
+
// population before the first-time backfill has settled (reuse the guard).
|
|
613
|
+
if (await firstTimeBackfillIncomplete(db, bucket)) return 0;
|
|
614
|
+
|
|
615
|
+
// Captured once per invocation and reused for the ordinal. The ordinal is
|
|
616
|
+
// floor((sweepInstant - anchor) / offsetMs), so it is grid-quantized: a Hatchet
|
|
617
|
+
// retry (a fresh fn() invocation, seconds–minutes later) lands in the SAME
|
|
618
|
+
// interval window and recomputes the SAME ordinal → SAME idempotencyKey →
|
|
619
|
+
// absorbed by the userEvents dedup. `after` is always ordinal 1 (fully stable).
|
|
620
|
+
// Residual edge: an `every` retry that straddles an interval boundary yields a
|
|
621
|
+
// new ordinal and thus one extra dwell fire — bounded to a single duplicate by
|
|
622
|
+
// the key, and only possible for sub-retry-window intervals. Documented, not
|
|
623
|
+
// load-bearing for the common (hours/days) intervals.
|
|
624
|
+
const sweepInstant = Date.now();
|
|
625
|
+
let fired = 0;
|
|
626
|
+
|
|
627
|
+
for (const reaction of dwellReactions) {
|
|
628
|
+
const schedule = reaction.dwellSchedule;
|
|
629
|
+
if (!schedule) continue;
|
|
630
|
+
const { label, after, every } = schedule;
|
|
631
|
+
const offsetMs = after ?? every;
|
|
632
|
+
if (offsetMs == null) continue;
|
|
633
|
+
const cutoff = new Date(sweepInstant - offsetMs);
|
|
634
|
+
|
|
635
|
+
// Continuous-member gate. coalesce(dwellAnchorAt, enteredAt) is the dwell
|
|
636
|
+
// clock. Oldest-served-first (Section 6.5).
|
|
637
|
+
const candidates = await db
|
|
638
|
+
.select({
|
|
639
|
+
id: bucketMemberships.id,
|
|
640
|
+
userId: bucketMemberships.userId,
|
|
641
|
+
userEmail: bucketMemberships.userEmail,
|
|
642
|
+
entryCount: bucketMemberships.entryCount,
|
|
643
|
+
anchor: sql<Date>`coalesce(${bucketMemberships.dwellAnchorAt}, ${bucketMemberships.enteredAt})`,
|
|
644
|
+
dwellState: bucketMemberships.dwellState,
|
|
645
|
+
})
|
|
646
|
+
.from(bucketMemberships)
|
|
647
|
+
.innerJoin(contacts, eq(contacts.externalId, bucketMemberships.userId))
|
|
648
|
+
.where(
|
|
649
|
+
and(
|
|
650
|
+
eq(bucketMemberships.bucketId, bucket.id),
|
|
651
|
+
eq(bucketMemberships.status, "active"),
|
|
652
|
+
isNull(bucketMemberships.deletedAt),
|
|
653
|
+
isNull(contacts.deletedAt),
|
|
654
|
+
// Fold the comparison into the fragment with an explicit cast: a JS
|
|
655
|
+
// Date passed to lte() against a raw sql`coalesce(...)` fragment has no
|
|
656
|
+
// column type to drive param encoding, so the pg driver throws on the
|
|
657
|
+
// Date (and the per-bucket try/catch would silently swallow it → 0
|
|
658
|
+
// dwell fires). Binding the ISO string + ::timestamptz is well-typed.
|
|
659
|
+
sql`coalesce(${bucketMemberships.dwellAnchorAt}, ${bucketMemberships.enteredAt}) <= ${cutoff.toISOString()}::timestamptz`,
|
|
660
|
+
),
|
|
661
|
+
)
|
|
662
|
+
.orderBy(sql`${bucketMemberships.lastEvaluatedAt} asc nulls first`)
|
|
663
|
+
.limit(BATCH_SIZE);
|
|
664
|
+
|
|
665
|
+
if (candidates.length >= BATCH_SIZE) {
|
|
666
|
+
logger.warn("Bucket dwell pass bounded to BATCH_SIZE/tick", {
|
|
667
|
+
bucketId: bucket.id,
|
|
668
|
+
label,
|
|
669
|
+
batchSize: BATCH_SIZE,
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
for (const m of candidates) {
|
|
674
|
+
const state = (m.dwellState ?? {}) as Record<string, string>;
|
|
675
|
+
const lastFired = state[label] ? Date.parse(state[label]) : null;
|
|
676
|
+
const anchorMs = new Date(m.anchor).getTime();
|
|
677
|
+
|
|
678
|
+
if (after != null) {
|
|
679
|
+
// one-shot: already fired for this membership → skip.
|
|
680
|
+
if (lastFired != null) continue;
|
|
681
|
+
} else {
|
|
682
|
+
// every: not yet due since the last fire (or the anchor).
|
|
683
|
+
const since = lastFired ?? anchorMs;
|
|
684
|
+
if (sweepInstant - since < offsetMs) continue;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// Deterministic per (membership, sweepInstant) so a retry recomputes it.
|
|
688
|
+
const ordinal =
|
|
689
|
+
after != null ? 1 : Math.floor((sweepInstant - anchorMs) / offsetMs);
|
|
690
|
+
|
|
691
|
+
// PUSH FIRST (at-least-once; idempotencyKey + userEvents dedup absorb
|
|
692
|
+
// retries), THEN stamp. emitBucketTransition handles the
|
|
693
|
+
// userEvents/exitOn/analytics parity.
|
|
694
|
+
await emitBucketTransition({
|
|
695
|
+
db,
|
|
696
|
+
registry: journeyRegistry,
|
|
697
|
+
hatchet,
|
|
698
|
+
logger,
|
|
699
|
+
kind: "dwell",
|
|
700
|
+
bucket,
|
|
701
|
+
userId: m.userId,
|
|
702
|
+
userEmail: m.userEmail,
|
|
703
|
+
epoch: m.entryCount,
|
|
704
|
+
source: "reconcile",
|
|
705
|
+
dwellLabel: label,
|
|
706
|
+
dwellOrdinal: ordinal,
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
// Stamp the membership (inter-sweep gate). status='active' clause = leave
|
|
710
|
+
// interop (a row flipped to 'left' between SELECT and UPDATE no-ops).
|
|
711
|
+
await db
|
|
712
|
+
.update(bucketMemberships)
|
|
713
|
+
.set({
|
|
714
|
+
dwellState: sql`jsonb_set(coalesce(${bucketMemberships.dwellState}, '{}'::jsonb), ${`{${label}}`}, ${`"${new Date(sweepInstant).toISOString()}"`}::jsonb)`,
|
|
715
|
+
lastEvaluatedAt: new Date(),
|
|
716
|
+
updatedAt: new Date(),
|
|
717
|
+
})
|
|
718
|
+
.where(
|
|
719
|
+
and(
|
|
720
|
+
eq(bucketMemberships.id, m.id),
|
|
721
|
+
eq(bucketMemberships.status, "active"),
|
|
722
|
+
),
|
|
723
|
+
);
|
|
724
|
+
fired += 1;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
return fired;
|
|
729
|
+
}
|
|
730
|
+
|
|
533
731
|
/**
|
|
534
732
|
* reconcileJoins (absence buckets): materialize NEW members the real-time path
|
|
535
733
|
* cannot see — a user who STOPS doing X fires no event, so only the clock can
|
|
@@ -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
|
});
|