Skip to content

Commit 9ad5885

Browse files
authored
Fix streaming sync retry backoff (#70)
1 parent df044ec commit 9ad5885

4 files changed

Lines changed: 121 additions & 7 deletions

File tree

PowerSync/PowerSync.Common/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# PowerSync.Common Changelog
22

3+
## 0.1.3 (unreleased)
4+
5+
- Fix streaming sync retry loop reconnecting with no delay after an exception, ignoring `RetryDelayMs`.
6+
37
## 0.1.2
48

59
- Add support for MacCatalyst.

PowerSync/PowerSync.Common/Client/Sync/Stream/StreamingSyncImplementation.cs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,11 @@ protected async Task StreamingSync(CancellationToken? signal, PowerSyncConnectio
369369
break;
370370
}
371371
iterationResult = await StreamingSyncIteration(nestedCts.Token, options);
372+
373+
if (iterationResult.ImmediateRestart == true || iterationResult.LegacyRetry == true)
374+
{
375+
shouldDelayRetry = false;
376+
}
372377
}
373378
catch (Exception ex)
374379
{
@@ -413,20 +418,15 @@ protected async Task StreamingSync(CancellationToken? signal, PowerSyncConnectio
413418
nestedCts = new CancellationTokenSource();
414419
}
415420

416-
if (iterationResult != null && (iterationResult.ImmediateRestart != true && iterationResult.LegacyRetry != true))
421+
if (shouldDelayRetry)
417422
{
418-
419423
UpdateSyncStatus(new SyncStatusOptions
420424
{
421425
Connected = false,
422426
Connecting = true
423427
});
424428

425-
// On error, wait a little before retrying
426-
if (shouldDelayRetry)
427-
{
428-
await DelayRetry();
429-
}
429+
await DelayRetry();
430430
}
431431
}
432432
}

PowerSync/PowerSync.Maui/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# PowerSync.Maui Changelog
22

3+
## 0.1.3 (unreleased)
4+
5+
- Upstream PowerSync.Common version bump (See Powersync.Common changelog 0.1.3 for more information)
6+
37
## 0.1.2
48

59
- Add support for MacCatalyst.
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
namespace PowerSync.Common.Tests.Client.Sync;
2+
3+
using System.Collections.Concurrent;
4+
5+
using PowerSync.Common.Client;
6+
using PowerSync.Common.Client.Connection;
7+
using PowerSync.Common.Client.Sync.Stream;
8+
using PowerSync.Common.Tests.Utils;
9+
using PowerSync.Common.Tests.Utils.Sync;
10+
11+
/// <summary>
12+
/// dotnet test -v n --framework net8.0 --filter "StreamingSyncRetryTests"
13+
/// </summary>
14+
public class StreamingSyncRetryTests
15+
{
16+
[Fact(Timeout = 15000)]
17+
public async Task RetryLoop_AppliesDelayBetweenFailedAttempts()
18+
{
19+
const int retryDelayMs = 200;
20+
const double tolerance = 0.75;
21+
22+
var attemptTimes = new ConcurrentQueue<DateTime>();
23+
var attemptSignal = new SemaphoreSlim(0);
24+
25+
var dbFilename = $"sync-retry-{Guid.NewGuid():N}.db";
26+
var throwing = new ThrowingRemote(new TestConnector(), attemptTimes, attemptSignal);
27+
28+
var db = new PowerSyncDatabase(new PowerSyncDatabaseOptions
29+
{
30+
Database = new SQLOpenOptions { DbFilename = dbFilename },
31+
Schema = TestSchemaTodoList.AppSchema,
32+
RemoteFactory = _ => throwing
33+
});
34+
35+
try
36+
{
37+
await db.Init();
38+
39+
// Fire-and-forget: Connect() awaits Connected=true, which never fires
40+
// because every iteration throws. The retry loop runs in the background.
41+
_ = db.Connect(
42+
new TestConnector(),
43+
new PowerSyncConnectionOptions { RetryDelayMs = retryDelayMs }
44+
);
45+
46+
for (int i = 0; i < 4; i++)
47+
{
48+
Assert.True(
49+
await attemptSignal.WaitAsync(TimeSpan.FromSeconds(5)),
50+
$"Did not observe attempt #{i + 1} within timeout — retry loop is not running"
51+
);
52+
}
53+
54+
var timestamps = attemptTimes.ToArray();
55+
Assert.True(timestamps.Length >= 4);
56+
57+
for (int i = 1; i < timestamps.Length; i++)
58+
{
59+
var deltaMs = (timestamps[i] - timestamps[i - 1]).TotalMilliseconds;
60+
Assert.True(
61+
deltaMs >= retryDelayMs * tolerance,
62+
$"Retry gap #{i} was {deltaMs:F0}ms, expected >= {retryDelayMs * tolerance:F0}ms (RetryDelayMs={retryDelayMs})"
63+
);
64+
}
65+
}
66+
finally
67+
{
68+
await db.Disconnect();
69+
await db.Close();
70+
DatabaseUtils.CleanDb(dbFilename);
71+
}
72+
}
73+
}
74+
75+
internal sealed class ThrowingRemote : Remote
76+
{
77+
private readonly ConcurrentQueue<DateTime> timestamps;
78+
private readonly SemaphoreSlim signal;
79+
80+
public ThrowingRemote(
81+
IPowerSyncBackendConnector connector,
82+
ConcurrentQueue<DateTime> timestamps,
83+
SemaphoreSlim signal
84+
) : base(connector)
85+
{
86+
this.timestamps = timestamps;
87+
this.signal = signal;
88+
}
89+
90+
public override Task<System.IO.Stream> PostStreamRaw(SyncStreamOptions options)
91+
{
92+
timestamps.Enqueue(DateTime.UtcNow);
93+
signal.Release();
94+
throw new HttpRequestException(
95+
"HTTP InternalServerError: simulated [PSYNC_S2305] from ThrowingRemote"
96+
);
97+
}
98+
99+
public override Task<T> Get<T>(string path, Dictionary<string, string>? headers = null)
100+
{
101+
var response = new StreamingSyncImplementation.ApiResponse(
102+
new StreamingSyncImplementation.ResponseData("1")
103+
);
104+
return Task.FromResult((T)(object)response);
105+
}
106+
}

0 commit comments

Comments
 (0)