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 is | A service that continuously polls every registered CRN and publishes a snapshot | The on-chain registry every CRN registers itself in — an Aleph aggregate under 0xa1B3bb7d2332383D96b7796B908fB7f7F3c2Be10 |
| Served by | One host, one origin | Every Aleph API host (api2.aleph.im, api.aleph.im, …) |
| Node identity, address, score | yes | yes (score, falling back to scoreV1) |
| Registration state | implied (inactive nodes filtered) | status, parent, inactive_since, locked |
qemu_support | yes | no |
system_usage (free CPU / RAM / disk) | yes | no |
| Liveness | yes — the whole point of the service | no — registration is not aliveness |
| Geo fields | sometimes pre-filled | never |
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:
- requests
crns.json; - 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;
- walks the configured Aleph API hosts in order until one serves the aggregate;
- 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:
| Verdict | Meaning | Effect |
|---|---|---|
reachable | the node served its executions list | kept |
unreachable | the node answered, but with an error status | dropped |
unknown | the 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:
| Value | Behavior |
|---|---|
auto (default) | read crns.json, fall back to the aggregate |
aggregate | skip crns.json entirely |
list | disable 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.