Skip to content

Backfill pre-roll videos for existing product campaigns - #277

Merged
ralyodio merged 1 commit into
masterfrom
backfill-ad-videos
Sep 24, 2026
Merged

ralyodio merged 1 commit into
masterfrom
backfill-ad-videos

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Queues a five-second pre-roll render for every existing product campaign. Three pieces: the sweep, a classifier, and the backfill script.

The sweep, because without it none of this works

ad_video_jobs is the durable record and the BullMQ job is the transient work item — the right way round, but it leaves a gap. If Redis is down when a campaign is saved, or the process dies between the insert and the enqueue, the row sits queued with nothing scheduled to look at it.

Several comments in this package already promised "a sweep can pick it up." Nothing did. processDueVideoRenders is that sweep: it reschedules stranded rows after a minute's grace, and fails a row outright if it carries no design snapshot, so a broken row can't become a hot loop against Redis. It also means a backfill can just insert rows from wherever it runs and let the worker schedule them.

The classifier, because "the ads" isn't every campaign row

Of 450 campaigns, 269 point at blog posts and 4 at social links — only 177 advertise a product.

Getting this wrong is expensive in one direction specifically: rendering videos for the posts that were explicitly excluded. So the shapes come from the live table rather than from imagination. Most blog campaigns have no /blog/ path prefix at all:

Shape Count Why a naive match misses it
dev.profullstack.com/~anthony/blog/126-post.html 152 blog lives under a ~user directory, so /^\/blog/ never matches
dev.to/chovy/<slug> 117 path is /user/slug with nothing blog-shaped in it

My own first pass at this classified only 12 campaigns as blog posts and 348 as "product deep links" — sampling the data is what caught it.

Affiliate and referral links are kept as products (m.do.co/c/…, Amazon /dp/…, aff.php?aff=…): they advertise something a person can buy, which is the distinction that matters. An unparseable destination is deliberately not classified as a product — something we can't show to be one shouldn't get a render.

9 tests pin this against real production URL shapes.

The script

Calls the same queueCampaignVideo the dashboard calls, so backfilled videos can't differ from freshly-saved ones with nothing to explain why.

  • Re-runnable. Jobs dedupe on a hash of the design, so a campaign that already has a job for its current copy is skipped rather than re-encoded; an interrupted pass continues where it stopped.
  • bumpRevision: false — a backfill is not an edit, and bumping would invalidate media an earlier pass already rendered for the same unchanged design.
  • Never rewrites campaign copy, never changes campaign status. The only writes are the video creative row and its render job.
  • Skips rejected campaigns and creatives — a rejected campaign must not gain servable media.
  • --dry-run, --limit N, --delay ms.

Verification

Dry run against production:

450 campaigns: 177 product, 269 blog, 4 social.
177 to queue (dry run — nothing written).
Done. queued=177 already-had=0 skipped=0 failed=0

That matches an independent SQL classification of the same table. Repo-wide: 2547 passed / 1 failed — the pre-existing tracker-geo failure (geolite2-city-mmdb not installed locally), unrelated. Root and worker typechecks clean.

Rollout

Not run yet beyond the dry run. After this merges and deploys I'll run --limit 1 as a canary, confirm it produces a real validated MP4 end to end, then release the remaining 176. Rough cost: ~177 renders at roughly a minute or two each, concurrency 2, and about 8 MB of storage per campaign (~1.4 GB total) in the existing ad-assets bucket.

Adds the sweep the rest of this package kept promising, a classifier for what
counts as a product ad, and a backfill script that queues a render for each one.

The sweep first, because without it the backfill would not work and several
comments in this package were writing cheques nothing cashed. ad_video_jobs is
the durable record and the BullMQ job is the transient work item, which is the
right way round, but it leaves a gap: if Redis is down at save time, or the
process dies between the insert and the enqueue, the row sits `queued` with
nothing scheduled to look at it. processDueVideoRenders reschedules those after
a minute's grace, and fails a row outright if it carries no design snapshot so
a broken row cannot become a hot loop against Redis. It also means a backfill
can simply insert rows from wherever it runs and let the worker schedule them.

The classifier exists because "generate videos for the ads" is not the same set
as "every campaign row". Of 450 campaigns, 269 point at blog posts and 4 at
social links; only 177 advertise a product. Getting that wrong is expensive in
one direction specifically — rendering videos for the posts that were excluded
— so the shapes are taken from the live table rather than imagined. Most blog
campaigns have no /blog/ path prefix at all:

  dev.profullstack.com/~anthony/blog/126-post.html   152 campaigns, ~user dir
  dev.to/chovy/<slug>                                117 campaigns, /user/slug

A naive /^\/blog/ match catches neither, which is why the publishing platforms
are matched on the domain. Affiliate and referral links are kept as products:
they advertise something a person can buy, which is the distinction that
matters. An unparseable destination is deliberately not classified as a product
— something that cannot be shown to be one should not get a render.

