Guarding playback position syncing with playedUpToModified check#4085
Guarding playback position syncing with playedUpToModified check#4085bjtitus wants to merge 6 commits into
Conversation
…sition overwrites SyncTask.importEpisode unconditionally overwrites the local playedUpTo with whatever the server returns, without checking recency. This can cause playback position to jump backward when multiple sync responses arrive with different positions for the same episode. The fix, behind the syncPlayedUpToTimestampCheck feature flag: - EpisodeDataManager: Add saveIfNotModified(playedUpTo:remoteModified:) that atomically sets both playedUpTo AND playedUpToModified in a single SQL UPDATE with a WHERE playedUpToModified < ? guard. This ensures a stale remote write is rejected if a newer one was already accepted. - SyncTask+ServerChanges: When the flag is enabled, use the new guarded write instead of the unconditional saveEpisode. The seek is gated on the write succeeding, so both the DB and player are protected together. - EpisodeDataManager.markAllSynced: When the flag is enabled, preserve playedUpToModified instead of zeroing it. This allows the timestamp guard to work across sync cycles. The trade-off is that the episode remains in unsyncedEpisodes queries and its position is re-sent on subsequent syncs (idempotent and harmless). - FeatureFlag: Add syncPlayedUpToTimestampCheck, defaulting to false for controlled rollout via remote config. - Tests: Add SyncTaskTests+PositionRace covering both flag-off (documenting the current unguarded behavior) and flag-on (verifying stale writes are rejected within and across sync cycles).
This can help debug seek issues between devices. Right now we can't tell _which_ episode was seeking.
There was a problem hiding this comment.
Pull request overview
This PR addresses playback position “skip-back” during sync (notably between iPhone and Apple Watch) by preventing stale server playedUpTo values from overwriting newer local progress, using the server-provided playedUpToModified timestamp as a recency guard (behind a feature flag).
Changes:
- Add
syncPlayedUpToTimestampCheckfeature flag (default off) and gate the new behavior behind it. - Update
SyncTask.importEpisodeto conditionally writeplayedUpToonly when the remoteplayedUpToModifiedis newer, and only seek when the guarded write succeeds. - Add DB support for an atomic guarded update (
playedUpTo+playedUpToModified) and add a regression test suite documenting the race and verifying the guarded behavior.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| podcasts/PlaybackManager.swift | Adds episode title context to sync-seek logging to aid diagnosis. |
| Modules/Utils/Sources/PocketCastsUtils/Feature Flags/FeatureFlag.swift | Introduces the syncPlayedUpToTimestampCheck flag (default off). |
| Modules/Server/Tests/PocketCastsServerTests/SyncTaskTests+PositionRace.swift | Adds tests covering the stale-position overwrite race and the flagged guarded behavior. |
| Modules/Server/Sources/PocketCastsServer/Public/Sync/SyncTask+ServerChanges.swift | Uses the guarded playedUpTo write (and gates seek) when the flag is enabled. |
| Modules/DataModel/Sources/PocketCastsDataModel/Public/DataManager.swift | Exposes a public saveIfNotModified(playedUpTo:remoteModified:episodeUuid:) entry point. |
| Modules/DataModel/Sources/PocketCastsDataModel/Private/Managers/EpisodeDataManager.swift | Implements the atomic guarded SQL UPDATE and changes markAllSynced behavior under the flag. |
| /// SQL fragment for columns to zero when marking episodes as synced. | ||
| /// When syncPlayedUpToTimestampCheck is enabled, playedUpToModified is | ||
| /// preserved so that the timestamp guard in importEpisode can reject stale | ||
| /// remote positions across sync cycles. The trade-off is that the episode | ||
| /// remains in unsyncedEpisodes queries and its playedUpTo will be re-sent | ||
| /// in subsequent sync requests (idempotent and harmless). | ||
| private var markSyncedSetClause: String { | ||
| if FeatureFlag.syncPlayedUpToTimestampCheck.enabled { | ||
| return "playingStatusModified = 0, durationModified = 0, keepEpisodeModified = 0, archivedModified = 0" | ||
| } else { | ||
| return "playingStatusModified = 0, playedUpToModified = 0, durationModified = 0, keepEpisodeModified = 0, archivedModified = 0" | ||
| } |
There was a problem hiding this comment.
With syncPlayedUpToTimestampCheck enabled, markAllSynced intentionally preserves playedUpToModified, but unsyncedEpisodes(...) treats playedUpToModified > 0 as “needs upload” and SyncTask.changedEpisodes will include playedUpTo/playedUpToModified in outgoing sync payloads. This means any episode that received a remote position update will be re-sent on every subsequent sync until something else clears playedUpToModified, increasing sync traffic and risking ping‑pong between devices. Consider storing the remote recency marker separately from the “local unsynced” modified column (or otherwise preventing preserved remote timestamps from being treated as outbound changes).
There was a problem hiding this comment.
@SergioEstevao I believe this comment is accurate and is something we need to fix before merging this PR.
EpisodeDataManager.markSyncedSetClause(lines 880‑886) skipsplayedUpToModified = 0when
syncPlayedUpToTimestampCheckis on, by design (to keep the guard insaveIfNotModifiedworking
across syncs).EpisodeDataManager.unsyncedEpisodes(line 207) uses playedUpToModified > 0 as the "dirty"
predicate.SyncTask+LocalChanges.changedEpisodes(lines 63‑66) gatesplayedUpTo/playedUpToModifiedon that same column.
So any episode that ever received a remote position update stays in the unsynced set forever and gets re-uploaded on every sync. It's not a true ping-pong (the server's own timestamp guard should reject the echo since playedUpToModified matches), but outbound payloads grow unboundedly over time.
There was a problem hiding this comment.
@leandroalonso do you think we could change playedUpToModified to be an enum so different values will translate differently in terms of sync process?
| /// when the local playedUpToModified is 0 (which it always is during sync, | ||
| /// because processServerData calls markAllSynced first). |
There was a problem hiding this comment.
The doc comment for testFlagEnabled_remotePositionAcceptedWhenLocalTimestampIsZero says playedUpToModified is “always … 0 during sync because processServerData calls markAllSynced first”, but this PR explicitly changes markAllSynced to preserve playedUpToModified when syncPlayedUpToTimestampCheck is enabled. Updating this comment to reflect the new behavior (e.g., “when the local timestamp is 0”) will keep the test documentation accurate.
| /// when the local playedUpToModified is 0 (which it always is during sync, | |
| /// because processServerData calls markAllSynced first). | |
| /// when the local playedUpToModified is 0 (i.e. when the local timestamp is 0). | |
| /// |
|
Version |
# Conflicts: # Modules/Sources/PocketCastsUtils/Feature Flags/FeatureFlag.swift # Modules/Tests/PocketCastsServerTests/SyncTaskTests+PositionRace.swift
|
Version |
|
Version |
|
Version |
|
Version |
|
Version |
Fixes PCIOS-572
Summary
SyncTask.importEpisodeunconditionally overwrites the localplayedUpTowith whatever the server returns with no recency check. When multiple sync responses arrive with different positions for the same episode, a stale response can overwrite a newer one, causing the player to seek backward.playedUpToModifiedtimestamp field, butimportEpisodenever checks it.Log evidence
User reported skip-back on episode
27fe8dee. Phone logs show four sync-driven seeks in 5 seconds including a backward jump:All
startPlaybackAfterSeek false— the signature ofseekToFromSync, the code path this PR guards.Changes
All behind the
syncPlayedUpToTimestampCheckfeature flag:EpisodeDataManager.saveIfNotModified(playedUpTo:remoteModified:)atomically sets bothplayedUpToandplayedUpToModifiedin one SQL UPDATE withWHERE playedUpToModified < ?. A stale remote write is rejected if a newer one was already accepted.importEpisodeuses the guard —SyncTask+ServerChanges.swiftnow checks the remoteplayedUpToModifiedtimestamp before writing. The seek is gated on the write succeeding.markAllSyncedpreserves the timestamp — Previously,markAllSyncedzeroedplayedUpToModifiedbefore imports, destroying the cross-cycle guard. With the flag on, it skips zeroingplayedUpToModified. Trade-off: the episode stays inunsyncedEpisodesand its position is re-sent on subsequent syncs (idempotent, harmless).Things to look for in logs
Once the flag is enabled in a release, look for these in user logs to verify the fix:
importEpisode: rejected stale playedUpTo X (remoteModified Y not newer than local)- the guard blocked a stale write. If this appears alongside a user report that skip-back stopped, it confirms the root cause and that the fix is working.seek to X startPlaybackAfterSeek false for episode [name]- sync triggered a seek, and the episode name lets us match phone and watch activity for the same episode across devices.remoteModifiedin a rejection log is always0- the server is not populatingplayedUpToModified, so the client is blocking all remote position updates rather than only stale ones. That points to a server-side issue.rejected stale playedUpTologs, but multipleseek to X startPlaybackAfterSeek false for episode [same name]lines still appear in quick succession - the backward seeks are likely coming from somewhere other thanimportEpisode(for exampleRetrieveCustomFilesTaskorchangePlaybackPositionCommand).seek to X startPlaybackAfterSeek false for episode [name]lines at similar timestamps - both devices are seeking the same episode, which was previously hard to verify because watch logs did not include episode names.Test plan
Playback
Seek
Checklist
CHANGELOG.mdif necessary.