Skip to main content

CRN discovery: crns.json and the corechannel aggregate

Every deploy starts with the same question: which compute node (CRN) should host this VM? Relay Button can answer it from two sources. This page explains what each one knows, which is used when, and why the more decentralized of the two is deliberately not the default.

The two sources

crns.json (crns-list.aleph.sh)corechannel aggregate
What it isA service that continuously polls every registered CRN and publishes a snapshotThe on-chain registry every CRN registers itself in — an Aleph aggregate under 0xa1B3bb7d2332383D96b7796B908fB7f7F3c2Be10
Served byOne host, one originEvery Aleph API host (api2.aleph.im, api.aleph.im, …)
Node identity, address, scoreyesyes (score, falling back to scoreV1)
Registration stateimplied (inactive nodes filtered)status, parent, inactive_since, locked
qemu_supportyesno
system_usage (free CPU / RAM / disk)yesno
Livenessyes — the whole point of the serviceno — registration is not aliveness
Geo fieldssometimes pre-fillednever

The aggregate is the ground truth crns.json is derived from. That makes it the right fallback, but it is a registry, not a health check: a node can be linked and correctly registered while being switched off, full, or unable to run QEMU instances at all.

Which one is used

crns.json is the default. The aggregate is the fallback.

The candidate list is read by fetchCrnsWithSource() in @le-space/core (packages/core/src/crns.ts), which:

  1. requests crns.json;
  2. falls back to the aggregate if that request fails or returns an empty list — an empty candidate set is just as useless to a deploy as a 502;
  3. walks the configured Aleph API hosts in order until one serves the aggregate;
  4. reports which source it used, so the deploy log names it.

The fallback exists because crns-list.aleph.sh was the one centralized hop in an otherwise decentralized deploy path. When it served 502 for over an hour, deploys died with it even though the Aleph API hosts stayed healthy. In the browser that outage does not even look like an outage: the gateway's error page carries no Access-Control-Allow-Origin header, so the UI reports a CORS failure rather than a 502.

Why the aggregate is not the default

This is the honest trade-off, and it is not about trust or decentralization — it is about the two fields the aggregate cannot carry.

qemu_support. Instance deploys need QEMU. A node that does not support it can never host the VM, but it looks perfectly healthy in the registry. Without this flag we hand such a node a full allocation attempt before finding out.

system_usage. filterDeployableCrns() uses free CPU, memory and disk to drop nodes that cannot fit the requested vcpus / memoryMiB / diskMiB before any request is sent. Without it, a full node is indistinguishable from an idle one until it answers 503 Insufficient capacity — and each such mistake costs a failover cycle measured in minutes, not seconds.

Both fields are simply absent on aggregate-sourced records rather than false, which filterDeployableCrns() already treats as "unknown, keep" — so nothing is wrongly rejected. But "keep" is not "verified": the filter goes from informed to permissive.

So defaulting to the aggregate would trade a rare outage for a permanent rise in failed first attempts. The fallback buys the resilience without paying that cost on every healthy day.

The reachability probe

The aggregate has no liveness signal, so when candidates come from it, the GitHub Actions deploy path verifies them itself. filterReachableCrns() asks each candidate for GET /about/executions/list — trying /v2/… first, then the v1 route — and drops the ones that do not answer. This is step 4 of the original proposal and overlaps with the faster-failover work in NiKrause/relay-button#83.

Three details worth knowing:

  • Only the head of the ranking is probed (max_crn_attempts × 2). Failover walks the ordered tail anyway, and probing every registered node would cost more than it saves.
  • A non-404 answer counts as alive. The node is clearly serving requests; only a missing route justifies falling back to the v1 path.
  • If nothing answers, the unverified ranking is kept rather than failing the run. A runner that cannot reach CRNs directly should still get to try.

The probe replaces some of what crns.json polling gave us, but not all of it: it proves a node is answering, not that it has capacity or QEMU support. That is why it is a safety net for the fallback, not a reason to prefer it.

In the browser

The browser probes too, with two differences that matter.

It runs at deploy time, not at refresh time. The CRN picker renders from the list as it arrives; the probe fires once the user has committed to deploying, against the (at most five) candidates that would actually be tried. Probing during refresh would put N round-trips in front of the picker for every page load.

It distinguishes "dead" from "blocked". A cross-origin request that never reaches the node — CORS, a blocked port, an offline client — is our origin's problem, not evidence about the node. probeCrnAvailability() therefore returns three verdicts:

VerdictMeaningEffect
reachablethe node served its executions listkept
unreachablethe node answered, but with an error statusdropped
unknownthe request never got an answer (blocked)kept

Dropping candidates for our own origin restriction would be worse than not probing at all, so unknown keeps the node in the running. A CRN the user pinned by hand is never dropped either — an explicit choice outranks the probe.

Choosing the source explicitly

All three surfaces take the same three values:

ValueBehavior
auto (default)read crns.json, fall back to the aggregate
aggregateskip crns.json entirely
listdisable the fallback, so an outage surfaces as an error

list exists for testing: it is the only way to see the failure the fallback normally hides.

GitHub Actions

- uses: NiKrause/relay-button/.github/actions/aleph-vm-deploy@main
with:
crn_source: aggregate

Or ALEPH_VM_CRN_SOURCE=aggregate when driving @le-space/node directly.

Browser: the crnSource prop

<SponsorRelayFab crnSource="aggregate" />

Browser: the live switch

For testing against the real network, the prop is inconvenient — it needs a rebuild, and a crns.json outage is not something you can reproduce on demand. localStorage.LE_SPACE_CRN_SOURCE overrides the prop and is read on every refresh, so it takes effect on the next refresh cycle without a page reload:

// Force the decentralized path and watch it pick a node
localStorage.LE_SPACE_CRN_SOURCE = 'aggregate'

// Prove the fallback is what is saving you — this one throws on an outage
localStorage.LE_SPACE_CRN_SOURCE = 'list'

// Back to normal
localStorage.removeItem('LE_SPACE_CRN_SOURCE')

Pair it with localStorage.LE_SPACE_UI_DEBUG = '1' to see which source was used, why the fallback triggered, and what the probe dropped:

[le-space/ui] crns:fallback { source: 'aggregate', count: 42, listError: 'CRN list request failed: 502' }
[le-space/ui] crns:probe { message: 'Skipping 1 unreachable CRN of 5 probed' }

The current source is also on the controller state as crnSource ('list' | 'aggregate' | null), so a host app can surface it in its own UI.

What would make the aggregate a safe default

Nothing here is permanent. The default flips the moment the capacity gap closes, for example if:

  • CRNs expose QEMU support and free capacity on an endpoint the probe already hits, so one round-trip recovers both fields; or
  • the corechannel aggregate itself grows those fields; or
  • the deploy path gets cheap enough failover (503 fast-fail, a per-deployer failure ledger with cooldown) that a wrong first pick stops being expensive.

Until then, crns.json stays the preferred source and the aggregate keeps the deploy path alive when it is not there.