Files
MoShiro/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteMetricStore.cs
T
justme e1ec7c9b04
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/verify Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful
fix(query): anchor=latest follows the newest sample of any level, not just the 1m rows [CI]
2026-10-19 10:34:04 +08:00

221 lines
11 KiB
C#

using Fleet.Core;
using Microsoft.Data.Sqlite;
using MoShiro.Backend.Analysis;
using MoShiro.Backend.Application;
namespace MoShiro.Backend.Storage.Sqlite;
/// <summary>指标窗口的通用存储:每个桶一行,内容是 <c>指标 id → 累加器状态</c>(对本层不透明,只在合并和补丁时解码)。</summary>
public sealed class SqliteMetricStore : IMetricStore
{
private readonly SqliteDatabase _db;
public SqliteMetricStore(SqliteDatabase db)
{
_db = db;
}
public ValueTask UpsertAsync(MetricLevel level, IReadOnlyList<MetricRow> rows, CancellationToken ct) =>
_db.ExecuteAsync(c =>
{
foreach (MetricRow row in rows)
{
Upsert(c, level, row, row.Metrics);
}
}, ct);
public ValueTask MergeAsync(MetricLevel level, IReadOnlyList<MetricRow> rows, CancellationToken ct) =>
_db.ExecuteAsync(c =>
{
foreach (MetricRow row in rows)
{
byte[]? existing = Load(c, level, row);
if (existing is null || !AccumulatorCodec.Decode(existing).TryGet(out MetricSet? merged, out _))
{
Upsert(c, level, row, row.Metrics);
continue;
}
if (AccumulatorCodec.Decode(row.Metrics).TryGet(out MetricSet? incoming, out _))
{
merged.Merge(incoming);
}
Upsert(c, level, row with { Partial = row.Partial || IsPartial(c, level, row) }, AccumulatorCodec.Encode(merged));
}
}, ct);
public ValueTask PatchAsync(MetricLevel level, IReadOnlyList<MetricRow> rows, IReadOnlySet<string> replaceMetricIds, CancellationToken ct) =>
_db.ExecuteAsync(c =>
{
foreach (MetricRow row in rows)
{
byte[]? existing = Load(c, level, row);
MetricSet target = new();
if (existing is not null && AccumulatorCodec.Decode(existing).TryGet(out MetricSet? current, out _))
{
foreach ((string id, Accumulator acc) in current.Items)
{
if (!replaceMetricIds.Contains(id))
{
target.Set(id, acc);
}
}
}
if (AccumulatorCodec.Decode(row.Metrics).TryGet(out MetricSet? fresh, out _))
{
foreach ((string id, Accumulator acc) in fresh.Items)
{
if (replaceMetricIds.Contains(id))
{
target.Set(id, acc);
}
}
}
Upsert(c, level, row, AccumulatorCodec.Encode(target));
}
}, ct);
public ValueTask<IReadOnlyList<MetricRow>> ReadAsync(MetricLevel level, NodeId agentId, string series, long fromMs, long toMs, CancellationToken ct) =>
_db.ReadAsync<IReadOnlyList<MetricRow>>(
c => c.Query(
$"SELECT agent_id, series, window_start, partial, metrics FROM {level.TableName()} WHERE agent_id = @a AND series = @s AND window_start >= @from AND window_start < @to ORDER BY window_start",
ReadRow, ("@a", agentId.Value), ("@s", series), ("@from", fromMs), ("@to", toMs)),
ct);
public ValueTask<long?> EarliestAsync(MetricLevel level, NodeId agentId, string series, CancellationToken ct) =>
_db.ReadAsync(
c => c.Query($"SELECT MIN(window_start) FROM {level.TableName()} WHERE agent_id = @a AND series = @s", r => r.NullableLong(0), ("@a", agentId.Value), ("@s", series)).FirstOrDefault(),
ct);
public ValueTask<long?> LatestAsync(MetricLevel level, NodeId agentId, string series, CancellationToken ct) =>
_db.ReadAsync(
c => c.Query($"SELECT MAX(window_start) FROM {level.TableName()} WHERE agent_id = @a AND series = @s", r => r.NullableLong(0), ("@a", agentId.Value), ("@s", series)).FirstOrDefault(),
ct);
public ValueTask<IReadOnlyList<MetricRow>> ReadAgentsAsync(MetricLevel level, IReadOnlyCollection<NodeId> agentIds, string series, long fromMs, long toMs, CancellationToken ct) =>
_db.ReadAsync<IReadOnlyList<MetricRow>>(
c =>
{
if (agentIds.Count == 0)
{
return [];
}
// 代理 ID 经参数传入:占位符是固定格式的 @a0、@a1…,不拼接任何外部值
var parameters = new List<(string, object?)> { ("@s", series), ("@from", fromMs), ("@to", toMs) };
var placeholders = new List<string>();
int i = 0;
foreach (NodeId agent in agentIds)
{
placeholders.Add("@a" + i);
parameters.Add(("@a" + i, agent.Value));
i++;
}
return c.Query(
$"SELECT agent_id, series, window_start, partial, metrics FROM {level.TableName()} WHERE series = @s AND window_start >= @from AND window_start < @to " +
$"AND agent_id IN ({string.Join(',', placeholders)}) ORDER BY agent_id, window_start",
ReadRow, [.. parameters]);
},
ct);
public ValueTask<int> DeleteOlderThanAsync(MetricLevel level, long cutoffMs, CancellationToken ct) =>
level == MetricLevel.H1
? ValueTask.FromResult(0)
: _db.WriteAsync(c => c.Exec($"DELETE FROM {level.TableName()} WHERE window_start < @cutoff", ("@cutoff", cutoffMs)), ct);
public ValueTask EnqueueAsync(IReadOnlyList<QueueItem> items, CancellationToken ct) =>
_db.ExecuteAsync(c =>
{
foreach (QueueItem item in items)
{
c.Exec(
"INSERT INTO analysis_queue (agent_id, series, window_start, not_before, reason, seq) VALUES (@a, @s, @w, @n, @r, 1) " +
"ON CONFLICT(agent_id, series, window_start) DO UPDATE SET not_before = MIN(not_before, excluded.not_before), reason = excluded.reason, seq = seq + 1",
("@a", item.AgentId.Value), ("@s", item.Series), ("@w", item.WindowStart), ("@n", item.NotBefore), ("@r", item.Reason));
}
}, ct);
public ValueTask<IReadOnlyList<QueueItem>> DequeueDueAsync(long nowMs, int limit, CancellationToken ct) =>
_db.ReadAsync<IReadOnlyList<QueueItem>>(
c => c.Query(
"SELECT agent_id, series, window_start, not_before, reason, seq FROM analysis_queue WHERE not_before <= @now ORDER BY not_before LIMIT @limit",
r => new QueueItem(new NodeId(r.GetString(0)), r.GetString(1), r.GetInt64(2), r.GetInt64(3), r.GetString(4), r.GetInt64(5)),
("@now", nowMs), ("@limit", limit)),
ct);
public ValueTask CompleteAsync(IReadOnlyList<QueueItem> items, CancellationToken ct) =>
_db.ExecuteAsync(c =>
{
foreach (QueueItem item in items)
{
// 处理期间又有迟到的块入队时序号已经变了,这一项会保留到下一轮
c.Exec(
"DELETE FROM analysis_queue WHERE agent_id = @a AND series = @s AND window_start = @w AND seq = @seq",
("@a", item.AgentId.Value), ("@s", item.Series), ("@w", item.WindowStart), ("@seq", item.Seq));
}
}, ct);
public ValueTask<int> QueueLengthAsync(CancellationToken ct) =>
_db.ReadAsync(c => (int)c.Scalar<long>("SELECT COUNT(*) FROM analysis_queue"), ct);
public ValueTask MarkRollupDirtyAsync(MetricLevel level, NodeId agentId, string series, long bucketStart, CancellationToken ct) =>
_db.ExecuteAsync(
c => c.Exec(
"INSERT OR IGNORE INTO rollup_dirty (level, agent_id, series, bucket_start) VALUES (@l, @a, @s, @b)",
("@l", (int)level), ("@a", agentId.Value), ("@s", series), ("@b", bucketStart)),
ct);
public ValueTask<IReadOnlyList<(MetricLevel Level, NodeId AgentId, string Series, long BucketStart)>> TakeRollupDirtyAsync(int limit, CancellationToken ct) =>
_db.WriteAsync<IReadOnlyList<(MetricLevel, NodeId, string, long)>>(c =>
{
// 先低层后高层:5 分钟层先于 1 小时层,保证 1 小时层读到的是最新的 5 分钟层
List<(MetricLevel, NodeId, string, long)> taken = c.Query(
"SELECT level, agent_id, series, bucket_start FROM rollup_dirty ORDER BY level, bucket_start LIMIT @limit",
r => ((MetricLevel)r.GetInt32(0), new NodeId(r.GetString(1)), r.GetString(2), r.GetInt64(3)),
("@limit", limit));
foreach ((MetricLevel level, NodeId agent, string series, long bucket) in taken)
{
c.Exec(
"DELETE FROM rollup_dirty WHERE level = @l AND agent_id = @a AND series = @s AND bucket_start = @b",
("@l", (int)level), ("@a", agent.Value), ("@s", series), ("@b", bucket));
}
return taken;
}, ct);
public ValueTask<IReadOnlyList<MetricVersionRecord>> GetVersionsAsync(CancellationToken ct) =>
_db.ReadAsync<IReadOnlyList<MetricVersionRecord>>(
c => c.Query("SELECT metric_id, fingerprint, recompute_from, recompute_cursor FROM metric_versions", r => new MetricVersionRecord(r.GetString(0), r.GetString(1), r.NullableLong(2), r.NullableLong(3))),
ct);
public ValueTask SetVersionAsync(MetricVersionRecord version, CancellationToken ct) =>
_db.ExecuteAsync(
c => c.Exec(
"INSERT INTO metric_versions (metric_id, fingerprint, recompute_from, recompute_cursor) VALUES (@id, @f, @from, @cur) " +
"ON CONFLICT(metric_id) DO UPDATE SET fingerprint = excluded.fingerprint, recompute_from = excluded.recompute_from, recompute_cursor = excluded.recompute_cursor",
("@id", version.MetricId), ("@f", version.Fingerprint), ("@from", version.RecomputeFrom), ("@cur", version.RecomputeCursor)),
ct);
private static byte[]? Load(SqliteConnection c, MetricLevel level, MetricRow row) =>
c.Scalar<byte[]>(
$"SELECT metrics FROM {level.TableName()} WHERE agent_id = @a AND series = @s AND window_start = @w",
("@a", row.AgentId.Value), ("@s", row.Series), ("@w", row.WindowStart));
private static bool IsPartial(SqliteConnection c, MetricLevel level, MetricRow row) =>
c.Scalar<long>(
$"SELECT partial FROM {level.TableName()} WHERE agent_id = @a AND series = @s AND window_start = @w",
("@a", row.AgentId.Value), ("@s", row.Series), ("@w", row.WindowStart)) != 0;
private static void Upsert(SqliteConnection c, MetricLevel level, MetricRow row, byte[] metrics) =>
c.Exec(
$"INSERT INTO {level.TableName()} (agent_id, series, window_start, partial, metrics) VALUES (@a, @s, @w, @p, @m) " +
"ON CONFLICT(agent_id, series, window_start) DO UPDATE SET partial = excluded.partial, metrics = excluded.metrics",
("@a", row.AgentId.Value), ("@s", row.Series), ("@w", row.WindowStart), ("@p", row.Partial ? 1 : 0), ("@m", metrics));
private static MetricRow ReadRow(SqliteDataReader r) => new(new NodeId(r.GetString(0)), r.GetString(1), r.GetInt64(2), r.GetInt64(3) != 0, r.Bytes(4));
}