The script calls the same queueCampaignVideo the dashboard calls, so backfilled
videos cannot differ from freshly saved ones with nothing to explain why. It is
re-runnable: jobs dedupe on a hash of the design, so a campaign that already has
a job for its current copy is skipped rather than re-encoded. It passes
bumpRevision false, because a backfill is not an edit and bumping would
invalidate media an earlier pass already rendered for the same unchanged design.
It never rewrites campaign copy and never changes campaign status.

Dry run against production: 450 campaigns, 177 product, 269 blog, 4 social,
177 queueable with no campaign lacking a usable creative.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

48 finding(s)

HIGH/CRITICAL: 2 | MEDIUM: 31 | LOW: 15

Severity Rule Location
HIGH tls-verification-disabled lib/onion.ts:48
HIGH secret-generic-credential lib/sp/platforms/facebook.ts:32
MEDIUM js-unescaped-html-sink app/(app)/dashboard/admin/email-broadcast/EmailBroadcastForm.tsx:125
MEDIUM js-unescaped-html-sink app/(app)/dashboard/projects/[id]/autoblog/articles/[articleId]/page.tsx:214
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:67
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:97
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:104
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:110
MEDIUM js-unescaped-html-sink app/(marketing)/recent/page.tsx:186
MEDIUM js-unescaped-html-sink app/(marketing)/recent/page.tsx:190
MEDIUM js-unescaped-html-sink app/c/[project]/[slug]/page.tsx:77
MEDIUM js-unescaped-html-sink app/c/[project]/page.tsx:57
MEDIUM js-unescaped-html-sink app/careers.js/route.ts:228
MEDIUM js-unescaped-html-sink app/careers.js/route.ts:285
MEDIUM js-unescaped-html-sink app/layout.tsx:129
MEDIUM js-open-redirect app/login/form.tsx:39
MEDIUM js-unescaped-html-sink app/r/[token]/page.tsx:176
MEDIUM js-open-redirect app/signup/form.tsx:43
MEDIUM js-open-redirect components/billing/buy-credits-modal.tsx:98
MEDIUM js-unescaped-html-sink components/json-ld.tsx:8
MEDIUM js-unescaped-html-sink components/report/markdown-view.tsx:15
MEDIUM js-unescaped-html-sink lib/careers/page-templates.ts:198
MEDIUM js-dynamic-code-execution lib/crawl-limits.ts:67
MEDIUM redos-nested-quantifier lib/emailMarkdown.ts:41
MEDIUM redos-nested-quantifier lib/emailMarkdown.ts:324
MEDIUM redos-nested-quantifier lib/lx/articleGen.ts:99
MEDIUM redos-nested-quantifier lib/tracker/agent-gate.ts:61
MEDIUM sh-remote-script-execution prober/deploy/provision.sh:30
MEDIUM sql-template-interpolation scripts/detect-slot-themes.ts:31
MEDIUM sql-template-interpolation scripts/purge-constructed-keywords.ts:163
MEDIUM sql-template-interpolation scripts/purge-offniche-keywords.ts:124
MEDIUM js-dynamic-code-execution scripts/test-crawl-limits.mjs:14
MEDIUM js-dynamic-code-execution scripts/test-crawl-limits.mjs:24
LOW secret-generic-credential app/(marketing)/docs/autoblog-webhook/page.tsx:145
LOW secret-generic-credential lib/sp/platforms/linkedin.ts:25
LOW js-dynamic-code-execution tests/careers-page-templates.test.ts:21
LOW js-dynamic-code-execution tests/careers-widget-script.test.ts:19
LOW js-dynamic-code-execution tests/careers-widget-script.test.ts:69
LOW js-dynamic-code-execution tests/contract/ad-visitor-id.test.ts:51
LOW js-dynamic-code-execution tests/contract/ad-visitor-id.test.ts:52
LOW js-dynamic-code-execution tests/contract/ads-click-cooldown-redis.test.ts:20
LOW js-dynamic-code-execution tests/contract/ads-click-cooldown-redis.test.ts:24
LOW js-dynamic-code-execution tests/contract/ads-click-cooldown-redis.test.ts:25
LOW js-dynamic-code-execution tests/contract/ads-click-cooldown-redis.test.ts:26
LOW js-dynamic-code-execution tests/contract/ads-click-cooldown-redis.test.ts:31
LOW js-dynamic-code-execution tests/contract/ads-click-cooldown-redis.test.ts:35
LOW secret-generic-credential tests/contract/posthog-integration.test.ts:13
LOW secret-generic-credential tests/lead-campaign.test.ts:16

Snippets are redacted; ThreatCrush never prints matched credential material.

Comment thread lib/ads/video/classify.ts
*/
export function classifyCampaign(destinationUrl: string): CampaignKind {
let host = "";
let path = "/";
@ralyodio
ralyodio merged commit 7405df2 into master Sep 24, 2026
10 checks passed
@ralyodio
ralyodio deleted the backfill-ad-videos branch September 24, 2026 13:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants