Tau.Acuvim/portal/src/Tau.Acuvim.Portal/Services/FleetPushClient.cs
Diseri Pearson a92b4277ae Phase 14: Push + ingest pipeline (end-to-end fleet aggregation)
Customer-stack measurements now flow to the Admin-stack central DB via
HTTPS POST, with firmware buffer-and-replay back-fills handled correctly.

Client side (push)
- monitoring.PowerMeasurements gains ReceivedAt (default NOW()) +
  index. Push selects WHERE ReceivedAt > LastCursor, so back-dated
  rows from offline-buffer replays are picked up automatically.
- app.FleetPushState table holds per-resource cursors + backoff state.
- FleetPushClient: HttpClient wrapper, X-Customer-Token header,
  X-Batch-Type, X-Push-Cursor. 413 returns retry-after halving signal.
- FleetPushService: BackgroundService loop. Per tick: sites (full set),
  devices (full set), measurements (cursor-driven up to 3 batches).
  Exponential backoff per resource on failure (1m → 30m cap).
  Honors 429 Retry-After. Only registered when RunMode=Client AND
  FleetIngest__Enabled=true.

Admin side (ingest)
- /api/fleet/ingest: anonymous, X-Customer-Token authed against
  fleet.Customers via SHA-256 indexed lookup. 401 on bad token; 400
  on bad batch type.
- FleetIngestService dispatches by X-Batch-Type:
  sites/devices → upsert by (CustomerId, Id) with ON CONFLICT UPDATE
  measurements → bulk INSERT ON CONFLICT (Time, CustomerId, DeviceId)
                 DO NOTHING (idempotent under re-delivery).
- Updates fleet.Customers.FirstSeenAt/LastSeenAt on each successful batch.
- Writes fleet.IngestEvents audit row per batch (accepted, rejected,
  bytes, client cursor, time-spread, error).
- FleetTimescaleBootstrapper runs after MigrateAsync in Admin mode:
  CREATE EXTENSION timescaledb, create_hypertable on fleet.PowerMeasurements,
  chunk interval 7 days, compression with segmentby=(CustomerId,DeviceId)
  + compress_orderby "Time" DESC, compression policy 7 days, hourly_per_device
  continuous aggregate (realtime, materialized_only=false, 30-day start_offset
  so back-fills get materialized on next refresh tick).

Wiring
- docker-compose.yml threads Application__RunMode + FleetIngest__* from
  .env (defaults safely off) so a single dev host can run two stacks.
- .env.example documents the new vars under their own section.

Tests
- FleetIngestValidationTests (2 new). 53/53 passing.

Verified end-to-end on the dev host
- Client (portal-dev_portal, RunMode=Client, FleetIngest__Enabled=true)
  pushes to Admin (portal-admin-test, RunMode=Admin, separate admin_fleet DB)
  via container DNS.
- Customer registered on Admin (DEV0001), token captured, dropped into
  Client .env, Client restarted, push service started on schedule.
- Ingested measurements (including a 2026-04-01 back-dated sample
  simulating firmware replay) all land in fleet.PowerMeasurements with
  the correct CustomerId.
- Customer.FirstSeenAt/LastSeenAt update, IngestEvents records every
  batch (sites + devices per tick, measurements when cursor advances).
- Hypertable confirmed via timescaledb_information.hypertables;
  hourly_per_device CA confirmed via timescaledb_information.continuous_aggregates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 10:17:58 +02:00

112 lines
4.1 KiB
C#

using System.Net;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Options;
using Tau.Acuvim.Portal.Configuration;
using Tau.Acuvim.Portal.DTOs;
namespace Tau.Acuvim.Portal.Services;
public sealed class FleetPushResult
{
public bool Succeeded { get; init; }
public int Accepted { get; init; }
public int Rejected { get; init; }
public HttpStatusCode StatusCode { get; init; }
public string? Error { get; init; }
public TimeSpan? RetryAfter { get; init; }
}
// HttpClient wrapper for POSTing batches to the Admin ingest endpoint.
public sealed class FleetPushClient(
HttpClient http,
IOptions<FleetIngestOptions> options,
ILogger<FleetPushClient> log)
{
private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web);
public Task<FleetPushResult> PushSitesAsync(IReadOnlyList<FleetSiteDto> rows, DateTime cursor, CancellationToken ct)
=> PushAsync("sites", rows, cursor, ct);
public Task<FleetPushResult> PushDevicesAsync(IReadOnlyList<FleetDeviceDto> rows, DateTime cursor, CancellationToken ct)
=> PushAsync("devices", rows, cursor, ct);
public Task<FleetPushResult> PushMeasurementsAsync(IReadOnlyList<FleetMeasurementDto> rows, DateTime cursor, CancellationToken ct)
=> PushAsync("measurements", rows, cursor, ct);
private async Task<FleetPushResult> PushAsync<T>(
string batchType,
IReadOnlyList<T> rows,
DateTime cursor,
CancellationToken ct)
{
var opts = options.Value;
if (string.IsNullOrWhiteSpace(opts.Url) || string.IsNullOrWhiteSpace(opts.Token))
{
return new FleetPushResult { Succeeded = false, Error = "FleetIngest URL or Token not configured." };
}
var json = JsonSerializer.Serialize(rows, JsonOpts);
var bytes = Encoding.UTF8.GetBytes(json);
if (bytes.Length > opts.BatchMaxBytes)
{
return new FleetPushResult { Succeeded = false, StatusCode = HttpStatusCode.RequestEntityTooLarge,
Error = $"Local pre-check: batch size {bytes.Length} > {opts.BatchMaxBytes}. Halve batch and retry." };
}
using var req = new HttpRequestMessage(HttpMethod.Post, opts.Url);
req.Headers.Add("X-Customer-Token", opts.Token);
req.Headers.Add("X-Batch-Type", batchType);
req.Headers.Add("X-Push-Cursor", cursor.ToString("O"));
req.Content = new ByteArrayContent(bytes);
req.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
try
{
using var resp = await http.SendAsync(req, ct);
var status = resp.StatusCode;
if (status == HttpStatusCode.OK)
{
try
{
var body = await resp.Content.ReadAsStringAsync(ct);
var result = JsonSerializer.Deserialize<FleetIngestResult>(body, JsonOpts);
return new FleetPushResult
{
Succeeded = true,
Accepted = result?.Accepted ?? rows.Count,
Rejected = result?.Rejected ?? 0,
StatusCode = status
};
}
catch
{
return new FleetPushResult { Succeeded = true, Accepted = rows.Count, StatusCode = status };
}
}
TimeSpan? retryAfter = null;
if (resp.Headers.RetryAfter?.Delta is { } d) retryAfter = d;
return new FleetPushResult
{
Succeeded = false,
StatusCode = status,
RetryAfter = retryAfter,
Error = $"HTTP {(int)status} {status}"
};
}
catch (TaskCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
log.LogWarning(ex, "Fleet push transport failure to {Url}", opts.Url);
return new FleetPushResult { Succeeded = false, Error = ex.Message };
}
}
}