perf: eliminate redundant table scans, improve concurrency, optimize SignalR

- Remove full event-count recount (partition scan) from UpdateSessionStatusAsync;
  IncrementSessionEventCountAsync already maintains the count atomically
- Remove redundant GetEarliestSessionEventTimestampAsync calls from
  UpdateSessionStatusAsync and IncrementSessionEventCountAsync; the caller
  (IngestEventsFunction) already computes and passes the timestamp
- Switch UpdateSessionStatusAsync from Replace to Merge mode, writing only
  changed fields to drastically reduce ETag conflicts under concurrency
- Increase ETag retry limit from 3 to 5 with jitter-based exponential backoff
- Add server-side time-range filter (90 days) to UsageMetrics queries,
  avoiding loading the entire sessions table into memory
- Remove full session object from SignalR eventStream message; session delta
  updates now flow via the existing newevents tenant-group message
- Add newevents handler to session detail page for live session delta updates
- Auto-rejoin SignalR groups on reconnect to prevent silent update loss

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-02-28 23:49:47 +01:00
parent 65f4c5a2bb
commit 477b573f1f
5 changed files with 117 additions and 76 deletions
@@ -448,6 +448,9 @@ namespace AutopilotMonitor.Functions.Functions
}).ToList<object>() }).ToList<object>()
: null; : null;
// Signal-only pattern: no full session object — the detail page already receives
// the session delta via the "newevents" message (tenant group) and fetches fresh
// events from Table Storage on receipt. Sending the full session here was redundant.
var eventsMessage = new SignalRMessageAction("eventStream") var eventsMessage = new SignalRMessageAction("eventStream")
{ {
GroupName = $"session-{request.TenantId}-{request.SessionId}", GroupName = $"session-{request.TenantId}-{request.SessionId}",
@@ -455,7 +458,6 @@ namespace AutopilotMonitor.Functions.Functions
sessionId = request.SessionId, sessionId = request.SessionId,
tenantId = request.TenantId, tenantId = request.TenantId,
newEventCount = processedCount, newEventCount = processedCount,
session = updatedSession,
newRuleResults = slimRuleResults newRuleResults = slimRuleResults
} } } }
}; };
@@ -243,7 +243,7 @@ namespace AutopilotMonitor.Functions.Services
/// <summary> /// <summary>
/// Gets all sessions for a tenant /// Gets all sessions for a tenant
/// </summary> /// </summary>
public async Task<List<SessionSummary>> GetSessionsAsync(string tenantId, int maxResults = 100) public async Task<List<SessionSummary>> GetSessionsAsync(string tenantId, int maxResults = 100, DateTime? since = null)
{ {
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
@@ -252,10 +252,16 @@ namespace AutopilotMonitor.Functions.Services
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions); var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions);
var sessions = new List<SessionSummary>(); var sessions = new List<SessionSummary>();
// Query sessions by tenant (PartitionKey) // Query sessions by tenant (PartitionKey), optionally filtered by StartedAt
// maxPerPage capped at 1000 (Azure Table Storage limit), pagination is automatic // maxPerPage capped at 1000 (Azure Table Storage limit), pagination is automatic
var filter = $"PartitionKey eq '{tenantId}'";
if (since.HasValue)
{
filter += $" and StartedAt ge datetime'{since.Value:yyyy-MM-ddTHH:mm:ss.fffffffZ}'";
}
var query = tableClient.QueryAsync<TableEntity>( var query = tableClient.QueryAsync<TableEntity>(
filter: $"PartitionKey eq '{tenantId}'", filter: filter,
maxPerPage: Math.Min(maxResults, 1000) maxPerPage: Math.Min(maxResults, 1000)
); );
@@ -279,16 +285,23 @@ namespace AutopilotMonitor.Functions.Services
/// <summary> /// <summary>
/// Gets all sessions across all tenants (for galactic admin mode) /// Gets all sessions across all tenants (for galactic admin mode)
/// </summary> /// </summary>
public async Task<List<SessionSummary>> GetAllSessionsAsync(int maxResults = 100) public async Task<List<SessionSummary>> GetAllSessionsAsync(int maxResults = 100, DateTime? since = null)
{ {
try try
{ {
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions); var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions);
var sessions = new List<SessionSummary>(); var sessions = new List<SessionSummary>();
// Query all sessions without tenant filter // Query all sessions, optionally filtered by StartedAt to limit dataset size
// maxPerPage is capped at 1000 (Azure Table Storage limit); pagination is automatic // maxPerPage is capped at 1000 (Azure Table Storage limit); pagination is automatic
var query = tableClient.QueryAsync<TableEntity>(maxPerPage: Math.Min(maxResults, 1000)); string? filter = since.HasValue
? $"StartedAt ge datetime'{since.Value:yyyy-MM-ddTHH:mm:ss.fffffffZ}'"
: null;
var query = tableClient.QueryAsync<TableEntity>(
filter: filter,
maxPerPage: Math.Min(maxResults, 1000)
);
await foreach (var entity in query) await foreach (var entity in query)
{ {
@@ -329,14 +342,19 @@ namespace AutopilotMonitor.Functions.Services
} }
/// <summary> /// <summary>
/// Updates the session status and current phase /// Updates the session status and current phase.
/// Uses Merge mode to write only changed fields, reducing ETag conflicts under concurrency.
/// The caller (IngestEventsFunction) provides earliestEventTimestamp from the current batch;
/// no redundant Events-table scan is performed here.
/// Event count is maintained atomically by IncrementSessionEventCountAsync and is not
/// recounted here — avoiding an expensive full-partition scan on every status change.
/// </summary> /// </summary>
public async Task<bool> UpdateSessionStatusAsync(string tenantId, string sessionId, SessionStatus status, EnrollmentPhase? currentPhase = null, string? failureReason = null, DateTime? completedAt = null, DateTime? earliestEventTimestamp = null, DateTime? latestEventTimestamp = null) public async Task<bool> UpdateSessionStatusAsync(string tenantId, string sessionId, SessionStatus status, EnrollmentPhase? currentPhase = null, string? failureReason = null, DateTime? completedAt = null, DateTime? earliestEventTimestamp = null, DateTime? latestEventTimestamp = null)
{ {
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId)); SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId));
const int maxRetries = 3; const int maxRetries = 5;
int retryCount = 0; int retryCount = 0;
while (retryCount < maxRetries) while (retryCount < maxRetries)
@@ -345,7 +363,7 @@ namespace AutopilotMonitor.Functions.Services
{ {
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions); var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions);
// Get the existing entity // Read the existing entity to check idempotency guards and compute derived fields
var entity = await tableClient.GetEntityAsync<TableEntity>(tenantId, sessionId); var entity = await tableClient.GetEntityAsync<TableEntity>(tenantId, sessionId);
var session = entity.Value; var session = entity.Value;
@@ -361,6 +379,9 @@ namespace AutopilotMonitor.Functions.Services
} }
} }
// Build a Merge update with only the fields that actually change
var update = new TableEntity(tenantId, sessionId);
// Status promotion rule: a Pending session (WhiteGlove pre-provisioning complete) // Status promotion rule: a Pending session (WhiteGlove pre-provisioning complete)
// must not regress to InProgress via phase-change events from the resumed boot. // must not regress to InProgress via phase-change events from the resumed boot.
// The Pending → InProgress transition happens exclusively in StoreSessionAsync // The Pending → InProgress transition happens exclusively in StoreSessionAsync
@@ -368,35 +389,24 @@ namespace AutopilotMonitor.Functions.Services
if (status == SessionStatus.InProgress && existingStatusStr == SessionStatus.Pending.ToString()) if (status == SessionStatus.InProgress && existingStatusStr == SessionStatus.Pending.ToString())
{ {
_logger.LogInformation($"Session {sessionId} is Pending (WhiteGlove), preserving status (InProgress blocked by promotion rule)"); _logger.LogInformation($"Session {sessionId} is Pending (WhiteGlove), preserving status (InProgress blocked by promotion rule)");
// Phase, timestamps, and event count are still updated below. // Phase, timestamps are still updated below.
} }
else else
{ {
// Update status update["Status"] = status.ToString();
session["Status"] = status.ToString();
} }
// Update current phase if provided // Update current phase if provided
if (currentPhase.HasValue) if (currentPhase.HasValue)
{ {
session["CurrentPhase"] = (int)currentPhase.Value; update["CurrentPhase"] = (int)currentPhase.Value;
} }
// Align StartedAt with the earliest event timestamp provided by the caller
var currentStartedAt = session.GetDateTimeOffset("StartedAt")?.UtcDateTime ?? DateTime.MaxValue; var currentStartedAt = session.GetDateTimeOffset("StartedAt")?.UtcDateTime ?? DateTime.MaxValue;
var effectiveEarliest = earliestEventTimestamp; if (earliestEventTimestamp.HasValue && earliestEventTimestamp.Value < currentStartedAt)
// Self-heal StartedAt by also looking at persisted events in case an older event
// arrived in a previous request/batch.
var earliestStoredEventTimestamp = await GetEarliestSessionEventTimestampAsync(tenantId, sessionId);
if (earliestStoredEventTimestamp.HasValue &&
(!effectiveEarliest.HasValue || earliestStoredEventTimestamp.Value < effectiveEarliest.Value))
{ {
effectiveEarliest = earliestStoredEventTimestamp.Value; update["StartedAt"] = earliestEventTimestamp.Value;
}
if (effectiveEarliest.HasValue && effectiveEarliest.Value < currentStartedAt)
{
session["StartedAt"] = effectiveEarliest.Value;
} }
// Set completion time if succeeded or failed // Set completion time if succeeded or failed
@@ -404,14 +414,14 @@ namespace AutopilotMonitor.Functions.Services
{ {
// Use the provided completedAt timestamp (from event) if available, otherwise use current time // Use the provided completedAt timestamp (from event) if available, otherwise use current time
var effectiveCompletedAt = completedAt ?? DateTime.UtcNow; var effectiveCompletedAt = completedAt ?? DateTime.UtcNow;
session["CompletedAt"] = effectiveCompletedAt; update["CompletedAt"] = effectiveCompletedAt;
// Store accurate DurationSeconds based on first event timestamp (not registration StartedAt) // Store accurate DurationSeconds based on first event timestamp (not registration StartedAt)
// The agent registers before events arrive, so registration StartedAt is always earlier // The agent registers before events arrive, so registration StartedAt is always earlier
// than the actual enrollment start — using the first event gives the true enrollment duration. // than the actual enrollment start — using the first event gives the true enrollment duration.
var durationStart = effectiveEarliest ?? currentStartedAt; var durationStart = earliestEventTimestamp ?? currentStartedAt;
if (durationStart < effectiveCompletedAt) if (durationStart < effectiveCompletedAt)
session["DurationSeconds"] = (int)(effectiveCompletedAt - durationStart).TotalSeconds; update["DurationSeconds"] = (int)(effectiveCompletedAt - durationStart).TotalSeconds;
} }
// Track the most recent event timestamp for excessive data sender detection // Track the most recent event timestamp for excessive data sender detection
@@ -419,28 +429,18 @@ namespace AutopilotMonitor.Functions.Services
{ {
var currentLastEventAt = session.GetDateTimeOffset("LastEventAt")?.UtcDateTime; var currentLastEventAt = session.GetDateTimeOffset("LastEventAt")?.UtcDateTime;
if (!currentLastEventAt.HasValue || latestEventTimestamp.Value > currentLastEventAt.Value) if (!currentLastEventAt.HasValue || latestEventTimestamp.Value > currentLastEventAt.Value)
session["LastEventAt"] = latestEventTimestamp.Value; update["LastEventAt"] = latestEventTimestamp.Value;
} }
// Set failure reason if failed // Set failure reason if failed
if (status == SessionStatus.Failed && !string.IsNullOrEmpty(failureReason)) if (status == SessionStatus.Failed && !string.IsNullOrEmpty(failureReason))
{ {
session["FailureReason"] = failureReason; update["FailureReason"] = failureReason;
} }
// Update event count (full recount for accuracy on status changes) // Merge mode: only the fields set above are written; all other fields remain untouched.
var eventsTableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Events); // This drastically reduces ETag conflicts when concurrent requests update different fields.
var partitionKey = $"{tenantId}_{sessionId}"; await tableClient.UpdateEntityAsync(update, session.ETag, TableUpdateMode.Merge);
var eventCountFilter = $"PartitionKey eq '{partitionKey}'";
var eventCount = 0;
await foreach (var _ in eventsTableClient.QueryAsync<TableEntity>(eventCountFilter, select: new[] { "RowKey" }))
{
eventCount++;
}
session["EventCount"] = eventCount;
// Update entity with optimistic concurrency
await tableClient.UpdateEntityAsync(session, session.ETag, TableUpdateMode.Replace);
_logger.LogInformation($"Updated session {sessionId} status to {status}"); _logger.LogInformation($"Updated session {sessionId} status to {status}");
return true; return true;
@@ -454,8 +454,9 @@ namespace AutopilotMonitor.Functions.Services
return false; return false;
} }
// Exponential backoff: 50ms, 100ms, 200ms // Exponential backoff with jitter to decorrelate concurrent retries
await Task.Delay(50 * (int)Math.Pow(2, retryCount - 1)); var baseDelay = 50 * (int)Math.Pow(2, retryCount - 1);
await Task.Delay(baseDelay + Random.Shared.Next(0, baseDelay));
_logger.LogDebug($"Retrying session {sessionId} update (attempt {retryCount}/{maxRetries})"); _logger.LogDebug($"Retrying session {sessionId} update (attempt {retryCount}/{maxRetries})");
} }
catch (Exception ex) catch (Exception ex)
@@ -471,13 +472,15 @@ namespace AutopilotMonitor.Functions.Services
/// <summary> /// <summary>
/// Increments the session event count without touching status or phase fields. /// Increments the session event count without touching status or phase fields.
/// Uses Merge mode to safely handle concurrent updates. /// Uses Merge mode to safely handle concurrent updates.
/// The caller provides earliestEventTimestamp from the current batch;
/// no redundant Events-table scan is performed here.
/// </summary> /// </summary>
public async Task IncrementSessionEventCountAsync(string tenantId, string sessionId, int increment, DateTime? earliestEventTimestamp = null, DateTime? latestEventTimestamp = null) public async Task IncrementSessionEventCountAsync(string tenantId, string sessionId, int increment, DateTime? earliestEventTimestamp = null, DateTime? latestEventTimestamp = null)
{ {
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId)); SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId));
const int maxRetries = 3; const int maxRetries = 5;
int retryCount = 0; int retryCount = 0;
while (retryCount < maxRetries) while (retryCount < maxRetries)
@@ -493,20 +496,11 @@ namespace AutopilotMonitor.Functions.Services
["EventCount"] = currentCount + increment ["EventCount"] = currentCount + increment
}; };
// Align StartedAt with the earliest event timestamp provided by the caller
var currentStartedAt = entity.Value.GetDateTimeOffset("StartedAt")?.UtcDateTime ?? DateTime.MaxValue; var currentStartedAt = entity.Value.GetDateTimeOffset("StartedAt")?.UtcDateTime ?? DateTime.MaxValue;
var effectiveEarliest = earliestEventTimestamp; if (earliestEventTimestamp.HasValue && earliestEventTimestamp.Value < currentStartedAt)
// Self-heal StartedAt by checking already persisted events for this session.
var earliestStoredEventTimestamp = await GetEarliestSessionEventTimestampAsync(tenantId, sessionId);
if (earliestStoredEventTimestamp.HasValue &&
(!effectiveEarliest.HasValue || earliestStoredEventTimestamp.Value < effectiveEarliest.Value))
{ {
effectiveEarliest = earliestStoredEventTimestamp.Value; update["StartedAt"] = earliestEventTimestamp.Value;
}
if (effectiveEarliest.HasValue && effectiveEarliest.Value < currentStartedAt)
{
update["StartedAt"] = effectiveEarliest.Value;
} }
// Track the most recent event timestamp for excessive data sender detection // Track the most recent event timestamp for excessive data sender detection
@@ -528,7 +522,9 @@ namespace AutopilotMonitor.Functions.Services
_logger.LogWarning($"Failed to increment event count for session {sessionId} after {maxRetries} retries due to ETag conflicts"); _logger.LogWarning($"Failed to increment event count for session {sessionId} after {maxRetries} retries due to ETag conflicts");
return; return;
} }
await Task.Delay(50 * (int)Math.Pow(2, retryCount - 1)); // Exponential backoff with jitter to decorrelate concurrent retries
var baseDelay = 50 * (int)Math.Pow(2, retryCount - 1);
await Task.Delay(baseDelay + Random.Shared.Next(0, baseDelay));
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -92,10 +92,10 @@ namespace AutopilotMonitor.Functions.Services
private async Task<PlatformUsageMetrics> ComputeUsageMetricsInternalAsync() private async Task<PlatformUsageMetrics> ComputeUsageMetricsInternalAsync()
{ {
// Query all sessions (galactic admin only!) // Query sessions from the last 90 days only — covers all displayed metrics (today/7d/30d)
// Note: This queries ALL sessions across ALL tenants - can handle millions of records // with a generous margin. Avoids loading the entire table into memory.
// Since this is on-demand only, we can afford to process large datasets // "Total" counters use PlatformStats (cumulative, already tracked separately).
var allSessions = await _storageService.GetAllSessionsAsync(maxResults: 1000000); var allSessions = await _storageService.GetAllSessionsAsync(maxResults: 1000000, since: DateTime.UtcNow.AddDays(-90));
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var today = now.Date; var today = now.Date;
@@ -210,8 +210,8 @@ namespace AutopilotMonitor.Functions.Services
private async Task<PlatformUsageMetrics> ComputeTenantUsageMetricsInternalAsync(string tenantId) private async Task<PlatformUsageMetrics> ComputeTenantUsageMetricsInternalAsync(string tenantId)
{ {
// Query sessions for specific tenant only // Query sessions for specific tenant only — last 90 days
var tenantSessions = await _storageService.GetSessionsAsync(tenantId, maxResults: 1000000); var tenantSessions = await _storageService.GetSessionsAsync(tenantId, maxResults: 1000000, since: DateTime.UtcNow.AddDays(-90));
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var today = now.Date; var today = now.Date;
@@ -225,19 +225,17 @@ export default function SessionDetailPage() {
useEffect(() => { useEffect(() => {
// Listen for event stream signal — backend sends a lightweight signal (no event payload). // Listen for event stream signal — backend sends a lightweight signal (no event payload).
// Frontend fetches fresh events from Table Storage on receipt: canonical truth, no gaps. // Frontend fetches fresh events from Table Storage on receipt: canonical truth, no gaps.
const handleEventStream = (data: { sessionId: string; tenantId: string; newEventCount: number; session?: Session; newRuleResults?: RuleResult[] }) => { const handleEventStream = (data: { sessionId: string; tenantId: string; newEventCount: number; newRuleResults?: RuleResult[] }) => {
console.log('Event stream signal received via SignalR:', data); console.log('Event stream signal received via SignalR:', data);
if (data.sessionId !== sessionIdRef.current) return; if (data.sessionId !== sessionIdRef.current) return;
// Fetch full events from storage (single source of truth), but debounce bursts. // Fetch full events from storage (single source of truth), but debounce bursts.
// Session updates arrive via the "newevents" message (tenant group) — no session
// object in this signal to keep payloads minimal.
scheduleFetchEvents(); scheduleFetchEvents();
// Session update from SignalR (no extra fetch needed) if (data.tenantId) {
if (data.session) { setSessionTenantId(prev => prev || data.tenantId);
setSession(data.session);
if (data.session.tenantId) {
setSessionTenantId(prev => prev || data.session!.tenantId);
}
} }
// Rule results from SignalR (only on enrollment completion) // Rule results from SignalR (only on enrollment completion)
@@ -246,10 +244,25 @@ export default function SessionDetailPage() {
} }
}; };
// Listen for session delta updates via the tenant group ("newevents").
// This replaces the full session object that was previously sent inside "eventStream".
const handleNewEvents = (data: { sessionId: string; tenantId: string; sessionUpdate?: Partial<Session> }) => {
if (data.sessionId !== sessionIdRef.current) return;
if (data.sessionUpdate) {
setSession(prev => prev ? { ...prev, ...data.sessionUpdate } : prev);
}
if (data.tenantId) {
setSessionTenantId(prev => prev || data.tenantId);
}
};
on('eventStream', handleEventStream); on('eventStream', handleEventStream);
on('newevents', handleNewEvents);
return () => { return () => {
off('eventStream', handleEventStream); off('eventStream', handleEventStream);
off('newevents', handleNewEvents);
if (eventRefreshTimeoutRef.current) { if (eventRefreshTimeoutRef.current) {
clearTimeout(eventRefreshTimeoutRef.current); clearTimeout(eventRefreshTimeoutRef.current);
} }
@@ -70,10 +70,40 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) {
setConnectionState(signalR.HubConnectionState.Reconnecting); setConnectionState(signalR.HubConnectionState.Reconnecting);
}); });
newConnection.onreconnected((connectionId) => { newConnection.onreconnected(async (connectionId) => {
setConnectionState(signalR.HubConnectionState.Connected); setConnectionState(signalR.HubConnectionState.Connected);
retryCountRef.current = 0; // Reset retry count on successful reconnect retryCountRef.current = 0; // Reset retry count on successful reconnect
joinedGroupsRef.current.clear(); // Clear joined groups on reconnect - need to re-join
// Auto-rejoin all previously joined groups after reconnect.
// The server-side connection ID changed, so all group memberships are lost.
const previousGroups = Array.from(joinedGroupsRef.current);
joinedGroupsRef.current.clear();
for (const groupName of previousGroups) {
try {
const token = await getAccessToken();
const response = await fetch(`${API_BASE_URL}/api/realtime/groups/join`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ connectionId, groupName })
});
if (response.ok) {
joinedGroupsRef.current.add(groupName);
} else {
console.warn(`[SignalR] Failed to rejoin group ${groupName} after reconnect (status ${response.status})`);
}
} catch (error) {
console.warn(`[SignalR] Error rejoining group ${groupName} after reconnect:`, error);
}
}
if (previousGroups.length > 0) {
console.log(`[SignalR] Rejoined ${joinedGroupsRef.current.size}/${previousGroups.length} groups after reconnect`);
}
}); });
// Start connection // Start connection