@hogsend/engine 0.5.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/package.json +6 -6
- 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.ts +81 -0
- package/src/container.ts +37 -5
- package/src/index.ts +14 -0
- package/src/lib/boot.ts +11 -1
- package/src/lib/bucket-emit.ts +47 -5
- package/src/routes/admin/buckets.ts +39 -9
- package/src/worker.ts +19 -2
- package/src/workflows/bucket-backfill.ts +90 -1
- package/src/workflows/bucket-reconcile.ts +205 -7
|
@@ -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
|