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 45 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);
}There was a problem hiding this comment.
Not a bug on this runtime — Dictionary<TKey,TValue>.Remove has not invalidated an active enumerator since .NET Core 3.0, and this repo targets .NET 10. The loop completes, clusterRecords stays bounded, and nothing reaches the try/catch in RunPassLoop.
Verified on the actual runtime rather than from memory:
.NET 10.0.11
Remove during foreach: OK - removed 5, 5 left
Add during foreach: THREW (expected - proves the guard is live)
The second line is the control. An Add during enumeration does still throw, so the version check is live — the Remove result isn't a case of the guard being absent or optimised away.
Since this is easy to re-flag, 999a649 records the guarantee at the loop itself so the next reader doesn't reach for a second pass:
// Removing during enumeration is deliberate and supported: since .NET Core 3.0
// Dictionary.Remove does not invalidate an active enumerator, so this needs no second pass
// and no key list. Adding still would invalidate it — only removal is exempt.Happy to take the two-pass rewrite anyway if the team would rather not depend on that guarantee, but it would be defensive rather than a fix — and it would add a List<string> allocation to a method that currently has none.
The other three findings are answered in #34 (comment): #3 fixed in c5e55f2, #2 refuted (realm length is already capped at 255 by FieldValidator on all three intake paths), #4 declined with measurements.
Scene listener meets per-realm grids. Main added SpatialGrid peer bookkeeping back for the listener's cell lookups; that lives in RealmSpatialGrids now, so SceneListenerCellMapper takes the owner and CollectSceneListenerSubjects resolves the listener's realm grid once — which drops its per-subject realm compare, the same way the observer path lost one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Main brought its own bucket histogram and Prometheus writers with the latency SLIs. Reuse them instead of the hand-rolled bucket array the merge left alongside: one primitive, labels for free, and ClusterSizeHistogram plus a second WriteHistogram both go. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review catch. The eviction branch set dropped after the dequeue, so a stale order entry with no pending message would have counted as a loss — sending an operator after capacity for a non-event. The drain loop already tolerates that case; the counter now does too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Thanks — thorough review. Branch is now merged up to P1 —
|
Uncontended Lock enter+exit |
18.2 ns |
| One peer move (grid write) | 262–617 ns by cell occupancy |
of which copy-on-write HashSet clone |
~90%, and all the allocation (232 B → 14 KB at occupancy 256) |
The lock is ~3–7% of a write even uncontended, and the dominant cost is the occupant-set clone. A production counter on that path would add cost to the thing being measured for a signal that is not the bottleneck. SpatialInterestBenchmarks already exercises the 4-worker contended write path, which is the right place to answer this — and if it ever does become load-bearing, the fix named in BucketHistogram's own comment applies here too: shard per worker rather than measure.
One note on the merge
main brought BucketHistogram + WriteHistogramHeader/WriteHistogramSeries with the latency SLIs, which duplicated the cluster-size histogram this PR had added. Folded onto yours in bae3102 — ClusterSizeHistogram and the second WriteHistogram are both gone.
The merge also resolved scene-listener against per-realm grids: SceneListenerCellMapper now takes RealmSpatialGrids, and CollectSceneListenerSubjects resolves the listener's realm grid once — which let its per-subject realm comparison go, the same way the observer path lost one.
755/755 tests pass on the merged branch; no new compiler warnings.
Main made the scene-listener AoI per realm too, from the other side: a listener announces parcels per realm against one global grid, then filters (realm, parcel) exactly. With per-realm grids the realm half of that filter is structural, so CollectSceneListenerSubjects walks the announced realms, resolves each one's grid, and filters parcel-exact — SceneListenerState.Observes had no caller left and is gone. Kept main's rect-range cell mapper over the per-parcel corner walk; it just sources cell coordinates from RealmSpatialGrids now. Cell keys stay realm-independent, which is what lets one covering set serve a multi-realm announcement: probing a realm's grid with another realm's cells over-covers and never mis-covers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review flagged it as a bug. It is supported since .NET Core 3.0 — Dictionary.Remove does not invalidate an active enumerator — so say so at the loop rather than let the next reader reach for a second pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Production ships Logging:LogLevel:Default = Warning, so the Information lines these two services used for "not configured" never reached a deployment log — a tracker that never started, or a feed left in stats-only mode, was invisible where an operator looks. Both now warn, matching BansPollingHttpService and FeatureFlagsPoller. Also warns on a non-positive Nats:ChannelCapacity, which leaves the feed running while every assignment evicts the one before it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
c3a4b02 to
c637c25
Compare
Describe reports claims only, never the token, and flags a room or identity mismatch. ConsoleInputReader no-ops when stdin is redirected, so --bot-count=1 no longer throws on a piped run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Minting is a step inside island assignment, done by the assigner. Records how to resolve the deployed commit, why /core-status does not answer this, and the gap between a valid-looking token and the explorer connecting. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Gatekeeper prefixes island rooms and core does not, so the room name in the [livekit] line identifies the producer exactly. Also records that gatekeeper mints on seven paths that need no Pulse feed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Asserts ConnectionState == ConnConnected, the same criterion the explorer uses, so a run distinguishes a token that arrived from a token that works. Reports the room the server granted, not the one the token asked for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
/archipelago/status reports ws-connector's build, not core's, so it cannot answer whether core is alive — the room name can. Records that retiring core is an infra action, and documents --join-livekit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
peer.<domain> is a different realm on a different stack. Zone's explorer realm runs the core-removal build with no producer, so a bot there is welcomed and then hears nothing — while a bot on peer.<domain> gets islands and tokens. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
E2E validation on zone — 2026-09-02Deployed this branch to dev via Deploy Dev (Debug) (run 33658198567), then ran the numbered scenarios from Bots: Headline: the feed works, the last hop does not
No Scenario results
Scenario 7 is the one the task doc says to write first — the regression guard for Scale10 concurrent bots spread over a 400-unit spawn radius collapsed into a single cluster of radius 266 — the percolation behaviour documented in Harness defects found while running this
Also added on this branch: |
Addendum — provenance of the
|
| Field | Observed | Pulse | Old core |
|---|---|---|---|
id |
C2 … C14 |
Clusters:IdPrefix = "C" |
ROOM_PREFIX — I default, peer-zone on artemis |
maxPeers |
0 |
uncapped → reports 0 | LIVEKIT_ISLAND_SIZE = 100 |
Cross-source join. With a heartbeating bot (--comms-enabled), one island resolves across two different subjects and two different services:
{"id":"C14","maxPeers":0,"center":[1499.98,0,1499.98],"radius":0,
"peers":[{"address":"0x3f4c…afdb","parcel":[93,93],"position":[1500,0,1500]}]}Island id/centre/radius come from Pulse's engine.islands; the peer entry comes from ws-connector's peer.*.heartbeat. They agree on wallet and position — a correlation stale or cached data cannot produce. The earlier peers: [] was simply bots run without --comms-enabled, which therefore never heartbeat and cannot be resolved by stats; not a broken join.
Liveness. Centres tracked the coordinates each run chose, cleared within one poll of killing the bots, and the cluster counter advanced C2 → C14 across the session.
(lastPing appears frozen within a poll window — expected: HeartbeatPump's default interval is 30 s. It is not a liveness signal at this timescale.)
metaforge exits 2 for an account that already exists, which is the normal outcome of every re-run. The single-bot path already tolerated it; the multi-process orchestrator and the scene-listener path did not, so each was usable exactly once per account prefix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
decentraland-bot
left a comment
There was a problem hiding this comment.
Code Review — PR #34: Peer Clustering over AoI Grid with NATS Feed
Independent review of this PR after the prior review's findings were addressed. Reviewed the full diff (~3,600 lines production code, ~5,800 lines tests/benchmarks, across 95 files), the author's responses, and the fix commits.
Prior findings — resolved
| # | Prior finding | Resolution | Status |
|---|---|---|---|
| P1 | PruneVanishedClusters dictionary modification during foreach |
Not a bug — Dictionary.Remove during enumeration is supported since .NET Core 3.0; repo targets .NET 10. Author verified on actual runtime and added an explanatory comment (999a649). |
✅ Correctly refuted |
| P2 | No realm-name length cap | Already capped at 255 by FieldValidator on all three intake paths. |
✅ Already handled |
| P2 | dropped counter could miscount on stale changeOrder entry |
Fixed in c5e55f2 — dropped = true now sits inside the if (Remove(...)) branch. |
✅ Fixed |
| P2 | Lock contention metric | Declined — sub-µs hold times at ceiling. | ✅ Acceptable |
Independent analysis
Architecture — Clean separation of concerns:
ClusterTracker(derivation on dedicated thread) →ClusterBoard(immutable pass, lock-free read) →NatsPublisher(fail-soft outbox, sole connection owner)RealmSpatialGrids(structural realm isolation, per-realm grids) replaces per-candidate realm predicates in AoI and clustering- The two
BackgroundServiceinstances (tracker, publisher) have distinct failure domains linked only through the outbox interface
Threading model — Correctly reasoned:
- Single-writer-per-peer-slot invariant upheld:
RealmSpatialGrids.Setis called only by the owning worker,Removeonly by the disconnect cleanup path Volatile.Read/WriteinRealmSpatialGridsandClusterBoardare correct: the Volatile operations guard the array/reference stores against reordering, and single-writer ensures no TOCTOU on the per-slot reads outside the lock- The tracker's dedicated
LongRunningthread avoids thread-pool starvation;Task.Delayrather thanThread.Sleepkeeps the cancellation responsive
Outbox design — Well-considered:
- Per-subject coalescing for
cluster_change(one slot per peer, not a shared FIFO) prevents the cross-peer eviction that a shared queue would allow - Separate latest-wins slot for topology prevents topology from competing with peer assignments for capacity
- Object pooling (rent/return) with correct lifecycle: the
rented = nullidiom on hand-off ensures exactly-once return on every exit path (success, failure, cancellation) - The c5e55f2 fix ensures
droppedcounts only genuine evictions, not stale order entries
Union-find — Correct weighted union-find with path halving. Forward-half neighborhood probing (4 directions instead of 8) is sound because adjacency is symmetric. NodeKey custom hash avoids the entropy-destroying long.GetHashCode fold (x ^ z) that would collapse cells on anti-diagonals.
Sticky ID inheritance — The two-notion design (computed vs. published assignment per peer) correctly prevents the debounce starvation bug the author documented: a fragment mid-debounce inherits its own computed ID for overlap measurement, while the published assignment drives the feed change-detection.
Teleport ordering — The cross-realm teleport sequence (remove from old grid → publish snapshot → place in new grid) correctly avoids the stale-view problem where an observer of the old realm sees a subject whose snapshot names the new realm. The brief invisibility window (peer in no grid) is documented and preferable to the alternative.
NATS reconnection — Sound:
- Client defaults (unlimited retries, 2–5s jittered backoff) suit a fail-soft feed
IgnoreAuthErrorAbort = trueprevents a rotated credential from permanently killing the feed- Supervision loop rebuilds after a faulted pipeline, with 5s backoff to prevent spin
- Linked
CancellationTokenSourceensures drain and heartbeat fail together MarkDisconnected()in thefinallyblock prevents the connected gauge from sticking at 1 after a rebuild
Discovery heartbeat — The single ServiceDiscoveryMessage instance is safely reused: it's rewritten before each publish, and PublishAsync completes serialization before its task returns (verified against NATS.Client.Core 3.0.1's CommandWriter). The heartbeat is sent directly rather than through the outbox, correctly avoiding backlog delays.
Security review
- ✅ Credential handling:
SanitizeBrokerUrlstrips userinfo from NATS URLs before logging viaUri.TryCreate, extracting only host:port. Unparseable URLs get a placeholder, never echoed raw. - ✅ No secrets committed: NATS URL comes from environment variables;
appsettings.jsonships"Url": ""(shadow mode). - ✅ Config precedence:
AddNatsUrlAliasfillsNats:Urlonly when not already set, preventing the flatNATS_URLfrom silently overriding an explicitNats__Url. - ✅ Client-supplied realm names: Capped at 255 by
FieldValidator; grid instances are bounded by connected peers and dropped when empty. - ✅ Wallet addresses in NATS subjects: Lower-cased for consistent keying. Ethereum addresses are hex-safe, so no NATS subject injection risk from the auth-validated wallet format.
- ✅ NATS client logging: Floored at Warning in
appsettings.jsonso a Debug-level Default can't pull it down to per-publish verbosity. - ✅
SnapshotBoard.PublishAPI change:void→PeerSnapshotreturn is backward-compatible (callers can ignore).
Consumer impact
New NATS subjects (peer.{addr}.cluster_change, engine.islands, engine.discovery) are consumed by gatekeeper (PR #283) and archipelago-workers (PR #128) — separate deployment PRs. No existing consumers broken. SpatialGrid's API changes are internal to Pulse.
Observations (no action required)
- Known gap — cluster ID uniqueness:
C{n}resets on restart and collides across instances. Documented in the PR and tracked for iteration 2. The mitigation (scope toserver_idor boot epoch) is straightforward when needed. - Scene listener cost:
CollectSceneListenerSubjectsnow iterates realms × cells instead of just cells. For the typical single-realm case this is zero overhead; multi-realm listeners pay proportionally but the inner grid lookup is aConcurrentDictionary.TryGetValue. ClusterPass.clusterIdByPeerallocation: FullmaxPeers-sized array per pass (~32KB at 4095). Documented as the dominant allocation, intentionally not pooled because readers hold by reference.
Verdict
All prior findings are resolved. No P0 or P1 issues found in independent review. The code is exceptionally well-documented, the threading model is carefully reasoned, error handling is robust, test coverage is thorough (527 tests, including the three defects caught by tests that the PR description documents), and the benchmarks validate the performance claims. The shadow-mode default (tracker runs, feed off until URL injected) provides a safe rollout path.
Approved.
Reviewed by Jarvis 🤖 · Requested by mikhail-dcl via GitHub
Cluster compositions from
|
| # | Composition | Result | Evidence |
|---|---|---|---|
| 1 | Sporadic — areas close enough to bridge, a second pair far enough to stay split | ✅ Pass | 8 bots, realm csB. Bots at x=0,10 and x=150,160 merged into C17 @ 80, r=80; bots at x=1000,1010 and x=1400,1410 stayed split as C18 @ 1005, r=5 and C19 @ 1405, r=5. Stable over 12 polls. Exactly the documented semantic: adjacent cells bridge, a 400 u gap does not. |
| 2 | DenseAndSparse — a crowd plus sparser regions, some merging | Not run as a shaped composition (blocked, below). Related evidence from the scale run: 10 bots over a 400 u spread collapsed to one cluster of radius 266 — crowd-merging behaviour, but without the separate sparse regions that make this case distinct. | |
| 3 | Chained — areas in a line, each bridging into the next | ⛔ Blocked | Launched as 4 pairs at x=0/150/300/450 (realm csC); every bot failed before connecting — see below. The bridging mechanism is nonetheless demonstrated by case 1, which is one link of the same chain. |
| 4 | CeilingUniform — uniform Genesis fill at Transport.MaxPeers |
⛔ Not reproducible at this scale | The case is defined at 4 095 peers, and its documented result (percolation collapse into ~2 clusters) is a property of density. At ≤ 50 bots the realm is far below the percolation threshold, so any run here would confirm nothing about it. ClusterTrackerBenchmarks remains the right instrument. |
Blocker. Catalyst began returning 403 Forbidden on every profile fetch (CatalystProfileGateway.GetAsync), after earlier returning No profile found on Catalyst for most freshly bulk-created wallets. Both are harness/environment limits reached by the volume of runs in this session — not Pulse behaviour. Cases 2 and 3 should be re-run once the limit clears; the layouts are:
- Chained: pairs at x = 0, 150, 300, 450 (150 u spacing, one cell apart) → expect a single cluster spanning ~450 u.
- DenseAndSparse: a crowd at (0,0), sparse regions at x = 600 and 750 (should merge with each other) plus x = 1500 and 2200 (should not) → expect 4 clusters.
Two harness notes for whoever re-runs this:
- Bulk account creation is ~15–20 s per account and the profile is not immediately fetchable afterwards; pre-create the pool well ahead of the run, and expect some wallets to need a retry.
--bot-offsetwith--total-bot-countputs a process in worker mode, which reuses pre-created accounts instead of creating them. That is the way to run several shaped groups concurrently without racing MetaForge's account store.
A deployed run split an eight-cell chain at a uniform one-cell pitch into two halves, and left x=300/400 separate while x=0/100 merged. All four shapes are now asserted directly — adjacent cells at and away from the origin, the eight-cell chain, and a neighbour that appears after a cluster exists. They pass, so the tracker is not the source. Also: a bot no longer dies when its Catalyst profile is unreachable. The server relays profile_version and emotes without validating them, so a rate limit or an account from another environment cost the whole run for nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cluster compositions, second pass — with the IP whitelist in place40 bots now connect cleanly ( Measured adjacency threshold — matches the designThree separations, three realms, 18 bots, stable over 12 polls:
Exactly ±1-cell adjacency at a 100 u cell. Clusters are also confirmed uncapped: 25 co-located bots formed one cluster ( Unresolved: an eight-cell chain splits at one specific link
Deterministic across two runs, and reproducible with just the two failing groups in isolation (x=300 and x=400 → The tracker is not the cause. Four unit tests now assert exactly these shapes and all pass (794 total, 0 failed):
Union uses the forward half-ring What that leaves: the deployed artifact or its configuration differs from what these tests cover. Resolving it needs the running server's commit and its I deliberately have not pushed a fix: the validation exonerates the logic rather than convicting it, so there is nothing verified to change. Case status
|
Publishing global x=300 stores 299.984: the in-parcel offset is quantized to 8 bits over [0,16], and offset 12 encodes to 191 rather than 191.25. So the peer indexes into cell 2, not 3. Harmless on its own — the cells are adjacent — but it decides membership for anything placed exactly on a boundary. Eight groups at multiples of 100 landed in cells 0,1,2,2,4,5,6,6, left cell 3 empty, and split an apparently unbroken chain in two. The same eight groups placed mid-cell form one cluster. Tests now go through PeerSnapshotPublisher rather than setting the grid directly, so the decode is covered, and the boundary case is asserted against the decoded position instead of the input. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Root cause of the chain split — found, and it is not a clustering bugRecorded here because the earlier comment left it unexplained. What happens. The in-parcel offset is quantized to 8 bits over Harmless in itself: the two cells are adjacent, so a peer's clustering relative to its neighbours is unchanged. It only decides anything for a position sitting exactly on a cell boundary — which is what every group in my layout was doing. It predicts the observation exactly. Eight groups at multiples of 100:
Occupied cells are Confirmed by moving off the boundaries. The same eight groups at 350, 450 … 1050 (cells 3–10) give one cluster: No production impact. The error is bounded by half a quantization step (≈ 0.031 u), so it can only reclassify a peer within 0.031 u of a boundary, and always into an adjacent cell. Real populations are continuously distributed; a cell gap requires a genuinely empty 100 u band. Nothing to fix, so nothing was changed in the tracker. Covered by tests (797 passing). The earlier tests set the grid directly and so skipped the decode entirely — that is why they passed while a live run failed:
Case status — all four now resolved
Also confirmed along the way: clusters are uncapped (25 co-located bots → one cluster), and the adjacency band is ±1 cell at One incidental finding: my fourth sparse region was requested at x=2750, beyond the encodable world ( |
ConclusionsClustering works. Validated on zone against the deployed branch, read from Pulse's own
The one anomaly was mine, not the server's. An eight-group chain split in half because every group sat exactly on a cell boundary, where the in-parcel offset's 8-bit quantization (step ≈ 0.0627) moves Still blocking end-to-end. No Changes on this branch from the exercise: |
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.QA instructions — verifying this change from the client
This change is what ultimately assigns a player their island, so it is verified in unity-explorer through the Island room. Run it against zone, where the server side of this feature is deployed, on a client build that carries the room indicator described in Part 3 — see Step 1. The steps are self-contained; they also live in
unity-explorer/docs/qa-archipelago-island-room.md.Three things to hold on to before starting. The client is told nothing unprompted after its handshake, so an unassigned island is indistinguishable from a client hang — Part 5 separates them. The Island room deliberately overlaps with gatekeeper's Scene room, so an avatar carried by both is correct, not a duplicate. And which tag is healthy depends on the client's transport: with Pulse active no client announces its profile over LiveKit at all, so the LiveKit rooms report presence without an announcement. Part 3 is the legend for all three.
Archipelago governs exactly one thing in the client: the Island room, the global comms room that carries remote avatars. Gatekeeper's Scene room is a separate path and appears here only where the two interact — which they do constantly, so it cannot be ignored.
Every step states what a pass looks like (✅) and what to treat as a failure (❌). Report any ❌ with the values from Part 2 and the realm details from Part 5.
Part 1 — Build, launch, and set up the debug menu
Step 1. Use a unity-explorer build that has the room indicator
The per-room state glyphs every test below is read from come from unity-explorer#9980. Use its branch (
feat/room-indicator-livekit-presence), ordevonce that has merged.Avatars on LiveKitrow. It only exists with the change.Pulseand nothing else, and Tests 2, 3 and 4 cannot be executed at all.Step 2. Launch against zone, with debug enabled
--dclenv zonepoints every backend atdecentraland.zone. The server side of this feature is deployed to zone only — onorgthere is nothing to test.--debugbuilds the room widgets. Without it they do not exist.✅ The client reaches world and the debug panel is visible.
❌ No panel (see
--debugabove), or you land on an org realm — zone is a separate account space on Sepolia, so log in with a zone/test wallet, not a mainnet one.Step 3. Confirm both flags actually took
Type
/app-argsin chat.dclenvset tozoneanddebugpresent.Step 4. Learn the panel toggle
Type
/debugin chat./debugagain shows it.Unknown command— the debug flag did not apply. Return to Step 2.Step 5. Confirm the room widgets exist
Type
/debug helpin chat.Step 6. Open the Island widget
Expand Room: Island.
Step 7. Turn on the room indicators
Expand Room: Info and enable Show Room Indicator.
🔗Gatekeeper 🔗Island ⚡Pulse. Part 3 is the legend. Your own avatar is not tagged.Nonefor an avatar that is on screen.Part 2 — Reading "Room: Island"
Room StateRunningConnecting StateConnConnectedAttempt to ConnectConnection LoopConnection QualityExcellent/GoodRemote ParticipantsRoom SidRM_…Self SidPA_…Not connectedin the Sid rows only meansRoom Stateis notRunningyet — readRoom Statefirst.Part 3 — Reading the room indicator
Needed to judge Tests 2–4.
The tag above a remote avatar names every room that accounts for it, each prefixed by a glyph. The glyph separates two facts that are not the same thing:
🟢🔗👻⚡So
🔗Gatekeeper 🔗Island ⚡Pulsereads: in both LiveKit rooms, announcing over neither, and carried by Pulse. That is what almost every avatar reads in these steps. A peer outside your scene reads🔗Island ⚡Pulse.⚡never shares a tag with🟢or👻. Announcing over LiveKit and being carried by Pulse are mutually exclusive per peer: a peer on Pulse addresses its LiveKit announcement only to peers that announced to it first, and your client, being on Pulse itself, never does. So an avatar either reads🔗on its rooms with⚡Pulse, or reads🟢on its rooms without⚡Pulsebecause that peer is not on Pulse. A tag carrying both is a bug — report it.What normal looks like. These steps assume Pulse is on, which is the default whenever the
pulsefeature flag is enabled. With Pulse carrying profiles no client announces over LiveKit, so the expected reading is🔗on each LiveKit room plus⚡Pulse. An avatar reading🟢and no⚡Pulseis not a failure either — that peer is not on Pulse, which is what a client whose Pulse connection fell back at start-up looks like. Report a👻that sticks, and any tag mixing⚡with🟢or👻.Who owns the avatar. The rooms overlap on purpose. A player in your scene is normally seen by both. The client keeps one avatar per wallet and records the sources as a set of flags:
Under Pulse, Pulse is normally that last flag: it creates the avatar and it is what keeps it alive. Archipelago is still the safety net at scene borders in the sense that matters here — the avatar must not blink out when the Scene room drops the player.
Counting.
Room: Inforeconciles the avatar roster against LiveKit's:Active AvatarsAvatars on LiveKitAvatars off LiveKitLiveKit w/o AvatarThe last two are not failures on their own: the Island room covers a wider area than the transport that creates avatars, so it legitimately lists people you cannot see, and the reverse happens briefly during hand-offs. The load-bearing reading is
Avatars on LiveKit— it must be non-zero whenever both rooms areRunningand other players are nearby. Zero there whileRemote Participantsis non-zero means none of the avatars on screen has a LiveKit session behind it.Part 4 — Tests
Test 1 — Connects on login
Room State: Running,Connecting State: ConnConnected,Room Sidpopulated.Connecting Statenever reachesConnConnected;Room Sidstays empty beyond ~30 s; orAttempt to Connectcycles forever. Do Part 5 before filing — an unassigned island looks exactly like this and is a backend state, not a client bug.Test 2 — Avatars arrive tagged, both rooms on
Gatekeeperand anIslandentry; players outside your scene carryIslandonly. Nearly all read🔗on those entries plus⚡Pulse; a peer that is not on Pulse reads🟢with no⚡Pulseinstead.Remote Participantsis non-zero andAvatars on LiveKitis non-zero.None;Avatars on LiveKitis 0 whileRemote Participantsis not; nobody carries anIslandentry while players are clearly outside your scene; or any tag mixes⚡with🟢or👻.Test 3 — Scene-border handoff
Gatekeeperentry appears and disappears as the Scene room picks them up or drops them, while theIslandentry stays, and the avatar never disappears, reloads or flickers.Nonewhile the player is still visible; or a👻Gatekeeperappears at all — a peer on Pulse announces over no LiveKit room, so a hand-off cannot produce one.Test 4 — Prove each room's contribution with Deactivate
Do this while a player in your scene is visible and carries both a
Gatekeeperand anIslandentry.Pulse owns the avatars, so this test reads the tag, not the screen. Deactivating a LiveKit room removes that room's entry from every tag, but the avatars stay — Pulse created them and Pulse keeps them alive. An avatar vanishing here is a failure. The one exception is an avatar reading
🟢with no⚡Pulse: that peer is not on Pulse, so LiveKit is the only thing holding it and it is expected to go when its rooms do.Room State: Stopped,Connection Loop: Stopped,Attempt to Connect: None.Gatekeeperentry disappears and the avatar stays.Gatekeeperentry survives a stopped room, or the avatar disappears.Gatekeeperentry to come back.Islandentry disappears and the avatar stays. Players outside your scene stay too, now with noIslandentry.Islandentry survives a stopped room, or the avatar disappears.⚡Pulsealone,Avatars on LiveKitdrops to0, and every Pulse-carried avatar is still on screen.Avatars on LiveKitstays above 0; or an avatar that was carrying⚡Pulsedisappears.Test 5 — Island reassignment while moving
Room Sid.Room Sidchanges at least once. After each changeConnecting Statereturns toConnConnectedand the set of avatars carrying anIslandentry turns over — old ones lose it, new ones gain it. A brief flicker during the switch is normal. Avatars from the old island may stay on screen without anIslandentry; that is Pulse's own area of interest, not a stale island.Room Sidnever changes over a long traverse;Connecting Statedoes not return toConnConnectedafter a change; or avatars keep anIslandentry long after the sid changed.Test 6 — Realm change and teleport
Room StateleavesRunningand returns to it with a newRoom Sid. No avatar from the previous realm still carries anIslandentry.Room Statenever returns toRunning; the oldRoom Sidpersists into the new realm; or an avatar from the previous realm keeps itsIslandentry.Test 7 — Reconnect after network loss
Connecting StateleavesConnConnected, then recovers on its own. Recovery can take ~15–20 s: the client retries on a 5 s backoff and forces a fresh handshake after 3 attempts.Connection Loopstays stopped.Test 8 — Same wallet elsewhere
Room StateleavesRunning.Runningwith no participants; or both sessions stay connected.Part 5 — Triage before filing
An unassigned island is indistinguishable from a client hang. The server sends nothing unprompted after the handshake, so with no island assigned the client sits on a healthy connection receiving nothing. Rule that out first.
Step 1. Check the realm the explorer actually uses (zone)
Read
healthy,acceptingUsersand thecommsblock.healthy: falseoracceptingUsers: false→ the realm's comms are down. Not a client bug.peer.decentraland.zone, which serves a different realm. The explorer resolves Genesis fromrealm-provider-ea; checking the wrong host gives a healthy answer about a realm nobody is on.Step 2. Decide what you have
Room Sidstayed empty all session.Room Sidpopulated at least once) and the client still failed to reachConnConnected, or failed to recover in Tests 3–7.Step 3. Include in the report
commsblock from Step 1.Room: Infocounters.--debugor a Debug build was used.