feat: peer clustering over the AoI grid with a NATS feed - #34
feat: peer clustering over the AoI grid with a NATS feed#34mikhail-dcl wants to merge 30 commits into
Conversation
Signed-off-by: Mikhail Agapov <mikhail.agapov@decentraland.org>
Union-find over occupied SpatialGrid cells with sticky IDs and a dwell debounce, published to a lock-free ClusterBoard and a publish-only, fail-soft NATS feed. Off the hot path on its own thread. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Migration-plan scenarios plus a capacity-ceiling case, warm and cold variants. BenchmarkSwitcher so any suite runs from the CLI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A shared oldest-first queue could evict peer A's assignment to admit peer B's, leaving A in a stale room until its next reassignment. Changes now coalesce per peer, the topology gets its own latest-wins slot and is emitted first, and real loss is counted apart from superseding. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Archipelago's services read a flat NATS_URL, so one injected secret reaches Pulse under either name. Nats__Url wins when both are set. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Makes the cluster feed exercisable locally; 8222 exposed for /varz. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Trim rationale essays and drop notes about what consumers do with the output.
Pair the CONNECTED gauge on every exit, surface a faulted loop instead of hiding it until shutdown, redact the broker URL, and count outside the outbox lock. Publish protos straight into the client buffer writer over pooled message instances. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Add dcl_pulse_nats_publish_failed_total and reduce dropped to eviction only, so each counter names one lever. Wire the client's own logger and subscribe ServerError. Correct comments that overstated retention, delivery ordering and what published counts. Also carries the pipeline supervision loop that rebuilds a faulted connection. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Drop island from the filename and update the benchmark reference that pointed at the old path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Clusters:Enabled on, Nats:Url still empty — the tracker runs and reports metrics everywhere while publishing nothing until a broker URL is injected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Clusters:Enabled is on by default; the feed stays off until a broker URL is set, so the shipped default is shadow mode. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Never registered in DI, no tests, no benchmarks, and its config section bound to nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Realm was applied as a manual filter in interest management and cluster derivation. RealmSpatialGrids now holds one SpatialGrid per realm and routes each peer into exactly one, so the per-candidate realm compare and the tracker's realm interning are gone. A grid is dropped with its last occupant, bounding live grids by connected peers rather than by the client-supplied realm names ever seen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Mikhail Agapov <mikhail.agapov@decentraland.org>
Signed-off-by: Mikhail Agapov <mikhail.agapov@decentraland.org>
The ceiling figure quoted Pass + churn without subtracting Churn, which the benchmark's own guidance says to do. ~395 us cold, and the cold/warm definition now sits in its own paragraph. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Shared surface for the LiveKit conn-string harness, landed ahead of the Comms and Bridge work so those can proceed without contending on the same files. - ClientOptions: --mode, --comms-enabled, --comms-url, --nats-url, --bridge-mode, --expect-conn-string-within. Defaults keep existing runs unchanged (comms off). - MetaForge.RunCommandAsync: check the exit code and surface stderr. It previously returned an empty string on failure, so an outdated metaforge missing a subcommand presented as a JSON parse error rather than "rebuild metaforge". Both pipes are drained before waiting so a full stderr buffer cannot deadlock the wait. - NATS.Client.Core 3.0.1, matching the server, for the bridge subscriber. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Each bot can now hold two channels on one identity: its Pulse session, and
a ws-connector session on the same wallet that receives
IslandChangedMessage.ConnStr. The shared identity is the point — it makes
peer.{addr}.cluster_change -> engine.peer.{addr}.island_changed a
verifiable correspondence rather than two unrelated observations.
Comms/ — ws-connector channel. WebSocketCommsConnection (binary frames,
multi-frame reassembly), ArchipelagoSignFlow (challenge -> signed
challenge -> welcome), ConnStringListener, HeartbeatPump at 30 s against
the server's 90 s idleTimeout. Signing goes out to `metaforge account
sign`; no key material enters this process.
Comms/AdapterAddress — --comms-url also takes a realm's raw adapter
string. unity-explorer spreads this over six types and two interfaces;
the work is three string operations and no I/O. Unlike explorer, a
non-ws adapter is an error rather than a route to a different room type:
silently resolving to "no island ever arrives" is the exact failure this
harness exists to catch.
Bridge/ — stub gatekeeper behind --mode=bridge, closing the loop without
Postgres or LiveKit credentials so the harness can gate CI. Emits
island-{clusterId}, matching what the real gatekeeper produces, so
assertions transfer. Synthetic conn strings by default.
Redaction moved out of Bridge/ and applied to the observed conn string
too — the listener was logging the token it received.
A comms failure is a separate failure domain: it reports on [comms] and
leaves the Pulse session running.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It parses into ClientOptions but nothing reads it; the deadline belongs to the regression scenarios, which do not exist yet. Documenting it as live would have it silently ignored in exactly the runs it is meant to bound. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The test client carried a second entry point that subscribed to
peer.*.cluster_change and published engine.peer.{addr}.island_changed --
a broker-side role wearing a client's binary. Removed, with --mode,
--nats-url and --bridge-mode, and the NATS.Client.Core reference with
them. The client now speaks only what a real client speaks: Pulse over
ENet/WebTransport and ws-connector over a WebSocket.
Beyond the layering, a harness whose observations come from its own
writes proves less than it appears to. comms-gatekeeper ships the real
translation and is what the harness now expects.
The cost is real and is stated in the docs rather than glossed: gatekeeper
needs Postgres and a LiveKit host/key/secret, so the suite can no longer
run credential-free, and the task spec's acceptance criterion 4 is not
satisfiable as written. It is not added to docker-compose.e2e.yml -- a
committed compose file is the wrong home for a credentialed service.
Also fixes a regression from the scaffold commit: RunCommandAsync gained
an exit-code check, but Program.cs calls `account create` per bot at
startup and MetaForge exits 2 when the account already exists -- the
normal case on every re-run. That turned a working idempotent call into a
crash. The check is now opt-out, and that one call opts out.
ConnStringRedaction stays; it guards the conn string the client observes,
which with a real gatekeeper is always a live JWT.
Recoverable from 2f9233e if the stub is ever wanted as a standalone tool.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tion, no downstream sharding
Ran the full chain against a live stack for the first time: five bots on five wallets -> one cluster -> five cluster_change -> comms-gatekeeper -> five island_changed -> five conn strings at the client. Recorded as section 0, as the shape to compare a run against. Corrects the claim, made in 6040b72 and in this document, that the harness cannot gate CI without LiveKit credentials. It can. generateCredentials builds an AccessToken and calls addGrant -- it signs a JWT offline and never contacts the LiveKit host, so gatekeeper mints with any key/secret pair. The token will not open a room, which is beside the point: the assertion is that a valid conn string arrived for the right wallet. Acceptance criterion 4 is therefore satisfiable against the real gatekeeper, no stub required. Also documented, all hit while running it: - Heartbeats carry real positions and ws-connector republishes them, but nothing subscribes since archipelago-core was removed. The mint is driven by cluster_change. Anyone reasoning from the legacy archipelago flow will expect otherwise. - A host-run Pulse takes port 5000 and starves ws-connector, and answers /metrics only on localhost, not 127.0.0.1. - Gatekeeper's "Listening" line precedes "Cluster subscriber started" by about a second; only the second means the subscription is up. - COMMS_GATEKEEPER_AUTH_TOKEN is required at startup and was not listed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A histogram of cluster sizes, one observation per cluster per pass, so quantiles are computed at query time and stay aggregatable — a pre-computed median cannot be averaged across instances. Peers, cluster count and the largest cluster stay gauges: the mean is the aggregatable peers/clusters pair, and the histogram cannot recover a maximum from its top bucket. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ran the same test client against the current, un-migrated infrastructure: zone Pulse for the game protocol, the deployed archipelago for comms. No code changes, no local services. 5/5 welcomed, 5/5 conn strings delivered against wss://dcl.livekit.cloud, nothing unredacted. This is the claim the client makes good on: it is agnostic about which service answers. Heartbeats out, islandChanged in, one socket. Today the deployed archipelago answers from heartbeat position; after the migration comms-gatekeeper answers from Pulse's cluster_change. The client does not change. The --comms-url is the realm's comms.adapter from /about pasted verbatim; AdapterAddress reduces it. That is the case it was written for. The finding that matters for the scenario runner: the two producers do not agree on shape. Deployed archipelago emits `peer-zone1` with a populated peers map and *merges* islands (from=peer-zone5, ...4, ...3, ...2 all converging). Gatekeeper emits `island-C3` with an empty peers map. An assertion of the form `island_id == "island-" + cluster_id` is therefore gatekeeper-specific and fails against current infra. Assertions meant to survive the migration must key on relationships -- same island vs different, count and order of reassignments -- never on the id's spelling. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two fixtures, from the two halves of the harness that are actually
testable today.
AdapterAddressTests runs in the normal suite -- pure, no infra. Covers the
zone adapter string verbatim from /about, plus the four forms that must be
rejected. explorer routes a non-ws adapter to a different room type; here
there is no other room type, and resolving quietly to one that never
delivers is the failure this harness exists to catch, so it has to throw.
ConnStringE2ETests is [Explicit] + Category("E2E"), so `dotnet test` does
not pick it up; run it with --filter TestCategory=E2E. It drives the
client's own Comms types rather than re-implementing the handshake or
parsing stdout, which is why DCLPulseTests now references the client Exe.
Defaults to deployed zone and passes there in 5 s.
Assertions key on relationships, never on how an island id is spelled. The
deployed archipelago emits `peer-zone1`; gatekeeper emits
`island-{clusterId}`. Pinning the spelling passes against one producer and
fails against the other while nothing is broken.
The fixture is scoped to the heartbeat-driven producer, which is what is
deployed. Once gatekeeper takes over, assignment comes from Pulse's
cluster_change and heartbeats stop driving it -- it will then also need a
Pulse session or it will time out against a healthy stack. Said so in the
fixture rather than leaving it to be discovered.
Both negative controls verified: an unreachable ws-connector fails in 2 s,
and forcing both bots onto one wallet fails with "ws-connector kicked the
session: KrNewSession" rather than an opaque timeout -- the listener and
pump faults are routed into the same completion source for exactly that.
Not caused by this change: ENetHostedServiceShutdownTests
.ShutdownGracefully_DeliversGracefulReasonToClient fails on Host.Create.
Confirmed identical on a clean HEAD worktree with none of these changes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The fixture passed only because the local MetaForge build was first on PATH. `account sign` is unreleased, so on any other machine the run got as far as a real challenge from a real ws-connector and died there -- which reads as a protocol fault rather than a stale binary. OneTimeSetUp now probes `account sign --help`, which exits 0 where the subcommand exists and 127 where it does not, without touching an account or producing a signature. Failing there costs no socket and names the fix. This is the version check the task spec asked for under D1's risks and that the first cut did not have. Documented the workaround concretely in the prerequisites -- build MetaForgeCLI, put its output first on PATH -- rather than "recent enough", which is not actionable while the command is unreleased. Verified both directions: released metaforge fails in OneTimeSetUp with the actionable message and no connection attempt; the local build still passes 2/2 against zone in 5 s. Default suite unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The prefix existed for a shadow-mode rollout scenario that was dropped, and no
Decentraland consumer supports prefixed subjects — gatekeeper and ws-connector
subscribe to the literal strings. peer.{addr}.cluster_change, engine.islands and
engine.discovery are now compile-time constants.
decentraland-bot
left a comment
There was a problem hiding this comment.
Code Review — PR #34: Peer Clustering over AoI Grid with NATS Feed
Thoroughly reviewed this large PR (~6,500 new lines across 88 files). The design is well-documented, the architecture is sound, and the test coverage is strong. One correctness bug needs fixing before merge.
Findings Summary
- P1 Critical: 1 — blocks merge
- P2 Minor: 3 — suggestions
P1 — Blocks Merge
1. PruneVanishedClusters modifies Dictionary during foreach enumeration
ClusterTracker.PruneVanishedClusters() iterates clusterRecords (a Dictionary<string, ClusterRecord>) and calls clusterRecords.Remove(clusterId) inside the loop. In .NET, this throws InvalidOperationException on the first removal.
The try/catch in RunPassLoop catches the exception and logs it, so the BackgroundService recovers on the next pass — but:
- Pruning never succeeds: every attempt to remove a vanished cluster throws before reaching the second entry.
clusterRecordsgrows unbounded with stale cluster IDs, bounded only by the rate of cluster creation.- An error is logged on every pass that has vanished clusters, creating log noise.
Fix: collect keys to prune into a temporary list, then remove outside the enumeration:
private void PruneVanishedClusters()
{
if (clusterRecords.Count == components.Count) return;
List<string>? toRemove = null;
foreach ((string clusterId, ClusterRecord record) in clusterRecords)
{
if (record.LastLivePass != passNumber)
(toRemove ??= []).Add(clusterId);
}
if (toRemove is null) return;
foreach (string id in toRemove)
clusterRecords.Remove(id);
}P2 — Suggestions
2. No length validation on client-supplied realm names (RealmSpatialGrids.cs)
Realm names arrive from clients and are used as ConcurrentDictionary keys. The grid lifecycle is correctly bounded (one grid per peer, dropped when last occupant leaves), so the count is safe. However, there is no length cap on the realm string itself — a malicious client could send multi-megabyte realm names that persist as dictionary keys until the peer leaves. Consider capping realm name length at the intake boundary (e.g., TeleportHandler).
3. dropped counter may miscount on defensive eviction path (NatsPublisher.QueueChange)
In the eviction branch, dropped = true is set unconditionally after the changeOrder.TryDequeue, even when pendingChangeBySubject.Remove(evicted) returns false (i.e., the evicted subject was stale). Under normal operation, the lock invariant ensures that every subject in changeOrder is in pendingChangeBySubject, so the Remove should always succeed. The TryDequeueNext drain loop has the same defensive continue pattern, suggesting the author considered the possibility. In practice this appears correct, but the unconditional dropped = true means if the invariant ever breaks, the metric would overcount without an actual eviction.
4. Consider a contention metric for the shared RealmSpatialGrids write lock
All realms share a single Lock for writes. At sub-microsecond hold times and MaxPeers=4095, this is likely fine, but adding a contention metric would provide visibility under production load.
Security Review
- ✅
SanitizeBrokerUrlproperly strips userinfo (credentials) from NATS URLs before logging, usingUri.TryCreateto extract only host and port. - ✅
ConnStringRedactionredactsaccess_token=values from LiveKit connection strings via regex. - ✅ No secrets or credentials in
docker-compose.debug.ymlordocker-compose.e2e.yml.E2E_NATS_URLis read from host env with a safe local default. - ✅
AddNatsUrlAliasprecedence is correct — the alias cannot override an explicitNats__Url. - ✅
SnapshotBoard.Publishreturn-type change (void → PeerSnapshot) is additive and backward-compatible for callers.
Architecture Notes
- The weighted union-find with path halving is a good fit — O(N+C) per pass, measured at ~395μs at the 4095-peer ceiling.
- Structural realm isolation via per-realm
SpatialGridinstances eliminates per-candidate realm comparisons in the hot path. - The coalescing outbox with per-feed supersede semantics (latest-wins for topology, per-peer for cluster_change) is well-designed.
- Fail-soft NATS integration: empty URL → stats-only mode; broker outage → outbox retention; no blocking on the hot path.
- The known gaps (cluster ID uniqueness, missing re-publish sweep, percolation at high density) are well-documented and appropriate to defer.
CI Status
All checks passing ✅ (Build linux-x64, Build osx-arm64, Build win-x64, Run tests).
Reviewed by Jarvis 🤖 · Requested by mikhail-dcl via GitHub
| /// <summary> | ||
| /// Materializes the immutable pass result: per-cluster geometry plus per-peer detail. | ||
| /// </summary> | ||
| private ClusterPass BuildPass() |
There was a problem hiding this comment.
[P1] Bug: Dictionary modification during foreach enumeration.
This will throw InvalidOperationException in .NET whenever a cluster vanishes. The try/catch in RunPassLoop catches it, but pruning never completes — clusterRecords grows unbounded with stale entries.
Suggested fix: collect keys first, then remove:
private void PruneVanishedClusters()
{
if (clusterRecords.Count == components.Count) return;
List<string>? toRemove = null;
foreach ((string clusterId, ClusterRecord record) in clusterRecords)
{
if (record.LastLivePass != passNumber)
(toRemove ??= []).Add(clusterId);
}
if (toRemove is null) return;
foreach (string id in toRemove)
clusterRecords.Remove(id);
}
Pulse becomes the sole author of peer clusters, derived from the same grids and
SnapshotBoardthat drive area-of-interest, and feeds NATS with whatarchipelago-corepublishes today. No client protocol change, no worker involvement, nothing added to the per-tick or per-packet path.Design and rationale: docs/clustering-on-aoi.md. Plan of record: Notion "Archipelago ⇒ Pulse migration plan", iteration 1.
What ships
ClusterTracker—BackgroundServiceon its own thread, one pass per second: weighted union-find with path halving over occupied grid cells, 8-neighbour adjacency, run one realm at a time. Sticky IDs plus a dwell debounce for stability. Buffers are fields cleared between passes; no LINQ.ClusterBoard— the immutableClusterPassswapped in with oneVolatile.Write; readers lock-free.NatsPublisher— sole connection owner, publish-only, fail-soft, coalescing outbox.ClustersandNats.Clusters:Enabledships true,Nats:Urlships empty, so the default is shadow mode: the tracker runs and reports metrics but publishes nothing until a URL is injected. Clearing the URL is the rollback."Island" now means archipelago's concept and the wire contracts carrying it only —
engine.islands,IslandStatusMessage,IslandChangedMessage, the/islandsstats paths. Clusters are uncapped, realm-partitioned, and carry no transport details.Realm isolation
Realm used to be a read-time filter: a per-candidate
string.Equalsin both AoI implementations, plus per-pass realm interning and a per-cell member partition in the tracker. It is now structural —RealmSpatialGridsholds oneSpatialGridper realm and routes each peer into exactly one, so an observer resolves its own realm's grid and every candidate it finds is already same-realm. Seven realm comparisons leave the read paths andNodeKeydrops its realm.PeerIndexwould let one peer mint unbounded full-size arrays by teleporting to fresh names. A grid is dropped with its last occupant, bounding live grids by connected peers.Setadds to the new cell before vacating the old, so the peer is never momentarily in neither, and a solo peer changing cells cannot evict the grid it is moving within.SnapshotBoard.Publishnow returns the ledger-resolved snapshot (public API change), so the publisher picks a grid without a second seqlock read.SpatialAreaOfInterestand its options are deleted — never DI-registered, no tests, config section bound to nothing.NATS output
peer.{addr}.cluster_changedecentraland.pulse.PeerClusterChange { cluster_id, realm }engine.islandskernel.comms.v3.IslandStatusMessageengine.discoverykernel.comms.v3.ServiceDiscoveryMessageGatekeeper subscribes to
cluster_change, mints the LiveKit conn-string and re-emits the existingisland_changed, so WS Connector and clients are untouched.PeerClusterChangeis deliberately notIslandChangedMessage: that carriesconn_str(a signed LiveKit JWT) and its producer runs a ban check, both of which stay with gatekeeper.The broker URL is read from either
Nats__Urlor the flatNATS_URLarchipelago's services use, so one injected secret serves both;Nats__Urlwins.The outbox is not one queue.
engine.islandsis a whole-world snapshot, so a newer one fully replaces an undelivered one — one latest-wins slot.cluster_changesupersedes only per peer, so those are held one entry per peer. A shared FIFO with oldest-first eviction, the original design, could discard peer A's assignment to admit peer B's, leaving A on a stale cluster until a reassignment that may never come if A stops moving. Loss now needs more thanNats:ChannelCapacitydistinct peers pending at once, and is counted separately (dropped) from benign superseding (superseded).Topology is emitted before the events referencing it, but best-effort only: gatekeeper and stats are separate subscribers, so consumers must tolerate an unknown cluster id. Per peer, its own events are ordered.
Reconnection. Client defaults suit a fail-soft feed (unlimited retries, 2–5 s jittered backoff), plus two additions:
IgnoreAuthErrorAbort = true, since by default the client stops reconnecting permanently after the same auth error twice — turning a rotated credential into a feed only a restart recovers; and a supervision loop rebuilding after 5 s, since broker loss is handled inside the client and the pipeline exiting therefore means it faulted.Benchmarks
src/DCLPulseBenchmarks,-c Release. BenchmarkDotNet 0.15.8, .NET 10.0.10, Ryzen 9 9955HX, X64 RyuJIT x86-64-v4.Clustering pass —
ClusterTrackerBenchmarks. Cold isPass + churn−Churn only, a difference of means, so its error is both rows' summed. Quote cold:Passrepeats over an unchanging grid and stays cached, which a second of production traffic does not.SporadicChainedDenseAndSparseCeilingUniformAt the 4 095-peer ceiling that is ~0.04 % of one core at 1 Hz. Allocation is dominated by the immutable
ClusterPass, which readers hold by reference and so cannot be pooled.CeilingUniformdocuments a limit, not a win: cell-adjacency clustering is site percolation, and 4 095 peers over Genesis City at 100 u cells occupy ≈ 0.83 of cells against a Moore threshold of ≈ 0.407, so the partition collapses to 2 clusters with 4 091 peers in one. Downstream room sharding becomes load-bearing rather than an overflow path — see §3.2.Feed encoding —
NatsEncodeBenchmarks. Justifies publishing straight into the client's writer rather thanToByteArray(), which allocated, walked the message twice and memcpy'd:ToByteArray+ copycluster_change(13 B)An intermediate pooled-bytes shape removed the allocation but not the double walk (36.12 ns / 28.10 µs) — only a benchmark separates it from zero-copy.
Metrics
Nine series on the existing
/metrics, types as emitted byPrometheusFormatter.dcl_pulse_clustersdcl_pulse_cluster_passes_totaldcl_pulse_cluster_pass_duration_us_totaldcl_pulse_cluster_reassignments_totalDwellPassesdcl_pulse_nats_published_totaldcl_pulse_nats_dropped_totaldcl_pulse_nats_superseded_totaldcl_pulse_nats_reconnects_totaldcl_pulse_nats_connectedGrafana. Scrape
/metricson port 5000; supplyauthorization.credentialswhenMetricsBearerTokenis set. Add a "Clusters" row topulse-server-dashboard.json:dcl_pulse_clusters; mean pass duration asrate(…pass_duration_us_total[5m]) / rate(…passes_total[5m]);rate()of passes, reassignments and published;droppedandsupersededon one panel, since the pair is only meaningful read together;dcl_pulse_nats_connectedas a stat with 0 → Down. Userate()on every_total, notirate(), at a 1 Hz production rate.Alerts worth having:
rate(dropped[5m]) > 0for 5m; passes< 0.5/sfor 5m; mean pass duration> 500 000 µsfor 10m. Aconnected == 0alert must be scoped to deployments whereNats:Urlis set — Prometheus cannot see whether a URL is configured, so ungated it fires everywhere by design.Decommissioned with archipelago-core (gone after cutover)
dcl_archipelago_islands_countdcl_pulse_clusters(gauge, clusters derived by the last pass)dcl_archipelago_peers_countdcl_pulse_cluster_peers(gauge; counts clustered peers — excludes peers with no realm yet or no wallet inIdentityBoard, so it can read lower than connected peers)Dashboards using dcl_archipelago_islands_count and/or dcl_archipelago_peers_count:
Comms — Folder: Core Team: Catalysts
→ Open
• Peers Over Time (timeseries) — sum(dcl_archipelago_peers_count{instance=
"$instance"}) by (instance)"$instance"}) by (instance)• Islands Over Time (timeseries) — sum(dcl_archipelago_islands_count{instance=
Main realm comms — Folder: Core Team: Comms Service
→ Open
• sum(dcl_archipelago_peers_count{catalyst=""})
• sum(dcl_archipelago_islands_count{catalyst=""})
Online users — Folder: Core Team: Services
→ Open
• Online users (timeseries) — sum(dcl_archipelago_peers_count{catalyst=~"peer-.*"}) by (catalyst)
• Online users (delta) (timeseries) — sum(delta(dcl_archipelago_peers_count[$__interval:])) by (catalyst)
• Online users (timeseries) — sum(dcl_archipelago_peers_count)
Performance — Folder: World team
→ Open
• Connected users all clients (timeseries) — sum(max_over_time(dcl_archipelago_peers_count[$__interval:]))
• Connected users E@ (timeseries) — sum(max_over_time(dcl_archipelago_peers_count{service="archipelago-ea-core"}[$__interval:]))
Verification
dotnet buildclean; 527 tests pass, 0 failures.NATS.Client.Corepackage addition.engine.islandsat 1/s,engine.discoveryon its timer,connected 1,dropped 0.connected1 → 0 → 1,reconnects1, publishing resumed,dropped0 — the outbox retained each peer's latest assignment across the outage.NATS feed disabled,connected 0, no errors.peer.{addr}.cluster_changeis unit-tested but not live-verified — it needs an authenticated peer.Three defects came from tests rather than review: sticky-ID inheritance read the published assignment instead of the previous pass's computed one, starving the debounce so a mid-debounce fragment was minted a fresh ID every pass and could never be reassigned; the shared-outbox eviction above; and the teleport ordering under Realm isolation, which the deleted per-candidate realm filter had been masking.
Dependency
Requires protocol#453.
ServiceStatus/ServiceDiscoveryMessagewere never merged to@dcl/protocolmain — archipelago reaches them only via a pinned CDN branch build whose source commit no longer exists. Proto regeneration here fails without it.pulse_clusters.proto(carryingPeerClusterChange) still needs its own protocol PR; gatekeeper also needs the generated TS types to subscribe.Deployment order
Known gaps
C{n}counts from zero, so it resets on restart and collides across instances: after a restartC1names a different crowd and gatekeeper maps it onto the previousC1's LiveKit room. Scope toserver_idor a boot epoch. Live-voice-room correctness, not cosmetics.IsTeleporton the latest snapshot only, so a teleport followed by movement inside one pass goes through the debounce. Cross-realm teleports are always immediate.engine.islandsreportsmax_peers = 0since clusters are uncapped, soGET /islandsreads 0 where it read 100.engine.parcel_changesare iteration 2.