Part 3 of 7 · Social scheduler series ~5 min read

How the schedule fires on time

Once a day, at 7am local time, an EventBridge Scheduler rule fires the scheduler Lambda. The Lambda reads the calendar, looks at one approved post at a time, works out when it’s due, and decides whether to leave it alone or to book it for sending — and if it’s already due, hand it off. The whole decision is plain Python. No model. No guesswork. Every posting window lives in the rules doc, where the owner can edit it without a deploy.

Key takeaways

  • The scheduler runs once a day via EventBridge Scheduler at 7am local time.
  • Posting windows live in the rules doc — default is no posts before 8am or after 8pm, plus blackout days.
  • Four moves per post, every tick: resting, queue, send, retry.
  • DynamoDB tracks each post’s status so a post is never double-sent.
  • The scheduler itself never calls a model. The decision is entirely deterministic.

The decision flow, per post

Decision flow per post on every daily tick A vertical decision flow diagram. At the top, an input box "Post from sheet" with the row's text, channels, scheduled time, approval flag, and DynamoDB status. Below that, a step "Compute minutes_to_send" — the scheduled time in the configured timezone minus now. Below that, a check "Approved and not sent?" — if no, route to "Resting" (do nothing this tick). If yes, continue. The next step "Look up posting window for channels" — pulls the window from the rules doc; for example default 8am to 8pm, skip blackout days. The next step "Is the send time inside the window today?" — if the scheduled time falls outside the posting window or on a blackout day, the scheduler nudges it to the next allowed minute. If the scheduled minute has not arrived yet, route to "Queue" — book a one-off EventBridge Scheduler rule for the exact send minute. If the minute has arrived, look at the DynamoDB status to see whether a send already succeeded or a channel failed and is eligible for another attempt. If no send yet, route to "Send" (hand the post to the Sender). If a prior send failed and the retry budget is not spent, route to "Retry" — queue it again after a short delay. Each terminal box — Resting, Queue, Send, Retry — emits an event to EventBridge with the move and the post context. A note at the bottom: the rules doc holds the windows; the scheduler's code only enforces them — change a window in the doc and tomorrow's tick uses the new value. Post from sheet text · channels · time · approved Step 1 Compute minutes_to_send scheduled (TZ) − now Step 2 Approved and not sent? read DDB ss-status table Step 3 Look up posting window e.g. 8am–8pm, skip blackout Step 4 Inside window, due now? not yet → queue minute hit → send Step 5 Prior send failed? read DDB ss-sends table Resting do nothing Queue book exact minute Send hand to Sender Retry failed, budget left if no not yet due now ok failed The rules doc holds the windows — change one and tomorrow’s tick uses the new value.
Fig 3. The scheduler’s decision tree, per post, per daily tick. Five steps decide which of four moves applies. The rules doc holds the posting windows; the scheduler only enforces them.

Posting windows: 8am to 8pm isn’t magic, it’s in the doc

The rules doc has one short section for the posting windows. It names them in plain prose: “Post only between 8am and 8pm local. No posts on Sundays. Skip these blackout dates: Dec 25, Jan 1.” The window is when posts are allowed to leave. A post scheduled for 2am isn’t sent at 2am; the scheduler nudges it to the first allowed minute — 8am the same morning, or the next non-blackout day if the whole day is blocked.

The windows exist for a reason. A post that lands at 3am gets buried by morning and barely seen. A post on a blackout day — a holiday you’re closed for, a day you’d rather stay quiet — is one you scheduled by mistake or want to skip. Different businesses have different rhythms; the windows reflect that.

Per-post overrides exist too. The sheet has an optional column called send_exact. Set it to yes and the scheduler honors the exact minute you typed, even outside the normal window — the right escape hatch for the time-sensitive announcement you want out at 6am sharp.

Four moves, always

Every post, every tick, lands in exactly one of four buckets. The names are simple on purpose.

  • Resting. The post is more than a day away, or it hasn’t been approved, or it’s already gone out. Do nothing. Most posts, most ticks, are resting.
  • Queue. The post is approved and due today but the minute hasn’t arrived. Book a one-off EventBridge Scheduler rule for the exact send minute. Write a row to the ss-status DynamoDB table marking the post as queued so the next tick doesn’t book it twice.
  • Send. The send minute has arrived. Hand the post to the Sender, which does the format check and the actual posting (Part 4). Mark the post as sending in DynamoDB.
  • Retry. A channel failed on a previous attempt — the platform was briefly down, the token needed a refresh — and the retry budget for that post isn’t spent. Queue it again after a short delay (a few minutes, growing on each attempt, up to a small cap). When the budget runs out, the post is marked failed and the owner is told. Most sends never need a retry; the budget is there for the rare flake.

State that makes the decision deterministic

The scheduler reads two DynamoDB tables every tick. ss-status records each post’s current state: (post_id, state, queued_for) where state is one of draft, queued, sending, sent, or failed. ss-sends records every actual send attempt: (post_id, channel, attempt, result, posted_url_or_error). With those two tables, the move-decision logic is a few dozen lines of Python and zero magic. A given post with a given scheduled time, a given approval flag, and a given send history always produces the same move. Re-running the tick produces no extra posts (because the state in DynamoDB shows what already fired).

Holding a post is an explicit reset: its state drops back to draft and any queued one-off send is cancelled. Sending it now is the opposite: the state jumps straight to sending. Part 5 covers those actions in detail.

Why the daily tick uses no model

The scheduler could call a model on the tick to pick a “better” time to post, or to decide whether a post is worth sending at all. It doesn’t. Two reasons. First, the daily tick should be the one part of the system that is utterly predictable — if the sheet says post at 9am and it’s approved, it posts at 9am. A model in that loop introduces variance the owner can’t reason about. Second, model calls cost money, and most posts most ticks are resting, so the call would be wasted nearly every time.

Bedrock fires elsewhere — on the draft-helper lane in Part 2, and on the monthly recap mentioned in Part 6. Not on the daily tick. The scheduler itself is plain Python that reads a calendar and books sends.

Next post: how a post gets formatted per channel, how the format checks run, and what the Sender does when a check fails.

All posts