Trade With Viet
Session Explainer
Internal use only — enter access password
bugfix

Killing the recurring BEUP duplicate-post bug at the root

2026-06-26 · explainer.tradewithviet.com

We stopped BEUP's videos from double-posting — by fixing the architecture, not the symptom

BEUP's auto-posting kept putting the same video out twice and firing YouTube + Facebook at the same minute. It had been 'fixed' several times and kept coming back in a new shape. We traced it to a missing invariant — there was no single source of truth for what was scheduled — and rebuilt the publishing spine. The recurring bug is now structurally impossible, and you can finally see the whole schedule in one place.

StatusLiveScopeBEUP n8n video publishingDate2026-06-26

6-layer thinking chainChuỗi tư duy 6 tầng

L1
Raw requestYêu cầu gốc
"Check again why yesterday and the day before there were a lot of posts on YouTube and Facebook posted at the same time frame. I suppose you already fixed the n8n workflow, but here it is repeating again."
L2
Reframe — what was really neededDiễn giải lại — nhu cầu thật
The real ask was not "find today's bug." It was "why does every fix fail and the bug return with a different explanation each time?" That pattern — fixed, returns in a new shape, fixed again — is the fingerprint of an unfixed architectural root cause, not a code defect. So the true job: make the correct behaviour structural, so it cannot recur regardless of how content is scheduled.
L3
ConstraintsRàng buộc
Reality we had to work inside: (1) the 'schedule' existed only as dozens of invisible parked n8n Wait executions — not queryable, so every new scheduling run was blind and stacked on top of the old one; (2) n8n's Data Table API is disabled on this instance; (3) the old BEUP Supabase project was paused for >90 days and is permanently unrestorable; (4) the security layer correctly refuses to let an AI handle the Notion token in plaintext; (5) 21 posts were already scheduled and must not break; (6) this is a live publishing system — any cutover must not double-post or drop a post.
L4
Options weighedCác lựa chọn cân nhắc
Paths weighed: (a) patch the dedup lock again — but it had been patched before and kept failing, cost: guaranteed recurrence; (b) coarsen the lock to once-per-day only — stops the visible double-post but leaves the deeper cause, cost: a new shape next month; (c) full rebuild: one durable ledger as the single source of truth + one scheduler that owns timing + an idempotent producer, cost: most work but actually ends the recurrence; (d) do nothing structural.
L5
Principle invokedNguyên tắc áp dụng
A bug that returns with a new explanation each time is a missing invariant, not a bug. Make the invariant structural: exactly one post per (clip, platform, day), owned by one scheduler, recorded in one durable store.
L6
Pick + recognition signalLựa chọn + dấu hiệu nhận biết
Picked: ship the cheap backstop first for instant relief (per-day dedup on all four publishers), THEN do the full rebuild (Notion ledger + a single VID-TICK scheduler + idempotent producer). Rejected the minute-lock patch (proven to fail) and do-nothing. Recognition signal: when a bug you 'already fixed' comes back, stop patching the symptom — name the invariant the system never actually enforced, and make the system enforce it.

