Skip to content

Manual labels - #314

Merged
KirillPamPam merged 3 commits into
mainfrom
manual_labels
Jul 31, 2026
Merged

Manual labels#314
KirillPamPam merged 3 commits into
mainfrom
manual_labels

Conversation

@KirillPamPam

@KirillPamPam KirillPamPam commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Manual upstream labels, replacing options.archive

Summary

An upstream can now declare labels statically in the config, the way dshackle does:

upstreams:
  - id: eth-full
    chain: ethereum
    labels:
      archive: false
      provider: hetzner

Those labels are seeds: they are written into protocol.UpstreamState.Labels when the upstream is
constructed, so label matchers and the gRPC label selectors see them immediately — including when
disable-labels-detection is true and no detector ever runs. A runtime detector that owns the same key
overwrites the manual value on its first round, exactly as it would overwrite any earlier value.

One exception, and it is the mechanism that replaces the old flag: when the manual archive label is exactly
"false", the EVM archive detector is never started, so the configured value stands for the process
lifetime.

chains.Options.ArchiveCapability (options.archive) is deleted.

Motivation

Labels could only be discovered at runtime. Operators who already know what a node is — an archive node, a
particular provider, a tier — had no way to say so, and the one escape hatch that existed (options.archive)
was a single-purpose boolean bolted onto the options struct. It could pin exactly one label, only on EVM
chains, and only to false.

The old flag also expressed its intent awkwardly: archive: false meant "publish archive=false, skip
detection", while archive: true meant "ignore me, run detection anyway" — identical to leaving it unset.
Expressing the same thing as a label makes the mechanism general (any key, any value) and drops a special
case from chains.Options.

What changed

The config field (internal/config/upstream_config.go)

Labels UpstreamLabels `yaml:"labels"`

UpstreamLabels is map[string]string underneath, with its own UnmarshalYAML. A plain map[string]string
would reject archive: false outright (cannot unmarshal !!bool into string), and unquoted scalars are how
dshackle configs are written, so the unmarshaller walks the mapping node and stores each value's literal text:
false"false", 3"3", "false""false".

It rejects, at parse time: a non-mapping labels, a sequence or nested map as a value, and a duplicate key
(node-walking bypasses yaml.v3's own duplicate check, so we do it ourselves). YAML aliases are resolved before
the kind checks — both for the labels node and for each value — so a shared labels block can be anchored and
reused across upstreams. Merge keys (<<) stay unsupported but say so instead of blaming a label named <<.

Upstream.validate rejects an empty key and an empty value.

The type is named UpstreamLabels, not Labels, because internal/upstreams/upstream.go imports both this
package and protocol, which already has a Labels type.

Seeding the state (internal/upstreams/upstream.go)

NewBaseUpstream fills the Labels object DefaultUpstreamState already allocated, before the state is
published:

initialState := protocol.DefaultUpstreamState(...)
for label, value := range conf.Labels {
	initialState.Labels.AddLabel(label, value)
}
upState.Store(initialState)

No new protocol constructor, no second Labels object, and DefaultUpstreamState's signature is unchanged.
Mutating the state before Store is safe because nothing else holds a reference yet, and atomic.Value.Store
gives the happens-before for every later Load.

NewBaseUpstreamWithParams is deliberately untouched — it receives an already-built state from its caller, and
seeding there would stomp the caller's intent.

The archive detector (chains_specific/evm_specific/evm_chain_specific.go)

archiveLabelsDetector and its static-false detector are gone. LabelsProcessor() now delegates to a
private labelsDetectors(), which appends the archive detector only when the label doesn't pin it:

if !archiveDetectionSuppressed(e.manualLabels) {
	labelsDetectors = append(labelsDetectors, eth_labels.NewEthArchiveLabelsDetector(...))
}

func archiveDetectionSuppressed(manualLabels map[string]string) bool {
	return manualLabels["archive"] == "false"
}

The match is exact-string. archive: False, "FALSE" and "0" do not suppress detection — documented,
because it fails silently rather than loudly.

internal/upstreams/labels/static_labels_detector.go is deleted; the archive path was its only caller, and a
seeded value needs no detector republishing a constant every interval.

Tron shares the EVM detectors

NewTronSpecific's json-rpc branch delegates to NewEvmChainSpecific, and the old options.archive was
honoured on that path. It therefore takes the same final manualLabels parameter and forwards it, with
getChainSpecific passing conf.Labels. Without this, archive: false would have silently stopped working
for Tron json-rpc upstreams — a regression, not a no-op. The rest branch needs nothing:
TronRestSpecific.LabelsProcessor has no archive detector.

Removing options.archive

The field is deleted from pkg/chains/options.go. Config parsing is non-strict (yaml.Unmarshal), so a
leftover options.archive key is silently ignored — no shim, no deprecation validator. The docs call this
out, because the practical effect is that archive auto-detection starts running again for that upstream.

Notes / impact

  • Breaking, quietly. Anyone setting upstream.options.archive: false or
    chain-defaults.<chain>.options.archive: false must move it to the upstream's labels. The old key parses
    fine and does nothing.
  • labels is per-upstream only. There is no chain-defaults.<chain>.labels; that key is also silently
    ignored. This is the one capability the old flag had that the replacement doesn't — it could be set for a
    whole chain at once.
  • Values must be quoted only when YAML would misread them otherwise — bare false, 3, 1.5 are all
    fine. archive: null becomes the literal string "null", which suppresses nothing; bare archive: is
    rejected as an empty value.
  • Not related to group-labels. group-labels remains config-only input to label-balancing and is never
    published into upstream state; manual labels take no part in label-balancing. Both fields exist on the
    upstream and the docs now contrast them explicitly.
  • No new routing behavior. Manual labels flow through the same UpstreamState.Labels that detectors write,
    so LabelMatcher, LabelExistsMatcher, chain_supervisor_state.go's aggregation and the dshackle
    NodeDetails mapper consume them unchanged.

Adjacent fix: the seeded state could be dropped at startup

StartUpstreams called up.Start() before up.Subscribe(...), and SubscriptionManager.Publish drops
events when there is no subscriber. The InitUpstreamStateEvent that carries the seeded labels is published
inside Start(), so under an unlucky schedule it was lost — and unlike the old static detector, a seed does
not re-publish itself every interval to heal.

internal/upstreams/upstream_supervisor.go now runs Subscribe → b.upstreams.StoreStart. Moving the
Store up as well closes the second half: an event emitted during Start() now finds the upstream registered,
so processEvents' GetUpstream(event.Id) can't return nil and silently skip Resume()/PartialStop().

Docs

docs/nodecore/05-upstream-config.md:

  • New labels entry in the per-upstream Fields list: seed semantics, the exact/case-sensitive false match,
    bare-scalar values, and the contrast with group-labels.
  • Migration note covering both silently-ignored keys (options.archive and chain-defaults.<chain>.labels).
  • The options.archive bullet is gone; the top-level example's upstream carries an inert
    labels: {provider: hetzner} rather than archive: false, so copy-pasting the canonical example can't
    disable archive detection by accident.
  • The EVM label-detectors row of the Validators-and-labels table notes the skip.

Testing

go test -race -p 8 ./internal/... ./pkg/... — 80 packages, no failures, no races. go vet -tags e2e ./test/e2e/http/ clean. make lint — 0 issues.

  • Config: scalar coercion (bare false/3/1.5 and quoted "false"), null value, non-scalar value,
    non-mapping labels, duplicate key (exact message), alias-to-scalar, alias-as-whole-mapping, merge key, and
    fixture-driven empty-key / empty-value validation through NewAppConfig.
  • Seeding: labels present in the state NewBaseUpstream produces, including with
    disable-labels-detection: true; a detector event for the same key replaces the seed. The overwrite test
    asserts the seeded value is in place before applying the event, so it fails if seeding is removed —
    verified by deleting the loop.
  • Suppression: the predicate across "false" / "true" / "False" / "" / unrelated / nil, plus a test
    over labelsDetectors() asserting the archive detector is absent for archive: "false" and present for nil,
    "true" and "False". Presence/absence rather than a count, so an unrelated new detector won't break it.
    Verified load-bearing by forcing the condition true.
  • e2e: archive_selector_e2e_test.go's generated config moved to labels. Note that with seeding this
    test no longer exercises the archive detector — both upstreams carry their label before any probe runs — so
    it now validates manual label → gRPC selector → routing, with the detector contract covered at unit level.
    Compile-checked here (go vet -tags e2e); the Docker run needs NODECORE_E2E_DRPC_KEY.

Known gap: the two lines in upstream_factory.go that pass conf.Labels into the constructors have no test
that fails if they're mutated to nil — the detector tests call the constructors directly. getChainSpecific
is package-private and already covered by a white-box test, so this is closable there.


func archiveLogsRule() string {
return `[{"method":"eth_getLogs","result":[]}]`
return `[{"method":"eth_capabilities","result":{"blocks":{"oldestBlock":"0x0"},"logs":{"oldestBlock":"0x0"},"state":{"oldestBlock":"0x0"},"tx":{"oldestBlock":"0x0"},"receiptOldestBlock":"0x0"},"stateproofs":{"oldestBlock":"0x0"}}]`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change looks unrelated to manual labels: this test runs with disable-labels-detection: true, so nothing in it exercises the new code path. It is also not mentioned in the PR description.

Beyond scope, the stub does not match evmCapabilitiesResponse (internal/upstreams/lower_bounds/evm_bounds/capabilities.go:224) in two places:

  • "receiptOldestBlock":"0x0" — the expected shape is receipts: {oldestBlock: ...}, so Receipts stays nil.
  • "stateproofs" sits after the closing brace of result, i.e. it is a sibling of "method"/"result" and therefore a rule field rather than a capability. The Hardhat hook (test/e2e/internal/hardhat/hardhat.config.cjs:38) ignores unknown rule fields silently, so StateProofs stays nil too.

With ReceiptsBound/ProofBound uncovered, detectFromCapabilities returns false (capabilities.go:314) and falls back to the search path — whose eth_getLogs stub is removed by this same diff. The assertion on line 44 (result must be an empty array) then depends on the real fork provider answering eth_getLogs for 0x1000..0x1001.

Suggest moving this to a separate PR and running it against Docker.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed the JSON. Let's leave it here, this test is super flaky, don't want to create a separate PR

Comment thread pkg/chains/options.go
DisableLabelsDetection *bool `yaml:"disable-labels-detection"`
DisableLogIndexValidation *bool `yaml:"disable-log-index-validation"`
DisableLivenessSubscriptionValidation *bool `yaml:"disable-liveness-subscription-validation"`
ArchiveCapability *bool `yaml:"archive"`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Config parsing is non-strict (internal/config/config.go:57 uses yaml.Unmarshal), so removing this field means a leftover options.archive: false is silently ignored: the archive detector starts running again and overwrites the label with whatever it detects. For clients using the gRPC archive label selector that is a routing change with no log line.

A cheap migration path is to keep the field as deprecated and translate it in Upstream.setDefaults (internal/config/defaults.go:320, right after setOptionsDefaults, so chain-defaults are already merged):

if u.Options.ArchiveCapability != nil {
    log.Warn().Msgf("upstream '%s': options.archive is deprecated, use labels.archive instead", u.Id)
    if _, set := u.Labels["archive"]; !set {
        if u.Labels == nil {
            u.Labels = UpstreamLabels{}
        }
        u.Labels["archive"] = strconv.FormatBool(*u.Options.ArchiveCapability)
    }
}

This also restores the chain-defaults capability the PR description lists as lost. If a shim is not wanted, a validator that fails startup with an explicit message would still be preferable to a silent routing change.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

RateLimit *RateLimiterConfig `yaml:"rate-limit"`
RateLimitAutoTune *RateLimitAutoTuneConfig `yaml:"rate-limit-auto-tune"`
GroupLabels []string `yaml:"group-labels"`
Labels UpstreamLabels `yaml:"labels"`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ChainDefaults (line 213) is a plain struct and Upstream.setDefaults already receives it, so adding Labels UpstreamLabels \yaml:"labels"`there plus a per-key merge (upstream value wins) would cover the chain-defaults case in a few lines. Without it, an operator running 30 upstreams on one chain has to repeat the same label 30 times, which is the one thingoptions.archive` could do and this cannot.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's skip it for now. With such setting it's very easy to make a mess applying labels to all the upstreams

Comment thread internal/config/upstream_config.go Outdated
type UpstreamLabels map[string]string

func (u *UpstreamLabels) UnmarshalYAML(node *yaml.Node) error {
node = resolveYamlAlias(node)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This call is unreachable. yaml.v3 resolves an alias node before invoking a custom unmarshaller — AliasNode is handled in d.unmarshal ahead of d.prepare, which is what dispatches to UnmarshalYAML. For labels: *shared this method receives Kind == MappingNode (4), never AliasNode (16).

I verified this by deleting the line: TestUpstreamLabelsAliasForWholeMapping and the rest of the package still pass, so the line is also not covered by the new tests. Only the per-value call on line 152 is load-bearing. The PR description and the design doc state that aliases are resolved "both for the labels node and for each value", which holds only for the value case.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

Comment thread internal/config/upstream_config.go Outdated
labels := make(UpstreamLabels, len(node.Content)/2)
for i := 0; i+1 < len(node.Content); i += 2 {
key, value := node.Content[i], node.Content[i+1]
if key.Value == "<<" {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This matches on the key's text, so a quoted "<<": x — a literal label named <<, not a merge key — is rejected with the merge-key message. yaml.v3 distinguishes the two by tag, so key.Tag == "!!merge" would be the accurate check.

Comment thread internal/config/upstream_config.go Outdated
return fmt.Errorf("label '%s' must have a scalar value", key.Value)
}
if _, exists := labels[key.Value]; exists {
return fmt.Errorf("duplicate label '%s'", key.Value)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Errors raised from a custom unmarshaller carry no position information, so this surfaces as duplicate label 'archive' with no line number and no upstream id — awkward to locate in a config with many upstreams. Same for the messages on lines 144 and 154.

Decoding into map[string]yaml.Node keeps the literal-text behaviour and gets the diagnostics from yaml.v3 instead:

func (u *UpstreamLabels) UnmarshalYAML(node *yaml.Node) error {
	if node.Kind != yaml.MappingNode {
		return errors.New("labels must be a mapping of label names to scalar values")
	}
	var raw map[string]yaml.Node
	if err := node.Decode(&raw); err != nil {
		return err
	}
	labels := make(UpstreamLabels, len(raw))
	for key, value := range raw {
		if value.Kind != yaml.ScalarNode {
			return fmt.Errorf("label '%s' must have a scalar value", key)
		}
		labels[key] = value.Value
	}
	*u = labels
	return nil
}

I checked the behaviour of this variant: false/0x10/1e3/"false" all keep their literal text; a duplicate key reports line 3: mapping key "archive" already defined at line 2; a non-scalar value still hits the message above; an alias for the whole mapping still works. Merge keys also start working (<<: *shared plus archive: "false" yields both labels) rather than being rejected, which removes the need for both the << branch and resolveYamlAlias.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree, done

Comment thread internal/config/upstream_config.go Outdated
}
}

for label, value := range u.Labels {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Map iteration order is random, so with several offending labels the reported error varies between runs. Iterating over sorted keys would make the message deterministic.

// the process lifetime. Any other value (including "true") lets the detector run and
// publish what it finds.
func archiveDetectionSuppressed(manualLabels map[string]string) bool {
return manualLabels["archive"] == "false"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "archive" key is now spelled out in two packages — here and in internal/upstreams/labels/eth_labels/eth_archive_detector.go:33. Exporting a single constant (for example eth_labels.ArchiveLabel) would keep the detector's output key and the suppression check from drifting apart on a rename.


b.upstreams.Store(up.GetId(), up)

// to not lost the first events

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reordering is correct — Start() pushes InitUpstreamStateEvent onto stateChan and only then launches processStateEvents (upstream.go:236-237), so the publish happens after Start() returns and the old StartSubscribe order did have a real window. Subscribing first is also safe from a blocking standpoint, since Publish is non-blocking with a 100-slot buffer (pkg/utils/subscriptions.go:131). Moving Store up likewise removes the nil GetUpstream skip at upstream_supervisor.go:177-185.

Two notes:

  • Typo: to not lostto not lose. It would also help to state what is being fixed (the InitUpstreamStateEvent published from Start() was droppable) and why SubscribeWithReplay (pkg/utils/subscriptions.go:88), which exists for exactly this "late subscriber must learn current state" case, was not used.
  • This is the only behavioural change in the PR without a test.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FIxed

Comment thread docs/nodecore/05-upstream-config.md Outdated
- `failsafe-config` - Upstream-level failsafe configuration. Only the `retry` policy can be specified at this level (hedging and timeouts are configured globally on `upstream-config.failsafe-config`)
- `group-labels` - List of priority-group labels this upstream belongs to, used by [label-balancing](#label-balancing). These are **config-defined** labels, independent of the runtime labels produced by label detectors. An upstream may belong to several groups but is still selected at most once per request
- `labels` - Map of manual labels published for this upstream. Values are strings; unquoted YAML scalars are accepted and stored as their literal text (`archive: false` is the same as `archive: "false"`). Keys and values must both be non-empty. Manual labels are **seeds**: they are published to the upstream's state at startup - so they are visible to [gRPC](12-grpc-server.md) label selectors and label matchers even when `disable-labels-detection` is `true` - but a runtime label detector that owns the same key overwrites them on its first round. The one exception is `archive: false`, which skips the EVM archive detector entirely so the configured value stands - the match is an exact, case-sensitive comparison against the literal text `false`, so `archive: False` or `archive: "FALSE"` does **not** suppress the detector and silently leaves auto-detection running. This is distinct from `group-labels`, which is config-only input to [label-balancing](#label-balancing) and is never published to upstream state; manual labels take no part in label-balancing
> **Migration**: the old `options.archive` flag has been removed. Set `labels: {archive: false}` on the upstream instead. A leftover `options.archive` key is silently ignored (config parsing does not reject unknown keys), which means archive auto-detection starts running again for that upstream. Note that `options.archive` used to also be settable at `chain-defaults.<chain>.options.archive`, but `labels` has no chain-defaults equivalent - it is per-upstream only. A `chain-defaults.<chain>.labels` key is likewise silently ignored, so the migration must be repeated on every upstream of the chain individually

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the deprecation shim suggested on pkg/chains/options.go is added, this note becomes "options.archive is deprecated, still honoured, and warns at startup", and the chain-defaults paragraph can go away. Worth revisiting once that is decided.

@KirillPamPam
KirillPamPam merged commit d03dcae into main Jul 31, 2026
5 checks passed
@KirillPamPam
KirillPamPam deleted the manual_labels branch July 31, 2026 15:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants