Repository navigation
fix(engine): stop routing traffic to switched-off offers - #10
Merged
Merged
Conversation
Turning an offer off in the admin UI did not stop it receiving traffic:
ClickHandler picked from flow.target_offers and resolved the winner with
findById(), and neither looked at is_active. A malformed offer_id in the
JSONB also reached the uuid column and aborted the request with 22P02
instead of being skipped.
Fixed by verifying the winner rather than pre-filtering the candidates.
OfferPicker's sticky mode is hash(visitor) % sum(weights), so narrowing
the list before the pick changes the modulus and moves every visitor,
not just the ones on the offer that was switched off — measured at ~50%
churn for three candidates of equal weight. Picking on the full list and
checking only the winner keeps existing assignments intact, keeps the
normal path at one primary-key read, and re-picks among active
candidates only for the visitors who actually lost their offer.
A flow that matched but has nothing routable now falls through to the
campaign trash mode, the same exit the no-flow-matched branch already
takes, so the operator's configured fallback fires instead of the schema
answering 204 into the void. A flow carrying a static schema-config url
keeps using it.
Offer ids are normalised to lowercase where they are accepted.
PostgreSQL compares uuids case-insensitively but returns them
canonically lowercased, so a mixed-case reference would otherwise fail
to match the row it had just loaded.
{offer:X} references in trash_url resolve to active offers only, on both
the id and the name branch.
Closes #9. Supersedes #7, whose reproduction and most of whose tests are
kept here.
Co-Authored-By: Alex <work.alexeyklimenko@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VwWdv3Mwhzae634qnfj14Z
This was referenced Aug 23, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Supersedes #7 and closes #9, keeping @klimenkoalex's reproduction and most of their tests.
The bug
ClickHandlerpicked fromflow.target_offersand resolved the winner withfindById(). Neither looked atis_active, so switching an offer off in the admin UI did not stop it receiving traffic. A malformedoffer_idin the JSONB also reached theuuidcolumn and aborted the request with22P02rather than being skipped.The fix: verify the winner, don't pre-filter the candidates
OfferPicker's sticky mode ishash(visitor_uuid) % sum(weights). Dropping a candidate before the pick changes the modulus, so every assignment moves — not just the ones on the offer that was switched off. Measured, three offers at equal weight, middle one deactivated, 3000 synthetic visitors:Picking on the full list and checking only the winner keeps those assignments intact, keeps the normal path at a single primary-key read, and re-picks among the active candidates only for the visitors who actually lost their offer.
A matched flow with nothing routable falls through to the campaign trash mode
ClickHandleralready states the contract for the no-flow-matched branch:"Flow matched, but every candidate is inactive" is the same situation from the operator's side, and now takes the same exit. A flow that carries a static
schema_config.urlkeeps using it — that is an operator-set target, not an absence of one.Case normalisation
Offer ids are lowercased where they are accepted. PostgreSQL compares
uuidcase-insensitively but returns the value canonically lowercased, so a mixed-case reference would otherwise fail to match the row it had just loaded — which is how the version in #7 turned an uppercaseoffer_idinto a silent204for an offer that is live.Verification
Run against the dev stack with the isolated
db-testdatabase.src/The new tests were checked against the implementation in #7 as well, to confirm they are guards rather than decoration — five go red there:
The last one passes on both, as it should — it guards this change, not that one.
Kept from #7
inactive offer is removed before active offer weights are evaluated,a malformed offer_id does not take the rest of the flow down with it,an offer_id that no longer exists is skipped in favour of a live one, and bothtrash {offer:...}inactive cases — unchanged.findActiveByIds()is theirs too, with the doc comment tightened to say the ids must arrive pre-validated.