The Drip Campaign
The Drip Campaign: every product ships one - welcome on day 0, tips on day 3, the gentle upsell on day 7 - and every team ships the same three bugs with it: the DOUBLE SEND (cron fired twice; your users got two welcomes and one impression: amateurs), the GHOST MAIL (user unsubscribed on Tuesday, your Thursday job mailed them anyway, now it's a compliance ticket), and the COHORT SMEAR (users who signed up Wednesday get Monday's schedule). The cures are boring and absolute: an …
Scheduling & Concurrency
Round 20
Kasper Timm Hansen
exit 0
bundle exec ruby examples/drip_campaign.rb
a real captured run
THE DRIP CAMPAIGN (three emails, three classic bugs, three boring cures)
the week's outbox (cron double-fired on day 3; bo and di unsubscribed):
day 0 welcome -> ana
day 0 welcome -> bo
day 0 welcome -> di
day 1 welcome -> cy
day 3 tips -> ana
day 4 tips -> cy
day 7 upsell -> ana
the three cures, verified: the (user, step) LEDGER made the
day-3 double-fire invisible (same tick ran twice; the outbox
can't tell); unsubscribes were checked at SEND time, the last
possible moment - di unsubscribed the morning of their tips day
and got nothing, because a check at schedule time is a promise
made too early; and cy's whole sequence shifted with their
signup day (day 1 welcome, day 4 tips), because offsets belong
to the user's clock, not the calendar's. none of this needed a
marketing platform - it needed a ledger, a late check, and
per-user arithmetic. the right thing should be the default
thing; in this campaign, it's the only thing.
source
# frozen_string_literal: true # The Drip Campaign: every product ships one - welcome on day 0, # tips on day 3, the gentle upsell on day 7 - and every team ships # the same three bugs with it: the DOUBLE SEND (cron fired twice; # your users got two welcomes and one impression: amateurs), the # GHOST MAIL (user unsubscribed on Tuesday, your Thursday job # mailed them anyway, now it's a compliance ticket), and the # COHORT SMEAR (users who signed up Wednesday get Monday's # schedule). The cures are boring and absolute: an idempotency # ledger keyed by (user, step), unsubscribes checked AT SEND TIME # - the last possible moment - and every offset computed from the # user's OWN signup day. Time here is simulated; the bugs are not. # # bundle exec ruby examples/drip_campaign.rb # # Runs offline; exits 1 unless the outbox is exactly right after a # week that includes a double-fired cron and two unsubscribes. require class="s">"bundler/setup" require class="s">"agentic" require class="s">"set" Agentic.logger.level = class="y">:fatal CAMPAIGN = [ {step: class="s">"welcome", day_offset: 0}, {step: class="s">"tips", day_offset: 3}, {step: class="s">"upsell", day_offset: 7} ].freeze USERS = { class="s">"ana" => {signed_up: 0}, # the loyal one: gets all three class="s">"bo" => {signed_up: 0}, # unsubscribes day 2: welcome only class="s">"cy" => {signed_up: 1}, # signs up a day late: schedule shifts with them class="s">"di" => {signed_up: 0} # unsubscribes ON day 3, before the tick: no tips }.freeze OUTBOX = [] SENT = Set.new # the idempotency ledger: (user, step), forever UNSUBSCRIBED = {} # user => day it happened # One scheduler tick: compute due sends, deliver them as a plan def tick(day) due = USERS.flat_map { |user, info| CAMPAIGN.select { |step| info[class="y">:signed_up] + step[class="y">:day_offset] == day }.map { |step| [user, step] } } return if due.empty? orchestrator = Agentic:class="y">:PlanOrchestrator.new(concurrency_limit: 4) due.each do |user, step| task = Agentic:class="y">:Task.new(description: class="s">"#{step[class="y">:step]} -> #{user}", agent_spec: {class="s">"name" => class="s">"mailer", class="s">"instructions" => class="s">"send"}) orchestrator.add_task(task, agent: ->(_t) { key = [user, step[class="y">:step]] next class="y">:duplicate_suppressed if SENT.include?(key) # cure one: the ledger next class="y">:unsubscribed if UNSUBSCRIBED.key?(user) # cure two: checked at SEND time SENT << key OUTBOX << {day: day, to: user, mail: step[class="y">:step]} class="y">:sent }) end orchestrator.execute_plan end puts class="s">"THE DRIP CAMPAIGN (three emails, three classic bugs, three boring cures)" puts 8.times do |day| UNSUBSCRIBED[class="s">"bo"] = day if day == 2 UNSUBSCRIBED[class="s">"di"] = day if day == 3 # unsubscribes the morning of their tips day tick(day) tick(day) if day == 3 # the cron fires twice on day 3. it always eventually does. end puts class="s">" the week's outbox (cron double-fired on day 3; bo and di unsubscribed):" OUTBOX.each { |m| puts format(class="s">" day %d %-8s -> %s", m[class="y">:day], m[class="y">:mail], m[class="y">:to]) } puts by_user = OUTBOX.group_by { |m| m[class="y">:to] }.transform_values { |ms| ms.map { |m| m[class="y">:mail] } } failures = [] failures << class="s">"ana should get the full sequence" unless by_user[class="s">"ana"] == [class="s">"welcome", class="s">"tips", class="s">"upsell"] failures << class="s">"bo was mailed after unsubscribing" unless by_user[class="s">"bo"] == [class="s">"welcome"] failures << class="s">"cy's schedule didn't shift with their signup" unless OUTBOX.select { |m| m[class="y">:to] == class="s">"cy" }.map { |m| m[class="y">:day] } == [1, 4] failures << class="s">"di's send-time unsubscribe was ignored" unless by_user[class="s">"di"] == [class="s">"welcome"] failures << class="s">"the double-fired cron double-sent" unless OUTBOX.size == OUTBOX.uniq { |m| [m[class="y">:to], m[class="y">:mail]] }.size failures << class="s">"outbox size wrong (#{OUTBOX.size})" unless OUTBOX.size == 7 # cy's upsell lands on day 8, beyond this week puts class="s">" the three cures, verified: the (user, step) LEDGER made the" puts class="s">" day-3 double-fire invisible (same tick ran twice; the outbox" puts class="s">" can't tell); unsubscribes were checked at SEND time, the last" puts class="s">" possible moment - di unsubscribed the morning of their tips day" puts class="s">" and got nothing, because a check at schedule time is a promise" puts class="s">" made too early; and cy's whole sequence shifted with their" puts class="s">" signup day (day 1 welcome, day 4 tips), because offsets belong" puts class="s">" to the user's clock, not the calendar's. none of this needed a" puts class="s">" marketing platform - it needed a ledger, a late check, and" puts class="s">" per-user arithmetic. the right thing should be the default" puts class="s">" thing; in this campaign, it's the only thing." exit(failures.empty? ? 0 : 1)