Skip to content

Commit 07a771e

Browse files
Merge pull request #73 from ably/2025-08-21-improve-test-reliability
Attempt to improve test reliability
2 parents 19ba2e9 + 7df42d2 commit 07a771e

2 files changed

Lines changed: 50 additions & 39 deletions

File tree

Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool
9898
(receivedObjectProtocolMessages, receivedObjectProtocolMessagesContinuation) = AsyncStream.makeStream()
9999
(receivedObjectSyncProtocolMessages, receivedObjectSyncProtocolMessagesContinuation) = AsyncStream.makeStream()
100100
(waitingForSyncEvents, waitingForSyncEventsContinuation) = AsyncStream.makeStream()
101-
(completedGarbageCollectionEvents, completedGarbageCollectionsEventsContinuation) = AsyncStream.makeStream()
101+
(completedGarbageCollectionEventsWithoutBuffering, completedGarbageCollectionEventsWithoutBufferingContinuation) = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(0))
102102
mutableState = .init(objectsPool: .init(logger: logger, userCallbackQueue: userCallbackQueue, clock: clock))
103103
garbageCollectionInterval = garbageCollectionOptions.interval
104104
garbageCollectionGracePeriod = garbageCollectionOptions.gracePeriod
@@ -332,17 +332,17 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool
332332
gracePeriod: garbageCollectionGracePeriod,
333333
clock: clock,
334334
logger: logger,
335-
eventsContinuation: completedGarbageCollectionsEventsContinuation,
335+
eventsContinuation: completedGarbageCollectionEventsWithoutBufferingContinuation,
336336
)
337337
}
338338
}
339339

340-
// These drive the testsOnly_completedGarbageCollectionEvents property that informs the test suite when a garbage collection cycle has completed.
341-
private let completedGarbageCollectionEvents: AsyncStream<Void>
342-
private let completedGarbageCollectionsEventsContinuation: AsyncStream<Void>.Continuation
340+
// These drive the testsOnly_completedGarbageCollectionEventsWithoutBuffering property that informs the test suite when a garbage collection cycle has completed.
341+
private let completedGarbageCollectionEventsWithoutBuffering: AsyncStream<Void>
342+
private let completedGarbageCollectionEventsWithoutBufferingContinuation: AsyncStream<Void>.Continuation
343343
/// Emits an element whenever a garbage collection cycle has completed.
344-
internal var testsOnly_completedGarbageCollectionEvents: AsyncStream<Void> {
345-
completedGarbageCollectionEvents
344+
internal var testsOnly_completedGarbageCollectionEventsWithoutBuffering: AsyncStream<Void> {
345+
completedGarbageCollectionEventsWithoutBuffering
346346
}
347347

348348
// MARK: - Testing
@@ -352,12 +352,12 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool
352352
/// - testsOnly_receivedObjectProtocolMessages
353353
/// - testsOnly_receivedObjectStateProtocolMessages
354354
/// - testsOnly_waitingForSyncEvents
355-
/// - testsOnly_completedGarbageCollectionEvents
355+
/// - testsOnly_completedGarbageCollectionEventsWithoutBuffering
356356
internal func testsOnly_finishAllTestHelperStreams() {
357357
receivedObjectProtocolMessagesContinuation.finish()
358358
receivedObjectSyncProtocolMessagesContinuation.finish()
359359
waitingForSyncEventsContinuation.finish()
360-
completedGarbageCollectionsEventsContinuation.finish()
360+
completedGarbageCollectionEventsWithoutBufferingContinuation.finish()
361361
}
362362

363363
// MARK: - Mutable state and the operations that affect it

Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift

Lines changed: 41 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
)
338346
private 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

Comments
 (0)