Skip to content

Conversation

@alex-vt
Copy link
Contributor

@alex-vt alex-vt commented Jan 16, 2026

JIRA ticket
Will be released in: 2026.2.0

Notable changes

Goal: sync revamp, with reactive usecases.

Reasons:

  • ObserveSyncInfoUseCase (sync info UI computation) is convoluted and has many suspension points, despite being driven by combined flows. This also adds jitter to visualized sync process.
  • EventSyncManager and SyncOrchestrator are large, broadly scoped, essentially each contain loosely related use cases. They are neither repositories nor usecases.
  • Accessing sync state & times is complicated.
  • Gathering counts for syncable entities, such as events and images, is complicated, and there's no reactive observation support for most.

Boundaries for the changes - what's intentionally out of scope:

  • WorkManager and workers. This is already the optimal foundation for sync.
  • UI/UX. Nothing visually new.

Main changes:

  • ObserveSyncInfoUseCase made fully reactive - without suspension points.
  • SyncUseCase, a unified (events + images) sync status reactive observation use case introduced. It is a StateFlow, with up-to-date sync status available as .value syncronously from anywhere.
  • CountSyncableUseCase, a unified (events + images) counters reactive observation use case introduced.

Additional effort (ongoing, partial):

  • EventSyncManager and SyncOrchestrator slimmed down as some of their functions were moved to usecases.
  • ObserveSyncInfoUseCase computation logic is simplified only as far as direct application of the new usecases goes. This is intentional as a waypoint in this ongoing work, to limit the size of changes and for easier code review.
  • SyncUseCase now only observes the sync process. This is intentional, to limit the size of changes. Sync controls are still in the SyncOrchestrator.

