@@ -334,7 +334,15 @@ class MainActorStorage<T> {
334334
335335// MARK: - Test suite
336336
337- @Suite ( . objectsFixtures)
337+ @Suite (
338+ . objectsFixtures,
339+ // These tests exhibit flakiness (hanging, timeouts, occasional Realtime
340+ // connection limits) when run concurrently, where I think that we had up to
341+ // 100 ARTRealtime instances active at the same time. So we're running them in
342+ // serial to unblock CI builds until we can understand the issue better. See
343+ // https://github.com/ably/ably-liveobjects-swift-plugin/issues/72.
344+ . serialized,
345+ )
338346private struct ObjectsIntegrationTests {
339347 // TODO: Add the non-parameterised tests
340348
@@ -2916,22 +2924,23 @@ private struct ObjectsIntegrationTests {
29162924 action: { ctx in
29172925 let objects = ctx. objects
29182926
2919- let maps = try await withThrowingTaskGroup ( of: ( any LiveMap ) . self, returning: [ any LiveMap ] . self) { group in
2920- for mapFixture in primitiveMapsFixtures {
2927+ let maps = try await withThrowingTaskGroup ( of: ( index : Int , map : any LiveMap) . self, returning: [ any LiveMap ] . self) { group in
2928+ for (index , mapFixture) in primitiveMapsFixtures. enumerated ( ) {
29212929 group. addTask {
2922- if let entries = mapFixture. liveMapEntries {
2930+ let map = if let entries = mapFixture. liveMapEntries {
29232931 try await objects. createMap ( entries: entries)
29242932 } else {
29252933 try await objects. createMap ( )
29262934 }
2935+ return ( index: index, map: map)
29272936 }
29282937 }
29292938
2930- var results : [ any LiveMap ] = [ ]
2931- while let map = try await group. next ( ) {
2932- results. append ( map )
2939+ var results : [ ( index : Int , map : any LiveMap ) ] = [ ]
2940+ while let result = try await group. next ( ) {
2941+ results. append ( result )
29332942 }
2934- return results
2943+ return results. sorted { $0 . index < $1 . index } . map ( \ . map )
29352944 }
29362945
29372946 for ( i, map) in maps. enumerated ( ) {
@@ -3728,7 +3737,7 @@ private struct ObjectsIntegrationTests {
37283737 var channel : ARTRealtimeChannel
37293738 var objects : any RealtimeObjects
37303739 var client : ARTRealtime
3731- var waitForGCCycles : @Sendable ( Int ) async -> Void
3740+ var waitForTombstonedObjectsToBeCollected : @Sendable ( Date ) async throws -> Void
37323741 }
37333742
37343743 static let scenarios : [ TestScenario < Context > ] = [
@@ -3741,7 +3750,7 @@ private struct ObjectsIntegrationTests {
37413750 let channelName = ctx. channelName
37423751 let channel = ctx. channel
37433752 let objects = ctx. objects
3744- let waitForGCCycles = ctx. waitForGCCycles
3753+ let waitForTombstonedObjectsToBeCollected = ctx. waitForTombstonedObjectsToBeCollected
37453754
37463755 // Wait for counter creation
37473756 async let counterCreatedPromise : Void = waitForObjectOperation ( ctx. objects, . counterCreate)
@@ -3779,9 +3788,10 @@ private struct ObjectsIntegrationTests {
37793788 " Check object's \" tombstone \" flag is set to \" true \" after OBJECT_DELETE " ,
37803789 )
37813790
3782- // We expect 2 cycles to guarantee that grace period has expired, which will always be
3783- // true based on the test config used
3784- await waitForGCCycles ( 2 )
3791+ let tombstonedAt = try #require( poolEntry. tombstonedAt)
3792+
3793+ // Wait for objects tombstoned at this time to be garbage collected
3794+ try await waitForTombstonedObjectsToBeCollected ( tombstonedAt)
37853795
37863796 // Object should be removed from the local pool entirely now, as the GC grace period has passed
37873797 #expect(
@@ -3798,7 +3808,7 @@ private struct ObjectsIntegrationTests {
37983808 let root = ctx. root
37993809 let objectsHelper = ctx. objectsHelper
38003810 let channelName = ctx. channelName
3801- let waitForGCCycles = ctx. waitForGCCycles
3811+ let waitForTombstonedObjectsToBeCollected = ctx. waitForTombstonedObjectsToBeCollected
38023812
38033813 let keyUpdatedPromise = try root. updates ( )
38043814 async let keyUpdatedWait : Void = {
@@ -3853,9 +3863,10 @@ private struct ObjectsIntegrationTests {
38533863 " Check map entry for \" foo \" on root has \" tombstone \" flag set to \" true \" after MAP_REMOVE " ,
38543864 )
38553865
3856- // We expect 2 cycles to guarantee that grace period has expired, which will always be
3857- // true based on the test config used
3858- await waitForGCCycles ( 2 )
3866+ let tombstonedAt = try #require( underlyingData [ " foo " ] ? . tombstonedAt)
3867+
3868+ // Wait for objects tombstoned at this time to be garbage collected
3869+ try await waitForTombstonedObjectsToBeCollected ( tombstonedAt)
38593870
38603871 // The entry should be removed from the underlying map now
38613872 let underlyingDataAfterGC = internalRoot. testsOnly_data
@@ -3879,10 +3890,11 @@ private struct ObjectsIntegrationTests {
38793890
38803891 // Configure GC options with shorter intervals for testing
38813892 var options = testCase. options
3882- options . garbageCollectionOptions = . init (
3883- interval: 2.0 , // JS uses 0.5s but I've found that, at least testing locally, this was not enough to compensate for the clock skew between my local clock and whatever was used to generate the tombstonedAt timestamps server-side.
3893+ let garbageCollectionOptions = InternalDefaultRealtimeObjects . GarbageCollectionOptions (
3894+ interval: 0.5 ,
38843895 gracePeriod: 0.25 ,
38853896 )
3897+ options. garbageCollectionOptions = garbageCollectionOptions
38863898
38873899 let objectsHelper = try await ObjectsHelper ( )
38883900 let client = try await realtimeWithObjects ( options: options)
@@ -3894,18 +3906,17 @@ private struct ObjectsIntegrationTests {
38943906 try await channel. attachAsync ( )
38953907 let root = try await objects. getRoot ( )
38963908
3897- // Helper function to wait for a specific number of GC cycles
3909+ // Helper function to wait for enough GC cycles to occur such that objects tombstoned at a specific time should have been garbage collected. This is a slightly different approach to the JS tests, which wait for a certain number of GC cycles to occur, but I think that this is a bit more robust in the face of clock skew between the local clock and whatever was used to generate the tombstonedAt timestamps server-side.
38983910 let internallyTypedObjects = try #require( objects as? PublicDefaultRealtimeObjects )
3899- let waitForGCCycles : @Sendable ( Int) async -> Void = { cycles in
3900- let gcEvents = internallyTypedObjects. testsOnly_proxied. testsOnly_completedGarbageCollectionEvents
3901-
3902- var gcCalledTimes = 0
3903- for await _ in gcEvents {
3904- gcCalledTimes += 1
3905- if gcCalledTimes >= cycles {
3906- break
3907- }
3911+ let waitForTombstonedObjectsToBeCollected : @Sendable ( Date) async throws -> Void = { ( tombstonedAt: Date ) in
3912+ // Sleep until we're sure we're past tombstonedAt + gracePeriod
3913+ let timeUntilGracePeriodExpires = ( tombstonedAt + garbageCollectionOptions. gracePeriod) . timeIntervalSince ( . init( ) )
3914+ if timeUntilGracePeriodExpires > 0 {
3915+ try await Task . sleep ( nanoseconds: UInt64 ( timeUntilGracePeriodExpires * Double( NSEC_PER_SEC) ) )
39083916 }
3917+
3918+ // Wait for the next GC event
3919+ await internallyTypedObjects. testsOnly_proxied. testsOnly_completedGarbageCollectionEventsWithoutBuffering. first { _ in true }
39093920 }
39103921
39113922 try await testCase. scenario. action (
@@ -3916,7 +3927,7 @@ private struct ObjectsIntegrationTests {
39163927 channel: channel,
39173928 objects: objects,
39183929 client: client,
3919- waitForGCCycles : waitForGCCycles ,
3930+ waitForTombstonedObjectsToBeCollected : waitForTombstonedObjectsToBeCollected ,
39203931 ) ,
39213932 )
39223933 }
0 commit comments