fix(infrared): add cutoff timestamp to failInterruptedLearning to prevent race with subsequent tasks
This commit is contained in:
+19
@@ -139,6 +139,25 @@ class RoomInfraredKeyRepositoryTest {
|
|||||||
assertEquals(InfraredKeyStatus.READY, k3.status)
|
assertEquals(InfraredKeyStatus.READY, k3.status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun failInterruptedLearningRespectsCutoffEpochMillis() = runTest {
|
||||||
|
repository.insert(testKey(id = "old_k", sdkKey = "old_k", status = InfraredKeyStatus.LEARNING).copy(createdAtEpochMillis = 100L))
|
||||||
|
repository.insert(testKey(id = "new_k", sdkKey = "new_k", status = InfraredKeyStatus.LEARNING).copy(createdAtEpochMillis = 200L))
|
||||||
|
|
||||||
|
val countResult = repository.failInterruptedLearning(
|
||||||
|
profileId = ProviderProfileId("profile1"),
|
||||||
|
cutoffEpochMillis = 150L,
|
||||||
|
updatedAtEpochMillis = 150L,
|
||||||
|
) as DomainResult.Success
|
||||||
|
assertEquals(1, countResult.value)
|
||||||
|
|
||||||
|
val oldK = (repository.getKey(InfraredKeyRef(ProviderProfileId("profile1"), InfraredKeyId("old_k"))) as DomainResult.Success).value!!
|
||||||
|
assertEquals(InfraredKeyStatus.FAILED, oldK.status)
|
||||||
|
|
||||||
|
val newK = (repository.getKey(InfraredKeyRef(ProviderProfileId("profile1"), InfraredKeyId("new_k"))) as DomainResult.Success).value!!
|
||||||
|
assertEquals(InfraredKeyStatus.LEARNING, newK.status)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun persistsAndObservesDeviceName() = runTest {
|
fun persistsAndObservesDeviceName() = runTest {
|
||||||
val initial = repository.observeDeviceName(ProviderProfileId("profile1")).first() as DomainResult.Success
|
val initial = repository.observeDeviceName(ProviderProfileId("profile1")).first() as DomainResult.Success
|
||||||
|
|||||||
+10
-2
@@ -39,9 +39,17 @@ class InfraredLearningCoordinator(
|
|||||||
relearnInfraredKey(ref)
|
relearnInfraredKey(ref)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun failInterruptedLearning(profileId: ProviderProfileId, updatedAtEpochMillis: Long = System.currentTimeMillis()) {
|
fun failInterruptedLearning(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
cutoffEpochMillis: Long = System.currentTimeMillis(),
|
||||||
|
updatedAtEpochMillis: Long = cutoffEpochMillis,
|
||||||
|
) {
|
||||||
externalScope.launch(NonCancellable) {
|
externalScope.launch(NonCancellable) {
|
||||||
keyRepository.failInterruptedLearning(profileId, updatedAtEpochMillis)
|
keyRepository.failInterruptedLearning(
|
||||||
|
profileId = profileId,
|
||||||
|
cutoffEpochMillis = cutoffEpochMillis,
|
||||||
|
updatedAtEpochMillis = updatedAtEpochMillis,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -81,13 +81,16 @@ interface InfraredDao {
|
|||||||
lastErrorCode = NULL,
|
lastErrorCode = NULL,
|
||||||
lastErrorDetail = :errorDetail,
|
lastErrorDetail = :errorDetail,
|
||||||
updatedAtEpochMillis = :updatedAtEpochMillis
|
updatedAtEpochMillis = :updatedAtEpochMillis
|
||||||
WHERE profileId = :profileId AND status = 'LEARNING'
|
WHERE profileId = :profileId
|
||||||
|
AND status = 'LEARNING'
|
||||||
|
AND createdAtEpochMillis <= :cutoffEpochMillis
|
||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
suspend fun failInterruptedLearning(
|
suspend fun failInterruptedLearning(
|
||||||
profileId: String,
|
profileId: String,
|
||||||
targetStatus: String,
|
targetStatus: String,
|
||||||
errorDetail: String,
|
errorDetail: String,
|
||||||
|
cutoffEpochMillis: Long,
|
||||||
updatedAtEpochMillis: Long,
|
updatedAtEpochMillis: Long,
|
||||||
): Int
|
): Int
|
||||||
|
|
||||||
|
|||||||
+6
@@ -11,6 +11,7 @@ import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
|
|||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyRepository
|
import com.flagship.abox.manager.domain.infrared.InfraredKeyRepository
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
|
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
|
||||||
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
|
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
@@ -147,6 +148,7 @@ class RoomInfraredKeyRepository(
|
|||||||
|
|
||||||
override suspend fun failInterruptedLearning(
|
override suspend fun failInterruptedLearning(
|
||||||
profileId: ProviderProfileId,
|
profileId: ProviderProfileId,
|
||||||
|
cutoffEpochMillis: Long,
|
||||||
updatedAtEpochMillis: Long,
|
updatedAtEpochMillis: Long,
|
||||||
): DomainResult<Int> {
|
): DomainResult<Int> {
|
||||||
return try {
|
return try {
|
||||||
@@ -154,10 +156,14 @@ class RoomInfraredKeyRepository(
|
|||||||
profileId = profileId.value,
|
profileId = profileId.value,
|
||||||
targetStatus = InfraredKeyStatus.FAILED.name,
|
targetStatus = InfraredKeyStatus.FAILED.name,
|
||||||
errorDetail = INTERRUPTED_LEARNING_ERROR_DETAIL,
|
errorDetail = INTERRUPTED_LEARNING_ERROR_DETAIL,
|
||||||
|
cutoffEpochMillis = cutoffEpochMillis,
|
||||||
updatedAtEpochMillis = updatedAtEpochMillis,
|
updatedAtEpochMillis = updatedAtEpochMillis,
|
||||||
)
|
)
|
||||||
DomainResult.Success(count)
|
DomainResult.Success(count)
|
||||||
} catch (throwable: Throwable) {
|
} catch (throwable: Throwable) {
|
||||||
|
if (throwable is CancellationException) {
|
||||||
|
throw throwable
|
||||||
|
}
|
||||||
DomainResult.Failure(mapThrowableToDomainError(throwable))
|
DomainResult.Failure(mapThrowableToDomainError(throwable))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-4
@@ -77,11 +77,12 @@ class InfraredRemoteViewModel @Inject constructor(
|
|||||||
init {
|
init {
|
||||||
if (authenticatedProfileId != null) {
|
if (authenticatedProfileId != null) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
val startupCutoff = System.currentTimeMillis()
|
||||||
runtimeModeStore.mode.collect { mode ->
|
runtimeModeStore.mode.collect { mode ->
|
||||||
val effective = profileResolver.resolve(authenticatedProfileId, mode)
|
val effective = profileResolver.resolve(authenticatedProfileId, mode)
|
||||||
learningCoordinator.failInterruptedLearning(effective)
|
learningCoordinator.failInterruptedLearning(effective, cutoffEpochMillis = startupCutoff)
|
||||||
if (effective != authenticatedProfileId) {
|
if (effective != authenticatedProfileId) {
|
||||||
learningCoordinator.failInterruptedLearning(authenticatedProfileId)
|
learningCoordinator.failInterruptedLearning(authenticatedProfileId, cutoffEpochMillis = startupCutoff)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -185,13 +186,14 @@ class InfraredRemoteViewModel @Inject constructor(
|
|||||||
fun toggleLearningMode(enabled: Boolean) {
|
fun toggleLearningMode(enabled: Boolean) {
|
||||||
isLearningModeFlow.value = enabled
|
isLearningModeFlow.value = enabled
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
|
val shutdownCutoff = System.currentTimeMillis()
|
||||||
underlyingLearningDeferred?.cancel()
|
underlyingLearningDeferred?.cancel()
|
||||||
underlyingLearningDeferred = null
|
underlyingLearningDeferred = null
|
||||||
activeLearningJob?.cancel()
|
activeLearningJob?.cancel()
|
||||||
activeLearningJob = null
|
activeLearningJob = null
|
||||||
activeLearningSlotFlow.value = null
|
activeLearningSlotFlow.value = null
|
||||||
state.value.effectiveProfileId?.let { profile ->
|
state.value.effectiveProfileId?.let { profile ->
|
||||||
learningCoordinator.failInterruptedLearning(profile)
|
learningCoordinator.failInterruptedLearning(profile, cutoffEpochMillis = shutdownCutoff)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
messageFlow.value = RemoteUiMessage.Info("🔴 学习录制模式已开启:点击任意按键即可开始录制")
|
messageFlow.value = RemoteUiMessage.Info("🔴 学习录制模式已开启:点击任意按键即可开始录制")
|
||||||
@@ -285,12 +287,13 @@ class InfraredRemoteViewModel @Inject constructor(
|
|||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
super.onCleared()
|
super.onCleared()
|
||||||
|
val teardownCutoff = System.currentTimeMillis()
|
||||||
underlyingLearningDeferred?.cancel()
|
underlyingLearningDeferred?.cancel()
|
||||||
underlyingLearningDeferred = null
|
underlyingLearningDeferred = null
|
||||||
activeLearningJob?.cancel()
|
activeLearningJob?.cancel()
|
||||||
activeLearningJob = null
|
activeLearningJob = null
|
||||||
state.value.effectiveProfileId?.let { profile ->
|
state.value.effectiveProfileId?.let { profile ->
|
||||||
learningCoordinator.failInterruptedLearning(profile)
|
learningCoordinator.failInterruptedLearning(profile, cutoffEpochMillis = teardownCutoff)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-3
@@ -33,12 +33,13 @@ interface InfraredKeyRepository {
|
|||||||
suspend fun deleteIfNotLearning(ref: InfraredKeyRef): DomainResult<Unit>
|
suspend fun deleteIfNotLearning(ref: InfraredKeyRef): DomainResult<Unit>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Atomically marks this profile's interrupted LEARNING keys as FAILED, clears the vendor
|
* Atomically marks this profile's interrupted LEARNING keys created on or before [cutoffEpochMillis]
|
||||||
* error code, stores [INTERRUPTED_LEARNING_ERROR_DETAIL], and updates their timestamp.
|
* as FAILED, clears the vendor error code, stores [INTERRUPTED_LEARNING_ERROR_DETAIL], and updates their timestamp.
|
||||||
*/
|
*/
|
||||||
suspend fun failInterruptedLearning(
|
suspend fun failInterruptedLearning(
|
||||||
profileId: ProviderProfileId,
|
profileId: ProviderProfileId,
|
||||||
updatedAtEpochMillis: Long,
|
cutoffEpochMillis: Long = Long.MAX_VALUE,
|
||||||
|
updatedAtEpochMillis: Long = cutoffEpochMillis,
|
||||||
): DomainResult<Int>
|
): DomainResult<Int>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -159,13 +159,14 @@ class InMemoryInfraredKeyRepository : InfraredKeyRepository {
|
|||||||
|
|
||||||
override suspend fun failInterruptedLearning(
|
override suspend fun failInterruptedLearning(
|
||||||
profileId: ProviderProfileId,
|
profileId: ProviderProfileId,
|
||||||
|
cutoffEpochMillis: Long,
|
||||||
updatedAtEpochMillis: Long,
|
updatedAtEpochMillis: Long,
|
||||||
): DomainResult<Int> {
|
): DomainResult<Int> {
|
||||||
recoveryCalls += InfraredRecoveryCall(profileId, updatedAtEpochMillis)
|
recoveryCalls += InfraredRecoveryCall(profileId, updatedAtEpochMillis)
|
||||||
recoveryError?.let { return DomainResult.Failure(it) }
|
recoveryError?.let { return DomainResult.Failure(it) }
|
||||||
var changed = 0
|
var changed = 0
|
||||||
keys.replaceAll { _, key ->
|
keys.replaceAll { _, key ->
|
||||||
if (key.profileId == profileId && key.status == InfraredKeyStatus.LEARNING) {
|
if (key.profileId == profileId && key.status == InfraredKeyStatus.LEARNING && key.createdAtEpochMillis <= cutoffEpochMillis) {
|
||||||
changed += 1
|
changed += 1
|
||||||
key.copy(
|
key.copy(
|
||||||
status = InfraredKeyStatus.FAILED,
|
status = InfraredKeyStatus.FAILED,
|
||||||
|
|||||||
+2
-1
@@ -100,11 +100,12 @@ class FakeInfraredKeyRepository : InfraredKeyRepository {
|
|||||||
|
|
||||||
override suspend fun failInterruptedLearning(
|
override suspend fun failInterruptedLearning(
|
||||||
profileId: ProviderProfileId,
|
profileId: ProviderProfileId,
|
||||||
|
cutoffEpochMillis: Long,
|
||||||
updatedAtEpochMillis: Long,
|
updatedAtEpochMillis: Long,
|
||||||
): DomainResult<Int> = synchronized(lock) {
|
): DomainResult<Int> = synchronized(lock) {
|
||||||
var changed = 0
|
var changed = 0
|
||||||
keys.replaceAll { _, key ->
|
keys.replaceAll { _, key ->
|
||||||
if (key.profileId == profileId && key.status == InfraredKeyStatus.LEARNING) {
|
if (key.profileId == profileId && key.status == InfraredKeyStatus.LEARNING && key.createdAtEpochMillis <= cutoffEpochMillis) {
|
||||||
changed += 1
|
changed += 1
|
||||||
key.copy(
|
key.copy(
|
||||||
status = InfraredKeyStatus.FAILED,
|
status = InfraredKeyStatus.FAILED,
|
||||||
|
|||||||
+35
@@ -207,6 +207,41 @@ class FakeInfraredKeyRepositoryTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun failInterruptedLearningRespectsCutoffEpochMillis() = runTest {
|
||||||
|
val repository = FakeInfraredKeyRepository()
|
||||||
|
repository.insert(
|
||||||
|
savedKey(
|
||||||
|
id = "old-learning",
|
||||||
|
sdkKey = "old-learning",
|
||||||
|
status = InfraredKeyStatus.LEARNING,
|
||||||
|
).copy(createdAtEpochMillis = 100L),
|
||||||
|
)
|
||||||
|
repository.insert(
|
||||||
|
savedKey(
|
||||||
|
id = "new-learning",
|
||||||
|
sdkKey = "new-learning",
|
||||||
|
status = InfraredKeyStatus.LEARNING,
|
||||||
|
).copy(createdAtEpochMillis = 200L),
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = repository.failInterruptedLearning(
|
||||||
|
profileId = profileOne,
|
||||||
|
cutoffEpochMillis = 150L,
|
||||||
|
updatedAtEpochMillis = 150L,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(DomainResult.Success(1), result)
|
||||||
|
assertEquals(
|
||||||
|
InfraredKeyStatus.FAILED,
|
||||||
|
repository.successfulKey(InfraredKeyRef(profileOne, InfraredKeyId("old-learning")))?.status,
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
InfraredKeyStatus.LEARNING,
|
||||||
|
repository.successfulKey(InfraredKeyRef(profileOne, InfraredKeyId("new-learning")))?.status,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun deviceConfigurationIsIsolatedByProfile() = runTest {
|
fun deviceConfigurationIsIsolatedByProfile() = runTest {
|
||||||
val repository = FakeInfraredDeviceConfigurationRepository()
|
val repository = FakeInfraredDeviceConfigurationRepository()
|
||||||
|
|||||||
Reference in New Issue
Block a user