Repository navigation
Stop guessing TikTok video ids from captions - #400
Merged
Merged
Conversation
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.
…nknown own video in the repair
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Nightwatch #70:
LogicException: The remote id is already attached to another TryPost publication.inCollectPublicationMetrics.When TikTok had not reported a post's public video id yet,
TikTokPublisherfell back tovideo/listand 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_idonly 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:TikTokPublisher. Until TikTok reports the id, a post keeps its publish_id and the profile URL, and stayspublished.ResolveTikTokVideoId, dispatched byPublishToSocialPlatformone minute after a public TikTok post is published without its video id, asksTikTokPublisher::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.AssignTikTokVideoIdwrites 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 throughreconcileRemoteId, 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 throughSyncTryPostPublication, so it gets the same merge and the same conflict guard. The only other writer isImportExternalPosts::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).LogicExceptionstays 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.AssignTikTokVideoIdretries its transaction on a deadlock (it locks the post before the publication; the importer locks them the other way).TikTokAnalyticsis 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 throughReadPublicationAnalytics) moved toPersistedPostMetricsReadTest.Post::scopePublishedToTikTok()for the query the sweep and the repair share..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 fromanalytics_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:
The second check matters: posts whose video was created long before
published_atcan 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):Tests
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) andRepairTikTokVideoIdsCommandTest(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.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 assertsvideo/listis never called. Caption-match tests removed with the code.video_123as the public video id now use numeric ids, as TikTok'sint64field does.