Repository navigation
Replies: 1 comment
|
Hi @RandalSchwartz, in June you reported two Qora bugs (#1, #2) with root-cause analyses precise enough that both were fixed in 1.1.0. Thanks again. For 1.4 I'm designing a persisted offline mutation queue and wrote the trade-offs up here. Given how closely you read the internals, I'd value your take on question 1 (string keys vs typed handles) or question 3 (dependent offline writes) if you have ten minutes. No pressure either way. |
0 replies
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Context
Qora 1.3 queues writes made while offline (
MutationOptions(offlineQueue: true)) and replays them in order on reconnect. The queue lives in memory: if the app is killed before the network returns, the writes are lost. The cache itself already survives restarts throughPersistQoraClient.1.4 makes the queue survive restarts.
The hard part
A queued mutation is a closure today: the mutator and callbacks of the widget that called it. A closure cannot be written to disk. After a restart, Qora needs another way to know what to call. The queue has to be data, not functions.
Design
1. Mutations registered by key
Mutations that must survive a restart are registered once, at startup, before
hydrate():A call site opts in with the same key and passes no mutator:
The registered mutator runs in both lives, in the same session and after a restart. One mutator per key means:
mutationId, so the server can deduplicate them;metacarriesmutationId(generated when the call is queued, stable across replays) andattempt.A call without
mutationKeykeeps working exactly as in 1.3: its own mutator, in memory only.2. Two lives for a queued call
onMutatecontext (snapshot)onSuccess/onError/onSettledinvalidates/invalidatesTagsonlyonError, the builder's state, the queue snapshotmutationEventsAfter a restart no snapshot is needed: optimistic
setQueryDatawrites are not persisted, so the restored cache only holds server data. Invalidating after replay is enough to reconcile.3. Delivery and replay failures
Durable before queued. A queued mutation reports its queued state only after its storage write has completed. If the write fails, the mutation fails immediately with that error instead of appearing saved. An app must never tell its user "saved" for a write that only exists in memory.
Immutable once sent. After the first request has gone out, the stored variables of a mutation never change, even if the response was lost. Only
attemptsandlastErrorare updated. A replay therefore always sends the same payload under the samemutationId, which is what server-side deduplication expects.At-least-once. A mutation is written to storage before its request and deleted after a successful response. If the app dies after the server applied the write but before the response arrived, the mutation is replayed on the next start. Exactly-once requires the server to deduplicate on
mutationId(for example as anIdempotency-Keyheader, or a client-generated ID with create-as-upsert). The docs will say this plainly.Permanent failure (the server rejected the write).
onErrorruns if the call site is still alive. What happens next depends on the scope (section 4):blockedand the error. The mutations behind it in that scope get statusheldand are not sent. Other scopes and the global lane keep draining.failed, leaves the replay order, and the global lane continues, as in 1.3. One bad unscoped write never blocks the rest.Holding a scope matters beyond tidiness. With client-generated IDs and create-as-upsert, an update sent after a rejected create could create the record with partial data that never passed the create's validation.
Transient failure (timeout, connection reset, 503): the mutation and the ones behind it in its scope stay queued. Replay retries with exponential backoff while the device is online, not only on the next reconnect. After
maxAttempts(default 5), the mutation is treated as permanent.Classification. Qora does not see HTTP status codes, so the app decides:
isPermanentonregisterMutation, falling back to a default inQoraClientConfig;blocked(in a scope) orfailed(without one).Unregistered (in-memory) mutations keep their 1.3 behaviour.
OfflineMutationQueue.stopOnFirstError, documented but without effect, is deprecated.Queue snapshot. The queue is readable at any time, including after a restart, as a list of entries with their key, scope, variables, attempts, last error and status:
queuedsendingblockedheldblockedentry in the same scope; not sent.failedStatuses are persisted. Replay can run during
hydrate(), before any widget listens tomutationEvents, so a stream alone would lose those failures. The same snapshot feeds the sync status API (useOfflineQueue).Decisions on a
blockedorfailedentry:retry(mutationId, variables: ...)enqueues a new mutation (newmutationId, optionally new variables), consistent with "immutable once sent". For ablockedentry, the new mutation takes its place at the head of the scope, and theheldentries drain in order after it succeeds.discard(mutationId)removes the entry. In a scope, the entries it was holding becomeblockedin turn: they may target a record that will never exist, so the user sees what would be lost and decides for each.discardScope(scope)drops a whole chain at once.maxAttemptsexhausted on a transient error leads to the same statuses as a permanent failure.Conflicts. A 409 is a permanent failure like any other: the mutation becomes
blockedorfailedwith its error. The app recognises the conflict from the error, lets the user choose, and retries with new variables. Optimistic concurrency (a base version the server checks) stays in the app's variables; Qora does not need to know about it. The docs will include a recipe.4. Ordering and scopes
Without a scope, all queued mutations share one global lane and replay in order, as in 1.3.
With
scope, mutations sharing a scope replay in order, and a mutation stuck in one scope does not block the others. Lanes replay concurrently, bounded by the existingReconnectStrategyconcurrency.A scope declares a dependency chain: a write in a scope may rely on every earlier write in the same scope. That is why a permanent failure holds the rest of the scope (section 3) instead of letting it run.
Qora cannot see which record a mutation targets, so the scope comes from the app. Pick the narrowest scope that covers the dependencies, usually the record's ID, or the parent's ID for children: a task created inside a project created offline must use the project's scope, or it can be sent before the project exists. A scope wider than needed holds more writes than necessary when something fails.
This is the model TanStack Query uses (
scope.idon mutations).5. Storage
The queue uses the same
StorageAdapteras the cache. Each queued mutation is its own entry, under a key with a reserved prefix and a sequence number, so entries cannot collide with query keys (JSON arrays) or app keys, and order survives a restart. Rewriting one entry never rewrites the whole queue.Stored record:
{version, mutationId, key, scope, variables, enqueuedAt, attempts, lastError, status}.versionallows the format to evolve.Files do not belong in the queue. Key-value adapters such as
SharedPreferencesstore strings and are not meant for binary payloads. For a photo taken offline, the app writes the bytes to a durable file first and queues its path in the variables.6. Logout
A queued write belongs to the user who made it. Replaying user A's writes after user B signs in would be a data leak.
clearStorage()also clears the persisted queue, whatever the status of its entries, andclearPendingMutations()covers the in-memory part. Since 1.3.1,hydrate()andclearStorage()only touch keys Qora wrote, so a shared Hive box orSharedPreferencesinstance is safe.Out of scope for 1.4
local:<mutationId>) that is swapped for the real ID once the create succeeds. That needs the create's response persisted (a result serializer onregisterMutation), an explicit link between the two mutations, and a hook to rewrite the dependent variables. The versioned storage format leaves room for it later. In 1.4 the supported path is client-generated IDs, with scopes for ordering.Answered questions
isPermanentper mutation with a client-level default.Decided after the first round
Still open
maxAttempts.All reactions