Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
969011f
feat(cdp): deliver a code send's content on its own transactional ema…
Silthus Oct 29, 2026
920e7c9
fix(cdp): read the primary once for concurrent jobs of a missing work…
Silthus Nov 1, 2026
4c7375d
Merge remote-tracking branch 'upstream/master' into email-v2/01-cdp-c…
Silthus Nov 2, 2026
b5889c9
Merge remote-tracking branch 'upstream/master' into email-v2/01-cdp-c…
Silthus Nov 2, 2026
27bfa23
fix(cdp): mint no distinct_id in tracking codes of a capture-off send
Silthus Nov 2, 2026
b9b0865
test(cdp): cover the capture choice in dev/test pixel and redirect codes
Silthus Nov 3, 2026
28d56e7
refactor(cdp): drop the dormant v3 tracking-code format
Silthus Nov 3, 2026
e7fc40c
fix(cdp): cache and batch confirmed primary misses
Silthus Nov 3, 2026
b7e2f40
fix(cdp): cache primary hits during replica lag
Silthus Nov 3, 2026
4255bac
fix(cdp): keep no primary result from a read a reload overtook
Silthus Nov 3, 2026
9a9eb8d
fix(cdp): resolve email feedback for a workflow only the primary has
Silthus Nov 3, 2026
4f0d3d7
fix(cdp): keep workflows found when a feedback lookup fails
Silthus Nov 3, 2026
ac6e18c
Merge remote-tracking branch 'origin/master' into rhs3-cdp-code-send-fix
Silthus Nov 3, 2026
abbf035
fix(cdp): count a failed primary lookup for email feedback
Silthus Nov 3, 2026
e2fd437
chore: merge master into email-v2/01-cdp-code-send
Silthus Nov 3, 2026
fc855c3
fix(cdp): refuse a code send field this version does not know
Silthus Nov 3, 2026
34347b2
fix(cdp): refuse unknown fields inside a code send's content, sender …
Silthus Nov 3, 2026
474a766
chore(cdp): drop the comment on the strict send schema
Silthus Nov 3, 2026
272024a
fix(cdp): accept only a code-send token on the code send route
Silthus Nov 3, 2026
8be4b84
Merge remote-tracking branch 'origin/master' into email-v2/01-cdp-cod…
Silthus Nov 3, 2026
ddea17d
refactor(cdp): lay a code send over the email step in the send hand-off
Silthus Nov 3, 2026
1be8e50
fix(cdp): replay a code send on rerun only when the request allows it
Silthus Nov 3, 2026
b5c9e59
refactor(cdp): read the capture choice in one place
Silthus Nov 3, 2026
2eac306
fix(cdp): keep a code send's choices when a step resumes from saved s…
Silthus Nov 3, 2026
0aa7884
fix(cdp): accept every dot-atom recipient the email pipeline can send to
Silthus Nov 3, 2026
3639e5d
fix(cdp): keep dots between the characters of a recipient's local part
Silthus Nov 3, 2026
ccdd9ff
fix(cdp): tell the user why a sensitive run was not re-run
Silthus Nov 3, 2026
75f7a34
Merge remote-tracking branch 'origin/master' into HEAD
Silthus Nov 3, 2026
20fb623
fix(cdp): call a skipped sensitive run's content a message, not an email
Silthus Nov 3, 2026
eb26545
chore(cdp): send the executor's email routing tests through a mocked SES
Silthus Nov 3, 2026
6f4136c
fix(cdp): refuse a code send token on the template send route
Silthus Nov 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion nodejs/src/cdp/_tests/fixtures-hogflows.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { HogFlow } from '~/cdp/schema/hogflow'
import { PostgresRouter } from '~/common/utils/db/postgres'
import { PostgresRouter, PostgresUse } from '~/common/utils/db/postgres'
import { UUIDT } from '~/common/utils/utils'
import { insertRow } from '~/tests/helpers/sql'

Expand All @@ -20,6 +20,20 @@ export const insertHogFlow = async (postgres: PostgresRouter, hogFlow: HogFlow):
return res
}

export const hideHogFlowsFromReplica = (
postgres: PostgresRouter,
ids: HogFlow['id'][]
): jest.SpyInstance<ReturnType<PostgresRouter['query']>, Parameters<PostgresRouter['query']>> => {
const query = postgres.query.bind(postgres)
return jest.spyOn(postgres, 'query').mockImplementation(async (target, queryString, values, tag, logLevel) => {
const result = await query(target, queryString, values, tag, logLevel)
if (target === PostgresUse.COMMON_READ && tag === 'fetchHogFlows') {
return { ...result, rows: result.rows.filter((row) => !ids.includes(row.id)) }
}
return result
})
}

