From 8d67bd69f29343dc1bc6da2efdd3749335060f95 Mon Sep 17 00:00:00 2001 From: phoenixworker Date: Tue, 18 Aug 2026 18:49:29 -0400 Subject: [PATCH 1/2] fix(sync): send known personal record IDs on pull Empty personalRecordIds forced the portal to page PRs by cursor only. When many PRs share one microsecond timestamp, that cursor loops and the client aborts after 4 pages. Send local PR UUIDs (and IDs merged earlier in the same pull) so get_personal_records_excluding_ids can advance and tombstones can actually be fetched. Related to phoenix-portal#97. --- .../phoenixproject/data/sync/SyncManager.kt | 32 +++++++++-- .../data/sync/PortalPullPaginationTest.kt | 57 +++++++++++++++++-- .../data/sync/SyncManagerTest.kt | 8 +-- .../testutil/FakePortalApiClient.kt | 2 + 4 files changed, 84 insertions(+), 15 deletions(-) diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/SyncManager.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/SyncManager.kt index 7e76bcb77..ed0be01be 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/SyncManager.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/SyncManager.kt @@ -1442,6 +1442,7 @@ class SyncManager( val rawRoutineIds = syncRepository.getAllRoutineIds(mergeProfileId) val rawCycleIds = syncRepository.getAllCycleIds(mergeProfileId) val rawBadgeIds = syncRepository.getAllBadgeIds(mergeProfileId) + val rawPersonalRecordIds = syncRepository.getAllPersonalRecordIds(mergeProfileId) // fix(pull 400): TemplateConverter mints cycle-derived routine IDs as // "cycle_routine_" which aren't valid UUIDs. The server's @@ -1475,19 +1476,28 @@ class SyncManager( list.takeLast(SyncConfig.MAX_PARITY_IDS) } - val knownEntityIds = KnownEntityIds( + val knownPersonalRecordIds = capParity( + filterUuids(rawPersonalRecordIds, "personalRecordIds"), + "personalRecordIds", + ).toMutableList() + + fun currentKnownEntityIds(): KnownEntityIds = KnownEntityIds( sessionIds = capParity(filteredSessionIds, "sessionIds"), routineIds = capParity(filteredRoutineIds, "routineIds"), cycleIds = capParity(filteredCycleIds, "cycleIds"), badgeIds = capParity(filteredBadgeIds, "badgeIds"), - // PRs use timestamp LWW, so the portal must return newer states even - // for UUIDs already present locally (including remote tombstones). - personalRecordIds = emptyList(), + // Send known PR UUIDs so the portal can page with + // get_personal_records_excluding_ids and still return tombstones + // via get_personal_record_tombstones. Empty lists force the server + // to rely only on the cursor, which loops when many PRs share one + // microsecond timestamp. + personalRecordIds = capParity(knownPersonalRecordIds.toList(), "personalRecordIds"), ) Logger.i("SyncManager") { - "Parity sync: sending ${knownEntityIds.sessionIds.size} session IDs, " + - "${knownEntityIds.routineIds.size} routine IDs, ${knownEntityIds.cycleIds.size} cycle IDs" + "Parity sync: sending ${currentKnownEntityIds().sessionIds.size} session IDs, " + + "${currentKnownEntityIds().routineIds.size} routine IDs, ${currentKnownEntityIds().cycleIds.size} cycle IDs, " + + "${currentKnownEntityIds().personalRecordIds.size} personal record IDs" } var pagesProcessed = 0 @@ -1544,11 +1554,14 @@ class SyncManager( ) } + val knownEntityIds = currentKnownEntityIds() + // Fetch next page // DIAGNOSTIC: Log pull request parameters to trace sync issues Logger.d("SyncManager") { "PULL REQUEST: deviceId=$deviceId, profileId=$activeProfileId, " + "knownSessions=${knownEntityIds.sessionIds.size}, knownRoutines=${knownEntityIds.routineIds.size}, " + + "knownPersonalRecords=${knownEntityIds.personalRecordIds.size}, " + "cursor=$currentCursor" } @@ -1653,6 +1666,13 @@ class SyncManager( return Result.failure(mergeResult.exceptionOrNull() ?: PortalApiException("Merge failed")) } + for (record in pullResponse.personalRecords) { + val id = record.id + if (CANONICAL_UUID_REGEX.matches(id) && id !in knownPersonalRecordIds) { + knownPersonalRecordIds += id + } + } + // Update pagination state finalSyncTime = pullResponse.syncTime diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/PortalPullPaginationTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/PortalPullPaginationTest.kt index 46f8b0a1b..bf50488ee 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/PortalPullPaginationTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/PortalPullPaginationTest.kt @@ -430,7 +430,7 @@ class PortalPullPaginationTest { // ==================== Parity Sync: knownEntityIds ==================== @Test - fun pullSendsKnownEntityIdsButOmitsPersonalRecordsForLwwRefresh() = runTest { + fun pullSendsKnownPersonalRecordIdsForParityAndTombstones() = runTest { authenticate() val s1 = "11111111-1111-4111-a111-111111111111" val s2 = "22222222-2222-4222-a222-222222222222" @@ -454,9 +454,9 @@ class PortalPullPaginationTest { assertEquals(emptyList(), known.cycleIds) assertEquals(listOf(b1, b2), known.badgeIds) assertEquals( - emptyList(), + listOf(pr1), known.personalRecordIds, - "Known PR UUIDs must be omitted so the portal returns newer active rows and tombstones", + "Known PR UUIDs must be sent so the portal can page and return tombstones", ) } @@ -484,7 +484,7 @@ class PortalPullPaginationTest { } @Test - fun pullFiltersNonUuidBadgesAndOmitsPersonalRecordsBeforeSend() = runTest { + fun pullFiltersNonUuidBadgesAndPersonalRecordsBeforeSend() = runTest { authenticate() val realBadge = "77777777-7777-4777-a777-777777777777" val realPr = "88888888-8888-4888-a888-888888888888" @@ -501,7 +501,54 @@ class PortalPullPaginationTest { known.badgeIds, "Local numeric badge ids must be filtered before send; portal parity uses UUID row ids", ) - assertEquals(emptyList(), known.personalRecordIds) + assertEquals( + listOf(realPr), + known.personalRecordIds, + "Local numeric PR ids must be filtered; canonical UUIDs must be sent", + ) + } + + @Test + fun pullIncludesPersonalRecordIdsFromEarlierPagesInTheSamePull() = runTest { + authenticate() + val localPr = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + val page1Pr = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" + fakeSyncRepo.personalRecordIds = listOf(localPr) + fakeApi.pushResult = Result.success(PortalSyncPushResponse(syncTime = "2026-03-02T12:00:00Z")) + fakeApi.pullResultsQueue = mutableListOf( + Result.success( + PortalSyncPullResponse( + syncTime = 1L, + hasMore = true, + nextCursor = "pr-page-2", + personalRecords = listOf( + PullPersonalRecordDto( + id = page1Pr, + userId = "user-123", + exerciseName = "Bench", + updatedAt = "2026-07-08T14:24:09.648091+00:00", + ), + ), + ), + ), + Result.success( + PortalSyncPullResponse( + syncTime = 2L, + hasMore = false, + personalRecords = emptyList(), + ), + ), + ) + + createManager().sync() + + assertEquals(2, fakeApi.pullKnownEntityIdsHistory.size) + assertEquals(listOf(localPr), fakeApi.pullKnownEntityIdsHistory[0].personalRecordIds) + assertEquals( + listOf(localPr, page1Pr), + fakeApi.pullKnownEntityIdsHistory[1].personalRecordIds, + "IDs merged on page 1 must be sent on page 2 so a stuck cursor cannot replay them", + ) } @Test diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/SyncManagerTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/SyncManagerTest.kt index aee5df74d..0a01bab32 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/SyncManagerTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/SyncManagerTest.kt @@ -1477,7 +1477,7 @@ class SyncManagerTest { // ===== Parity Sync Tests ===== @Test - fun pullSendsKnownEntityIdsButOmitsPersonalRecordsForLwwRefresh() = runTest { + fun pullSendsKnownEntityIdsIncludingPersonalRecords() = runTest { setupAuthenticated() // Set up fake repository with known entity IDs (simulating local database content). // IDs must be canonical UUIDs — SyncManager filters non-UUIDs before send to @@ -1511,9 +1511,9 @@ class SyncManagerTest { assertEquals(emptyList(), knownIds.cycleIds) assertEquals(listOf(badge1, badge2, badge3), knownIds.badgeIds) assertEquals( - emptyList(), + listOf(pr1, pr2), knownIds.personalRecordIds, - "Known PR UUIDs must be omitted so the portal returns newer active rows and tombstones", + "Known PR UUIDs must be sent so the portal can page and return tombstones", ) } @@ -1539,7 +1539,7 @@ class SyncManagerTest { knownIds.badgeIds, "Local numeric badge ids must be filtered before send; portal parity uses UUID row ids", ) - assertEquals(emptyList(), knownIds.personalRecordIds) + assertEquals(listOf(personalRecordId), knownIds.personalRecordIds) } @Test diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakePortalApiClient.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakePortalApiClient.kt index 0c49ec2cc..30070a723 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakePortalApiClient.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakePortalApiClient.kt @@ -80,6 +80,7 @@ open class FakePortalApiClient : val pullCallCursors: MutableList = mutableListOf() val pullCallProfileIds: MutableList = mutableListOf() val pullCallTimestampsMs: MutableList = mutableListOf() + val pullKnownEntityIdsHistory: MutableList = mutableListOf() var pullTimestampSourceMs: () -> Long = { currentTimeMillis() } var lastIntegrationSyncRequest: IntegrationSyncRequest? = null var lastPlaygroundSimulationRequest: IntegrationPlaygroundSimulationRequest? = null @@ -114,6 +115,7 @@ open class FakePortalApiClient : ): Result { pullCallCount++ lastPullKnownEntityIds = knownEntityIds + pullKnownEntityIdsHistory += knownEntityIds lastPullDeviceId = deviceId lastPullProfileId = profileId pullCallProfileIds += profileId From 42781b1ce6e35b2dc13a712d0fddae2257c18fa6 Mon Sep 17 00:00:00 2001 From: phoenixworker Date: Tue, 18 Aug 2026 19:06:49 -0400 Subject: [PATCH 2/2] fix(sync): compute known entity IDs once for the parity log currentKnownEntityIds() was interpolated four times in one log line. --- .../com/devil/phoenixproject/data/sync/SyncManager.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/SyncManager.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/SyncManager.kt index ed0be01be..e80330ff2 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/SyncManager.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/SyncManager.kt @@ -1494,10 +1494,11 @@ class SyncManager( personalRecordIds = capParity(knownPersonalRecordIds.toList(), "personalRecordIds"), ) + val entityIds = currentKnownEntityIds() Logger.i("SyncManager") { - "Parity sync: sending ${currentKnownEntityIds().sessionIds.size} session IDs, " + - "${currentKnownEntityIds().routineIds.size} routine IDs, ${currentKnownEntityIds().cycleIds.size} cycle IDs, " + - "${currentKnownEntityIds().personalRecordIds.size} personal record IDs" + "Parity sync: sending ${entityIds.sessionIds.size} session IDs, " + + "${entityIds.routineIds.size} routine IDs, ${entityIds.cycleIds.size} cycle IDs, " + + "${entityIds.personalRecordIds.size} personal record IDs" } var pagesProcessed = 0