feat: Enhanced SyncYomi with Robust Locking Mechanism

- Introduced `LockFile` data class to manage sync status and ownership.
- Enumerated `SyncStatus` to clearly indicate the stage of synchronization: Pending, Syncing, or Success.
- Implemented a new `beforeSync` method utilizing `OkHttpClient` for secure HTTP calls to manage the lock file.
- Added JSON serialization for lock file management and status updates.
- Implemented exponential backoff strategy to handle concurrent sync attempts and reduce race conditions.
- Added comprehensive logging for debugging and monitoring sync processes.

This update enhances the reliability and concurrency handling of SyncYomi's synchronization process, ensuring users experience seamless and efficient data syncing in a self-hosted environment.

Signed-off-by: KaiserBh <kaiserbh@proton.me>
This commit is contained in:
KaiserBh 2024-01-07 09:50:30 +11:00
parent 39d5714a1b
commit eb5b1610d0
No known key found for this signature in database
GPG Key ID: 14D73B142042BBA9
2 changed files with 108 additions and 0 deletions

View File

@ -1,9 +1,14 @@
package eu.kanade.tachiyomi.data.sync.service
import android.content.Context
import com.google.gson.JsonObject
import eu.kanade.tachiyomi.data.sync.SyncNotifier
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.PATCH
import eu.kanade.tachiyomi.network.POST
import kotlinx.coroutines.delay
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import logcat.LogPriority
@ -22,6 +27,94 @@ class SyncYomiSyncService(
syncPreferences: SyncPreferences,
private val notifier: SyncNotifier,
) : SyncService(context, json, syncPreferences) {
@Serializable
enum class SyncStatus {
@SerialName("pending")
Pending,
@SerialName("syncing")
Syncing,
@SerialName("success")
Success
}
@Serializable
data class LockFile(
@SerialName("id")
val id: Int?,
@SerialName("user_api_key")
val userApiKey: String?,
@SerialName("acquired_by")
val acquiredBy: String?,
@SerialName("last_synced")
val lastSynced: String?,
@SerialName("status")
val status: SyncStatus,
@SerialName("acquired_at")
val acquiredAt: String?,
@SerialName("expires_at")
val expiresAt: String?
)
override suspend fun beforeSync() {
val host = syncPreferences.syncHost().get()
val apiKey = syncPreferences.syncAPIKey().get()
val lockFileApi = "$host/api/sync/lock"
val deviceId = syncPreferences.uniqueDeviceID()
val client = OkHttpClient()
val headers = Headers.Builder().add("X-API-Token", apiKey).build()
val createLockfileJson = JsonObject().apply {
addProperty("acquired_by", deviceId)
}
val patchJson = JsonObject().apply {
addProperty("user_api_key", apiKey)
addProperty("acquired_by", deviceId)
}
val lockFileRequest = GET(
url = lockFileApi,
headers = headers,
)
val lockFileCreate = POST(
url = lockFileApi,
headers = headers,
body = createLockfileJson.toString().toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull()),
)
val lockFileUpdate = PATCH(
url = lockFileApi,
headers = headers,
body = patchJson.toString().toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull()),
)
// create lock file first
client.newCall(lockFileCreate).execute()
// update lock file acquired_by
client.newCall(lockFileUpdate).execute()
val json = Json { ignoreUnknownKeys = true }
var backoff = 2000L // Start with 2 seconds
val maxBackoff = 32000L // Maximum backoff time e.g., 32 seconds
var lockFile: LockFile
do {
val response = client.newCall(lockFileRequest).execute()
val responseBody = response.body.string()
lockFile = json.decodeFromString<LockFile>(responseBody)
logcat(LogPriority.DEBUG) { "SyncYomi lock file status: ${lockFile.status}" }
if (lockFile.status != SyncStatus.Success) {
logcat(LogPriority.DEBUG) { "Lock file not ready, retrying in $backoff ms..." }
delay(backoff)
backoff = (backoff * 2).coerceAtMost(maxBackoff)
}
} while (lockFile.status != SyncStatus.Success)
// update lock file acquired_by
client.newCall(lockFileUpdate).execute()
}
override suspend fun pullSyncData(): SyncData? {
val host = syncPreferences.syncHost().get()
val apiKey = syncPreferences.syncAPIKey().get()

View File

@ -2,6 +2,7 @@ package tachiyomi.domain.sync
import tachiyomi.core.preference.Preference
import tachiyomi.core.preference.PreferenceStore
import java.util.UUID
class SyncPreferences(
private val preferenceStore: PreferenceStore,
@ -22,4 +23,18 @@ class SyncPreferences(
Preference.appStateKey("google_drive_refresh_token"),
"",
)
fun uniqueDeviceID(): String {
val uniqueIDPreference = preferenceStore.getString("unique_device_id", "")
// Retrieve the current value of the preference
var uniqueID = uniqueIDPreference.get()
if (uniqueID.isBlank()) {
uniqueID = UUID.randomUUID().toString()
uniqueIDPreference.set(uniqueID)
}
return uniqueID
}
}