Skip to content

Stop guessing TikTok video ids from captions - #400

Merged
paulocastellano merged 18 commits into
mainfrom
fix/tiktok-post-id-resolution
Oct 10, 2026
Merged

paulocastellano merged 18 commits into
mainfrom
fix/tiktok-post-id-resolution

Conversation

@paulocastellano

@paulocastellano paulocastellano commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Nightwatch #70: LogicException: The remote id is already attached to another TryPost publication. in CollectPublicationMetrics.

When TikTok had not reported a post's public video id yet, TikTokPublisher fell back to video/list and took the newest video whose caption started the same way, created up to a day earlier. On channels whose captions all open alike (bcwc.cloud #BCWC…, #TeacherLife…) the new post's video was not listed yet, so each post took the previous post's video. When TikTok later reported the real id for that previous post, the video was already held by the next one and reconciliation threw.

On the prod clone: 17 posts on 4 channels carry another post's video (wrong metrics and link), and some of their real videos were imported as duplicate posts. Publishing itself was never affected: each video went out once, correctly.

Fix

TikTok sends publicaly_available_post_id only after moderation, which usually takes under a minute but can take hours (Get Post Status). On the clone it arrived weeks later. So TryPost now waits for that id instead of guessing:

  • No more caption matching in TikTokPublisher. Until TikTok reports the id, a post keeps its publish_id and the profile URL, and stays published.
  • ResolveTikTokVideoId, dispatched by PublishToSocialPlatform one minute after a public TikTok post is published without its video id, asks TikTokPublisher::publicVideoId(). Moderation usually ends within a minute, so this resolves most posts. social:resolve-tiktok-video-ids (every 15 minutes, a database query that calls TikTok only for posts whose recheck is due) catches slower reviews: on every run through a post's first hour, hourly through its first day, then daily for 30 days, following the Google Business reconcile pattern (last_reconciled_at). Only posts public to everyone are asked (TikTok never reports an id for followers, friends or private posts), and posts on channels that are not connected are skipped. The check is recorded before the id is assigned, so a failure follows the same cadence instead of repeating every run; a network timeout counts as not reported yet. Only a numeric id counts as a video id, the same rule that decides a post still awaits one.
  • AssignTikTokVideoId writes the id for the resolver and the metrics job, under a lock on the post (a post deleted meanwhile is left alone): it sets the video URL on the post and on its analytics publication (keeping the current one when the channel has no username) and moves the analytics publication through reconcileRemoteId, which merges snapshots and drops an imported duplicate. A post published before it had an analytics publication (30 of the 71 waiting posts on the clone) gets one first through SyncTryPostPublication, so it gets the same merge and the same conflict guard. The only other writer is ImportExternalPosts::claimedBySentPost (exact text, single candidate, deferred when ambiguous); for TikTok it now only claims for posts still on a publish_id, so it can no longer move a resolved post to a native video with the same caption (pre-existing gap).
  • The LogicException stays as an invariant guard: a video another TryPost post holds is never assigned. The resolver logs it once a day instead of failing the job; the repair command fixes those posts. A video discovered at the same moment (unique-index race) leaves the post for the next check.
  • AssignTikTokVideoId retries its transaction on a deadlock (it locks the post before the publication; the importer locks them the other way).
  • TikTokAnalytics is deleted. It had no caller since the analytics pipeline (feat: independent social posts, workspace analytics and media editing #374) replaced it; its per-post metrics read was dead code. Its one live test (reading saved TikTok metrics through ReadPublicationAnalytics) moved to PersistedPostMetricsReadTest.
  • Post::scopePublishedToTikTok() for the query the sweep and the repair share.
  • Rule recorded in .ai/rules/tiktok.md.

Repairing existing data

Run right after the deploy: php artisan tiktok:repair-video-ids --dry-run, then without --dry-run. Both phases read the video's creation time from analytics_publications.provider_published_at, which discovery rewrites with TikTok's own value; on the prod clone the dry run found the 17 expected posts, so check the production dry run reports a similar number. The dry run executes everything in a transaction and rolls it back, so its numbers are exact; media file deletions are only queued after a commit and are dropped with it. This is a one-off; remove it in a follow-up once it has run.

Phase 1 — a post holding the previous post's video. Only posts that still have a channel are considered. A post is repaired only when both hold:

  1. The video it holds was created right before another post of the channel was published.
  2. Exactly one video was created right before the post itself.

The second check matters: posts whose video was created long before published_at can be correct slow publishes (TikTok's status took 11–48 minutes) and are left alone. Repairs are filtered until they settle: a repair is kept only when its own video is claimed by no other repair and is free (unlinked, imported, or held by a post that is itself repaired). An imported copy of the returned video is deleted.

Phase 2 — a video several posts of a channel hold. Only published TryPost posts and imported posts take part. The TryPost post published right after the video was created keeps it, but only when that video is the only one created in the two minutes before it (the same evidence phase 1 requires, through one shared lookup); otherwise the video is left alone, so a native video is never deleted on weak evidence. Each other TryPost post gets its own video back when exactly one free video was created right before it (deleting its imported copy), and only otherwise loses the id and points at the profile again (the importer can still link its real video by exact text later). Imported copies of the shared video are deleted. Each video is settled with the posts holding it at that moment, since settling an earlier video can hand one of them its own video back. These came from the old post page caption match, which scanned newest first and could hand a post a later post's video.

Then the command dispatches the resolver for every post still on a publish_id on a connected channel, including those older than 30 days.

Run on the prod clone (queue sync, no worker, so nothing else in the queue ran; storage on the local bucket):

  • Phase 1: 17 posts got their own video back; 8 imported copies deleted.
  • Phase 2: 6 posts released, 1 imported copy deleted.
  • Resolver: 68 posts asked TikTok, 7 got their real id (including July posts: old publish_ids still answer). Most of the rest have no public id yet or had an expired token locally (the local TikTok app cannot refresh production tokens; production refreshes them every 15 minutes).
  • After: 0 video ids shared by two posts of a channel, 0 publications pointing at a different video than their post, 0 failed jobs; a second dry run finds nothing to repair.

Tests

  • New: ResolveTikTokVideoIdTest (resolves and moves the publication, waits while there is no id, skips private/resolved/imported posts, deleted post, sweep cadence and 30-day cap), AssignTikTokVideoIdTest (no publication, no username, conflict writes nothing) and RepairTikTokVideoIdsCommandTest (imported duplicate, a run of consecutive wrong posts, cascade and double-claim guards, slow publish untouched, shared videos with and without a single owner, an orphaned copy settled in the same run, dry run rolled back with media deletions deferred to a commit). The guard tests fail against the single-pass filter.
  • The metrics job test now asserts the video URL.
  • TikTokPublisherTest: publicVideoId (string/integer id, still in moderation, not a numeric id, rejected or unavailable status fetch, network timeout, rejected token refresh); the publish test asserts video/list is never called. Caption-match tests removed with the code.
  • The sweep skips imported, failed and retrying posts; a video held by another post records the check and is not re-dispatched before its cadence; a second repair run finds nothing.
  • TikTok fixtures that used video_123 as the public video id now use numeric ids, as TikTok's int64 field does.
  • Full suite in parallel (PostgreSQL): 9606 passed. Affected tests on MySQL: passed.

Paulo Castellano added 18 commits October 10, 2026 10:33
When TikTok had not reported a post's public video id yet, the publisher
matched the newest video whose caption started the same way. For channels
whose captions all open alike, that handed each post the previous post's
video, so 17 posts showed another video's metrics and link, and the real
video was imported as a duplicate post.

TikTok reports the id only after moderation, so a scheduled sweep now asks
the status endpoint again (every run in the first hour, hourly through the
first day, then daily for 30 days). Every write of the id goes through
AssignTikTokVideoId, which also moves the analytics publication.
tiktok:repair-video-ids gives the affected posts their own video back.
- Repair: repeat the free-video filter until it settles, and drop videos
  claimed by more than one post, so no post is left pointing at a video
  that moved to another.
- Resolver job drops itself when its post was deleted.
- The sweep selects only the columns it reads.
- A channel without a username keeps the post url it had.
- Use blank()/filled()/transform() instead of strict null checks.
- Rule names ImportExternalPosts::claimedBySentPost as the other writer.
- Tests for the repair chain and its guards, AssignTikTokVideoId, a deleted
  post and the video url the metrics job now writes.
…lytics

TikTokAnalytics had no caller left since the analytics pipeline replaced it;
only the new resolver used it, for a lookup that belongs to publishing. The
status fetch now lives in TikTokPublisher::publicVideoId(), next to the
publish polling, sharing one way to read the id from TikTok's response.

- Post::scopePublishedToTikTok() for the query the sweep and the repair share.
- The repair only reads posts that were published.
- Named recheck intervals in the sweep.
- Tests: publicVideoId, the sweep skipping unpublished posts, a second
  repair run, and the TikTok metrics read moved next to its siblings.
- A network timeout while asking TikTok for the video id counts as not
  reported yet instead of failing the job.
- The job records the check before assigning the id, so a video held by
  another post follows the recheck cadence instead of failing every run.
- Only a numeric id counts as a video id, the same rule that decides a post
  still awaits one; fixtures now use numeric ids like TikTok's int64.
- Put scopePublicationPublished's docblock back above it.
… channels

- AssignTikTokVideoId gives a post published before it had an analytics
  publication one first, so the move still drops the importer's copy of the
  video and refuses a video another post holds (30 of the 71 waiting posts
  on the prod clone have none).
- The sweep and the job skip posts whose channel is not connected instead of
  calling TikTok with a dead token every day.
- The repair reads its rows with data_get() and updates fillable columns
  with update().
- The repair eager-loads each post's channel; building a video url for a
  video without a permalink lazy-loaded it and failed under strict mode.
- The repair only dispatches posts on connected channels, so its count
  matches what the job will do.
- Tests: the url fallbacks, the imported copy's media, disconnected
  channels, the publisher's guards and the queue the check runs on.
The sweep only queries the database; TikTok is called only for a post still
on a publish_id whose recheck is due. Running every minute makes the first
check land within a minute of the publish, while the 5-minute, hourly and
daily rechecks keep the number of requests the same.
…n it is due

- The repair requires the post's channel, so a video url is always built
  from a live channel and channelless posts never match each other.
- AssignTikTokVideoId is covered for a post whose channel is gone.
- The schedule test checks the sweep is due at any minute instead of
  matching a cron string.
The publish job dispatches ResolveTikTokVideoId one minute after a public
TikTok post is published without its video id: moderation usually ends
within a minute, so this resolves most posts. The sweep drops to every 15
minutes and only catches slower reviews, rechecking on every run through
the first hour, hourly through the first day and daily after that.
…x the publication link

From an independent review of the PR:
- Only posts public to everyone await a video id: TikTok never reports one
  for followers, friends or private posts, so asking for 30 days was waste.
- AssignTikTokVideoId re-reads the post under a lock and stops if it was
  deleted while TikTok answered, instead of creating an orphan publication.
- The analytics publication gets the video link too, not only the post, so
  insights, exports and the API stop pointing at the profile.
- The importer never claims a native video for a TikTok post whose numeric
  id TikTok already reported; it only claims for posts still on a publish_id.
- filled() instead of truthiness in the metrics job.
- Tests for each, plus the metrics job with a publication without a post and
  with a video another post holds.
…once a day

From a second independent review:
- AssignTikTokVideoId retries its transaction on a deadlock: it locks the
  post before the publication while the importer locks them the other way.
- A video another TryPost post holds is logged once a day by the resolver
  instead of failing the job on every recheck for 30 days.
- The sweep filters public posts in SQL.
- Tests: a check already queued is not queued twice, a publication without
  a link gets the video link, the once-a-day log.
…t the same moment

A unique-index race with discovery inserting the same video is not a
deadlock, so it was not retried and failed the job; the next sweep merges
the discovered row, so the job now just returns.
Running the repair on the prod clone left six video ids held by more than one
post: TryPost posts that took a later post's video (the post page's caption
match scanned newest first) and an imported copy orphaned once its video went
back to its post. A second phase keeps each such video with the one TryPost
post published right after it was created, points the other TryPost posts
back at the profile with no video id, and deletes the imported copies. A video
without exactly one such post is left alone.

The dry run now runs both phases in a transaction and rolls it back, so its
numbers include what the first phase uncovers; media file deletions are only
queued for after a commit and are dropped with it. Imported copies the first
phase deletes are counted too.
…s their own video

From a review of the second phase:
- A post keeps a shared video only when that video is the only one created
  in the two minutes before it was published, the same evidence the first
  phase requires; otherwise a native video could be deleted or a wrong post
  kept. Both phases share one lookup, videoCreatedRightBefore().
- A post that loses a shared video gets its own video back when exactly one
  free video was created right before it (deleting its imported copy), and
  only otherwise drops the id.
- Only published TryPost posts and imported posts take part.
- A shared id must match exactly one analytics publication.
- Tests: a native video next to another, a released post getting its own
  video back, failed posts, ids without a publication or on other channels,
  and a failure rolling every repair back.
The second phase grouped every post once before changing anything, so a video
handed back while settling an earlier group was settled with its old holders
and could end on two posts. Each group now reads its posts again.
@paulocastellano
paulocastellano merged commit 9d07bc0 into main Oct 10, 2026
10 checks passed
@paulocastellano
paulocastellano deleted the fix/tiktok-post-id-resolution branch October 10, 2026 16:03
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.

1 participant