export const createHogFlowInvocationContext = (
data: Partial<HogFlowInvocationContext> = {}
): HogFlowInvocationContext => {
Expand Down
4 changes: 3 additions & 1 deletion nodejs/src/cdp/async-functions/send-email.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { DateTime } from 'luxon'

import { CyclotronInvocationQueueParametersEmailSchema } from '~/cdp/schema/cyclotron'
import { acceptedSendEmailFields } from '~/cdp/services/hogflows/transactional-email'

import { registerAsyncFunction } from '../async-function-registry'

registerAsyncFunction('sendEmail', {
execute: (args, _context, result) => {
execute: (args, context, result) => {
result.invocation.queueParameters = CyclotronInvocationQueueParametersEmailSchema.parse({
...args[0],
...acceptedSendEmailFields(context.invocation.state.send),
type: 'email',
})
},
Expand Down
382 changes: 300 additions & 82 deletions nodejs/src/cdp/cdp-api.serial.test.ts

Large diffs are not rendered by default.

327 changes: 187 additions & 140 deletions nodejs/src/cdp/cdp-api.ts

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ export class CdpCyclotronWorkerHogFlow extends CdpCyclotronWorker {
const loadedInvocations: CyclotronJobInvocationHogFlow[] = []
const failedInvocations: CyclotronJobInvocation[] = []
const canceledResults: CyclotronJobInvocationResult[] = []
const hogFlows = await this.hogFlowManager.getHogFlowsCheckingPrimaryOnMiss(
invocations.filter((item) => !item.cancelRequestedAt).map((item) => item.functionId)
)
Comment thread
Silthus marked this conversation as resolved.
Resolved

await Promise.all(
invocations.map(async (item) => {
Expand All @@ -103,7 +106,7 @@ export class CdpCyclotronWorkerHogFlow extends CdpCyclotronWorker {
}

const team = await this.deps.teamManager.getTeam(item.teamId)
const hogFlow = await this.hogFlowManager.getHogFlow(item.functionId)
const hogFlow = hogFlows[item.functionId]
if (!hogFlow || !team) {
logger.error('⚠️', 'Error finding hog flow', {
id: item.functionId,
Expand Down
34 changes: 31 additions & 3 deletions nodejs/src/cdp/consumers/cdp-cyclotron-worker-hogflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { createTeam, createTestTeamFixture, getTeam } from '~/tests/helpers/sql'

import { Hub, InternalPerson, Team } from '../../types'
import { FixtureHogFlowBuilder } from '../_tests/builders/hogflow.builder'
import { createHogFlowInvocationContext, insertHogFlow } from '../_tests/fixtures-hogflows'
import { createHogFlowInvocationContext, hideHogFlowsFromReplica, insertHogFlow } from '../_tests/fixtures-hogflows'
import {
CyclotronJobInvocation,
CyclotronJobInvocationHogFlow,
Expand Down Expand Up @@ -88,6 +88,7 @@ describe('CdpCyclotronWorkerHogFlow', () => {
let team: Team
let team2: Team
let hogFlows: HogFlow[]
let jobQueue: ReturnType<typeof createMockJobQueue>

const createSerializedHogFlowInvocation = (
hogFlow: HogFlow,
Expand Down Expand Up @@ -160,11 +161,11 @@ describe('CdpCyclotronWorkerHogFlow', () => {
]

const mockRepo = createMockPersonReadRepository(testPersons)
const mockJobQueue = createMockJobQueue()
jobQueue = createMockJobQueue()
processor = new CdpCyclotronWorkerHogFlow(
hub,
{ ...createCdpConsumerDeps(hub), personRepository: mockRepo },
mockJobQueue
jobQueue
)

hogFlows = []
Expand Down Expand Up @@ -587,6 +588,33 @@ describe('CdpCyclotronWorkerHogFlow', () => {
})
})

it('runs a flow the replica does not have yet, and fails one missing from both', async () => {
const [lateFlow, missingFlow] = ['Flow only the primary has', 'Flow neither database has'].map((name) =>
new FixtureHogFlowBuilder()
.withName(name)
.withTeamId(team.id)
.withStatus('active')
.withSimpleWorkflow()
.build()
)
await insertHogFlow(hub.postgres, lateFlow)
hideHogFlowsFromReplica(hub.postgres, [lateFlow.id])
const [lateInvocation, missingInvocation] = [lateFlow, missingFlow].map((flow) =>
createSerializedHogFlowInvocation(flow, {
event: { distinct_id: 'distinct_A_1', properties: {} } as any,
})
)

const results = await processor.processInvocations([lateInvocation, missingInvocation])

expect(results).toHaveLength(1)
expect(results[0]).toMatchObject({
finished: true,
invocation: { hogFlow: { id: lateFlow.id } },
})
expect(jobQueue.dequeueInvocations).toHaveBeenLastCalledWith([missingInvocation])
})

it('terminates invocations as canceled when the workflow is disabled after being queued', async () => {
const hogFlow = hogFlows[0]

Expand Down
3 changes: 3 additions & 0 deletions nodejs/src/cdp/rerun/rerun-job.manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,11 @@ describe('RerunJobManager', () => {

const jobId = await manager.enqueue(7, 'hog_function', functionId, {
filter: { ...baseFilter, invocation_ids: ids },
replay_code_sends: true,
})

const state = fetchState(await queryJob(jobId))
expect(state.request.replay_code_sends).toBe(true)
expect(state.function_kind).toBe('hog_function')
expect(state.function_id).toBe(functionId)
expect(state.request.filter.invocation_ids).toEqual(ids)
Expand Down Expand Up @@ -118,6 +120,7 @@ describe('RerunJobManager', () => {
expect(state.function_kind).toBe('hog_flow')
expect(state.request.filter).toMatchObject(filter)
expect(state.request.filter.invocation_ids).toBeUndefined()
expect(state.request.replay_code_sends).toBe(false)
expect(state.progress.cursor).toBeUndefined()
expect(state.progress.done).toBe(false)
})
Expand Down
2 changes: 1 addition & 1 deletion nodejs/src/cdp/rerun/rerun-job.manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export class RerunJobManager {
const state: RerunJobState = {
function_kind: functionKind,
function_id: functionId,
request: { filter },
request: { filter, replay_code_sends: request.replay_code_sends === true },
progress: {
queued: 0,
skipped: 0,
Expand Down
1 change: 1 addition & 0 deletions nodejs/src/cdp/rerun/rerun-job.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export interface RerunFilter {

export interface RerunRequest {
filter: RerunFilter
replay_code_sends?: boolean
}

/**
Expand Down
86 changes: 79 additions & 7 deletions nodejs/src/cdp/rerun/rerun-paginator.replay-fidelity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,11 @@ describe('RerunPaginatorService replay fidelity (hog_flow)', () => {

function buildPaginator(
clickhouse: ClickHouseClient,
hogFlowQueue: jest.Mocked<CyclotronJobQueuePostgresV2>
hogFlowQueue: jest.Mocked<CyclotronJobQueuePostgresV2>,
monitoring = {
queueLogs: jest.fn(),
flush: jest.fn().mockResolvedValue(undefined),
} as unknown as jest.Mocked<HogFunctionMonitoringService>
): RerunPaginatorService {
const hogFlowManager = {
getHogFlow: jest.fn().mockResolvedValue({ id: functionId, team_id: teamId, variables: [] }),
Expand All @@ -53,11 +57,6 @@ describe('RerunPaginatorService replay fidelity (hog_flow)', () => {
flush: jest.fn().mockResolvedValue(undefined),
dropQueuedRowsFor: jest.fn(),
} as unknown as jest.Mocked<HogInvocationResultsService>
const monitoring = {
queueLogs: jest.fn(),
flush: jest.fn().mockResolvedValue(undefined),
} as unknown as jest.Mocked<HogFunctionMonitoringService>

return new RerunPaginatorService(
clickhouse,
hogFunctionManager,
Expand Down Expand Up @@ -99,6 +98,12 @@ describe('RerunPaginatorService replay fidelity (hog_flow)', () => {
},
personId: 'person-1',
messageOptOuts: [{ identifier: 'opted-out@example.com', categoryId: '$all' }],
send: {
content: { subject: 'Welcome', html: '<p>Hi</p>', text: 'Hi' },
from: { integrationId: 12, email: 'security@example.com', name: 'Example' },
replyTo: ['help@example.com'],
tracking: { captureEngagement: false },
},
}
const rows = [
{
Expand All @@ -117,7 +122,11 @@ describe('RerunPaginatorService replay fidelity (hog_flow)', () => {
} as unknown as jest.Mocked<CyclotronJobQueuePostgresV2>

const paginator = buildPaginator(fakeClickhouse(rows), hogFlowQueue)
await paginator.processPage(teamId, state, { jobId: 'rerun-1', createdAt: DateTime.now() })
await paginator.processPage(
teamId,
{ ...state, request: { ...state.request, replay_code_sends: true } },
{ jobId: 'rerun-1', createdAt: DateTime.now() }
)

expect(hogFlowQueue.queueInvocations).toHaveBeenCalledTimes(1)
const [enqueued, opts] = hogFlowQueue.queueInvocations.mock.calls[0]
Expand All @@ -133,10 +142,73 @@ describe('RerunPaginatorService replay fidelity (hog_flow)', () => {
expect(invocation.state?.actionStepCount).toBe(3)
expect(invocation.state?.customerTaskIdempotencyVersion).toBe(version)
expect(invocation.state?.variables).toEqual({ ticket_id: '123' })
expect(invocation.state?.send).toEqual(persistedState.send)
// Sticky rerun counter still increments so max_attempts guards apply.
expect(invocation.state?.rerunAttempts).toBe(1)
})

const codeSend = {
content: { subject: 'Welcome', html: '<p>Hi</p>', text: 'Hi' },
from: { integrationId: 12, email: 'security@example.com', name: 'Example' },
tracking: { captureEngagement: false },
}

it.each([
['an email sent from code while replay_code_sends is undefined', { send: codeSend }, undefined, []],
['an email sent from code while replay_code_sends is false', { send: codeSend }, false, []],
[
'a sensitive message whose content was not stored',
{ sensitive_variables_omitted: true },
true,
[
expect.objectContaining({
instance_id: 'inv-3',
level: 'warn',
message:
"Not re-run: this run's message is sensitive, so its content wasn't stored and can't be sent again.",
}),
],
],
] as const)('skips a run with %s', async (_name, stored, allowed, expectedLogs) => {
const persistedState = {
event: { uuid: 'evt-3', distinct_id: 'd-3', properties: {}, timestamp: '2026-06-01T09:00:00Z' },
actionStepCount: 1,
variables: {},
...stored,
}
const rows = [
{
invocation_id: 'inv-3',
parent_run_id: '',
attempts: 0,
last_scheduled_at: '2026-06-01 09:00:00.000000',
first_scheduled_at: '2026-06-01 09:00:00.000000',
invocation_globals: JSON.stringify(persistedState),
},
]

const hogFlowQueue = {
queueInvocations: jest.fn().mockResolvedValue(undefined),
} as unknown as jest.Mocked<CyclotronJobQueuePostgresV2>

const monitoring = {
queueLogs: jest.fn(),
flush: jest.fn().mockResolvedValue(undefined),
} as unknown as jest.Mocked<HogFunctionMonitoringService>
const paginator = buildPaginator(fakeClickhouse(rows), hogFlowQueue, monitoring)
await paginator.processPage(
teamId,
{ ...state, request: { ...state.request, replay_code_sends: allowed } },
{ jobId: 'rerun-3', createdAt: DateTime.now() }
)

expect(hogFlowQueue.queueInvocations.mock.calls.flatMap(([invocations]) => invocations)).toEqual([])
const runLogs = monitoring.queueLogs.mock.calls
.flatMap(([entries]) => entries)
.filter((entry) => entry.instance_id === 'inv-3')
expect(runLogs).toEqual(expectedLogs)
})

it('leaves currentAction undefined for a flow that never progressed (fresh start is correct there)', async () => {
const persistedState = {
event: { uuid: 'evt-2', distinct_id: 'd-2', properties: {}, timestamp: '2026-06-01T09:00:00Z' },
Expand Down
30 changes: 28 additions & 2 deletions nodejs/src/cdp/rerun/rerun-paginator.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -641,7 +641,8 @@ export class RerunPaginatorService {
teamId,
state.function_kind,
state.function_id,
row
row,
state.request.replay_code_sends === true
)
if (!invocation) {
counterRerunInvocationsSkipped.labels(state.function_kind, 'rehydrate_failed').inc()
Expand Down Expand Up @@ -682,7 +683,8 @@ export class RerunPaginatorService {
teamId: number,
functionKind: RerunFunctionKind,
functionId: string,
row: InvocationRow
row: InvocationRow,
replayCodeSends: boolean
): Promise<CyclotronJobInvocation | null> {
let parsedGlobals: unknown
try {
Expand Down Expand Up @@ -774,6 +776,29 @@ export class RerunPaginatorService {
teamId,
invocation_id: row.invocation_id,
})
this.monitoringService.queueLogs(
[
{
team_id: teamId,
log_source: 'hog_flow',
log_source_id: functionId,
instance_id: row.invocation_id,
timestamp: DateTime.now(),
level: 'warn',
message:
"Not re-run: this run's message is sensitive, so its content wasn't stored and can't be sent again.",
},
],
'hog_flow'
)
return null
}
if (persistedState.send && !replayCodeSends) {
logger.warn('⚠️', 'Skipping rerun of an email sent from code while code sends are off', {
functionId,
teamId,
invocation_id: row.invocation_id,
})
return null
}
const eventForFilter = persistedState.event ?? {}
Expand Down Expand Up @@ -839,6 +864,7 @@ export class RerunPaginatorService {
personId: persistedState.personId,
// Later message steps check these before the per-worker preferences cache, which can predate them.
messageOptOuts: persistedState.messageOptOuts,
send: persistedState.send,
// Sticky rerun counter β€” mirror the hog function path so the
// lifecycle row producer can derive `attempts` / `is_retry`
// for flows too, and the `max_attempts` guard actually trips.
Expand Down
Loading
Loading