feat: mint LiveKit island rooms from Pulse cluster feed (Archipelago ⇒ Pulse, iteration 1) - #283
feat: mint LiveKit island rooms from Pulse cluster feed (Archipelago ⇒ Pulse, iteration 1)#283mikhail-dcl wants to merge 9 commits into
Conversation
First NATS client and first custom metrics in this service, both needed by the
Archipelago => Pulse migration: Pulse publishes cluster assignments over NATS and
this service must mint the LiveKit connection string.
Deliberately not @well-known-components/nats-component, which the archipelago
services use. It calls requireString('NATS_URL') at construction so an unset URL
throws at boot, it calls process.exit(1) when the connection closes, and it
exposes no metrics. This service must start normally with the feature off, must
not be taken down by a broker blip, and needs a connected gauge.
The adapter has no start method on purpose: the consumer drives connect()
explicitly, so activation does not depend on component key order. connect()
never throws, retries unlimited times, rechecks the stop flag after its await so
a shutdown racing an in-flight connect cannot leak a live connection, and
coalesces overlapping calls so registrations cannot be double-subscribed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three small units the subscriber pipeline composes.
rooms: resolves island-{clusterId}, or island-{clusterId}:{shard} once a cluster
exceeds ROOM_SHARD_SIZE. The shard is SHA-256 over the lower-cased wallet so every
replica agrees without shared state, and so checksum casing cannot split one wallet
across two rooms. An unknown cluster size means the cluster is not in the latest
snapshot yet, which is treated as small and unsharded.
topology: cluster sizes from the latest engine.islands snapshot. Replacement is
wholesale, not merged, because the publisher rebuilds the whole-world snapshot every
pass, so a cluster missing from a new one has genuinely ceased to exist. Rosters are
not retained; only sizes are needed.
peer-state: bounded, TTL-expiring store of each wallet's last assignment, feeding
fromIslandId. TTL is the only reclamation path available - Pulse's feed carries no
disconnect event of any kind.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…branches
getRoomMetadataFromRoomName tested the scene and world prefixes before the literal
island- prefix. Both of those are configurable and empty by default, and
startsWith('') is always true, so an island room could be reported as a scene room
with a bogus realm and an empty sceneId - and a sharded name like island-C12:3 parsed
as sceneId '3', sending room-metadata-sync off to fetch a nonexistent entity.
Moving the island- branch first removes the dependence on two env values staying
non-empty. It cannot reclassify anything else, because island- is a literal prefix no
other room shape in this service produces. Preferred over guarding the scene and world
branches, which would flip unknown-room classification from SCENE to UNKNOWN under the
empty-prefix config the whole test suite runs on.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replaces the one hop archipelago-core owned. Pulse clusters peers; this subscriber
turns an assignment into a LiveKit connection string and re-emits the existing
IslandChangedMessage, so the WebSocket Connector and every client stay untouched.
Pipeline per peer.{addr}.cluster_change: decode, check the wallet plus its recorded
device against the platform bans and the deny list, resolve the room from the latest
engine.islands sizes, mint, publish engine.peer.{addr}.island_changed. A banned wallet
gets no publish at all, not a publish with an empty connStr. The ban check fails open,
matching every other call site here, and does not cache a failure.
engine.islands is subscribed without a queue group because every replica needs the
topology; cluster_change is queue-grouped so N replicas do not each mint and publish
for every event. The outbound subject is deliberately unprefixed - the WebSocket
Connector subscribes to the literal subject.
Processing is serialized per wallet. Without it two events for one wallet can have
their mints resolve out of order, so an older event publishes a superseded room and
corrupts the next fromIslandId. There is deliberately no re-mint suppression: Pulse
only re-announces a cluster after forgetting a peer, which is a reconnect that needs a
fresh token.
Behind CLUSTER_SUBSCRIBER_ENABLED, default off, and independently inert when NATS_URL
is unset. With the feature off nothing subscribes and no connection is attempted, even
when a broker is reachable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Drives the whole pipeline end to end: real protobuf over a real broker, a real database-backed ban, and a real minted JWT whose HS256 signature is verified against the configured secret. The grant assertions pin the full policy inherited from archipelago-core - roomJoin, roomList false, canPublish, canSubscribe, canPublishData, canUpdateOwnMetadata, microphone-only sources, 300s TTL - so a drift in what users may publish cannot pass silently. Uses the real livekit adapter rather than a stub, unlike every other integration spec, because verifying the actual token is the point. It builds its own subscriber over the runner's real components instead of enabling the feature globally, which would make every other integration spec open a broker connection. Skips with a warning when no broker is reachable: the shared CI workflow provisions Postgres but not NATS, so this does not run there yet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…er endpoint The service overview claimed archipelago-core mints island tokens by calling LiveKit directly, which is exactly the hop this branch takes over, and listed an /island-adapter HTTP endpoint that does not exist in any route. Island tokens now come from the NATS cluster feed. Adds NATS and Pulse to the stack and dependency lists, and records the three decisions a future maintainer would otherwise reverse: no re-mint suppression, a peer's room reflecting the size at assignment time, and the per-wallet serialization - plus the cross-replica ordering limit that serialization cannot close. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Coverage Report for CI Build 30655110208Coverage increased (+0.8%) to 86.19%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
Cluster sizing is entirely Pulse's responsibility. Pulse publishes maxPeers: 0
on engine.islands specifically to advertise that clusters are uncapped, so a
locally configured ROOM_SHARD_SIZE threshold here contradicted the feed it was
reading. If a cluster outgrows a single LiveKit room, that is Pulse's to solve
by capping or splitting clusters, not this service's to paper over.
Sharding was the only consumer of the engine.islands topology snapshot, so the
subscription and its size cache go with it. Removes the topology module, the
wallet-hash shard function, ROOM_SHARD_SIZE and its clamp, and the
unknown_cluster metric - with one room per cluster there is no unknown-cluster
case to count. Room names are now island-{clusterId}, nothing more.
The pipeline is unchanged otherwise: per-wallet serialization, the wallet and
device ban gate, the empty-clusterId guard, the unprefixed outbound subject and
the non-blocking connect all stay as they were.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Structural changes, no behaviour change intended: - Use the [START_COMPONENT]/[STOP_COMPONENT] symbols instead of the deprecated plain start/stop, which made the lifecycle log a warning per boot. - Move the NATS adapter to the component folder layout (component/types/index) and drop src/types/nats.type.ts. - Move the peer state store to src/adapters/peer-state as a wired component: in-memory state is an adapter, and the subscriber now receives it via DI instead of constructing it inline. - Extract the platform-access lookup (ban + deny list) shared by the two signed-fetch token handlers and the subscriber into src/logic/access-gate. Each caller keeps its own precedence, fail-open policy and error mapping; only the lookup is shared. - Move the island room-name builder into the livekit adapter, next to getIslandNameFromRoomName and the ISLAND_ROOM_PREFIX it already owns. - Add getErrorMessage() and JSDoc on the new factories and interfaces. Fixes found while reviewing the above: - The NATS status feed was never closed. nats.js gives each status() caller its own iterator and only ever pushes into it, so the loop parked on next() forever, holding the closed connection reachable. Every reconnect stranded another one. resetConnection() now ends it, and both floating promises catch, so an iterator fault can no longer take the process down as an unhandled rejection. - A dropped publish was counted as a success. publish() no-ops when the connection is gone rather than throwing, so a connection lost during the mint incremented published_total and stored an assignment the peer never received, pointing the next fromIslandId at a room it was never told to join. publish() now reports delivery and the subscriber acts on it. - Cache bounds used `??`, which lets a configured 0 through: lru-cache reads max 0 as unbounded and ttl 0 as never-expires, so a zero silently removed the bound instead of tightening it. A never-expiring ban cache would keep serving a wallet cached as allowed after it was banned. - Renamed a destructured isBanned that shadowed the scene-scoped one in comms-scene-handler; platform bans throw before that point, so removing the inner declaration would have silently stopped enforcing scene bans. Tests: unit coverage for the four components is at 100% statements, branches, functions and lines. Adds integration coverage for the properties a unit test cannot reach, notably that the queue group really does stop N replicas minting N tokens for one assignment.
Standards pass over this branch + four fixes it surfacedPushed as WhyThe feature worked, but it deviated from the WKC standards in ways that would cost us later rather than now:
Reviewing that work then turned up four real defects, which is the more important half of this PR. HowStructural (no behaviour change intended)
The access gate deliberately shares only the lookup. The three callers genuinely differ and all three behaviours are preserved:
That ban-only fail-open is why the component owns one policy flag rather than none: it has to happen inside the concurrent pair, or a ban-store outage would silently drop deny-list enforcement along with it. Fixes
Testing100% statements/branches/functions/lines on all four components; 1329 passing across 94 suites. Where it mattered I checked the tests can actually fail: deleting the queue-group option, and deleting the reconnect re-subscription loop, both produce the expected failures. That second one is worth calling out — the old reconnect test asserted a new connection was opened but never that subscriptions were re-established on it, which is the nastiest failure mode here: connected, gauge at 1, health checks green, and completely deaf. New integration coverage targets what unit tests structurally cannot reach — chiefly that the queue group really does stop N replicas minting N tokens for one assignment. Known, unchanged
|
decentraland-bot
left a comment
There was a problem hiding this comment.
Thanks for the detailed PR and tests. I found a few issues that should be addressed before merging.
Findings:
- P1 — Cluster-token access checks fail open too broadly: a connection-info, ban-store, or deny-list lookup error currently allows token minting instead of preserving deny-list enforcement.
- P1 — Broker connection logging may expose credentials if credentials are embedded in the configured URL.
- P1 — Runtime protocol dependency is pinned to a branch CDN tarball; please re-pin to a released immutable package before merge as noted in the PR.
Notes:
- CI is passing: validations, test, quay build, and title check passed; one deploy-related check is skipped.
- Security review found the access-gate fail-open and URL logging issues above.
- Consumer/API impact: the client-facing
engine.peer.{addr}.island_changedsubject and payload remain backward-compatible; I did not find a downstream break from the preserved message shape. - I could not run tests locally because dependencies are not installed in the checkout, so I relied on CI and code review.
Reviewed by Jarvis 🤖 · Requested by Lautaro Petaccio (<@U025WCHLMN3>) via Slack
| } catch (error) { | ||
| // Fails open, like every ban check here - a lookup outage must not stop island formation. | ||
| // Deliberately not cached, so the next event retries instead of being wrong for the full TTL. | ||
| logger.warn(`Ban check failed for ${wallet}, allowing: ${getErrorMessage(error)}`) |
There was a problem hiding this comment.
[P1] This catch fails open for the whole access check, including deny-list failures and even an unrelated connection-info DB failure before accessGate runs. That means a deny-listed wallet can still receive an island LiveKit token during those outages. Please keep the intended ban lookup fail-open behavior, but continue enforcing the deny list; for example, fetch the device ID best-effort, then call accessGate.getAccessState({ address: wallet, deviceId }, { failOpenOnBanLookupError: true }) and let deny-list lookup failures stop this mint instead of returning false.
|
|
||
| connection = nc | ||
| setConnected(true) | ||
| logger.info(`Connected to NATS at ${url}`) |
There was a problem hiding this comment.
[P1] Avoid logging the full broker URL. These URLs often support embedded credentials, so this can write secrets into application logs. Please log only a redacted/sanitized endpoint (for example hostnames without userinfo) or a generic connection success message. The failure log below has the same issue.
| "@dcl/http-tracer-component": "^2.0.1", | ||
| "@dcl/memory-cache-component": "^2.4.3", | ||
| "@dcl/metrics": "^1.0.1", | ||
| "@dcl/protocol": "https://sdk-team-cdn.decentraland.org/@dcl/protocol/branch//dcl-protocol-1.0.0-30550755753.commit-b0705a3.tgz", |
There was a problem hiding this comment.
[P1] This runtime dependency is pinned to a branch CDN tarball. Even with the lockfile hash, production builds now depend on a non-release artifact and can drift from the final protocol contract. Please re-pin to a published immutable @dcl/protocol version before merge, as the PR description already calls out.
Raised in review as a P1. The behaviour is a deliberate product decision, not an oversight: an outage in the connection-info read, the ban store or the deny list must not stop island formation, because that leaves players unable to get into voice at all. Availability is judged the more important property here. Documents what it costs (a banned or deny-listed wallet can be minted a token while a lookup is failing), what bounds it (banning clears live rooms at ban time, and the result is not cached so the next event retries), and why this path differs from the signed-fetch HTTP path, where a deny-list error still rejects the request.
Re: the three P1sThanks for these. One is a deliberate design decision I've now documented in code ( 1. Access checks fail open — intended, now documented in codeThis is a deliberate product decision rather than an oversight, and we're keeping it. If any of the three lookups (connection-info read, platform ban store, deny list) fails and we fail closed, the peer gets no island token and therefore cannot get into voice at all. An outage in any single one of those dependencies would take down comms for everyone it touches, not just for moderated accounts. We judge platform availability the more important property here: a moderation gate that is briefly permissive is recoverable, a world nobody can connect to is not. Two things bound the exposure, and I'd rather state them than imply the trade-off is free — while a lookup is failing, a banned or deny-listed wallet can be minted an island token:
On the specific suggestion of "preserve deny-list enforcement even when the other lookups fail" — that's a real distinction and it is how the signed-fetch HTTP path behaves: there only the ban lookup fails open, and a deny-list error still rejects the request. The two paths differ on purpose. The HTTP path is a synchronous, user-initiated request that can surface an error and be retried by the client. The cluster subscriber is a background feed with no caller to report to, so dropping the event just leaves the peer silently roomless with nothing to trigger a retry.
2. Broker URL logging —
|
decentraland-bot
left a comment
There was a problem hiding this comment.
Re-review after the latest commit.
Verdict: approved. The previously raised blockers have either been documented as an intentional product/availability tradeoff or clarified sufficiently for this PR:
- The cluster-subscriber fail-open behavior is now documented at the call site, including the moderation exposure and why it differs from the synchronous HTTP token path.
- The current NATS configuration is passed as broker host/seed URLs only, not as embedded credentials.
- The temporary
@dcl/protocoltarball is commit-addressed and lockfile-pinned; please still re-pin to the released package during the normal protocol release follow-up.
Non-blocking follow-ups I recommend keeping tracked:
- Add an explicit metric/alert for cluster access-gate fail-open events, ideally with reason labels.
- Make the NATS-backed integration suite run in CI with a broker instead of self-skipping.
- Consider validating the subject wallet and
clusterIdcharset/length before minting, even if Pulse/NATS are trusted producers. - Consider an operational signal for
CLUSTER_SUBSCRIBER_ENABLED=truebut no events received, to catch prefix mismatches or a deaf subscriber.
CI is passing: validations, tests, quay build, and title check passed; the deploy-related check is skipped.
Consumer/API impact: the client-facing engine.peer.{addr}.island_changed subject and payload remain backward-compatible; no downstream break found from the preserved message shape.
I did not run tests locally because dependencies are not installed in this checkout; I relied on CI and code review.
Reviewed by Jarvis 🤖 · Requested by Lautaro Petaccio (<@U025WCHLMN3>) via Slack
| # NATS broker URL, comma-separated for a seed list. Same secret name as the archipelago | ||
| # services, so one injected value serves both. Unset also means off, independently of the | ||
| # flag above, so a missing broker cannot fail startup. | ||
| NATS_URL= |
There was a problem hiding this comment.
Would you mind creating a PR in the definitions repo to fill these configs?
| // Device id catches a banned player back on a fresh wallet, using what that wallet's | ||
| // last HTTP request recorded. Read-only - never call upsertPlayerConnection here; the | ||
| // HTTP path owns the real IP/device data and this would null it out. | ||
| const connectionInfo = await playerConnectionDb.getByAddress(wallet) |
There was a problem hiding this comment.
We should get the deviceId from the nats message. This would work for now, but Pulse should send it if possible.
Iteration 1 of the Archipelago ⇒ Pulse migration, gatekeeper side.
Pulse now owns peer clustering and publishes each peer's assignment to NATS. The one hop it cannot own — minting the LiveKit connection string, which needs LiveKit credentials and the ban authority — lands here. This subscribes to that feed, mints, and re-emits the existing
IslandChangedMessage, so WS Connector and every client stay untouched.Ships behind a feature flag, default off. Enabling it per environment is the cutover switch.
What changed vs archipelago-core
peer.*.heartbeat, flushed every 2 speer.{addr}.cluster_change, per assignment changeLIVEKIT_ISLAND_SIZE) — connected crowds fragmented into merge-order-dependent roomsI{base36}, sequential; split minorities always got a new IDC{n}, sticky IDs + 3-pass dwell debounceisland-{clusterId}— one cluster, one roomGET /users/:address/bansto this service, 1 s timeout, 20-way concurrency cappeersmapunity-explorerreads onlyconnStrDeliberately identical, so nothing user-visible drifts:
roomJoin,roomList: false,canPublish,canSubscribe,canPublishData,canUpdateOwnMetadata,canPublishSources: [microphone], 300 s TTL. Pinned by an integration test that verifies the JWT signature, not just its shape.connStrformatlivekit:{host}?access_token={jwt}.engine.peer.{addr}.island_changedand its message shape.connStr.What's new here
src/adapters/nats.ts— first NATS client in this service. Not@well-known-components/nats-component, which the archipelago services use: itrequireString('NATS_URL')at construction (throws at boot when unset),process.exit(1)s when the connection closes, and exposes no metrics. A broker blip must not take down scene tokens, voice chat, or scene bans.src/logic/cluster-subscriber/— the pipeline, plus room naming and per-wallet state.src/adapters/livekit.ts— theisland-branch ingetRoomMetadataFromRoomNamemoved ahead of the scene/world branches. Those match on configurable prefixes that are empty by default, andstartsWith('')is always true, so an island room could be reported to SNS as a scene room with a bogus realm. No other room shape can begin with the literalisland-.dcl_pulse_nats_*so both sides line up on one dashboard.Only
cluster_changeis consumed. Pulse'sengine.islandstopology snapshot is not — this service has no use for it now that it does no sizing of its own; that feed continues to serve archipelago-stats.peer.*.heartbeatandpeer.*.disconnectalso survive iteration 1 untouched and are deliberately unused here, since both retire in iteration 2.Ops
Config — all default to off or to safe values; nothing changes until you set the first two.
CLUSTER_SUBSCRIBER_ENABLEDNATS_URLNATS_SUBJECT_PREFIX''Nats:SubjectPrefixNATS_QUEUE_GROUPcomms-gatekeeper-clusterCLUSTER_PEER_STATE_TTL_MS/_MAX3600000/20000fromIslandIdCLUSTER_BAN_CACHE_TTL_MS30000Metrics:
dcl_gatekeeper_cluster_{events_received,tokens_minted,published,publish_failed,banned_skipped}_totalanddcl_gatekeeper_nats_connected. In steady statepublish_failedshould sit at 0.Cutover sequence:
NATS_URL+CLUSTER_SUBSCRIBER_ENABLED=trueper environment, with Pulse publishing.events_received_total > 0and clients joiningisland-C{n}rooms.archipelago-workers'feat!: remove archipelago-coredeploy.Rollback: disable the flag here first — it is the durable half. Otherwise a CI redeploy re-injects Pulse's secret and you get dual-publish flap.
Watch for: a
NATS_SUBJECT_PREFIXmismatch with Pulse fails silently — the connected gauge reads 1, every counter stays 0, no error, no log.events_received_total > 0is the only real check.Capacity note for Pulse, not this service: with no sharding here, a fully percolated realm maps to one LiveKit room. LiveKit rooms are single-node-bound (~low thousands of audio participants, 100 audio subscriptions per participant), so keeping clusters within that bound is Pulse's responsibility.
package.jsonpins@dcl/protocolto a CDN tarball built from the still-open protocol#454, which generates thePeerClusterChangeTypeScript this branch imports.Dockerfilerunsyarn install --frozen-lockfile, so every image build resolves that URL.yarn.lockcarries its sha1, so the risk is availability and contract drift rather than integrity — but this must be re-pinned to a published version once #454 merges.Testing
yarn typecheckclean.apps-with-db-buildworkflow provisions Postgres but not NATS, so that spec skips there with a warning. Adding a broker to the shared workflow is a follow-up.Follow-ups (not this PR)
@dcl/protocolafter #454 merges.cluster_changearrives within N seconds of connecting, to make a prefix mismatch loud.WORLD_ROOM_PREFIXis non-empty in every environment: a world literally namedisland-*.dcl.ethwould now classifyISLANDrather thanWORLD.getRoomMetadataFromRoomName's scene branch is unguarded against an emptySCENE_ROOM_PREFIX, which would misclassify voice and private-message rooms as scene rooms..idea/to.gitignore.🤖 Generated with Claude Code