Public Access
Merge branch 'main' into claude/m3-implementation-57f9d7
ci / build and test (push) Failing after 2s
ci / build and test (push) Failing after 2s
Three files conflicted, and two of the resolutions are more than a choice of side. QuickConnectTests had both branches fixing the same build break — main's M2 merge left the shell's constructor with an ISftpSessionFactory nobody passed. Main's version wins because it carries a comment saying why the palette never needs a session. VaultSession's conflict is adjacent edits: main added the remembered sign-in members and this branch changed SyncAsync's summary from "the active vault" to "one vault". Both kept. VaultViewModel is the one that matters. Main taught the background pass to report a sync that had to start over, on the grounds that a machine which silently re-read a whole vault has had something happen to it; this branch turned a pass into one report per readable vault. Taking either side alone would have lost the other, so ResyncedFromStart is now one of the conditions IsWorthReporting checks, per vault. Merging also broke something neither branch could have caught alone, and the build would not have said a word. SyncOnceAsync cleared LastSyncFailed unconditionally, which was right while a pass was one vault and a failure was an exception that never reached that line. A failure is now a report — one unreachable team vault must not stop the others syncing — so the flag was being cleared over a vault that had just failed, lighting the titlebar SYNCED. It is computed from the report instead, in the one place both callers go through, so the manual command gets it as well as the loop. The background pass still swallows the message and keeps the fact, which is what AnAutomaticPassThatFails_LeavesTheStatusAlone is there to hold it to. Two comments the auto-merge left describing a world with one vault in it: the SCOPES rail's, which said team vaults are refused by the access service, and the host sidebar's "One heading, for one vault".
This commit is contained in:
@@ -129,12 +129,30 @@ public sealed class SyncEngine
|
||||
{
|
||||
var state = await syncState.ReadAsync(vaultId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// At most one restart per pull. A server that rejects the cursor it has just issued is not
|
||||
// telling this client anything it can act on, and replaying the whole log against it would turn
|
||||
// one broken deployment into an unbounded amount of work.
|
||||
var alreadyStartedOver = false;
|
||||
|
||||
for (var page = 0; page < options.MaxPullPages; page++)
|
||||
{
|
||||
var response = await api.SyncPullAsync(
|
||||
vaultId,
|
||||
new SyncPullRequest(state.Cursor, options.PullPageSize, ItemKinds.SyncedTypes),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
SyncPullResponse response;
|
||||
|
||||
try
|
||||
{
|
||||
response = await api.SyncPullAsync(
|
||||
vaultId,
|
||||
new SyncPullRequest(state.Cursor, options.PullPageSize, ItemKinds.SyncedTypes),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (DodoSshApiException exception)
|
||||
when (!alreadyStartedOver && WasTheCursorRefused(exception, state.Cursor))
|
||||
{
|
||||
alreadyStartedOver = true;
|
||||
state = await StartOverAsync(state, report, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var change in response.Changes)
|
||||
{
|
||||
@@ -161,6 +179,69 @@ public sealed class SyncEngine
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether the server refused the cursor this client sent, rather than failing for some other reason.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Read from the problem code and not from the prose, which is free to change, and never claimed for a
|
||||
/// request that carried no cursor: "from the beginning" is the one position a client is allowed to ask
|
||||
/// for, so a rejection of <em>that</em> is a server this code cannot reason about and has to surface.
|
||||
/// It is also what keeps the retry from looping — the restarted request sends no cursor.
|
||||
/// </remarks>
|
||||
private static bool WasTheCursorRefused(DodoSshApiException exception, string? cursor) =>
|
||||
!string.IsNullOrEmpty(cursor)
|
||||
&& string.Equals(exception.Code, ProblemCodes.InvalidCursor, StringComparison.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Forgets this vault's position and reads the log again from the beginning.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A cursor the server will not accept is not a transient failure. Every later pass reads the same
|
||||
/// stored cursor and is refused the same way, so a vault that met one stayed there for good — pulling
|
||||
/// nothing, and pushing nothing either, because the pass threw before it reached the outbox. The user
|
||||
/// saw a 400 saying to resync from the beginning and had no way to do it. This is that way.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The causes are all on the far side: a rotated cursor signing key, a vault served from a restored
|
||||
/// database whose sequences no longer reach that far, a cache copied between machines. None of them is
|
||||
/// something the person at the keyboard did, and none of them is something they could act on if asked.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The mirror is deliberately kept.</b> Replaying from the beginning rewrites every row the server
|
||||
/// still has — applying a change is a blind overwrite — so the re-pull repairs the mirror as it goes.
|
||||
/// Clearing it first would claim more than the evidence supports: the position was refused, not the
|
||||
/// contents, and a machine that loses its connection halfway through the replay would be left with
|
||||
/// less than it started with. <c>SyncStateStore.ResetAsync</c> is the heavier remedy, for when the
|
||||
/// cache itself is the thing in doubt.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// That leaves one gap, and it is worth naming rather than leaving to be discovered: once the server
|
||||
/// starts collecting tombstones — <c>DodoOptions.TombstoneRetentionDays</c>, not implemented yet — a
|
||||
/// replay no longer carries a deletion older than the retention window. A machine that missed such a
|
||||
/// delete and then had its cursor refused would keep the row. Nothing here can tell that from a row
|
||||
/// the server still has, so the answer when it matters will be to clear the mirror as well, not to
|
||||
/// guess.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Written down before the replay begins, so a process that dies mid-replay starts the next one from
|
||||
/// the beginning as well, rather than meeting the same refusal again.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task<StoredSyncState> StartOverAsync(
|
||||
StoredSyncState state,
|
||||
SyncReportBuilder report,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var restarted = state with { Cursor = null };
|
||||
|
||||
await syncState.SaveAsync(restarted, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
report.ResyncedFromStart = true;
|
||||
|
||||
return restarted;
|
||||
}
|
||||
|
||||
private StoredSyncState Record(
|
||||
Guid vaultId,
|
||||
StoredSyncState state,
|
||||
|
||||
@@ -75,6 +75,15 @@ public sealed record SyncOptions
|
||||
/// True when the push loop hit <see cref="SyncOptions.MaxPushRounds"/> with work still outstanding. Not
|
||||
/// a failure: the next pass continues from here.
|
||||
/// </param>
|
||||
/// <param name="ResyncedFromStart">
|
||||
/// True when the server refused this machine's stored position and the whole log was read again from the
|
||||
/// beginning.
|
||||
/// <para>
|
||||
/// Deliberately absent from <see cref="NeedsAttention"/>. Nothing is outstanding and nothing was lost —
|
||||
/// the pass recovered on its own — but it explains a sync that pulled the entire vault on a day nobody
|
||||
/// changed anything, which is otherwise the sort of thing that looks like a fault.
|
||||
/// </para>
|
||||
/// </param>
|
||||
public sealed record SyncReport(
|
||||
Guid VaultId,
|
||||
int Pulled,
|
||||
@@ -87,7 +96,8 @@ public sealed record SyncReport(
|
||||
uint ServerKeyGeneration,
|
||||
bool RekeyRequired,
|
||||
long ServerTimeSkewMs,
|
||||
bool RoundsExhausted)
|
||||
bool RoundsExhausted,
|
||||
bool ResyncedFromStart)
|
||||
{
|
||||
/// <summary>Whether anything happened that a user should be told about.</summary>
|
||||
public bool NeedsAttention =>
|
||||
@@ -119,6 +129,8 @@ internal sealed class SyncReportBuilder(Guid vaultId)
|
||||
|
||||
internal bool RoundsExhausted { get; set; }
|
||||
|
||||
internal bool ResyncedFromStart { get; set; }
|
||||
|
||||
internal SyncReport Build() =>
|
||||
new(
|
||||
vaultId,
|
||||
@@ -132,7 +144,8 @@ internal sealed class SyncReportBuilder(Guid vaultId)
|
||||
ServerKeyGeneration,
|
||||
RekeyRequired,
|
||||
ServerTimeSkewMs,
|
||||
RoundsExhausted);
|
||||
RoundsExhausted,
|
||||
ResyncedFromStart);
|
||||
}
|
||||
|
||||
/// <summary>The record written to the conflict log when a merge had to override something.</summary>
|
||||
|
||||
Reference in New Issue
Block a user