How publishing works now (and why it couldn't before)

Before, the producer fired posts straight into n8n as 'wait-until-time' jobs that no one could see or count — so a second scheduling run, or a one-minute time drift, silently created a duplicate. Now everything routes through one visible ledger, and a single dispatcher is the only thing that publishes.

NEW: one schedule book, one dispatcherScheduler inputbeup-video-distribute.pyThe schedule bookNotion ledger1 row / clip|platform|dayThe dispatcherVID-TICK (every 5 min)reads due, claims, firesPublishers + 1/day lockVID-05/06/08/09YouTube · IG · Facebookupsertread duefireOLD: producer fired straight into n8n as invisible parked jobsparked Wait jobsnot queryable · no shared count→ two runs stacked → duplicate / same-minute
Technical termThuật ngữ kỹ thuậtPlain nameTên dễ hiểuRoleVai trò
beup_cadence.pyPosting rhythmThe single definition of timing: YouTube day 0 (20:00), Instagram +3 days (20:30), Facebook +5 days (08:30). Replaces three conflicting copies.
Notion 'BEUP Publish Ledger'The schedule bookOne row per clip+platform+day, keyed so duplicates are impossible. Status moves scheduled → claimed → posted. The first place you can actually SEE what is scheduled.
VID-TICK (cb7mkv6qQCVIFy8A)The dispatcherRuns every 5 minutes, reads rows that are due, claims them so they can't fire twice, calls the right publisher, marks them posted. The single owner of timing.
Dedup Guard (per-day)One-per-day lockBackstop inside each publisher: at most one post per platform per day, regardless of minute or which run sent it.
beup-video-distribute.py (--via ledger)Scheduler inputBakes the exact post payload and writes it to the ledger instead of firing a blind webhook. Re-running is a no-op (idempotent).

Read it left-to-right: you schedule a clip → it lands as one row in the ledger → the dispatcher picks it up when its time comes and publishes it once. The greyed-out bottom path is the old design: posts went straight into n8n as parked jobs nobody could see or count, so two scheduling runs (or a one-minute drift) quietly produced a duplicate.

Lesson to carry forward: a schedule you can't query is a schedule you can't trust. The fix wasn't a smarter lock — it was giving the schedule a single visible home and a single owner.

What we did, in order

1
Diagnose from evidence
Pulled real n8n execution history: 06-24 YouTube + Facebook both fired at 20:01; 06-25 YouTube posted twice (20:00 and 20:01). Confirmed the cause was an older invisible batch + a per-minute lock.
2
Per-day dedup (instant relief)
Coarsened the lock on all 4 publishers from per-minute to per-day. Same-platform double-posts now structurally blocked.
3
Fix Facebook video error
Removed the invalid URL thumbnail that was making FB video posts fail with error 100.
4
One canonical cadence
beup_cadence.py: a single timing definition, replacing three that disagreed.
5
Build the ledger
After two dead-end substrates, created the Notion 'BEUP Publish Ledger' + helper; round-trip tested idempotency.
6
Build + test the dispatcher
VID-TICK workflow; tested live end-to-end (query → claim → fire → mark) against a throwaway test row.
7
Idempotent producer
Producer now writes to the ledger; re-running never duplicates. Literal timing by default so batch scripts don't change behaviour.
8
Safe cutover
Switched the producer default to ledger + activated the dispatcher. The 21 already-scheduled posts drain on the old path untouched.

Layered decision cardsCác quyết định theo tầng

Per-day lock, not per-minute
L1The dedup lock kept letting duplicates through.
L2The lock identity included hours:minutes, but the post identity was per-day — an internal contradiction.
L3Two runs a minute apart (20:00 vs 20:01) looked like different slots to the lock.
L4Keep minute-lock (keeps failing) / drop to per-day / drop the lock entirely (unsafe).
L5The lock granularity must match the real cadence (one post per platform per day).
L6Per-day lock on all four publishers. Rejected minute-lock (proven failure) and no-lock. Recognition: if a dedup key is finer than the thing it's meant to dedup, it doesn't dedup.
Where the ledger lives
L1We need one durable store both the producer and n8n can reach.
L2It must be always-on — a store that can sleep is worse than no store.
L3n8n Data Table API disabled here; BEUP Supabase paused >90 days = permanently gone; the security layer blocks AI from handling the token.
L4n8n Data Table (unavailable) / resume Supabase (impossible) / new Supabase (auto-pauses again) / Notion (never pauses, token already present).
L5Pick the substrate that cannot reproduce the failure that just bit us (a sleeping store).
L6Notion ledger. Rejected Supabase (the auto-pause that caused the dead-end) and Data Table (no API). Recognition: choose infrastructure by its worst failure mode, not its best feature.
Producer timing: literal by default, cadence opt-in
L1Should the producer force the new cadence on every call?
L2Forcing cadence would silently shift the batch scripts' already-staggered times (e.g. push Facebook +5 days).
L3Batch scripts call once per platform with an explicit time and fb-offset 0; they already stagger across days.
L4Force cadence everywhere (breaks batch scripts) / literal default + --cadence opt-in / fork the scripts now.
L5A safe migration never silently changes a working caller's behaviour.
L6Literal timing by default; canonical spread only with --cadence. Rejected force-cadence (caught it before it shipped) and fork-now. Recognition: when you centralise a default, prove every existing caller still does what it did.
Cutover: let the parked posts drain
L1How do we switch to the new system without breaking the 21 scheduled posts?
L2The original plan (cancel parked jobs + strip Wait nodes) was the riskiest possible path on a live system.
L3The 21 posts are parked AT the Wait nodes; removing those nodes mid-flight could break them.
L4Migrate + cancel + strip (risky) / let parked posts drain on the old path while new content uses the ledger (low-risk).
L5Prefer the cutover with the smallest blast radius that still reaches the goal.
L6Let the 21 drain naturally; flip the producer to ledger + turn on the dispatcher; leave publishers untouched (the dispatcher only fires already-due rows, so the old Wait node is a harmless no-op). Recognition: you rarely need to rip out the old engine the same minute you start the new one.

Artifact mapBản đồ tệp tạo ra

PathĐường dẫnWhatLà gìWho reads itAi dùng
scripts/beup_cadence.pyThe single canonical posting rhythm (YT d0 / IG +3d / FB +5d, distinct times)Producer (and future schedulers) read timing from here
scripts/beup_ledger.pyNotion ledger helper: idempotent upsert / due-query / claim / markProducer writes; migration + audits read
scripts/beup-video-distribute.pyProducer: new --via ledger path + --cadence flag; bakes payload, upserts a rowManual + batch scripts; default now ledger mode
n8n: VID-TICK (cb7mkv6qQCVIFy8A)The dispatcher — reads due rows, claims, fires the publisher, marks postedRuns every 5 min, owns all timing
n8n: VID-05/06/08/09Publishers: dedup lock coarsened to per-day; Facebook thumbnail bug fixedCalled only by the dispatcher (or, for now, the draining parked jobs)
Notion DB 'BEUP Publish Ledger'The single source of truth for what is scheduled / postedYou can open it to see the whole schedule
.env (BEUP_PUBLISH_VIA=ledger, NOTION_PUBLISH_LEDGER_DB)Flips the producer into ledger mode + points it at the ledgerReversible: set back to webhook to revert
Pending manual actions — required before fully liveViệc cần làm thủ công — trước khi hoàn tất
  • Done already: you created the n8n 'Notion BEUP Ledger' credential (the one piece the security layer wouldn't let the AI handle).
  • Optional, after 2026-07-06 (once the 21 old posts have all fired): strip the now-unused Wait nodes from the four publishers — pure cleanup, not required.
  • Optional: add the --cadence flag inside the /beup-shorts-queue skill if you want the canonical YT/IG/FB day-spread enforced automatically on weekly batches.
  • Optional: build a daily 'drift audit' that reads the ledger and alerts if anything looks off before it posts.

Check your understanding

Why did the duplicate bug keep coming back after being 'fixed' several times?
Why was Notion chosen for the ledger over (a fresh) Supabase?
Why is turning on the new dispatcher safe even though the four publishers still have their old 'Wait-until-time' node?
In your own words: why does letting the 21 already-scheduled posts drain on the OLD path avoid the risk of double-posting during the switch?
Mastery checklist — tick what you can explain unpromptedBảng tự đánh giá — tích những gì bạn tự giải thích được