mirror of
https://github.com/yattee/yattee.git
synced 2026-10-26 14:42:53 +00:00
Let an incoming context settle what a repair cannot argue with
A repair is a fallback, not a choice: it borrows the timestamp of the selection it replaced so that a later choice still beats it, and it is never published. But that borrowed rank also won arguments it had no business winning. An incoming explicit context stamped at the same instant that loses the `DataContext.tieBreakKey` tie was neither adopted — the repair was not outranked — nor pushed back, because a repair pushes nothing. Neither side moved, and the fallback stood against the server's explicit value indefinitely. The tie is not theoretical: migrated contexts are all stamped at the epoch, and a repair keeps the timestamp of whatever it replaced, so two migrated-era values meet as equals. A stored repair now never outranks an incoming context — it is replaced by whatever arrived, older or tied, and nothing is ever enqueued for that. Adopting is safe whatever the incoming value names: one naming an account a tombstone dominates has already been turned back into a repair before the comparison, so it stays unpublished and lands on the same target this device's repair did. Two more from the same trace: - Staged records are applied by category, and contexts already come last so that a repair reads the accounts and the instance a dominated record left behind rather than falling to local past a signed-in sibling. That ordering was load-bearing and unremarked; it is now written down, along with what it does not cover — a context applied in an earlier batch than the account record that says where the deleted account lived still repairs without it, which the next explicit choice anywhere settles. - An install that repaired its context before the repair bit existed reads back as having chosen it, so that one repair can still be published once. Accepted and documented at the load site: no shipped release ever persisted a data context, the window is interim builds only, and the next explicit choice heals it.
This commit is contained in:
@@ -723,7 +723,9 @@ struct AccountSyncTests {
|
||||
harness.recorder.clear()
|
||||
await harness.coordinator.activate()
|
||||
|
||||
#expect(harness.accounts.currentContext == .account(sibling.id))
|
||||
// The repair yields to the choice the server holds rather than pushing a
|
||||
// fallback nobody made over it — and yielding sends nothing either.
|
||||
#expect(harness.accounts.currentContext == .local)
|
||||
#expect(!harness.recorder.savedNames.contains("datacontext-current"))
|
||||
#expect(harness.coordinator.recordToSave(named: "datacontext-current") == nil)
|
||||
}
|
||||
@@ -761,6 +763,38 @@ struct AccountSyncTests {
|
||||
#expect(harness.accounts.currentContext == .account(sibling.id))
|
||||
}
|
||||
|
||||
@Test("A context fetched ahead of the records it depends on is still applied last")
|
||||
func stagedContextIsAppliedAfterTheAccountRecords() async {
|
||||
let instances = InstancesManager()
|
||||
let instance = Instance(type: .invidious, url: URL(string: "https://inv-\(UUID().uuidString).example.com")!)
|
||||
instances.add(instance)
|
||||
defer { instances.remove(instance) }
|
||||
|
||||
let harness = Self.makeHarness(instances: instances)
|
||||
let sibling = harness.accounts.addAccount(instanceID: instance.id)
|
||||
harness.accounts.setStatus(.signedIn, for: sibling.id)
|
||||
|
||||
// The order records come back in is CloudKit's, not ours: the same three
|
||||
// as above, with the context enumerated first. Applied in that order the
|
||||
// repair would run before anything knew the deleted account existed, let
|
||||
// alone which server it lived on, and land on local past the sibling.
|
||||
let deleted = Account(instanceID: instance.id)
|
||||
harness.coordinator.stageFetched(
|
||||
records: [
|
||||
harness.mapper.toCKRecord(
|
||||
dataContext: StoredDataContext(context: .account(deleted.id), changedAt: Date())
|
||||
),
|
||||
harness.mapper.toCKRecord(account: deleted),
|
||||
harness.mapper.toCKRecord(accountDeletionID: deleted.id)
|
||||
],
|
||||
deletions: []
|
||||
)
|
||||
await harness.coordinator.activate()
|
||||
|
||||
#expect(harness.accounts.deletedAccountInstanceIDs[deleted.id] == instance.id)
|
||||
#expect(harness.accounts.currentContext == .account(sibling.id))
|
||||
}
|
||||
|
||||
@Test("Records fetched before the identity resolves are recovered by a bootstrap")
|
||||
func unresolvedIdentityForcesAnotherBootstrap() async {
|
||||
let defaults = Self.makeDefaults()
|
||||
|
||||
@@ -981,26 +981,153 @@ struct DataContextRepairTests {
|
||||
let older = StoredDataContext(context: .local, changedAt: chosenAt.addingTimeInterval(-60))
|
||||
#expect(!manager.applySyncedContext(older))
|
||||
|
||||
// The repair only outranks the server's copy because it inherited that
|
||||
// The repair only outranked the server's copy because it inherited that
|
||||
// timestamp. Publishing it would put a fallback nobody chose over an
|
||||
// explicit choice — the server's context stands, and the next real
|
||||
// selection anywhere re-converges everyone.
|
||||
#expect(manager.currentContext == .account(sibling.id))
|
||||
#expect(manager.storedContext.changedAt == chosenAt)
|
||||
// explicit choice, so the explicit value is taken as it stands instead —
|
||||
// and nothing is sent.
|
||||
#expect(manager.currentContext == .local)
|
||||
#expect(manager.storedContext.changedAt == older.changedAt)
|
||||
#expect(!manager.pendingContextUpload)
|
||||
|
||||
// Still a repair after a relaunch, or the next stale record read from
|
||||
// disk would publish it.
|
||||
// What was adopted is a choice, so it publishes like one when this
|
||||
// device later has the newer copy.
|
||||
let reloaded = makeManager(instances: instances, defaults: defaults)
|
||||
#expect(!reloaded.applySyncedContext(older))
|
||||
#expect(!reloaded.pendingContextUpload)
|
||||
|
||||
// Choosing it deliberately is a different thing entirely, and publishes.
|
||||
reloaded.setContext(.account(sibling.id))
|
||||
#expect(reloaded.applySyncedContext(older))
|
||||
#expect(reloaded.pendingContextUpload)
|
||||
}
|
||||
|
||||
@Test("A repair read back from disk still yields rather than publishing")
|
||||
func reloadedRepairStillYieldsToTheServersContext() {
|
||||
let instances = InstancesManager()
|
||||
let instance = makeInstance("inv")
|
||||
instances.add(instance)
|
||||
defer { instances.remove(instance) }
|
||||
|
||||
let defaults = UserDefaults(suiteName: "yattee.tests.\(UUID().uuidString)")!
|
||||
let manager = makeManager(instances: instances, defaults: defaults)
|
||||
let selected = manager.addAccount(instanceID: instance.id)
|
||||
let sibling = manager.addAccount(instanceID: instance.id)
|
||||
manager.setStatus(.signedIn, for: sibling.id)
|
||||
|
||||
manager.setContext(.account(selected.id))
|
||||
let chosenAt = manager.storedContext.changedAt
|
||||
manager.confirmContextUpload(changedAt: chosenAt)
|
||||
manager.applyRemoteTombstone(selected.id)
|
||||
#expect(manager.currentContext == .account(sibling.id))
|
||||
|
||||
// Relaunched before any record arrives: without the repair surviving on
|
||||
// disk this reads as a choice, and the stale record below would be
|
||||
// pushed back over rather than adopted.
|
||||
let reloaded = makeManager(instances: instances, defaults: defaults)
|
||||
reloaded.setStatus(.signedIn, for: sibling.id)
|
||||
#expect(reloaded.currentContext == .account(sibling.id))
|
||||
|
||||
let older = StoredDataContext(context: .local, changedAt: chosenAt.addingTimeInterval(-60))
|
||||
#expect(!reloaded.applySyncedContext(older))
|
||||
|
||||
#expect(reloaded.currentContext == .local)
|
||||
#expect(!reloaded.pendingContextUpload)
|
||||
}
|
||||
|
||||
@Test("A repair yields to an explicit context it ties with")
|
||||
func repairYieldsToAnExplicitContextOnATie() {
|
||||
let instances = InstancesManager()
|
||||
let instance = makeInstance("inv")
|
||||
instances.add(instance)
|
||||
defer { instances.remove(instance) }
|
||||
|
||||
let manager = makeManager(instances: instances)
|
||||
let selected = manager.addAccount(instanceID: instance.id)
|
||||
let sibling = manager.addAccount(instanceID: instance.id)
|
||||
manager.setStatus(.signedIn, for: sibling.id)
|
||||
|
||||
manager.setContext(.account(selected.id))
|
||||
let chosenAt = manager.storedContext.changedAt
|
||||
manager.confirmContextUpload(changedAt: chosenAt)
|
||||
manager.applyRemoteTombstone(selected.id)
|
||||
#expect(manager.currentContext == .account(sibling.id))
|
||||
|
||||
// Exactly the same instant — which two migrated contexts always are,
|
||||
// both being stamped at the epoch. The tie-break settles it against the
|
||||
// incoming value, so the repair is neither outranked nor pushed back and
|
||||
// would otherwise stand against the server's choice forever.
|
||||
let tied = StoredDataContext(context: .local, changedAt: chosenAt)
|
||||
#expect(!manager.applySyncedContext(tied))
|
||||
|
||||
#expect(manager.currentContext == .local)
|
||||
#expect(!manager.pendingContextUpload)
|
||||
}
|
||||
|
||||
@Test("A repair yields to an older context naming an account that is still there")
|
||||
func repairYieldsToAnOlderContextNamingALiveAccount() {
|
||||
let instances = InstancesManager()
|
||||
let instance = makeInstance("inv")
|
||||
instances.add(instance)
|
||||
defer { instances.remove(instance) }
|
||||
|
||||
let manager = makeManager(instances: instances)
|
||||
let selected = manager.addAccount(instanceID: instance.id)
|
||||
let sibling = manager.addAccount(instanceID: instance.id)
|
||||
let other = manager.addAccount(instanceID: instance.id)
|
||||
manager.setStatus(.signedIn, for: sibling.id)
|
||||
|
||||
manager.setContext(.account(selected.id))
|
||||
let chosenAt = manager.storedContext.changedAt
|
||||
manager.confirmContextUpload(changedAt: chosenAt)
|
||||
manager.applyRemoteTombstone(selected.id)
|
||||
#expect(manager.currentContext == .account(sibling.id))
|
||||
|
||||
let older = StoredDataContext(
|
||||
context: .account(other.id),
|
||||
changedAt: chosenAt.addingTimeInterval(-60)
|
||||
)
|
||||
#expect(!manager.applySyncedContext(older))
|
||||
|
||||
// Somebody chose that account; the sibling was picked by this device's
|
||||
// own statuses. Adopting it is what re-converges the devices, and it is
|
||||
// still not a reason to send anything.
|
||||
#expect(manager.currentContext == .account(other.id))
|
||||
#expect(!manager.pendingContextUpload)
|
||||
}
|
||||
|
||||
@Test("An older context naming the deleted account repairs again rather than landing on it")
|
||||
func repairYieldsToAnOlderContextNamingTheDeletedAccount() {
|
||||
let instances = InstancesManager()
|
||||
let instance = makeInstance("inv")
|
||||
instances.add(instance)
|
||||
defer { instances.remove(instance) }
|
||||
|
||||
let manager = makeManager(instances: instances)
|
||||
let selected = manager.addAccount(instanceID: instance.id)
|
||||
let sibling = manager.addAccount(instanceID: instance.id)
|
||||
manager.setStatus(.signedIn, for: sibling.id)
|
||||
|
||||
manager.setContext(.account(selected.id))
|
||||
let chosenAt = manager.storedContext.changedAt
|
||||
manager.confirmContextUpload(changedAt: chosenAt)
|
||||
manager.applyRemoteTombstone(selected.id)
|
||||
|
||||
// The server still holds the choice the tombstone killed. Yielding to it
|
||||
// means yielding to what it repairs to, so the result is the repair
|
||||
// again — and it is still a repair, so it still never publishes.
|
||||
let older = StoredDataContext(
|
||||
context: .account(selected.id),
|
||||
changedAt: chosenAt.addingTimeInterval(-60)
|
||||
)
|
||||
#expect(!manager.applySyncedContext(older))
|
||||
|
||||
#expect(manager.currentContext == .account(sibling.id))
|
||||
// Yielded rather than held onto: the borrowed timestamp goes back to
|
||||
// what the server holds, so this device stops out-ranking it.
|
||||
#expect(manager.storedContext.changedAt == older.changedAt)
|
||||
#expect(manager.storedContextIsRepair)
|
||||
#expect(!manager.pendingContextUpload)
|
||||
}
|
||||
|
||||
@Test("A repair recomputes statuses before it picks a sibling")
|
||||
func repairRefreshesStatusesBeforeChoosing() {
|
||||
let instances = InstancesManager()
|
||||
|
||||
Reference in New Issue
Block a user