NativeLink
Contribute

How to extend NativeLink

Complete file-by-file recipes for adding a store, a config field, a metric, or a gRPC service.

Who this is for: anyone adding a new component rather than changing an existing one. What you'll have at the end: four recipes, each listing every file a working change touches, including the ones that compile fine when you forget them and fail in CI. Time: use as a checklist.

Each recipe below is derived from the smallest existing example in the tree. Copy that example rather than starting from a blank file: it already has the right license header, the right derives, and the right last line.

Read codebase internals first if you have not; the trait split and the spawning rules are assumed here.

Adding a store

Model: nativelink-store/src/noop_store.rs, the smallest complete StoreDriver in the tree.

  1. Add the spec to nativelink-config/src/stores.rs. A new variant on StoreSpec (the JSON5 key is the snake_case form of the variant name; box the payload if it is large) plus a spec struct modelled on MemorySpec:

    #[derive(Serialize, Deserialize, Debug, Clone)]
    #[serde(deny_unknown_fields)]
    #[cfg_attr(feature = "dev-schema", derive(JsonSchema))]
    pub struct MyStoreSpec {
        /// What this field is for.
        /// Default: 0
        pub some_field: usize,
    }

    The dev-schema cfg_attr is not optional; without it your store never appears in the generated configuration reference.

  2. Write the store at nativelink-store/src/my_store.rs: the license header copied from a neighbouring file, the struct, a MetricsComponent impl, pub fn new(...) -> Arc<Self>, #[async_trait] impl StoreDriver, and default_health_status_indicator!(MyStore); as the last line.

  3. Declare the module: pub mod my_store; in nativelink-store/src/lib.rs.

  4. Wire the factory: an import and a match arm in nativelink-store/src/default_store_factory.rs. The match is exhaustive, so this one is a compile error if you forget it. It is the only step that is.

  5. Add the file to nativelink-store/BUILD.bazel, in the srcs list, alphabetically. Cargo does not care. Bazel does, and CI runs Bazel; this is the single most common way a store PR goes red after a green local run.

  6. Add a test at nativelink-store/tests/my_store_test.rs and register it in the crate's rust_test_suite. See the testing guide.

Two semantics to get right, both covered in the store model:

inner_store must return self if your store changes the bytes on the way through, and forward to the inner store if it only routes. Returning the wrong one is a runtime error, not a compile error.

update must drain its reader even when it is discarding the data. NoopStore does this; a store that returns early without draining will hang its writer.

Adding a config field

Config fields are documentation. The doc comment becomes the description in the generated reference, and the ```json fences in stores.rs are also harvested into the examples file, which is why they have to be valid JSON rather than illustrative fragments.

  1. Add the field with a doc comment ending in a default line:

    /// How long to wait before giving up on a connection.
    /// Default: 5 (seconds)
    #[serde(default, deserialize_with = "convert_duration_with_shellexpand")]
    pub connection_timeout_s: u64,

    The Default: line is parsed out of the description by the docs generator. Without it the reference shows no default.

  2. Pick the right deserialize_with helper from serde_utils.rs. They all expand shell variables, which is how a config file references the environment.

  3. Regenerate the reference if you want to see the result: bun --filter @nativelink/docs gen:config-reference from web/.

The helpers that exist:

HelperFor
convert_numeric_with_shellexpandAny integer type
convert_optional_numeric_with_shellexpandOption<N>
convert_string_with_shellexpandString
convert_optional_string_with_shellexpandOption<String>
convert_vec_string_with_shellexpandVec<String>
convert_boolean_with_shellexpandbool
convert_data_size_with_shellexpandByte sizes; accepts 1GB, 512MiB
convert_optional_data_size_with_shellexpandOption<size>
convert_duration_with_shellexpandDurations
convert_duration_with_shellexpand_and_negativeDurations that may be negative

There is no `default_as_true` helper

A bool that should default to true needs its own fn default_true() -> bool { true } and #[serde(default = "default_true")]. Several fields do this; there is no shared helper.

To rename a field without breaking existing configs, add a shim in backcompat.rs; both existing shims are #[serde(untagged)] enums that accept the old and new shapes and emit a tracing::warn! on the deprecated one.

Adding a metric

Decide which system first; see two metric systems.

The derive path touches one file. Add the field, annotate it:

#[metric(help = "Number of blobs currently resident")]
resident_blobs: AtomicU64,

The struct already derives MetricsComponent, and the value appears in the introspection tree automatically.

The OpenTelemetry path touches three:

  1. Declare the instrument in the appropriate LazyLock bundle in nativelink-util/src/metrics.rs (CACHE_METRICS, EXECUTION_METRICS, WORKER_METRICS, RPC_METRICS, SCHEDULER_METRICS, STORE_TIER_METRICS, HEALTH_METRICS or CONNECTION_METRICS). Use a dotted name, a .with_description, and a .with_unit that is a UCUM unit ("By", "s", "ms") or a braced count such as "{entry}". Then regenerate the metrics reference with bun --filter @nativelink/docs gen:metrics-reference from web/, which reads this file.

  2. Record it at the call site, using the pre-computed attribute struct where one exists (CacheMetricAttrs / ExecutionMetricAttrs) rather than building attributes inline.

  3. Extend nativelink-util/tests/metrics_test.rs.

Adding a gRPC service

Model: nativelink-service/src/fetch_server.rs: 165 lines, and it has every convention in it.

  1. Write the server at nativelink-service/src/my_server.rs. Import the generated trait and server type with the standard alias:

    use nativelink_proto::::my_server::{My, MyServer as Server};

    Hold per-instance state in a HashMap<String, _> keyed by instance name. Constructor signature:

    pub fn new(
        configs: &[WithInstanceName<MyConfig>],
        store_manager: &StoreManager,
    ) -> Result<Self, Error>
  2. Provide into_service, exactly this name, because the service_setup! macro calls it:

    pub fn into_service(self) -> Server<Self> {
        Server::new(self)
    }
  3. Split each RPC in two. A private inner_<rpc> returning nativelink_error::Error holds the logic; the public #[tonic::async_trait] method wraps it, converts to Status, and carries the instrumentation:

    #[instrument(
        err(level = Level::WARN),
        ret(level = Level::INFO),
        skip_all,
        fields(request = ?grpc_request.get_ref())
    )]
  4. Register it in the Routes::builder().routes() chain in src/bin/nativelink.rs, with .add_optional_service(...) and service_setup!. The macro applies the message-size limit (4 MiB by default) and compression settings, so registering without it silently opts out of both.

If the service needs new protos: add the package to PROTO_NAMES and to the gen_rs_protos srcs list in nativelink-proto/BUILD.bazel, then regenerate and commit the output:

bazel run nativelink-proto:update_protos

//nativelink-proto:update_protos_test is the CI gate that catches a forgotten regeneration.

Common questions

NextTesting guide

How the suites are organised, what #[nativelink_test] does for you, and which local environment problems produce which confusing failure.

On this page