a
      
      
      Synchronization  in Linkora
      
      Offline-First Two-Way Sync with Conflict Resolution That Just Works.
      
      calendar_clock
        
        Feb 16, 2025 01:05 PM IST
       
      
      
        
            
             uses multiple 
            techniques
             to make sure the data is synced with the remote database even when the 
            
             is not up (i.e., Linkora will push changes once the server is up the next time). Most of the important parts of this implementation happen in the app because it's the source of the data, so we'll have fine control over what's supposed to be pushed and what's not.
           
       
      
        
            Linkora supports 
            Two-Way Sync
             synchronization. It's up to people who use the app to decide how to use the syncing method. Linkora supports:
           
       
      
      
      
      
        
            Based on the selected option, Linkora will handle the respective implementations.
           
       
      
        
            All this in a nutshell looks like:
           
       
      
        suspend fun syncData() {
   if (canPushToServer()) {
      pushUnSyncedDataToServer()
   }
   if (canReadFromServer()) {
      establishSocketConnectionAndPerformOperations()
      getTombstonesInfoFromServer(after = TIME_STAMP).let {
         deleteFromLocalDataBasedOnTombstones(it)
      }
      getNewUpdatesFromServer(after = TIME_STAMP).let {
         updateLocalDataBasedOnRemoteUpdates(it)
      }
   }
} 
        content_copy
       
      
        
            By this, it is straightforward to understand that if the sync type is set to 
            Two-Way Sync
            , both of these conditional blocks will be true. Hence, we need to implement 
            Client-to-Server
             and 
            Server-To-Client
            .
           
       
      1. Client-to-Server
      
        
            In this case, we only need to consider:
           
       
      
        
            1. Pushing 
            CREATE
            • 
            UPDATE
            • 
            DELETE
             operations that happen locally.  
           
       
      
        
            That's all we care about. But there may be cases when the 
            sync-server
             might not be up. In that case, we need to save what's supposed to be pushed so that whenever the server and app are up, the app can send those changes. This also makes it local-first, as irrespective of server changes; it will always update locally.
           
       
      
        
            Now, the first thing is to 
            try saving locally and then pushing the changes
            . There are many operations where we need to push changes to the server, so I made a generic function that works for all these cases where we need to perform local operations and then push to the remote server:
           
       
      
        fun <LocalType, RemoteType> performLocalOperationWithRemoteSyncFlow(
   performRemoteOperation: Boolean,
   remoteOperation: suspend () -> Flow<Result<RemoteType>> = { emptyFlow() },
   remoteOperationOnSuccess: suspend (RemoteType) -> Unit = {},
   onRemoteOperationFailure: suspend () -> Unit = {},
   localOperation: suspend () -> LocalType
): Flow<Result<LocalType>> {
   return flow {
      emit(Result.Loading())
      val localResult = localOperation()
      Result.Success(localResult).let { success ->
         if (performRemoteOperation && canPushToServer()) {
            remoteOperation().collect { remoteResult ->
               remoteResult.onFailure { failureMessage ->
                  success.isRemoteExecutionSuccessful = false
                  success.remoteFailureMessage = failureMessage
                  onRemoteOperationFailure()
               }
               remoteResult.onSuccess {
                  remoteOperationOnSuccess(it.data)
               }
            }
         }
         emit(success)
      }
   }.catchAsThrowableAndEmitFailure(init = {
      if (performRemoteOperation && canPushToServer()) {
         onRemoteOperationFailure()
      }
   })
}
 
        content_copy
       
      
        
            It may seem like a lot is happening, but it's not. What this does is:
           
       
      
        
            1. Perform local operation.
           
       
      
        
            2. Try to push changes. If successful, the operation is successful.
           
       
      
        
            3. If pushing fails, 
            onRemoteOperationFailure()
             will be triggered if the sync type is set to 
            Client-to-Server
             or 
            Two-Way Sync
            .
           
       
      
        
            Now we need to figure out how to save the operations locally when there's a failure on the remote server (mostly because the server is down), so once the server is up, Linkora App can send those operations.
           
       
      
        
            For that, I have a table called 
            PendingSyncQueue
            :
           
       
      
        @Entity("pending_sync_queue")
