Public Access
Server: a cursor carries the sync horizon it was issued under, so a read from the beginning after a collection is not refused on its own pages
A cursor behind the horizon was refused on position alone. But the read a refused client starts from the beginning is itself behind the horizon on every page until it passes the collected deletion, since older untouched items come first. So its second page was refused, it started over, and it never finished, on any vault larger than one page. The cursor now records the horizon read before the page that produced it, and only a cursor issued under an older horizon is refused. A cursor issued under no horizon keeps the v1 format, so a server from before this still reads it.
This commit is contained in:
1 parent
8cb1457ca9
commit
55e5de0ce4
4 files changed
+213
-37
No files matched your search
@@ -658,6 +658,65 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
|
||||
(await PullAsync(client, vaultId, null)).Changes.ShouldNotContain(change => change.EntityId == entityId);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The client's way back after a refusal is to read from the beginning — and that read's own cursors are
|
||||
/// behind the horizon whenever an item older than the collected deletion is still there, which is nearly
|
||||
/// always. Refused on position alone, the second page sent the read back to the start, every time, for
|
||||
/// good. The cursor carries the horizon it was issued under, and one issued since the collection passes.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// One item a page, so the first page's cursor is the old item's position, well behind the horizon.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AReadFromTheBeginning_AfterACollection_IsNotRefusedOnItsOwnPages()
|
||||
{
|
||||
var (subject, vaultId) = await SeedUserWithVaultAsync();
|
||||
var client = fixture.CreateClientFor(subject);
|
||||
|
||||
var old = NewCreateBatch();
|
||||
await client.PostContractAsync(PushUrl(vaultId), old);
|
||||
await client.PostContractAsync(PushUrl(vaultId), NewCreateBatch());
|
||||
|
||||
var doomed = NewCreateBatch();
|
||||
var entityId = doomed.Operations[0].EntityId;
|
||||
await client.PostContractAsync(PushUrl(vaultId), doomed);
|
||||
|
||||
await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest(
|
||||
[
|
||||
new SyncPushOperation(
|
||||
Guid.CreateVersion7(), SyncEntityType.Host, entityId, SyncOperation.Delete,
|
||||
ExpectedVersion: 1, Payload: null, PlaintextFields: null),
|
||||
]));
|
||||
|
||||
await AgeTheDeletionAsync(entityId);
|
||||
await CollectAsync();
|
||||
|
||||
var seen = new List<Guid>();
|
||||
string? cursor = null;
|
||||
|
||||
for (var page = 0; page < 5; page++)
|
||||
{
|
||||
var response = await client.PostContractAsync(PullUrl(vaultId), new SyncPullRequest(cursor, 1, null));
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK, $"page {page} was refused");
|
||||
|
||||
var body = (await response.Content.ReadContractAsync<SyncPullResponse>()).ShouldNotBeNull();
|
||||
seen.AddRange(body.Changes.Select(change => change.EntityId));
|
||||
cursor = body.NextCursor;
|
||||
|
||||
if (!body.HasMore)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
seen.Count.ShouldBe(2);
|
||||
seen.ShouldContain(old.Operations[0].EntityId);
|
||||
seen.ShouldNotContain(entityId);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The other half of the retention: a deletion younger than it stays, entries and all, so a machine that
|
||||
/// has been away a week still hears of it.
|
||||
|
||||
Reference in new issue
Block a user