Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
058b18f
entity: feat: original and stored path types
DerDennisOP Oct 19, 2026
9180690
entity: feat: store path mapping trait
DerDennisOP Oct 19, 2026
99cc752
server: feat: start the server as a library
DerDennisOP Oct 19, 2026
9dbd983
proto: feat: refuse messages without a job
DerDennisOP Oct 19, 2026
e72533f
proto: feat: map worker paths inbound
DerDennisOP Oct 19, 2026
0968212
proto: feat: map worker paths outbound
DerDennisOP Oct 19, 2026
4b921db
proto: feat: map cache socket paths
DerDennisOP Oct 19, 2026
891c1c6
web: feat: map binary cache paths
DerDennisOP Oct 19, 2026
a5f12ac
web: feat: map uploaded nar paths
DerDennisOP Oct 19, 2026
5a5fc9b
graph: feat: sign over outbound paths
DerDennisOP Oct 19, 2026
97aba46
ssh: feat: map daemon store paths
DerDennisOP Oct 19, 2026
a73ff20
web: feat: map build response paths
DerDennisOP Oct 19, 2026
4d296c0
web: feat: map evaluation response paths
DerDennisOP Oct 19, 2026
c409d0e
web: feat: search nars per readable cache
DerDennisOP Oct 19, 2026
1589aa1
effects: feat: map reported derivation path
DerDennisOP Oct 19, 2026
c125518
core: feat: map upstream cache paths
DerDennisOP Oct 19, 2026
97d4f37
backend: feat: map server-computed paths
DerDennisOP Oct 19, 2026
4d6b823
backend: refactor: delete store path splitters
DerDennisOP Oct 19, 2026
1e711c7
proto: fix: keep corrupt eval cache fingerprint
DerDennisOP Oct 19, 2026
6f5accd
eval-cache: feat: entries belong to a cache
DerDennisOP Oct 19, 2026
bd10c9d
cache: feat: delete flat eval cache blobs
DerDennisOP Oct 19, 2026
a8c455b
backend: fix: map build request repository
DerDennisOP Oct 19, 2026
907ef31
proto: test: no original hash is stored
DerDennisOP Oct 19, 2026
7ed02a0
proto: fix: announce signed NARs by original hash
DerDennisOP Oct 19, 2026
8e96acd
proto: fix: keep build products outside the store
DerDennisOP Oct 19, 2026
71a5723
proto: fix: answer refusals the worker awaits
DerDennisOP Oct 19, 2026
30ed0d4
proto: test: check events and storage keys
DerDennisOP Oct 19, 2026
27cf05d
.gitignore: fix: hosted and hypervisor folders
DerDennisOP Oct 19, 2026
9b67934
proto: test: malformed cache path is not cached
DerDennisOP Oct 19, 2026
3e0f844
entity: feat: let a mapping resolve its context
DerDennisOP Oct 19, 2026
dd777db
backend: refactor: map a project once resolved
DerDennisOP Oct 19, 2026
c458593
web: test: unresolved project is unavailable
DerDennisOP Oct 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ backend/test-store

/proxy/
/gradjoeng/
/gradient-hypervisor/
/hypervisor/
/hosted/

# ---> Python
# Byte-compiled / optimized / DLL files
Expand Down
1 change: 1 addition & 0 deletions backend/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions backend/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,10 @@ publish.workspace = true
[lints]
workspace = true

[lib]
name = "gradient_server"
path = "src/lib.rs"

