Files
MoShiro/backend/src/MoShiro.Backend.Application/Live/HotLayer.cs
T
justme 12be44645f
ci/woodpecker/push/main-amd64-build Pipeline was successful
feat: install nodes and the backend from a static compose file; remove dead code [CI]
Agent, Probe and Backend now start from one static compose.yaml plus a local
.env: the panel (or the release assets, for the very first Backend) provides
the file, the panel's command writes the public values into .env, checks the
file's SHA-256 and runs compose up. Updating is compose pull && compose up -d.
The image is chosen by the CI channel (:latest, or MOSHIRO_VERSION in .env),
not by the panel; the previous one-line script stays as a fallback.

- packaging/compose/{agent,probe,backend}/compose.yaml are the single source,
  embedded in the Backend and served at /install/compose/{file}; the root
  compose.yaml moved there. Releases also carry them and list them in the
  signed SHA256SUMS.
- The container entrypoint defaults the node name to the host name.
- Join token creation returns a compose block (.env, url, hash, per-platform
  commands); the admin dialog leads with it.
- Remove code with no references (HotLayer members, LiveHub.SubscriberCount,
  QueryService.ParseParams, ReleaseService.Forget, a few constants) and reduce
  the install-code lookup to the role it is for.
2026-10-08 20:24:23 +08:00

122 lines
3.8 KiB
C#

using System.Collections.Concurrent;
using Fleet.Core;
using MoShiro.Backend.Analysis;
using MoShiro.Protocol;
namespace MoShiro.Backend.Application;
/// <summary>
/// 1 秒精度的内存热层:最近 1 小时的累加器。桶以 <see cref="AccumulatorCodec"/> 编码后的字节保存,
/// 15 条线路约十几 MB;它同时是 SSE 实时推送的数据来源。重启后为空,由后台任务从原始块回填。
/// </summary>
public sealed class HotLayer
{
public const long BucketMs = 1000;
public static readonly TimeSpan Retention = TimeSpan.FromHours(1);
private readonly ConcurrentDictionary<(NodeId Agent, string Series), Ring> _rings = new();
private readonly TimeProvider _time;
private volatile bool _ready;
public HotLayer(TimeProvider time)
{
_time = time;
}
/// <summary>回填完成之前热层不完整,查询规划器不会选用它。</summary>
public bool Ready => _ready;
public void MarkReady() => _ready = true;
/// <summary>评估一个块并合并进热层。同一个块重复合并(例如回填与实时竞争)会被忽略。</summary>
public void Merge(NodeId agent, string series, RawChunk chunk)
{
Ring ring = _rings.GetOrAdd((agent, series), _ => new Ring());
var buckets = new SortedDictionary<long, MetricSet>();
WindowEvaluator.Default.Evaluate(chunk, BucketMs, buckets);
long cutoff = _time.GetUtcNow().ToUnixTimeMilliseconds() - (long)Retention.TotalMilliseconds;
lock (ring)
{
if (!ring.Chunks.Add(chunk.Header.ChunkId))
{
return;
}
foreach ((long start, MetricSet set) in buckets)
{
if (start < cutoff)
{
continue;
}
if (ring.Buckets.TryGetValue(start, out byte[]? existing) && AccumulatorCodec.Decode(existing).TryGet(out MetricSet? merged, out _))
{
merged.Merge(set);
ring.Buckets[start] = AccumulatorCodec.Encode(merged);
}
else
{
ring.Buckets[start] = AccumulatorCodec.Encode(set);
}
}
Prune(ring, cutoff);
}
}
/// <summary>[from, to) 内的 1 秒桶(解码后的副本,按时间升序)。</summary>
public IReadOnlyList<KeyValuePair<long, MetricSet>> GetRange(NodeId agent, string series, long fromMs, long toMs)
{
if (!_rings.TryGetValue((agent, series), out Ring? ring))
{
return [];
}
var result = new List<KeyValuePair<long, MetricSet>>();
lock (ring)
{
foreach ((long start, byte[] bytes) in ring.Buckets)
{
if (start >= toMs)
{
break;
}
if (start >= fromMs && AccumulatorCodec.Decode(bytes).TryGet(out MetricSet? set, out _))
{
result.Add(new KeyValuePair<long, MetricSet>(start, set));
}
}
}
return result;
}
private static void Prune(Ring ring, long cutoff)
{
while (ring.Buckets.Count > 0)
{
long first = ring.Buckets.Keys.First();
if (first >= cutoff)
{
break;
}
ring.Buckets.Remove(first);
}
// 块 ID 集合只需要覆盖热层的时间窗,限制大小即可
if (ring.Chunks.Count > 2000)
{
ring.Chunks.Clear();
}
}
private sealed class Ring
{
public SortedDictionary<long, byte[]> Buckets { get; } = [];
public HashSet<ChunkId> Chunks { get; } = [];
}
}