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 package eu.kanade.tachiyomi.data.sync.service
import android.content.Context import android.content.Context
import com.google.gson.JsonObject
import eu.kanade.tachiyomi.data.sync.SyncNotifier import eu.kanade.tachiyomi.data.sync.SyncNotifier
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.PATCH
import eu.kanade.tachiyomi.network.POST 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.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import logcat.LogPriority import logcat.LogPriority
@ -22,6 +27,94 @@ class SyncYomiSyncService(
syncPreferences: SyncPreferences, syncPreferences: SyncPreferences,
private val notifier: SyncNotifier, private val notifier: SyncNotifier,
) : SyncService(context, json, syncPreferences) { ) : 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? { override suspend fun pullSyncData(): SyncData? {
val host = syncPreferences.syncHost().get() val host = syncPreferences.syncHost().get()
val apiKey = syncPreferences.syncAPIKey().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.Preference
import tachiyomi.core.preference.PreferenceStore import tachiyomi.core.preference.PreferenceStore
import java.util.UUID
class SyncPreferences( class SyncPreferences(
private val preferenceStore: PreferenceStore, private val preferenceStore: PreferenceStore,
@ -22,4 +23,18 @@ class SyncPreferences(
Preference.appStateKey("google_drive_refresh_token"), 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
}
} }