Repository navigation
fix(datastore): close races that could leave saves unsent or stall sync - #4404
Open
thisisabhash wants to merge 8 commits into
Open
thisisabhash wants to merge 8 commits into
thisisabhash wants to merge 8 commits into
Conversation
thisisabhash
marked this pull request as ready for review
October 28, 2026 20:36
thisisabhash
force-pushed
the
test/datastore-integ-flaky-waits
branch
from
October 28, 2026 21:55
0b5fb4c to
fe4cec3
Compare
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #4404 +/- ##
==========================================
+ Coverage 76.04% 78.91% +2.86%
==========================================
Files 1362 233 -1129
Lines 57864 13277 -44587
==========================================
- Hits 44003 10477 -33526
+ Misses 13861 2800 -11061
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
thisisabhash
force-pushed
the
test/datastore-integ-flaky-waits
branch
from
October 29, 2026 21:19
e991f8d to
45994db
Compare
thisisabhash
marked this pull request as draft
October 30, 2026 17:01
thisisabhash
marked this pull request as ready for review
October 30, 2026 19:05
DataStore integration jobs passed while retries hid failures: 26 of 147 jobs in
run 40360503947 and the 6 full runs before it, all of them async waits for a
sync or subscription event that timed out. Test-only changes:
- The ObserveQuery save/delete helpers and EndToEnd testCreate now subscribe
to AWSDataStorePlugin.publisher(for:) before mutating. observe() attaches its
sink in an unawaited Task, so the local event could be missed.
- The ConsecutiveUpdates tests wait for outboxMutationEnqueued before the second
mutation, so the outbox can't fold that mutation into one that isn't in flight.
- The V2 OptionalAssociations helpers each keep their own listener token. With
the shared token, a previous helper's closure could remove the next helper's
listener.
- The Base, Hub-events and Lazy Load bases set continueAfterFailure = true.
With false, a failed async test ran tearDown alongside the abandoned body
and then exited the process. To make true safe:
- The Hub-events and Lazy Load bases now stop, clear and settle before
Amplify.reset().
- CompositePK tests no longer skip that teardown.
- Lazy Load observe tests run their follow-up sync waits outside the
observing Task.
- The stress test's outbox wait is bounded.
…bases
These follow-ups are test-only.
- The CPK, AuthCognito, AuthIAM, MultiAuth and Stress bases also set
continueAfterFailure = true.
- Every DataStore integration base now tears down in the same order: stop,
clear, settle 1 s, then Amplify.reset(). Reset still runs when stop or clear
throws, so the next test can configure Amplify.
- A save made right after syncStarted can miss the outbox's first check and
never sync. Tests that save and then wait for the sync right after starting
now wait for ready: ConsecutiveUpdates, Scenario2, SubscriptionEndToEnd, V2
OptionalAssociations, and every Lazy Load test that goes through
setup(withModels:). The stress test opts out because it needs the outbox
status reported during startup.
- A sync-engine restart or a late duplicate event can fulfill some expectations
a second time, which aborts the runner. Those expectations now set
assertForOverFulfill = false. testObserveToQueryPostFromDeletedComment now
waits only for the delete event, and testParallelMutations fulfills once.
- Smaller fixes:
- testSaveThenMultipleUpdate waits for its first update to be in flight,
and the schemes no longer skip it.
- The Scenario8V2 tests register their listeners before saving.
- The EndToEnd tests cancel their tasks and wait up to 30 s for them to
finish.
- ObserveQuery guards indexing after a failed count check.
- The Lazy Load delete wait now allows 100 s, the same as create and
update.
- The CompositePK snapshot tests register models-only themselves instead of
for the whole class.
…anded The outbox could leave a saved mutation unsent until the next save or a restart. getNextMutationEvent queried the MutationEvent table and, when it was empty, parked its callback. submit checked for a parked callback, then inserted the new row. Neither pair was atomic. When the outbox's query ran before the insert committed, and submit checked before the outbox parked, the row was never delivered. The next save then found the parked callback and delivered its own row ahead of the stranded one, so a child could reach the service before its parent. AWSMutationDatabaseAdapter now runs each ingest and each dequeue as one block on a TaskQueue: - A dequeue that finds the table empty parks the request, and every later ingest serves it after writing. That includes an ingest whose save reports a failure, since its row may still have been written. - A parked request is always served with the oldest row, so delivery stays FIFO. - Marking the head in process can no longer overwrite a merge into that row or re-insert a create that a delete just folded away. - submit calls its completion off the queue, so callers can't hold up the outbox. AWSMutationEventPublisher now drops results requested by a subscriber it has since replaced. Before, a request parked before a restart could hand the new subscriber an extra event, which was then sent twice.
…sults OutgoingMutationQueue deleted each processed MutationEvent with Amplify.DataStore.delete, which goes through initStorageEngineAndTryStartSync. A result that arrived after stop() or clear() could start sync again or recreate the database file. The queue now deletes through its own storage adapter, as the outbox ingester already does. Work begun before a stop could also act on the next session. A late result could notify .processedEvent into the restarted queue and make it request a second event while one was in flight. An event delivered across a stop could be added to the suspended operation queue and sent again after the restart. A session generation, bumped on every stop, now drops that work. It replaces the check for the .stopped state, which missed .stopping and anything after a restart. A mutation whose result is dropped stays in the outbox, and the next session sends it again, as it already did when the result arrived after .stopped. A re-sent create fails its condition check, which calls the errorHandler and dispatches conditionalSaveFailed. A re-sent update or delete goes through conflict handling. Observers of the internal MutationEvent model no longer get a delete event for each processed mutation.
Statement+AnyModel and ModelSyncMetadataMigration looped over rows with for-in, and SQLite.swift's next() calls failableNext() with try!. A failed step crashed the app instead of throwing. CI crashed there with "disk I/O error (code: 10)" at Statement.swift:218, most likely from reconcile work still running when clear() deleted the database file. Both now call failableNext() themselves, as Statement+Model already does, so the error reaches the existing error handling. clear() also called its completion twice when it couldn't delete the database file. It now returns after the failure.
AppSyncRealTimeClient.connect() set the state to .connecting before its first try, and nothing reset it when the attempt ended without a connection. A subscribe task cancelled by an idle disconnect still ran connect(). RetryWithJitter's first Task.sleep then threw CancellationError, which isn't retried, and the client stayed .connecting with no attempt running. Every later connect() returned early, so new subscriptions on that client neither connected nor failed. Running out of retries left the client the same way. - A cancelled task no longer claims .connecting. - An attempt that ends without connecting resets the state, unless a newer attempt has taken over. - When retries run out, the client closes the socket, so a late handshake can't resume subscriptions, and sends every subscription WebSocketClient.Error.connectionLost, which subscribers get as a retryable network error. - A start that isn't acknowledged within 15 s on a live connection now fails that subscription the same way instead of leaving it silent. The start timeout was 5 s. 15 s is longer than the 10 s AppSync gives a Lambda authorizer, so a slow start still succeeds. - sendRequest no longer reports a connection error as a timeout, and it fails at once on an error frame it can't parse instead of timing out. Subscribers already receive both errors, so neither is reported twice.
…startup RemoteSyncEngine acted on its subscriptions failing or finishing only in .initializingSubscriptions and .syncEngineActive. When that happened during initial sync or a later startup step, it was ignored, and the engine stalled in .activatingCloudSubscriptions. Subscriptions that never connected or failed held the engine in .initializingSubscriptions with its outbox stopped. StorageEngine doesn't replace an engine that is still starting, so sync stayed down until the app stopped DataStore. - Subscription failures and completions are handled in every state from .initializingSubscriptions through .syncEngineActive. A failure goes to the retry policy and a completion ends sync, as both already did in the two states that handled them. - If subscriptions haven't connected 60 s after setup starts, startup fails with a retryable timeout, so the engine cleans up and restarts. - Cleanup cancels an initial sync still in progress. Left running, it dropped its remaining pages but still saved lastSync, so the next sync skipped those records. InitialSyncOperation.cancel() now sets isCancelled, which the operation already checks before reconciling a page and before saving lastSync. A query that cancel() cuts short no longer reaches the errorHandler. - An initial sync result from an earlier startup is dropped. - StateMachine.notify(action:if:) applies an action only while the condition holds for the current state, checked under the state machine's lock. Subscription completions, the timeout and initial sync results use it. An action that a state doesn't handle used to republish that state and run its work again. - A state handler that an error or stop has already replaced is skipped, and a superseded restart notifier is cancelled, so cleanup and restart run once. The handler reads the state through the new StateMachine.currentState, under the state machine's lock, because @published delivers a new state before it stores it.
thisisabhash
had a problem deploying
to
IntegrationTest
October 30, 2026 19:13 — with
GitHub Actions
Error
thisisabhash
had a problem deploying
to
IntegrationTest
October 30, 2026 19:13 — with
GitHub Actions
Error
thisisabhash
had a problem deploying
to
IntegrationTest
October 30, 2026 19:13 — with
GitHub Actions
Error
thisisabhash
had a problem deploying
to
IntegrationTest
October 30, 2026 19:13 — with
GitHub Actions
Error
thisisabhash
had a problem deploying
to
IntegrationTest
October 30, 2026 19:13 — with
GitHub Actions
Error
thisisabhash
had a problem deploying
to
IntegrationTest
October 30, 2026 19:13 — with
GitHub Actions
Error
thisisabhash
had a problem deploying
to
IntegrationTest
October 30, 2026 19:13 — with
GitHub Actions
Error
thisisabhash
had a problem deploying
to
IntegrationTest
October 30, 2026 19:13 — with
GitHub Actions
Error
thisisabhash
had a problem deploying
to
IntegrationTest
October 30, 2026 19:13 — with
GitHub Actions
Error
thisisabhash
had a problem deploying
to
IntegrationTest
October 30, 2026 19:13 — with
GitHub Actions
Error
thisisabhash
had a problem deploying
to
IntegrationTest
October 30, 2026 19:13 — with
GitHub Actions
Error
thisisabhash
had a problem deploying
to
IntegrationTest
October 30, 2026 19:13 — with
GitHub Actions
Error
thisisabhash
had a problem deploying
to
IntegrationTest
October 30, 2026 19:13 — with
GitHub Actions
Error
thisisabhash
had a problem deploying
to
IntegrationTest
October 30, 2026 19:13 — with
GitHub Actions
Error
thisisabhash
had a problem deploying
to
IntegrationTest
October 30, 2026 19:13 — with
GitHub Actions
Error
thisisabhash
had a problem deploying
to
IntegrationTest
October 30, 2026 19:13 — with
GitHub Actions
Error
…SavingInProgress
DataStore rolls back a save that finds no sync engine because stop()
completed after the save checked for one ("No SyncEngine available ...
rollback save"). The test caught that failure but still expected every
post in the local store, so it failed whenever a save hit that window.
It did, twice on watchOS in this PR's run 41114031750. A mock-API version
of the test rolls back about 15 of 1,000 saves, with or without this
PR's library changes.
The test now requires every post whose save succeeded to be queryable.
Each interrupted save must fail with internalOperation and leave no post
behind.
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Issue #
None. Found by analyzing DataStore integration runs. Run 40360503947 passed, but retries hid 10 failures in 9 tests. This PR's run 40775861072 still hid failures, and those trace to DataStore itself. Its run 40956868003 hid a sync startup that never finished.
Description
Problem. DataStore integration jobs can pass while hiding failures.
-test-iterations 3 -retry-tests-on-failurereruns the whole suite and counts a test as passed if it passed once. In run 40360503947 and the 6 full runs before it, retries hid at least one failure in 26 of 147 jobs. All of them were in the Base, Lazy Load and Transformer V2 suites, and every one was an async wait for a sync or subscription event that timed out. The test changes below fix the causes found in the tests. Run 40775861072, the first with those changes, still hid these:DataStoreConnectionOptionalAssociations.testRemovePostFromCommentAndBlogFromPost(V2, watchOS, 2 of 3 iterations). A sync wait timed out, and then the comment had lost its post.AWSDataStoreLazyLoadBlogPostComment8V2Tests.testDelete(tvOS). The create never synced, and the delete then found no sync metadata.DataStoreConnectionScenario3TestsandDataStoreConnectionScenario2Tests(Base, watchOS). A sync wait timed out.disk I/O error (code: 10)at SQLite.swift'sStatement.swift:218.Library changes 1–4 fix the races these point to. Run 40956868003, the first with them, still hid one more:
AWSDataStoreLazyLoadPostComment4V2Tests.testMultiSaveWithInterruptions(Lazy Load, iOS). That test callsstop()andstart()15 times in about 150 ms while saving. The outbox never reported empty within 120 s, and 14 of its 15 post/comment pairs never reached AppSync. A unit-test version with a mock API never stalled, on the new outbox code or the old (0 of 88 and 0 of 70 runs), and on main the same test once waited 40 minutes before its saves even began. The last sync engine never finished starting up, which library changes 5 and 6 fix.Library changes.
Saves stranded, then sent out of order (
AWSMutationDatabaseAdapter).getNextMutationEventqueried the MutationEvent table and, if it was empty, parked its callback.submitchecked for a parked callback, then inserted the new row. Neither pair was atomic. When the outbox's query ran before the insert committed, andsubmitchecked before the outbox parked, the row sat unsent until another save or a restart. The next save then found the parked callback and delivered its own row ahead of the stranded one. That fits each timeout above:testRemovePostFromCommentAndBlogFromPost, a comment reached the service before its post. The response hadpost: null, and reconciling it cleared the comment's local post ID.testDelete, the stranded create was folded into the delete, so nothing was sent.Now each ingest and each dequeue runs as one block on a
TaskQueue:submitcalls its completion off the queue, so callers can't hold up the outbox.Results for a replaced subscriber (
AWSMutationEventPublisher). A request parked before a restart could be served after it. That handed the new subscriber an extra event, which was then sent twice. The publisher now drops results requested by a subscriber it has since replaced.Late results after a stop (
OutgoingMutationQueue).Amplify.DataStore.delete, which goes throughinitStorageEngineAndTryStartSync. A result that arrived afterstop()orclear()could start sync again or recreate the database file. The queue now deletes through its own storage adapter, as the outbox ingester already does..processedEventinto the restarted queue and make it request a second event while one was in flight. An event delivered across a stop could be added to the suspended operation queue and sent again after the restart. A session generation, bumped on every stop, now drops that work. It replaces the.stoppedstate check, which missed.stoppingand anything after a restart.Crash on a failed SQLite step (
Statement+AnyModel,ModelSyncMetadataMigration). These looped over rows withfor … in, and SQLite.swift'snext()callsfailableNext()withtry!. The crash above most likely came from reconcile work still running whenclear()deleted the database file. Both now callfailableNext()themselves, asStatement+Modelalready does.clear()also called its completion twice when it couldn't delete the file. It now returns after the failure.Subscriptions that neither connect nor fail (
AppSyncRealTimeClient, API plugin).connect()set the state to.connectingbefore its first try, and nothing reset it when the attempt ended without a connection. A subscribe task cancelled by an idle disconnect still ranconnect().RetryWithJitter's firstTask.sleepthen threwCancellationError, which isn't retried, and the client stayed.connectingwith nothing running. Every laterconnect()returned early, so new subscriptions on that client neither connected nor failed. Rapid stop and start repeat exactly that sequence: subscribe, unsubscribe, idle disconnect. In a unit test it left the client stuck in 59 of 200 trials. Now:.connecting. An attempt that ends without connecting resets the state, unless a newer attempt has taken over.WebSocketClient.Error.connectionLost, which subscribers get as a retryable network error.startthat isn't acknowledged within 15 s on a live connection fails that subscription the same way. Before, it stayed silent. The start timeout was 5 s. 15 s is longer than the 10 s AppSync gives a Lambda authorizer, so a slow start still succeeds.sendRequestno longer reports a connection error as a timeout. It also fails at once on an error frame it can't parse, instead of timing out. Subscribers already receive both errors, so neither is reported twice.Sync startup that never finishes (
RemoteSyncEngine). The engine acted on its subscriptions failing or finishing only in.initializingSubscriptionsand.syncEngineActive. Anywhere else in startup it ignored them, and stalled in.activatingCloudSubscriptions. Subscriptions that never connected held it in.initializingSubscriptionswith its outbox stopped.StorageEnginedoesn't replace an engine that is still starting, so laterstart()andsave()calls didn't help. Now:.initializingSubscriptionsthrough.syncEngineActive. A failure goes to the retry policy and a completion ends sync, as both already did in the two states that handled them.lastSync, so the next sync skipped those records.stop()during initial sync already had this hole; restarting on a subscription failure would have made it common.InitialSyncOperation.cancel()now setsisCancelled, which the operation already checks before reconciling a page and before savinglastSync. A query cut short bycancel()no longer reaches theerrorHandler.StateMachine.notify(action:if:)applies an action only while its condition holds for the current state, checked under the state machine's lock. Subscription completions, the timeout and initial sync results use it. An action a state doesn't handle used to republish that state and run its work again.StateMachine.currentState, under the state machine's lock, because@Publisheddelivers a new state before it stores it.None of these change public API.
Test changes.
Missed local event (
DataStoreObserveQueryTests,DataStoreEndToEndTests.testCreate). The save and delete helpers wait for twoobserveevents: the local one and the remote one. They started the observer in an unawaitedTask, andobserve()attaches its sink in yet anotherTask. When the local event came first, the count stopped at 1 and the 100 s wait timed out. The helpers andtestCreatenow subscribe toAWSDataStorePlugin.publisher(for:), the publisher thatobserve()wraps, before mutating. They cancel the subscription after the wait. This race caused thetestInitialSyncWithPredicatefailures (15 at the helper's wait in the sampled runs) and thetestSortWithCreateUpdateDeleteones.Outbox folding (
DataStoreConsecutiveUpdatesTests). Four tests mutate twice in a row, then wait for a version that exists only if the first mutation was already in flight. The outbox folds a new mutation into a pending one that isn't in process yet:testSaveThenMultipleUpdatecan fold into one.The wait for the expected version then can't succeed. A new helper,
saveAndWaitUntilInFlight(_:), waits foroutboxMutationEnqueuedbefore the next mutation. That event is sent once the outbox has marked the mutation in process.testSaveThenMultipleUpdatehad been skipped in the schemes since chore(datstore-v2): fix integ test Scenario 4 and 6 #2341, with no reason given. It's no longer skipped.Shared listener token (V2
DataStoreConnectionOptionalAssociations). The save helpers kept their Hub listener in a sharedtokenproperty. The listener calledfulfill()and only then removedself.token. Butfulfill()lets the test start the next helper, which reassignstoken. So the closure could remove the next helper's listener, and that helper then waited out its full 60 s. Each call now keeps its own token and removes it after the wait.Failures ended the test process. This covers every DataStore integration test base:
SyncEngineIntegrationTestBase,HubEventsIntegrationTestBase,AWSDataStoreLazyLoadBaseTest, the CPK, AuthCognito, AuthIAM and MultiAuth bases, and the Stress base. They setcontinueAfterFailure = false. When an async test fails under that setting, XCTest runs tearDown alongside the abandoned test body, then exits the process with code 75, and xcodebuild relaunches it. All 59 failures in these suites in the sampled runs ended this way. None of V2's 10 did, because its base setstrue. They all settruenow. To make that safe:SyncEngineIntegrationTestBasealready did (chore: stabilize flaky unit and integration tests #4340): stop, clear, wait 1 s, thenAmplify.reset(). It also resets when stop or clear throws, so the next test can still configure Amplify. Before, the Hub-events and Lazy Load bases reset before the wait. All 7Fatal error: Schema not found for …crashes (Model.swift:48) in the sampled runs were in suites on those two bases. Suites onSyncEngineIntegrationTestBasehad none.AWSDataStoreLazyLoadCompositePKSnapshotTestsoverrodesetUpfor the whole class to register models only. As a result, the 19 tests in the class that start DataStore skipped stop and clear. Now only the 5 snapshot tests do that, andsetup(withModels:)also clears the flag as a safety net.createAndWaitForSyncof up to 100 s inside aTaskthat the test waited for only 60 s. Withtrue, a timeout would leave thatTaskrunning into the next test. Now theTaskonly returns the observed model, and the test does the follow-up save and lazy-load checks after its wait. The 60 s wait also no longer covers the child's whole sync round trip. That round trip likely caused thetestObserveQueryPosttimeout on tvOS in run 40360503947.testStop_whenSavingInProgressandtestParallelMutations_whenWaitingForEventToProcess_noMutationLosscancel their tasks and wait up to 30 s for them to finish.DataStoreObserveQueryTests, indexing after a count check is now guarded, and force unwraps after waits useXCTUnwrap. A failed check now fails the test instead of crashing the runner.Waiting for
ready.syncStartedfires while the outbox is still making its first check for events, so a save made right after it could hit the race that library change 1 fixes. In the sampled runs, Scenario 2, which waited forsyncStarted, failed its first wait 5 times out of 48. Scenario 1, which chore: stabilize flaky unit and integration tests #4340 moved toready, failed it 0 times out of 48. These tests now wait forreadybefore their first save, so they don't depend on startup timing either:DataStoreConsecutiveUpdatesTests;DataStoreConnectionScenario2Teststests that wait for a sync;SubscriptionEndToEndTests;DataStoreConnectionOptionalAssociations.The Lazy Load
setup(withModels:)now waits forreadytoo, andstartAndWaitForReady()returns at once after that. The stress test opts out, because it listens for the outbox status DataStore reports while starting.Expectations fulfilled twice. A second fulfill (
API violation - multiple calls made to -[XCTestExpectation fulfill]) aborts the runner. These expectations can legitimately be fulfilled again, either after a sync-engine restart or by a late copy of an event, so they now setassertForOverFulfill = false:ready,syncStartedandsubscriptionsEstablishedwaits in the bases and inDataStoreHubEventsTests;isSyncedsnapshot" waits;Two tests change instead:
testObserveToQueryPostFromDeletedCommentnow waits only for the delete event. Before, a late copy of the comment's create could satisfy it.testParallelMutations_whenWaitingForEventToProcess_noMutationLossfulfills once, when the last new title arrives.Smaller fixes.
DataStoreConnectionScenario8V2Testsnow registers its listeners before saving.testDeleteImplicitChildtimed out there.testStop_whenSavingInProgressnow requires only the posts whose save succeeded to be in the local store. A save thatstop()interrupts must fail withinternalOperationand leave no post behind. Before, the test caught that failure but still expected the post, and it failed twice on watchOS in run 41114031750. A mock-API version of the test rolls back about 15 of 1,000 saves, with or without this PR's library changes.Trade-offs.
continueAfterFailure = true, a failing test runs to the end. A backend failure that persists now costs each test its whole chain of waits, not just the first. A per-test time limit in the DataStore workflows would bound that (-test-timeouts-enabled YES -default-test-execution-time-allowance, asinteg_test_auth_client.ymldoes). That's a separatecichange..stopped. A re-sent create fails its condition check, which calls theerrorHandlerand dispatchesconditionalSaveFailed. A re-sent update or delete goes through conflict handling.MutationEventmodel no longer get a delete event for each processed mutation. Nothing in the repo observes it.Amplify.API.subscribenow get an error in two cases where the subscription used to go silent: when the client runs out of connect retries (about 40 s with no handshake), and when astartisn't acknowledged within 15 s on a live connection. Before, a later reconnect could still have resumed such a subscription.startis acknowledged after 5–15 s now subscribes. Before, its start timed out at 5 s, so it never reported.connected, though its data still arrived.Not in this PR.
.errorand keeps no xcresult or simulator logs. The Scenario 2 and 3 timeouts in particular could also come from a server error or a missed subscription event.MutationEvent.reconcilePendingMutationEventsVersionandMutationEventClearStatestill write MutationEvent rows outside the outbox queue, so they can still race a merge into the same pending row. This predates this PR and doesn't strand saves.stop()doesn't wait for in-flight reconcile work, soclear()still deletes the file under live statements. Library change 4 turns the crash into logged errors.RemoteSyncEngine.stopkeeps only the first caller's completion, so an overlapping secondstop()orclear()can wait forever.StorageEngine.saverolls back a save, with "No SyncEngine available", whenstop()completes after the save checked for a sync engine. A save that racesstop()can therefore fail, though it ran before or during the stop.InternalAWSPinpoint'sAnalyticsEventSQLStoragehas the samefor … inloop over SQLite rows.AWSGraphQLSubscriptionTaskRunner.run()stores its client stays registered on that client. The client then never sees itself as idle, so it never disconnects.AppSyncRealTimeClient.onWebSocketEvent,state.value != .disconnecting || state.value != .disconnectedis always true.RequestRetryablePolicydoesn't cap the backoff for URL errors, so repeated startup failures wait longer and longer between restarts.MutationEventClearStatedoesn't call its completion when a save fails, which would stall startup in.clearingStateOutgoingMutations.InitialSyncOperationsaveslastSynconce its last page is offered, not once that page's records are saved. A cleanup that cancels queued saves can still lose them.stop()and a failed initial sync for another model could already cause this..disconnectedevent cancels the start tasks of registered subscriptions. WhenWebSocketClientwon't reconnect by itself, nothing does, and those subscriptions never connect or fail. This predates this PR. DataStore now recovers through the 60 s timeout.startstill pending when its socket drops keeps waiting out its 15 s timeout after a reconnect, then fails asconnectionLost. The reconnect doesn't re-send it while it's still.subscribing. The 5 s timeout made that window narrower.WebSocketClient.disconnect()returns early when no socket is running, which leaves auto-reconnect on. So after retries run out on handshakes that failed at once, a later network change can still reconnect and resume subscriptions that were told the connection was lost.WebSocketClient'sdidCompleteWithErrordoesn't check that the task is still the current connection, asdidCloseWithdoes.Amplify.API.mutatedepend only on subscription delivery.readyalready guarantees the subscriptions are established, so there's no test-side fix for their timeouts.Testing
Unit tests on macOS:
xcodebuild test -scheme AWSDataStorePlugin): 616 tests, 0 failures, 21 skipped. Before the library changes: 592 tests, 0 failures.-scheme AWSAPIPlugin): 238 tests, 0 failures.Library changes 1–4 come with 12 new unit tests. Against the code on main, 10 of them fail.
testSubmit_whileTheOutboxWaits_deliversOnceAndQueuesTheNextSavepasses there too, because it guards behaviour that must not change.testConvertToUntypedModel_whenStepFails_throwscrashes the runner atStatement.swift:218.Library changes 5 and 6 come with 19 new unit tests:
AppSyncRealTimeClientFailureTests(7)RemoteSyncEngineStartupTests(7)InitialSyncCancelTests(3)StateMachineTestsI removed each of the 17 safeguards in these changes one at a time, and each removal fails at least one of these tests.
testSubscriptionsThatConnectInTime_doNotRestartSyncguards against the timeout firing early, so it passes either way.A unit-test version of
testMultiSaveWithInterruptions, with a mock API, never stalled in 88 runs on the new outbox code or in 70 runs on the old. It isn't part of this PR.xcodebuild build-for-testing(Xcode 27.1) succeeds for these 15 schemes:AWSDataStorePluginIntegrationTests,AWSDataStorePluginV2Tests,AWSDataStorePluginLazyLoadTests,AWSDataStorePluginCPKTests,AWSDataStorePluginAuthCognitoTests,AWSDataStorePluginAuthIAMTests,AWSDataStorePluginMultiAuthTests,DatastoreStressTests.…Watchvariant of each of the first seven.After the library changes I rebuilt
AWSDataStorePluginIntegrationTestsfor iOS Simulator andAWSDataStorePluginIntegrationTestsWatch, and after changes 1–4 also for tvOS Simulator. The other schemes compile the same plugin sources.SwiftFormat 0.60.1
--swiftversion 6.0 --lintreports 0 files needing formatting across every Swift file this PR touches. SwiftLint 0.54.0 reports no errors in the changed sources; CI's config excludesAmplifyPlugins/*/Tests. Both edited schemes passxmllint.The integration tests run in this PR's DataStore workflow. I didn't run them locally because they share CI's backend. The Stress target runs only on pushes to main (
stress_test.yml), so its change will first run after merge.General Checklist
Documentation update for the change if requiredGiven When Theninline code documentation and are named accordinglytestThing_condition_expectation()If breaking change, documentation/changelog update with migration instructionsBy submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.