data class PendingSyncQueue(
   @PrimaryKey(autoGenerate = true) val id: Long = 0,
   val operation: String,
   val payload: String
) 
        content_copy
       
      
        
            Now, the 
            operation
             refers to the endpoint at which the operation needs to be performed, and the 
            payload
             is the body of the POST request.
           
       
      
        
            A simple example of how this is done:
           
       
      
        onRemoteOperationFailure = {
   pendingSyncQueueRepo.addInQueue(
      PendingSyncQueue(
         operation = RemoteRoute.Link.ARCHIVE_LINK.name,
         payload = Json.encodeToString(
            IDBasedDTO(
               linkId, eventTimestamp
            )
         )
      )
   )
} 
        content_copy
       
      
        
            Where 
            every
             DTO contains 
            correlation
            . Here, the 
            IDBasedDTO
             looks like:
           
       
      
        @Serializable
data class IDBasedDTO(
   val id: Long,
   val eventTimestamp: Long,
   val correlation: Correlation = AppPreferences.getCorrelation(),
)
@Serializable
data class Correlation(
   val id: String, val clientName: String
)
 
        content_copy
       
      
        
            Correlation
             helps in identifying the client which performs the operation, because we don't want to perform locally after reading remote updates if that update was performed by us. If done by a different client, it won't match our 
            Correlation
            , so we can perform that knowing we're not the source.
           
       
      
        
            Now, once the server and app are both online, we can send queued data from 
            PendingSyncQueue
            . For the same example considered earlier, here's how it will be sent:
           
       
      
        when (queue.operation) {
   ARCHIVE_LINK.name -> {
      val idBasedDTO = Json.decodeFromString<IDBasedDTO>(queueItem.payload)
      val remoteLinkId = localLinksRepo.getRemoteLinkId(idBasedDTO.id)
      remoteLinksRepo.archiveALink(idBasedDTO.copy(id = remoteLinkId))
         .removeQueueItemAndSyncTimestamp(queueItem.id)
   }
}
private suspend inline fun Flow<Result<TimeStampBasedResponse>>.removeQueueItemAndSyncTimestamp(
   queueId: Long
) {
   this.collectLatest {
      it.onSuccess {
         pendingSyncQueueRepo.removeFromQueue(queueId)
         preferencesRepository.updateLastSyncedWithServerTimeStamp(it.data.eventTimestamp)
      }
   }
}
@Serializable
data class TimeStampBasedResponse(
   val eventTimestamp: Long,
   val message: String
) 
        content_copy
       
      
        
            This way, we can confirm the client will definitely send the data to the server (if it gets uninstalled, we can't do anything about it).
           
       
      
        
            In conclusion, the following image should give you a clear idea of how all these components work together to ensure 
            Client-to-Server
             sync works as expected:
           
       
      
        
            Now on the server-side, LWW (Last Write Wins) is implemented for some routes where updating is required. This makes sure the server only updates newer values in case all clients and the server aren't up at the same time:
           
       
      
        // on server-side
private fun checkForLWWConflictAndThrow(id: Long, timeStamp: Long) {
   transaction {
      FoldersTable.select(FoldersTable.lastModified).where {
         FoldersTable.id.eq(id)
      }.let {
         if (it.single()[FoldersTable.lastModified] > timeStamp) {
            throw LWWConflictException()
         }
      }
   }
}
---
override suspend fun markAsArchive(idBasedDTO: IDBasedDTO): Result<TimeStampBasedResponse> {
   return try {
      checkForLWWConflictAndThrow(id = idBasedDTO.id, timeStamp = idBasedDTO.eventTimestamp)
      // further impl
   } catch (e: Exception) {
      Result.Failure(e)
   }
}
 
        content_copy
       
      
        
            To support this, every table contains a column called 
            lastModified
            , which will also be sent in the POST request body and is needed for the 
            sync-server
            :
           
       
      
        @Entity(tableName = "folders")
@Serializable
data class Folder(
   val name: String,
   val note: String,
   val parentFolderId: Long?,
   @PrimaryKey(autoGenerate = true)
   val localId: Long = 0,
   val remoteId: Long? = null,
   val isArchived: Boolean = false,
   val lastModified: Long
)
 
        content_copy
       
      2. Server-to-Client
      
        
            Client-to-Server
             focuses on pushing changes, while 
            Server-to-Client
             focuses on reading changes that occurred on the remote database through the server.
           
       
      
        
            The app saves a 
            TIME_STAMP
             in its preferences, updated at every successful remote request. The 
            TIME_STAMP
             value is sent from the server (since server operations happen there).
           
       
      
        
            Changes can be read in two ways:
           
       
      
        
            1. 
            Using sockets
             if both app and server are online.
           
       
      
        
            2. 
            Custom implementations
             if the client is offline or disconnected from the server.
           
       
      1. Using sockets if both app and server are online
      
        
            When both app and server are online, it’s simple: use sockets and update as required. Linkora App handles this as follows:
           
       
      
        private suspend fun updateLocalDBAccordingToEvent(
   deserializedWebSocketEvent: WebSocketEvent
) {
   when (deserializedWebSocketEvent.operation) {
      MARK_FOLDER_AS_ARCHIVE.name -> {
         val idBasedDTO = json.decodeFromJsonElement<IDBasedDTO>(
            deserializedWebSocketEvent.payload
         )
         if (idBasedDTO.correlation.isSameAsCurrentClient()) {
            preferencesRepository.updateLastSyncedWithServerTimeStamp(idBasedDTO.eventTimestamp)
            return
         }
         val folderId = localFoldersRepo.getLocalIdOfAFolder(idBasedDTO.id)
         if (folderId != null) {
            localFoldersRepo.markFolderAsArchive(
               folderId, viaSocket = true
            ).collectAndUpdateTimestamp(idBasedDTO.eventTimestamp)
         }
      }
   }
}
 
        content_copy
       
      
        
            Similarly handle for every possible operation.
           
       
      2. Custom implementations if the client is offline or disconnected from the server
      
        
            We need to handle two scenarios if the client is offline or disconnected from the server:
           
       
      
      
        
            2. Updating data after the last known 
            TIME_STAMP
            .
           
       
      2.1 Handling deletions when offline
      
        
            We track deleted items using a server-side 
            Tombstone
             table structured as:
           
       
      
        object TombstoneTable : LongIdTable("tombstone") {
   val deletedAt = long("deleted_at")
   val operation = text("operation")
   val payload = text("payload")
} 
        content_copy
       
      
        
            The following example should give a brief idea about how this table is used:
           
       
      
        transaction {
   TombStoneHelper.insert(
      payload = Json.encodeToString(idBasedDTO),
      operation = LinkRoute.DELETE_A_LINK.name,
      deletedAt = eventTimestamp
   )
   LinksTable.deleteWhere {
      id.eq(idBasedDTO.id)
   }
} 
        content_copy
       
      
        
            And now on the client side, when both the app and server are online, we pull these tombstone records and delete the corresponding items locally.
           
       
      2.2 Updating data after the last known TIME_STAMP
      
        
            As mentioned earlier, the local database in the app contains a column called 
            lastModified
            . Similarly, tables in the remote database also include this column. The app sends its last known 
            TIME_STAMP
             to the server, which returns all changes made after that timestamp:
           
       
      
        LinksTable.selectAll().where {
   LinksTable.lastModified.greater(TIME_STAMP)
}.toList().forEach {
   updatedLinks.add(
      Link(
         id = it[LinksTable.id].value,
         linkType = LinkType.valueOf(it[LinksTable.linkType]),
         title = it[LinksTable.linkTitle],
         url = it[LinksTable.url],
         baseURL = it[LinksTable.baseURL],
         imgURL = it[LinksTable.imgURL],
         note = it[LinksTable.note],
         idOfLinkedFolder = it[LinksTable.idOfLinkedFolder],
         userAgent = it[LinksTable.userAgent],
         markedAsImportant = it[LinksTable.markedAsImportant],
         mediaType = MediaType.valueOf(it[LinksTable.mediaType]),
         eventTimestamp = it[LinksTable.lastModified]
      )
   )
} 
        content_copy
       
      
        
            Now the collected updates will be sent back to client, which it will update accordingly.
           
       
      
        
            In conclusion, the following images should give you a clear idea of how all these components work together to make sure 
            Server-to-Client
             sync operates as expected:
           
       
      
        
            1. If both app and server are online.
           
       
      
        
            2. If the client is offline or disconnected from the server.
           
       
      
      
        
            Overall, this is how synchronization works in Linkora. These operations are also used when performing manual syncing or importing data from external files, but that is outside the context of this topic, hence I didn't include it.