[[bin]]
name = "gradient-server"
path = "src/main.rs"
Expand Down
4 changes: 2 additions & 2 deletions backend/gradient-cache/src/eval_cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,8 @@ impl EvalCacheSweep {
let mut freed: u64 = 0;
let mut evicted = 0usize;
for row in models {
if let Err(e) = self.nar_storage.delete_eval_cache(&row.fingerprint).await {
warn!(fingerprint = %row.fingerprint, error = ?e, "eval-cache sweep: blob delete failed");
if let Err(e) = self.nar_storage.delete_eval_cache(&row.storage_path).await {
warn!(key = %row.storage_path, error = ?e, "eval-cache sweep: blob delete failed");
}

match EEvalCacheStore::delete_by_id(row.id).exec(&self.db).await {
Expand Down
1 change: 1 addition & 0 deletions backend/gradient-cache/src/schedule.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ pub(crate) fn child_specs(state: &ServerState) -> Vec<ChildSpec> {
ChildSpec::pass(SignSweep {
db: state.worker_db.clone(),
config: Arc::clone(config),
store_path_mapping: Arc::clone(&state.extensions.store_path_mapping),
period: secs(config.cache.sign_sweep_interval_secs),
}),
ChildSpec::pass(DebugIndex {
Expand Down
11 changes: 9 additions & 2 deletions backend/gradient-cache/src/signatures.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ ON CONFLICT (cached_path, cache) DO NOTHING
pub(crate) struct SignSweep {
pub(crate) db: WorkerDb,
pub(crate) config: Arc<RuntimeConfig>,
pub(crate) store_path_mapping: Arc<dyn StorePathMapping>,
pub(crate) period: Duration,
}

Expand Down Expand Up @@ -185,8 +186,14 @@ impl SignSweep {

let nar_hash_nix32 = normalize_nar_hash(nar_hash);

let sig_bytes =
signer.sign_narinfo_raw(&store_path, &nar_hash_nix32, nar_size as u64, &refs);
let mapper = StorePathMapper::for_cache(Arc::clone(&self.store_path_mapping), cache.id);
let sig_bytes = signer.sign_stored_narinfo(
&mapper,
&store_path,
&nar_hash_nix32,
nar_size as u64,
&refs,
);

let mut am = row.into_active_model();
am.signature = Set(Some(sig_bytes));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/*
* SPDX-FileCopyrightText: 2026 Wavelens GmbH <info@wavelens.io>
*
* SPDX-License-Identifier: AGPL-3.0-only
*/

use anyhow::Result;
use futures::FutureExt as _;
use futures::future::BoxFuture;
use gradient_db::DbContext;
use tracing::warn;

use super::StorageMigration;

const FLAT_KEYS: &str = "flat-keys";

pub(super) struct EvalCachePerCache;

impl StorageMigration for EvalCachePerCache {
fn name(&self) -> &'static str {
"m20261019_000000_eval_cache_per_cache"
}

fn units(&self) -> Vec<String> {
vec![FLAT_KEYS.to_owned()]
}

fn migrate<'a>(&'a self, db: &'a DbContext, _unit: &'a str) -> BoxFuture<'a, Result<()>> {
delete_flat_keys(db).boxed()
}
}

fn is_flat(key: &str) -> bool {
key.strip_prefix("eval-cache/")
.is_some_and(|rest| !rest.is_empty() && !rest.contains('/'))
}

async fn delete_flat_keys(db: &DbContext) -> Result<()> {
let store = &db.storage.nar_storage;
for key in store.list_eval_cache_keys().await? {
if is_flat(&key)
&& let Err(e) = store.delete_eval_cache(&key).await
{
warn!(%key, error = %e, "flat eval-cache blob delete failed");
}
}
Ok(())
}

#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::test_server_state;
use gradient_fixtures::mock::empty_db;
use gradient_storage::NarStore;
use gradient_types::ids::CacheId;

#[tokio::test]
async fn only_eval_cache_blobs_without_a_cache_are_deleted() {
let tmp = tempfile::tempdir().unwrap();
let nar = NarStore::local(tmp.path()).unwrap();
let per_cache = format!("eval-cache/{}/fp2", CacheId::now_v7());
nar.put_eval_cache("eval-cache/fp1", b"flat".to_vec())
.await
.unwrap();
nar.put_eval_cache(&per_cache, b"kept".to_vec())
.await
.unwrap();
let state = test_server_state(nar, empty_db(), |_| {});

EvalCachePerCache
.migrate(&state.db(), "flat-keys")
.await
.unwrap();

let store = &state.db().storage.nar_storage;
assert_eq!(store.get_eval_cache("eval-cache/fp1").await.unwrap(), None);
assert!(store.get_eval_cache(&per_cache).await.unwrap().is_some());
}
}
4 changes: 3 additions & 1 deletion backend/gradient-cache/src/storage_migrations/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
//! Storage migrations are to the NAR, log and blob layout what database migrations are to the
//! schema. Apply them in order and before any deep GC unit.

mod eval_cache_per_cache;

use crate::units::{Step, next_unit};
use anyhow::{Context, Result};
use futures::future::BoxFuture;
Expand All @@ -25,7 +27,7 @@ pub(crate) trait StorageMigration: Send + Sync {
}

fn migrations() -> Vec<Box<dyn StorageMigration>> {
Vec::new()
vec![Box::new(eval_cache_per_cache::EvalCachePerCache)]
}

pub(super) async fn step(db: &DbContext) -> Result<Step> {
Expand Down
1 change: 1 addition & 0 deletions backend/gradient-ci/src/actions/tests/fixtures.rs
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ pub fn make_ctx_with(worker: sea_orm::MockDatabase) -> crate::CiContext {
events: gradient_types::EventBus::default(),
wake: Default::default(),
memory: Default::default(),
store_path_mapping: std::sync::Arc::new(gradient_entity::IdentityMapping),
};
CiContext {
db,
Expand Down
22 changes: 22 additions & 0 deletions backend/gradient-core/src/extensions.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/*
* SPDX-FileCopyrightText: 2026 Wavelens GmbH <info@wavelens.io>
*
* SPDX-License-Identifier: AGPL-3.0-only
*/

use std::sync::Arc;

use gradient_entity::{IdentityMapping, StorePathMapping};

#[derive(Clone, Debug)]
pub struct ServerExtensions {
pub store_path_mapping: Arc<dyn StorePathMapping>,
}

impl Default for ServerExtensions {
fn default() -> Self {
Self {
store_path_mapping: Arc::new(IdentityMapping),
}
}
}
8 changes: 7 additions & 1 deletion backend/gradient-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/

pub mod extensions;
pub mod state_root;
pub mod upstream;
pub mod upstream_source;

pub use extensions::ServerExtensions;
pub use gradient_graph::Graph;
pub use state_root::{
AppState, BUILD_PROGRESS_TTL, EVAL_PROGRESS_TTL, InstanceMemory, LAST_USED_STAMP_INTERVAL,
Expand Down Expand Up @@ -57,7 +59,10 @@ pub enum InitError {
LocalStorage(String),
}

pub async fn init_state(cli: Cli) -> Result<Arc<ServerState>, InitError> {
pub async fn init_state(
cli: Cli,
extensions: ServerExtensions,
) -> Result<Arc<ServerState>, InitError> {
if cli.secrets.crypt_file.is_empty() || cli.secrets.jwt_file.is_empty() {
return Err(InitError::MissingSecrets);
}
Expand Down Expand Up @@ -219,6 +224,7 @@ pub async fn init_state(cli: Cli) -> Result<Arc<ServerState>, InitError> {
events: gradient_types::EventBus::default(),
wake: Default::default(),
graph: Graph::new(),
extensions,
memory: InstanceMemory {
db: DbMemory {
probe_requests: gradient_db::ProbeRequests::channel(),
Expand Down
37 changes: 37 additions & 0 deletions backend/gradient-core/src/state_root.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ use gradient_ci::CiContext;
use gradient_ci::manifest_state::{ManifestStateStore, PendingCredentialsStore};
use gradient_db::metrics::cache_traffic::CacheTraffic;
use gradient_db::{CacheDb, DbContext, DbMemory, Wakeups, WebDb, WorkerDb};
use gradient_entity::ids::{CacheId, ProjectId};
use gradient_entity::{MappingError, StorePathMapper};
use gradient_git_host::GitHostRegistry;
use gradient_graph::Graph;
use gradient_notify::EmailSender;
Expand All @@ -30,6 +32,8 @@ use gradient_util::debounce::Debounce;
use gradient_util::latest::Latest;
use gradient_util::shutdown::Shutdown;

use crate::ServerExtensions;

#[derive(Debug)]
pub struct InstanceMemory {
pub db: DbMemory,
Expand Down Expand Up @@ -66,6 +70,7 @@ pub struct AppState {
pub graph: Arc<Graph>,
pub memory: InstanceMemory,
pub wake: Wakeups,
pub extensions: ServerExtensions,
}

pub type ServerState = AppState;
Expand Down Expand Up @@ -106,9 +111,21 @@ impl AppState {
events: self.events.clone(),
wake: self.wake.clone(),
memory: self.memory.db.clone(),
store_path_mapping: Arc::clone(&self.extensions.store_path_mapping),
}
}

pub fn cache_mapper(&self, cache: CacheId) -> StorePathMapper {
StorePathMapper::for_cache(Arc::clone(&self.extensions.store_path_mapping), cache)
}

pub async fn project_mapper(
&self,
project: ProjectId,
) -> Result<StorePathMapper, MappingError> {
StorePathMapper::for_project(Arc::clone(&self.extensions.store_path_mapping), project).await
}

pub fn ci(&self) -> CiContext {
CiContext {
db: self.db(),
Expand All @@ -126,3 +143,23 @@ impl AppState {
self.db().record_evaluation_created(eval).await;
}
}

#[cfg(test)]
mod tests {
use std::sync::Arc;

use gradient_entity::ids::ProjectId;
use gradient_entity::{EXAMPLE_ORIGINAL_HASH, EXAMPLE_STORED_HASH, RotatedHashMapping};

#[tokio::test]
async fn the_server_mapping_reaches_the_database_context() {
let state = gradient_test_support::state::TestState::new()
.store_path_mapping(Arc::new(RotatedHashMapping))
.build();
let mapper = state.db().project_mapper(ProjectId::nil()).await.unwrap();
assert_eq!(
mapper.outbound_text(EXAMPLE_STORED_HASH).unwrap(),
EXAMPLE_ORIGINAL_HASH
);
}
}
Loading
Loading