Stop data sync after account authorization revoke

This commit is contained in:
2026-09-22 13:02:55 +08:00
parent 51c9524437
commit e8a8b2f926
6 changed files with 159 additions and 12 deletions
+21 -3
View File
@@ -190,24 +190,42 @@ public sealed class RemoteControlClient : IDisposable
cancellationToken, RemoteDataProtocol.MaxBatchBytes).ConfigureAwait(false);
}
public Task<IReadOnlyList<RemoteSyncBatch>> FlushDataBatchesAsync(
RemoteDataBatchQueue queue,
ReportingConfig reportingConfig,
CancellationToken cancellationToken = default) =>
FlushDataBatchesAsync(queue, reportingConfig, new HashSet<string>(StringComparer.Ordinal), cancellationToken);
public async Task<IReadOnlyList<RemoteSyncBatch>> FlushDataBatchesAsync(
RemoteDataBatchQueue queue,
ReportingConfig reportingConfig,
ISet<string> blockedAccountIds,
CancellationToken cancellationToken = default)
{
EnsureAuthenticated();
var confirmed = new List<RemoteSyncBatch>();
foreach (var batch in queue.Pending())
{
if (blockedAccountIds.Contains(batch.AccountId))
continue;
var filtered = RemoteDataBatchAuthorization.Filter(reportingConfig, batch, out _);
if (filtered is null)
{
queue.Drop(batch.BatchId);
continue;
}
var acknowledgement = await SubmitDataBatchAsync(reportingConfig, filtered, cancellationToken).ConfigureAwait(false);
if (acknowledgement.Accepted && queue.MarkConfirmed(acknowledgement))
confirmed.Add(filtered);
try
{
var acknowledgement = await SubmitDataBatchAsync(reportingConfig, filtered, cancellationToken).ConfigureAwait(false);
if (acknowledgement.Accepted && queue.MarkConfirmed(acknowledgement))
confirmed.Add(filtered);
}
catch (RemoteClientException exception) when (exception.StatusCode == 403 && exception.Code == "AccountNotAuthorized")
{
// Authorization revocation is terminal for locally buffered content: do not retain or retry it.
queue.DropAllForAccount(batch.AccountId);
blockedAccountIds.Add(batch.AccountId);
}
}
return confirmed;
}
+11
View File
@@ -362,6 +362,17 @@ public sealed class RemoteDataBatchQueue
}
}
public int DropAllForAccount(string accountId)
{
lock (gate)
{
var removed = state.Items.RemoveAll(item => string.Equals(item.AccountId, accountId, StringComparison.Ordinal));
if (removed > 0)
SaveLocked();
return removed;
}
}
public bool MarkConfirmed(RemoteDataBatchAck acknowledgement)
{
lock (gate)
@@ -48,6 +48,7 @@ public sealed class RemoteAgentHostedService(
var syncState = options.EnableDataSync
? new RemoteDataSyncStateStore(Path.Combine(options.DataDirectory, "remote-data-sync-state.json"))
: null;
var blockedDataSyncAccounts = new HashSet<string>(StringComparer.Ordinal);
var lastDataSyncAt = DateTimeOffset.MinValue;
string? registeredActiveAccountId = null;
bool? registeredActiveAccountVerified = null;
@@ -78,6 +79,7 @@ public sealed class RemoteAgentHostedService(
registeredActiveAccountId = snapshot.ActiveAccountId;
registeredActiveAccountVerified = snapshot.ActiveAccountVerified;
registeredReportingConfigVersion = reporting.ConfigVersion;
blockedDataSyncAccounts.Clear();
retry = RetryDelay;
}
@@ -87,20 +89,24 @@ public sealed class RemoteAgentHostedService(
if (dataQueue is not null && syncState is not null && dataCollector is not null
&& DateTimeOffset.UtcNow - lastDataSyncAt >= TimeSpan.FromSeconds(options.DataSyncIntervalSeconds))
{
await ReconcileDataStatusAsync(client, reporting, dataQueue, syncState, stoppingToken);
await ReconcileDataStatusAsync(client, reporting, dataQueue, syncState, blockedDataSyncAccounts, stoppingToken);
var blockedBeforeFlush = blockedDataSyncAccounts.ToHashSet(StringComparer.Ordinal);
try
{
var confirmedBatches = await client.FlushDataBatchesAsync(dataQueue, reporting, stoppingToken);
var confirmedBatches = await client.FlushDataBatchesAsync(dataQueue, reporting, blockedDataSyncAccounts, stoppingToken);
foreach (var confirmedBatch in confirmedBatches)
syncState.MarkConfirmed(confirmedBatch);
foreach (var accountId in blockedDataSyncAccounts.Except(blockedBeforeFlush, StringComparer.Ordinal))
logger.LogWarning("Data sync authorization was revoked; accountId={AccountId}; pending content was discarded and collection is paused.", accountId);
}
catch (Exception exception) when (exception is HttpRequestException or RemoteClientException)
{
logger.LogWarning("Data sync delivery deferred; type={ExceptionType}.", exception.GetType().Name);
}
// Collection must continue while the platform is unreachable; the durable queue is
// the offline buffer and will be flushed on the next successful connection.
await CollectDataBatchesAsync(remote, reporting, dataCollector, dataQueue, syncState, stoppingToken);
// the offline buffer and will be flushed on the next successful connection. Revoked
// accounts are the exception: their pending content is discarded and collection stops.
await CollectDataBatchesAsync(remote, reporting, dataCollector, dataQueue, syncState, blockedDataSyncAccounts, stoppingToken);
lastDataSyncAt = DateTimeOffset.UtcNow;
}
var pollAccountIds = reporting.Accounts
@@ -133,14 +139,14 @@ public sealed class RemoteAgentHostedService(
{
logger.LogWarning("Remote control-plane request failed; code={Code}; status={StatusCode}; correlationId={CorrelationId}.",
exception.Code, exception.StatusCode, exception.CorrelationId);
await TryCollectOfflineDataBatchesAsync(remote, activeReporting, dataCollector, dataQueue, syncState, stoppingToken);
await TryCollectOfflineDataBatchesAsync(remote, activeReporting, dataCollector, dataQueue, syncState, blockedDataSyncAccounts, stoppingToken);
await DelayAsync(retry, stoppingToken);
retry = TimeSpan.FromSeconds(Math.Min(retry.TotalSeconds * 2, 30));
}
catch (Exception exception)
{
logger.LogWarning("Remote agent cycle failed; type={ExceptionType}.", exception.GetType().Name);
await TryCollectOfflineDataBatchesAsync(remote, activeReporting, dataCollector, dataQueue, syncState, stoppingToken);
await TryCollectOfflineDataBatchesAsync(remote, activeReporting, dataCollector, dataQueue, syncState, blockedDataSyncAccounts, stoppingToken);
await DelayAsync(retry, stoppingToken);
retry = TimeSpan.FromSeconds(Math.Min(retry.TotalSeconds * 2, 30));
}
@@ -153,11 +159,14 @@ public sealed class RemoteAgentHostedService(
IRemoteDataCollector collector,
RemoteDataBatchQueue queue,
RemoteDataSyncStateStore state,
ISet<string> blockedAccountIds,
CancellationToken cancellationToken)
{
foreach (var account in reporting.Accounts.Where(item => item.Enabled))
{
cancellationToken.ThrowIfCancellationRequested();
if (blockedAccountIds.Contains(account.AccountId))
continue;
var scopes = AuthorizedChatScopes(reporting, account.AccountId);
if (scopes.Count == 0 || queue.Pending().Any(batch => string.Equals(batch.AccountId, account.AccountId, StringComparison.Ordinal)
&& string.Equals(batch.StreamKey, "messages", StringComparison.Ordinal)))
@@ -191,13 +200,14 @@ public sealed class RemoteAgentHostedService(
IRemoteDataCollector? collector,
RemoteDataBatchQueue? queue,
RemoteDataSyncStateStore? state,
ISet<string> blockedAccountIds,
CancellationToken cancellationToken)
{
if (reporting is null || collector is null || queue is null || state is null)
return;
try
{
await CollectDataBatchesAsync(remote, reporting, collector, queue, state, cancellationToken).ConfigureAwait(false);
await CollectDataBatchesAsync(remote, reporting, collector, queue, state, blockedAccountIds, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
@@ -214,6 +224,7 @@ public sealed class RemoteAgentHostedService(
ReportingConfig reporting,
RemoteDataBatchQueue queue,
RemoteDataSyncStateStore state,
ISet<string> blockedAccountIds,
CancellationToken cancellationToken)
{
foreach (var account in reporting.Accounts.Where(item => item.Enabled))
@@ -229,12 +240,23 @@ public sealed class RemoteAgentHostedService(
// Older control planes do not expose the reconciliation endpoint; retain the legacy ACK path.
continue;
}
catch (RemoteClientException exception) when (exception.StatusCode == 403 && exception.Code == "AccountNotAuthorized")
{
var droppedOnRevoke = queue.DropAllForAccount(account.AccountId);
blockedAccountIds.Add(account.AccountId);
logger.LogWarning(
"Data sync authorization is revoked; accountId={AccountId}; pending content was discarded and collection is paused; droppedPendingBatches={DroppedPendingBatches}.",
account.AccountId, droppedOnRevoke);
continue;
}
catch (HttpRequestException)
{
// A disconnected platform must not prevent local DB collection.
return;
}
if (blockedAccountIds.Remove(account.AccountId))
logger.LogInformation("Data sync authorization was restored; accountId={AccountId}; collection is resumed.", account.AccountId);
if (!state.Reconcile(remote, account.AccountId, "messages"))
continue;
var dropped = queue.DropForAccount(account.AccountId, "messages");