Repository navigation
Conversation
…il route A code send posts its accepted content to transactional_email/send. The route runs the same reserve, replay, refusal and enqueue steps as a template send. The accepted send binds the idempotency fingerprint, and a miss on a new workflow reads it from the primary. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A keyed retry of a code send replays after the project's default sender changed, and after Django resolved a named address to another sender. The key leaves out a default sender (`from.isDefault`) and keys a named address on the address and name the caller sent, not on the integration Django picked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An accepted send can carry hogFlowVersion, the version of the template's workflow Django graded the hash against. When the cached workflow is older, cdp-api reads it from the primary. When the primary is older too, it answers 503 workflow_version_behind and queues nothing. The version stays out of the idempotency fingerprint, so a retry graded after an upload still replays. Without the field, the send behaves as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The hogflow worker reads the job's workflow from the primary when its cached copy is older than the version the accepted send carries, and fails the job when the primary is older too, so the worker never sends older content than Django graded. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Concurrent sends that found an older cached workflow each read the primary, and the fresh copy was never cached, so the next send read the replica and the primary again. Primary reads are now shared per workflow and the result goes into the cache. A later replica load keeps a newer cached version. A send that joins a read which cannot meet its graded version reads the primary once more. The worker logs a version-behind job with both versions and counts it on cdp_hogflow_worker_jobs_failed_total, and the cdp-api refusal carries code workflow_version_behind. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The workflow_version_behind sub-code stays in logs, the outcome label and the cdp-api-to-Django body. Django maps it onto its existing public 503. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A queued send whose workflow is older than its graded version fails its job in the worker. It now also records `failed` with `workflow_unavailable` on the send's email status, as a run whose workflow is missing does, so the status API stops answering `queued` for an email that never goes out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…flow Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mplate-version-on-send # Conflicts: # nodejs/src/cdp/services/hogflows/hogflow-manager.service.test.ts # nodejs/src/cdp/services/hogflows/hogflow-manager.service.ts
…ry read for them Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
🤖 CI report✅ Trunk lane — non-backend laneThis PR is assigned to the non-backend lane. It does not run backend Python tests and may merge in parallel with PRs in other lanes. ✅ Duplication (Python) — cleanNew Python code duplication introduced by this branch. Fails at 70+ tokens in app code, or 150+ tokens when both copies live in test files. Advisory while the gate proves itself: extract a shared helper instead of copying. ✅ Duplication (TypeScript) — cleanNew TypeScript code duplication introduced by this branch. Fails at 70+ tokens in app code, or 150+ tokens when both copies live in test files. Advisory while the gate proves itself: extract a shared helper instead of copying. ❌ Hogbox preview — build failedThe preview didn't come up for commit Previews are optional and never block merging. A failure here is often a hogland or tailnet hiccup rather than anything in your PR, so the check stays green and this section is the status. |
There was a problem hiding this comment.
PostHog Review · Standard · reviewed 5d66b71: 1 Should fix.
This is the only automatic review while this pull request is a draft. Comment @posthog review to review new changes sooner. Otherwise, a review of the new changes runs when you mark it ready for review.
See this review and its findings in PostHog Review. Comment @posthog review --deep for a Deep review. A Deep review takes longer and looks deeper than a Standard review.
| if (predatesGradedVersion(hogFlow, send)) { | ||
| logger.error('⚠️', 'Hog flow is older than the version its send was graded at', { | ||
| id: item.functionId, | ||
| version: hogFlow.version, | ||
| gradedVersion: send?.hogFlowVersion, | ||
| }) | ||
| counterHogFlowJobsFailed.labels({ reason: 'workflow_version_behind' }).inc() |
There was a problem hiding this comment.
P2 · Record a terminal invocation failure before dequeuing
When the primary workflow is behind send.hogFlowVersion, this branch removes the job without producing a terminal invocation lifecycle row. queueUnavailableRun updates only email status, leaving the API's existing running lifecycle row unchanged and the run permanently unavailable for rerun. Durably record the terminal failure before dequeuing, as loadHogFunctions does.
…d run The worker dropped such a job without a result, so the run that the send API had opened stayed running and could not be rerun. It now returns a failed result through the normal result pipeline, which closes the run and records the email status from the run's error. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sion The previous commit returned a failed result for such a send. That closed the run, but it reported the email as hog_error. It now writes the run's terminal row before the job is dropped, the way the hog function worker does for a disabled function: the row carries the error kind workflow_version_behind, and the email status keeps workflow_unavailable. A job whose row cannot be written stays queued for a later retry. The test drives the whole batch and reads the rows that it produces. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The previous commit wrote only the run's terminal row, so the run lost its failed metric and log, and the email worker never counted the send as triggered. The worker again returns a failed result, the way it returns a canceled one, so every result sink sees the run. The email status follows the run's error kind, as for any failed run. The test moves to the email worker, which handles transactional sends, and drives the whole batch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Returning the failed result alone fails the job before its lifecycle row is published, so a broker outage or a crash between the two still leaves the run showing running. The worker now writes the terminal row durably first, as the hog function worker does for a job it drops, and returns the failed result only after that write. When the write fails, the job stays queued for a later retry and no result goes out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The failed result now carries its error kind, so the lifecycle row and the email status that the result pipeline writes keep workflow_version_behind instead of reclassifying the message as hog_error. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ode-send Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…otency-cdp Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mplate-version-on-send Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Merging to
After your PR is submitted to the merge queue, this comment will be automatically updated with its status. If the PR fails, failure details will also be posted here |
Master now holds the merged code send layers this branch was stacked on, so the merge keeps master's code and re-applies only this layer's change: an accepted send carries the workflow version Django graded, and a run behind that version reads the primary or fails as workflow_version_behind. The worker keeps master's batched primary read and reads the primary again only for a send graded at a newer version than the batch found. A version-gated primary read keeps its copy in the cache, and the cache rejects an older replica copy of it. Other primary reads keep master's mark-for-refresh behaviour. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Moved from Silthus#584. Part of the code-first rebuild of the email send stack; new in the rebuild, with no closed PR to replace: a template send delivers the content Django graded (decision D3 on map Silthus/posthog#461).
Problem
send/email/right after an upload can deliver the previous content, while Django answered that the caller'stemplate.hashis current.workflows_emailsactions per paying org: a developer who uploads a template and sends it at once gets the new content, so the first send from code is right. In scope: CDP email sends.Changes
Warning
🔴 Merge risk: high · two-way door: a revert sends from the cached copy again, as today
state.send.hogFlowVersionis set, which only Django from feat(messaging): send the workflow version a template send by key was checked against #127188 sets, behindworkflows-email-from-code.hogFlowVersion. When the cached copy is older, cdp-api and the hogflow worker read the workflow from the primary.503 delivery_unavailable, and puts the version lag indetailandwhy(feat(messaging): send the workflow version a template send by key was checked against #127188).workflow_version_behindappears only in the body cdp-api sends to Django, in the metric label and in the log. No public answer carries it.failedlifecycle row durably, with error kindworkflow_version_behind, as the hog function worker does for a job it drops. Then the failed result goes through the normal pipeline: the failed metric, the run log, the job failure, and afailedemail status. Every row keeps theworkflow_version_behindkind, so the run can be rerun by that kind. When the durable write fails, the job stays queued for a retry. Versions only go up, so this state should not happen.reason: 'workflow_unavailable', so the counter covers every job the worker fails before running it.version. An upload that lands between the grade and the send delivers the newer content. It is never older.43333f8. It now builds on feat(cdp): deliver a code send's content on its own transactional email route #127122 and feat(cdp): key a code send on the sender the caller chose #127129 only. Master's fix(cdp): answer transactional email sends without waiting on kafka #125444 (answer before the Kafka flush) and fix(workflows): drop suppressed cc/bcc instead of skipping the email #118456 (email status in the worker) are under it.How did you test this code?
Test seams: cdp-api's accepted-send route over HTTP (
cdp-api.serial.test.ts),processInvocationson the hogflow worker,processBatchon the email worker with the produced rows, and the hogflow manager service for the read count.Test rationale, each run red first:
running, loses its metrics or email status, or gets a later row that replacesworkflow_version_behindwithhog_error.{error}shape of cdp-api's other 503s.Proofs at
2b99456, all saved withfleet proof:64d8940, move only outcomes named by the goal: the version floor in cdp-api and the worker, the refusal when the database is older, the failed run in the worker, the graded version on the queued run, the shared primary reads, and the new failed-jobs count. No test that passes on base fails on HEAD.5d66b71still applies. The later commits change only how the worker closes a version-behind job, and read nothing new from storage.nodejs/. They come from master changes that the stacked base branch does not have yet.Proofs at the pre-rebase heads
2bdc74bandae46ab4Live runs saved with
fleet proof, curl into maildev. A lost reload is simulated in SQL: the workflow's version and subject change with no reload published.2bdc74b, Django Silthus/posthog#585 (now #127188)4d76e98)replayed: true, one email in totalae46ab4, differs from2bdc74bonly by the 503 body. No case above reaches that 503.Release status
Docs update
🤖 Agent context
Autonomy: Human-driven (agent-assisted)
Agent: Claude Code, Claude Opus 5.5 (claude-opus-5-5)
srv2of map Map: send code-managed email templates with one PostHog call Silthus/posthog#461, for decision D3.node3on 2026-10-31.tdd,writing-tests,stacking-prs,modernize-pr,pr-description,writing-pr-descriptions.delivery_unavailablewith the version lag indetailandwhy; replaces a new publicworkflow_version_behindcodegetHogFlowby name, so a rename fails loudly, and the public warm-up is a follow-upworkflow_version_behindlifecycle row first, then the failed result through the normal pipeline; replaces recording only the email statusInvolvement: one decision from Michael and one from the map's conductor; review round 1 (GPT-6.1 Sol, Opus fresh eyes) fixed, then final reads by GPT-6.1 Sol; after the restack, one GPT-6.1 Sol review (one status finding, fixed in
f9abed0) and a final read; a full two-family panel on 2026-11-01 (8 lanes: 1 fixed, 1 rejected, shadow diff owed and run) and a narrow round (fresh eyes: description fixes); after the move, a GPT-6.1 Sol correctness read of the whole PR at5d66b71, clean; a further review round on the worker's lifecycle row, fixed, and GPT-6.1 Sol final reads until one was clean at2b99456; live, cross-version and shadow-diff proof at2b99456; pre-rebase live and cross-version proof kept above.🤖 Generated with Claude Code