Special note about CommCare:

  • Periodic counting is additionally introduced to count enrolment records for CommCare quasi-reactively. It is there to bypass CommCare-side restrictions of how it updates data changes (it doesn't). Comments about that are added in the CommCareCandidateRecordDataSource code. A fix PR can be offered on the CommCare repo to have it notify the observer abount changes, as one of the options. This may need further evaluation of importance, considering that CommCareCandidateRecordDataSource isn't used anyway for UI-visible counters right now.

Not yet done (keeping scope limited) - also marked as todo MS-1278 in the codebase:

  • Move sync controls from SyncOrchestrator to SyncUseCase (with helper internal usecases where they fit)
  • Split the rest of SyncOrchestrator into more specific and focused use cases.
  • Split the rest of EventSyncManager into more specific and focused use cases.
  • Simplify code around the calling points of new use cases where it's not simplified yet.

Testing guidance

  • Sync counters and buttons on dashboard, sync info and logout screens should work correctly.
  • Some usecase calls were changes in other places - full regression testing recommended when the work is completed.

Additional work checklist

  • Effect on other features and security has been considered
  • Design document marked as "In development" (if applicable)
  • External (Gitbook) and internal (Confluence) Documentation is up to date (or ticket created)
  • Test cases in Testiny are up to date (or ticket created)
  • Other teams notified about the changes (if applicable)

…untEventsUseCase; crude replacements of counter usage
Note: reactive count is candidate record data sources are not implemented yet.
…rd data sources. CommCare limitations outlined in a comment.
… records from CommCare candidate data source
…rker, to prevent event sync flow from getting stuck
override suspend fun getNumberOfImagesToUpload(projectId: String): Int = localDataSource.listImages(projectId).count()

override suspend fun observeNumberOfImagesToUpload(projectId: String): Flow<Int> =
localDataSource.observeImageCounts(projectId).distinctUntilChanged()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not that it hurts but the local data source already has .distinctUntilChanged()

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's just a guarantee on this level of abstraction.

import kotlinx.coroutines.flow.map
import javax.inject.Inject

internal class CountEnrolmentRecordsUseCase @Inject constructor(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CountEnrolmentRecordsUseCase() suggests it does counting on demand, not that it creates a flow.
Do we even need a dedicated use case for this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed to ObserveEnrolmentRecordsCountUseCase. And yes - its specialized purpose is to flatMapLatest the counts for a project ID when the project ID changes. It abstracts the query away from the callers.

) {
internal operator fun invoke(): Flow<Int> = configRepository
.observeProjectConfiguration()
.map { it.projectId }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above.
Also, projectId is unused, do we need to "track" it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same thing as above. The image repository's function takes Project ID, but we are hiding it from the caller's concern, and switching automatically when it changes (tracking).

import kotlinx.coroutines.flow.distinctUntilChanged
import javax.inject.Inject

internal class EventSyncUseCase @Inject constructor(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to the above - name would suggest something else (I'd think it starts syncing). And here, too - I'm not convinced the one-liner merits extraction in a use case.

import kotlinx.coroutines.flow.transformLatest
import javax.inject.Inject

internal class ImageSyncUseCase @Inject constructor(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something like ObserveImageSyncStatus would convey the job it does better IMHO.

import javax.inject.Singleton

@Singleton
class EventDownSyncCountsRepository @Inject internal constructor(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not a repository. More like CountEventsToDownloadUseCase()

* together in a reactive way.
*/
@Singleton
class CountSyncableUseCase @Inject internal constructor(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe ObserveSyncableCountsUseCase would better convey the meaning?

@alex-vt alex-vt requested a review from BurningAXE January 22, 2026 10:58
@@ -0,0 +1,3 @@
package com.simprints.infra.sync

enum class SyncCommand { ObserveOnly }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have messed up and gave you the wrong info on this one, your initial code was correct. I have confused sealed object hierarchy with enums, which have basically identical behaviour and therefore I assumed that they should be styled similarly.

Feel free to leave this as-is to avoid unnecessary back-n-forth in this PR and fix it when new commands are added, tho.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are both variants in the codebase, for example ScreenOrientation also has non all-uppercase values. Anyway it's going to be overwritten indeed, I'll use upper case in phase 2 then.

combine(
observeEnrolmentRecordsCount(),
eventDownSyncCount(),
flow { // recordEventsToDownload
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe it would be better finally fix the "suspending function should not return flow" error in the data layer. If we are moving to use flows more, we should do it correctly.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've made the ones that are part of this branch changes, non-suspending. There are other suspending ones that return flows, already in main - won't fix, out of scope. Added a "housekeeping" ticket MS-1309 for that instead.

authStore.observeSignedInProjectId(),
eventSyncStateFlow,
imageSyncStatusFlow,
sync(eventSync = SyncCommand.OBSERVE_ONLY, imageSync = SyncCommand.OBSERVE_ONLY),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Arguably, this means that the naming should be better here or you are planning to put too much into a "use case". I would suggest rethinking the "use case" into some kind of "manager"/"helper"/"doer" with dedicated methods that hide all of that complexity and provide simpler API:

class SyncSomething constructor(...) {

  fun observe() = executeCommands(
    eventSync = SyncCommand.OBSERVE_ONLY, 
    imageSync = SyncCommand.OBSERVE_ONLY
  )

  // ...

  fun executeCommands(...) {
  // ...
  }

  // ...
}

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 61 out of 61 changed files in this pull request and generated 5 comments.

Comment on lines +37 to +49
flow { // recordEventsToDownload
emitAll(eventRepository.observeEventCount(type = null))
},
flow { // eventsToUpload
emitAll(
combine(
eventRepository.observeEventCount(EventType.ENROLMENT_V2),
eventRepository.observeEventCount(EventType.ENROLMENT_V4),
) { countV2, countV4 ->
countV2 + countV4
}
)
},
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The eventsToUpload parameter in line 38 is being set to the total count of all events (type = null), but line 40-48 shows that enrolmentsToUpload is specifically counting ENROLMENT_V2 and ENROLMENT_V4 types. This means enrolments are being counted twice: once in the total eventsToUpload count and again separately in enrolmentsToUpload. This double-counting will lead to incorrect sync counters displayed in the UI.

Copilot uses AI. Check for mistakes.
Comment on lines +37 to +51
flow { // recordEventsToDownload
emitAll(eventRepository.observeEventCount(type = null))
},
flow { // eventsToUpload
emitAll(
combine(
eventRepository.observeEventCount(EventType.ENROLMENT_V2),
eventRepository.observeEventCount(EventType.ENROLMENT_V4),
) { countV2, countV4 ->
countV2 + countV4
}
)
},
observeSamplesToUploadCount(),
) { totalRecords, recordEventsToDownload, eventsToUpload, enrolmentsToUpload, samplesToUpload ->
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parameter name recordEventsToDownload (line 37) is confusing because it's actually representing a flow that observes total events to upload (with type = null), not events to download. Based on the combine parameters and the final SyncableCounts construction, it appears the parameter on line 51 should actually be named eventsToUpload and the one on line 40-48 should be enrolmentsToUpload. The ordering and naming is inconsistent with the final data class, making this code difficult to understand and maintain.

Copilot uses AI. Check for mistakes.

val syncReporterStates = syncStartReporterStates(syncWorkers) + syncEndReporterStates(syncWorkers)

val lastSyncTime = eventSyncCache.readLastSuccessfulSyncTime()
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The lastSyncTime is read from cache on every flow emission (line 50), which happens whenever worker info changes. This means the flow will call eventSyncCache.readLastSuccessfulSyncTime() potentially many times during a sync. For consistency and performance, consider reading the lastSyncTime once when the sync completes (in the END_SYNC_REPORTER worker) or caching it within this flow's scope to avoid repeated cache reads.

Copilot uses AI. Check for mistakes.
Comment on lines +320 to +326
flow {
while (true) {
delay(CASE_COUNT_FALLBACK_POLL_INTERVAL_MILLIS)
emit(Unit)
}
},
)
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The periodic polling flow (lines 320-325) uses an infinite while (true) loop that continuously emits after delays. This coroutine is not properly scoped to the callbackFlow lifecycle. If the flow is collected and then cancelled, the periodic polling coroutine in lines 320-325 will continue running indefinitely because it's merged independently. The periodic polling should be part of the callbackFlow block so it gets cancelled when the flow is cancelled, or it should check isActive in the while loop condition.

Copilot uses AI. Check for mistakes.
Comment on lines +94 to +99
override fun observeImageCounts(projectId: String): Flow<Int> = imageRefChanges
.onStart {
emit(Unit)
} // initial listing
.mapLatest { listImages(projectId).size }
.distinctUntilChanged()
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The observeImageCounts flow uses mapLatest which cancels the previous listImages call when a new emission arrives. However, listImages performs filesystem I/O (line 84-91 walking the directory tree), which is not cancellable. If multiple image operations happen in quick succession, this could lead to multiple concurrent filesystem walks that can't be cancelled, potentially causing performance issues. Consider using map instead of mapLatest, or adding cancellation checks within listImages if it becomes a bottleneck.

Copilot uses AI. Check for mistakes.
@sonarqubecloud
Copy link

@alex-vt alex-vt removed the do not merge Pull requests that are intentionally on hold label Jan 27, 2026
@alex-vt
Copy link
Contributor Author

alex-vt commented Jan 27, 2026

As the release/2026.1.0 branch is now created, this (to target 2026.2.0) is ready to merge in main.

@alex-vt alex-vt merged commit 7ce8e04 into main Jan 27, 2026
20 checks passed
@alex-vt alex-vt deleted the spike/sync-revamp-usecase branch January 27, 2026 18:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants