From 5cfbdc5292cbc583070942aa2a64e4bf45ba9c3e Mon Sep 17 00:00:00 2001 From: GTC6244 <95836911+GTC6244@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:13:53 -0400 Subject: [PATCH 1/4] feat(cache): correlate action cache with master account + metadata endpoint (CPL-351) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a secondary metadata index (CacheMetadataIndex) alongside the IPFS action code cache that correlates each cached entry to the master account (account wallet address) that executed it, tracking size, created/last-run timestamps, and run count. Recording is best-effort in lit_action so it never fails execution, and a moka eviction_listener keeps the index consistent when binaries are evicted. Expose GET /core/v1/cache_metadata to return this metadata for the caller's account — never the cached code itself. Co-Authored-By: Claude Opus 4.8 --- k6/litApiServer.ts | 108 ++++++++ lit-api-server/src/core/cache_metadata.rs | 232 ++++++++++++++++++ lit-api-server/src/core/core_features.rs | 75 +++++- lit-api-server/src/core/mod.rs | 1 + .../src/core/v1/endpoints/actions.rs | 3 + .../src/core/v1/endpoints/configuration.rs | 19 ++ lit-api-server/src/core/v1/endpoints/mod.rs | 1 + lit-api-server/src/core/v1/models/response.rs | 34 +++ lit-api-server/src/main.rs | 27 +- 9 files changed, 495 insertions(+), 5 deletions(-) create mode 100644 lit-api-server/src/core/cache_metadata.rs diff --git a/k6/litApiServer.ts b/k6/litApiServer.ts index 05a3a591..93ec5a89 100644 --- a/k6/litApiServer.ts +++ b/k6/litApiServer.ts @@ -367,6 +367,62 @@ export interface LitActionClientConfigResponse { client_timeout_ms_buffer: number; } +/** + * GET /cache_metadata — metadata for the cached action code correlated to the authenticated master account. Excludes the cached binaries themselves. + */ +export interface CacheMetadataResponse { + /** On-chain account wallet address the caller's key resolves to. */ + account_address: string; + /** + * Number of cached entries correlated to this account. + * @minimum 0 + */ + entry_count: number; + /** + * Sum of `size_bytes` across the returned entries. + * @minimum 0 + */ + total_size_bytes: number; + /** The cached entries, sorted by most recent execution first. */ + entries: CacheEntryMetadataItem[]; +} + +/** + * One cached action-code entry in a `GET /cache_metadata` response (CPL-351). + +Describes the cached data only — never the code/binary itself. + */ +export interface CacheEntryMetadataItem { + /** IPFS id (cache key) of the cached action code. */ + ipfs_id: string; + /** + * Size of the cached code in bytes. + * @minimum 0 + */ + size_bytes: number; + /** + * Unix-epoch milliseconds when the entry was first cached. + * @minimum 0 + */ + created_at_ms: number; + /** + * Unix-epoch milliseconds of the most recent execution. + * @minimum 0 + */ + last_run_at_ms: number; + /** + * Number of executions recorded against this entry. + * @minimum 0 + */ + run_count: number; + /** + * Time-to-live of the entry, in seconds. `None` for the API-server IPFS cache, which is capacity-bounded (LRU) rather than time-expired. + * @minimum 0 + * @nullable + */ + ttl_seconds?: number | null; +} + /** * GET /billing/stripe_config — returns the Stripe publishable key for Stripe.js. */ @@ -715,6 +771,15 @@ export type GetLitActionClientConfigDefault = | LitActionClientConfigResponse | ErrMessage; +export type GetCacheMetadataHeaders = { + /** + * Account or usage API key. Alternatively use Authorization: Bearer . + */ + "X-Api-Key": string; +}; + +export type GetCacheMetadataDefault = CacheMetadataResponse | ErrMessage; + export type GetApiPayersDefault = string[] | ErrMessage; export type GetAdminApiPayerDefault = string | ErrMessage; @@ -2124,6 +2189,49 @@ Deprecated: minting is a metered write, so it should not live on a GET — link }; } + /** + * CPL-351: metadata about the action code cached for the caller's account. Returns TTL/size/last-run metadata only — never the cached code itself. + */ + getCacheMetadata( + headers: GetCacheMetadataHeaders, + requestParameters?: Params, + ): { + response: Response; + data: GetCacheMetadataDefault; + operationId: string; + } { + const k6url = new URL(this.cleanBaseUrl + `/cache_metadata`); + const mergedRequestParameters = this._mergeRequestParameters( + requestParameters || {}, + this.commonRequestParameters, + ); + const response = http.request("GET", k6url.toString(), undefined, { + ...mergedRequestParameters, + headers: { + ...mergedRequestParameters?.headers, + // In the schema, headers can be of any type like number but k6 accepts only strings as headers, hence converting all headers to string + ...Object.fromEntries( + Object.entries(headers || {}).map(([key, value]) => [ + key, + String(value), + ]), + ), + }, + }); + let data; + + try { + data = response.json(); + } catch { + data = response.body; + } + return { + response, + data, + operationId: "get_cache_metadata", + }; + } + getApiPayers(requestParameters?: Params): { response: Response; data: GetApiPayersDefault; diff --git a/lit-api-server/src/core/cache_metadata.rs b/lit-api-server/src/core/cache_metadata.rs new file mode 100644 index 00000000..b1967fc7 --- /dev/null +++ b/lit-api-server/src/core/cache_metadata.rs @@ -0,0 +1,232 @@ +//! Secondary metadata index for the shared Lit Action code cache (CPL-351). +//! +//! The primary cache (`ipfs_cache` in `main.rs`) holds the sandboxed action +//! binaries/code keyed by IPFS id. That cache intentionally knows nothing about +//! *who* the code belongs to. This index sits alongside it and provides the +//! "secondary lookup" the ticket asks for: a correlation from a **master user +//! account** (identified by its on-chain account wallet address — the value +//! both master and usage keys resolve to) to the metadata of the cache entries +//! that account has executed. +//! +//! Only descriptive metadata is stored here — size, timestamps, run count. +//! The cached code itself never enters this index and is never exposed by the +//! metadata endpoint. +//! +//! Consistency with the primary cache is maintained by wiring +//! [`CacheMetadataIndex::remove_entry`] into the primary cache's moka +//! `eviction_listener`, so evicted binaries drop their metadata too. + +use std::collections::{HashMap, HashSet}; +use std::sync::RwLock; +use std::time::SystemTime; + +/// Descriptive metadata about a single cached action-code entry. +/// +/// Deliberately excludes the cached code/binary — this struct is safe to +/// surface over the API. +#[derive(Clone, Debug)] +pub struct CacheEntryMetadata { + /// IPFS id (primary cache key) of the cached action code. + pub ipfs_id: String, + /// Size of the cached code in bytes. + pub size_bytes: u64, + /// When this entry was first recorded in the cache. + pub created_at: SystemTime, + /// When this entry was most recently executed. + pub last_run_at: SystemTime, + /// Total number of executions recorded against this entry (across every + /// account that has run it). + pub run_count: u64, + /// Account wallet addresses (master-account identities) that have executed + /// this entry. Used both for the reverse lookup and to keep the secondary + /// index consistent on eviction. + pub account_addresses: HashSet, +} + +#[derive(Default)] +struct State { + /// Primary metadata map: IPFS id -> metadata. + entries: HashMap, + /// Secondary lookup: account wallet address -> IPFS ids that account ran. + by_account: HashMap>, +} + +/// Thread-safe metadata index correlating cached action code with the master +/// account that executed it. Registered as Rocket managed state. +#[derive(Default)] +pub struct CacheMetadataIndex { + state: RwLock, +} + +impl CacheMetadataIndex { + pub fn new() -> Self { + Self::default() + } + + /// Record that `account_address` executed the cached entry `ipfs_id`. + /// + /// Creates the entry on first sight (stamping `created_at`), and on every + /// call refreshes `last_run_at`/`size_bytes`, increments `run_count`, and + /// correlates the entry with the account in the secondary index. + pub fn record_execution( + &self, + ipfs_id: &str, + size_bytes: u64, + account_address: &str, + now: SystemTime, + ) { + let mut st = self.state.write().expect("cache_metadata lock poisoned"); + + let entry = st + .entries + .entry(ipfs_id.to_string()) + .or_insert_with(|| CacheEntryMetadata { + ipfs_id: ipfs_id.to_string(), + size_bytes, + created_at: now, + last_run_at: now, + run_count: 0, + account_addresses: HashSet::new(), + }); + entry.size_bytes = size_bytes; + entry.last_run_at = now; + entry.run_count = entry.run_count.saturating_add(1); + entry.account_addresses.insert(account_address.to_string()); + + st.by_account + .entry(account_address.to_string()) + .or_default() + .insert(ipfs_id.to_string()); + } + + /// Drop all metadata for `ipfs_id`, keeping the secondary index consistent. + /// + /// Called from the primary cache's eviction listener when a binary is + /// evicted (capacity pressure), replaced, or explicitly removed. + pub fn remove_entry(&self, ipfs_id: &str) { + let mut st = self.state.write().expect("cache_metadata lock poisoned"); + let Some(meta) = st.entries.remove(ipfs_id) else { + return; + }; + for addr in meta.account_addresses { + let now_empty = if let Some(set) = st.by_account.get_mut(&addr) { + set.remove(ipfs_id); + set.is_empty() + } else { + false + }; + if now_empty { + st.by_account.remove(&addr); + } + } + } + + /// Secondary lookup: metadata for every cache entry `account_address` has + /// executed. Returns an empty vec for an unknown account. + pub fn entries_for_account(&self, account_address: &str) -> Vec { + let st = self.state.read().expect("cache_metadata lock poisoned"); + let Some(ids) = st.by_account.get(account_address) else { + return Vec::new(); + }; + ids.iter() + .filter_map(|id| st.entries.get(id).cloned()) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + const A: &str = "0xaaaa000000000000000000000000000000000000"; + const B: &str = "0xbbbb000000000000000000000000000000000000"; + + fn t(secs: u64) -> SystemTime { + SystemTime::UNIX_EPOCH + Duration::from_secs(secs) + } + + #[test] + fn records_and_looks_up_by_account() { + let idx = CacheMetadataIndex::new(); + idx.record_execution("QmA", 100, A, t(1)); + + let entries = idx.entries_for_account(A); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].ipfs_id, "QmA"); + assert_eq!(entries[0].size_bytes, 100); + assert_eq!(entries[0].run_count, 1); + assert_eq!(entries[0].created_at, t(1)); + assert_eq!(entries[0].last_run_at, t(1)); + } + + #[test] + fn unknown_account_is_empty() { + let idx = CacheMetadataIndex::new(); + idx.record_execution("QmA", 100, A, t(1)); + assert!(idx.entries_for_account(B).is_empty()); + } + + #[test] + fn repeated_runs_bump_count_and_last_run_but_keep_created_at() { + let idx = CacheMetadataIndex::new(); + idx.record_execution("QmA", 100, A, t(1)); + idx.record_execution("QmA", 120, A, t(5)); + + let entries = idx.entries_for_account(A); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].run_count, 2); + assert_eq!(entries[0].created_at, t(1)); + assert_eq!(entries[0].last_run_at, t(5)); + // size reflects the latest recorded value. + assert_eq!(entries[0].size_bytes, 120); + } + + #[test] + fn shared_entry_correlates_to_multiple_accounts() { + let idx = CacheMetadataIndex::new(); + idx.record_execution("QmShared", 50, A, t(1)); + idx.record_execution("QmShared", 50, B, t(2)); + + assert_eq!(idx.entries_for_account(A).len(), 1); + assert_eq!(idx.entries_for_account(B).len(), 1); + // Both accounts point at the same underlying entry. + let a = &idx.entries_for_account(A)[0]; + assert_eq!(a.run_count, 2); + assert_eq!(a.account_addresses.len(), 2); + } + + #[test] + fn eviction_removes_entry_and_cleans_secondary_index() { + let idx = CacheMetadataIndex::new(); + idx.record_execution("QmA", 100, A, t(1)); + idx.record_execution("QmB", 100, A, t(1)); + + idx.remove_entry("QmA"); + + let entries = idx.entries_for_account(A); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].ipfs_id, "QmB"); + } + + #[test] + fn eviction_of_last_entry_drops_account_bucket() { + let idx = CacheMetadataIndex::new(); + idx.record_execution("QmShared", 50, A, t(1)); + idx.record_execution("QmShared", 50, B, t(1)); + + idx.remove_entry("QmShared"); + + // Both accounts' buckets must be gone, not just the entry. + assert!(idx.entries_for_account(A).is_empty()); + assert!(idx.entries_for_account(B).is_empty()); + } + + #[test] + fn removing_unknown_entry_is_a_noop() { + let idx = CacheMetadataIndex::new(); + idx.record_execution("QmA", 100, A, t(1)); + idx.remove_entry("QmDoesNotExist"); + assert_eq!(idx.entries_for_account(A).len(), 1); + } +} diff --git a/lit-api-server/src/core/core_features.rs b/lit-api-server/src/core/core_features.rs index 35c840ba..97d31bc4 100644 --- a/lit-api-server/src/core/core_features.rs +++ b/lit-api-server/src/core/core_features.rs @@ -8,9 +8,12 @@ use crate::actions::client::{ MAX_MAX_RETRIES, MAX_MEMORY_LIMIT_MB, MAX_TIMEOUT_MS, }; use crate::actions::grpc::GrpcClientPool; +use crate::core::cache_metadata::CacheMetadataIndex; use crate::core::v1::helpers::api_status::ApiStatus; use crate::core::v1::models::request::LitActionRequest; -use crate::core::v1::models::response::{LitActionClientConfigResponse, LitActionResponse}; +use crate::core::v1::models::response::{ + CacheEntryMetadataItem, CacheMetadataResponse, LitActionClientConfigResponse, LitActionResponse, +}; use crate::observability::RequestSpan; use crate::stripe::StripeState; use crate::utils::parse_with_hash::ipfs_cid_to_u256; @@ -29,6 +32,7 @@ pub async fn lit_action( api_key: &str, grpc_client_pool: &GrpcClientPool, ipfs_cache: &Cache>, + cache_metadata: &CacheMetadataIndex, http_client: &reqwest::Client, chain_config: Arc, stripe_state: Option>, @@ -64,6 +68,22 @@ pub async fn lit_action( .insert(derived_ipfs_id.clone(), Arc::new(code_to_run.clone())) .await; + // CPL-351: correlate the cached binary with the caller's master account so + // its metadata can be surfaced by `GET /cache_metadata`. Best-effort — a + // failed on-chain wallet lookup must never fail action execution. + match crate::accounts::get_account_wallet_address(api_key).await { + Ok(account_address) => cache_metadata.record_execution( + &derived_ipfs_id, + code_to_run.len() as u64, + &account_address, + std::time::SystemTime::now(), + ), + Err(e) => tracing::debug!( + ipfs_id = %derived_ipfs_id, + "cache_metadata: skipped recording (wallet lookup failed): {e}" + ), + } + let deno_execution_env = DenoExecutionEnv { ipfs_cache: Some(moka::future::Cache::clone(ipfs_cache)), http_client: Some(reqwest::Client::clone(http_client)), @@ -122,6 +142,59 @@ pub async fn lit_action( Ok(lit_action_response) } +/// CPL-351: metadata about the action code cached for the caller's master +/// account. Resolves the API key to its on-chain account wallet address (the +/// identity shared by the master key and all its usage keys) and returns the +/// secondary-index metadata for that account. Never returns cached code. +pub async fn get_cache_metadata( + api_key: &str, + cache_metadata: &CacheMetadataIndex, +) -> Result { + let account_address = crate::accounts::get_account_wallet_address(api_key) + .await + .map_err(|e| { + ApiStatus::bad_request( + anyhow::anyhow!("failed to resolve account for API key: {e}"), + "Could not resolve the account for the provided API key.", + ) + })?; + + let mut entries: Vec = cache_metadata + .entries_for_account(&account_address) + .into_iter() + .map(|m| CacheEntryMetadataItem { + ipfs_id: m.ipfs_id, + size_bytes: m.size_bytes, + created_at_ms: system_time_to_millis(m.created_at), + last_run_at_ms: system_time_to_millis(m.last_run_at), + run_count: m.run_count, + // The API-server IPFS cache is capacity-bounded (LRU), not + // time-expired, so there is no per-entry TTL to report. + ttl_seconds: None, + }) + .collect(); + + // Most recently executed first. + entries.sort_by(|a, b| b.last_run_at_ms.cmp(&a.last_run_at_ms)); + + let total_size_bytes = entries.iter().map(|e| e.size_bytes).sum(); + + Ok(CacheMetadataResponse { + account_address, + entry_count: entries.len() as u64, + total_size_bytes, + entries, + }) +} + +/// Convert a `SystemTime` to Unix-epoch milliseconds, saturating pre-epoch +/// times to 0. +fn system_time_to_millis(t: std::time::SystemTime) -> u64 { + t.duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + pub async fn get_lit_action_client_config( chain_config: Arc, ) -> Result { diff --git a/lit-api-server/src/core/mod.rs b/lit-api-server/src/core/mod.rs index 34426b39..6e50f560 100644 --- a/lit-api-server/src/core/mod.rs +++ b/lit-api-server/src/core/mod.rs @@ -1,6 +1,7 @@ use crate::utils::{parse_with_hash::pkp_id_to_h160, u256_to_derviation_path}; pub mod account_management; +pub mod cache_metadata; pub mod core_features; pub mod eip712; pub mod v1; diff --git a/lit-api-server/src/core/v1/endpoints/actions.rs b/lit-api-server/src/core/v1/endpoints/actions.rs index 0f977784..21203e72 100644 --- a/lit-api-server/src/core/v1/endpoints/actions.rs +++ b/lit-api-server/src/core/v1/endpoints/actions.rs @@ -2,6 +2,7 @@ use std::sync::Arc; use crate::accounts::chain_config::ChainConfig; use crate::actions::grpc::GrpcClientPool; +use crate::core::cache_metadata::CacheMetadataIndex; use crate::core::core_features; use crate::core::v1::guards::billing::BilledLitActionApiKey; use crate::core::v1::guards::cpu_overload::CpuAvailable; @@ -27,6 +28,7 @@ pub(super) async fn lit_action( api_key: BilledLitActionApiKey, grpc_client_pool: &State>, ipfs_cache: &State>>, + cache_metadata: &State>, http_client: &State, chain_config: &State>, stripe_state: &State>>, @@ -39,6 +41,7 @@ pub(super) async fn lit_action( api_key.0.as_str(), grpc_client_pool.inner(), ipfs_cache.inner(), + cache_metadata.inner(), http_client.inner(), chain_config.inner().clone(), stripe_state.inner().clone(), diff --git a/lit-api-server/src/core/v1/endpoints/configuration.rs b/lit-api-server/src/core/v1/endpoints/configuration.rs index a9c52afc..06fc6b9e 100644 --- a/lit-api-server/src/core/v1/endpoints/configuration.rs +++ b/lit-api-server/src/core/v1/endpoints/configuration.rs @@ -2,10 +2,13 @@ use std::sync::Arc; use crate::accounts::chain_config::ChainConfig; use crate::core::account_management; +use crate::core::cache_metadata::CacheMetadataIndex; use crate::core::core_features; +use crate::core::v1::guards::apikey::ApiKey; use crate::core::v1::helpers::api_status::ApiResult; use crate::core::v1::helpers::api_status::ErrMessage; use crate::core::v1::helpers::open_api_response::OpenApiResponse; +use crate::core::v1::models::response::CacheMetadataResponse; use crate::core::v1::models::response::LitActionClientConfigResponse; use crate::core::v1::models::response::VersionResponse; use rocket::State; @@ -41,6 +44,22 @@ pub(super) async fn get_admin_api_payer() -> OpenApiResponse } } +/// CPL-351: metadata about the action code cached for the caller's account. +/// Returns TTL/size/last-run metadata only — never the cached code itself. +#[openapi(tag = "Configuration")] +#[get("/cache_metadata")] +pub(super) async fn get_cache_metadata( + api_key: ApiKey, + cache_metadata: &State>, +) -> OpenApiResponse { + OpenApiResponse { + response: ApiResult( + core_features::get_cache_metadata(&api_key.0, cache_metadata.inner()).await, + ) + .into(), + } +} + #[openapi(tag = "Configuration")] #[get("/version")] pub(super) async fn get_version() -> OpenApiResponse { diff --git a/lit-api-server/src/core/v1/endpoints/mod.rs b/lit-api-server/src/core/v1/endpoints/mod.rs index 03608274..8b081120 100644 --- a/lit-api-server/src/core/v1/endpoints/mod.rs +++ b/lit-api-server/src/core/v1/endpoints/mod.rs @@ -46,6 +46,7 @@ pub fn routes_with_spec() -> (Vec, OpenApi) { get_node_chain_config, get_chain_config_keys, get_lit_action_client_config, + get_cache_metadata, get_api_payers, get_admin_api_payer, billing_stripe_config, diff --git a/lit-api-server/src/core/v1/models/response.rs b/lit-api-server/src/core/v1/models/response.rs index 81eda75f..5e5cd0d0 100644 --- a/lit-api-server/src/core/v1/models/response.rs +++ b/lit-api-server/src/core/v1/models/response.rs @@ -182,6 +182,40 @@ pub struct NodeChainConfigResponse { pub contract_address: String, } +/// One cached action-code entry in a `GET /cache_metadata` response (CPL-351). +/// +/// Describes the cached data only — never the code/binary itself. +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] +pub struct CacheEntryMetadataItem { + /// IPFS id (cache key) of the cached action code. + pub ipfs_id: String, + /// Size of the cached code in bytes. + pub size_bytes: u64, + /// Unix-epoch milliseconds when the entry was first cached. + pub created_at_ms: u64, + /// Unix-epoch milliseconds of the most recent execution. + pub last_run_at_ms: u64, + /// Number of executions recorded against this entry. + pub run_count: u64, + /// Time-to-live of the entry, in seconds. `None` for the API-server IPFS + /// cache, which is capacity-bounded (LRU) rather than time-expired. + pub ttl_seconds: Option, +} + +/// GET /cache_metadata — metadata for the cached action code correlated to the +/// authenticated master account. Excludes the cached binaries themselves. +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] +pub struct CacheMetadataResponse { + /// On-chain account wallet address the caller's key resolves to. + pub account_address: String, + /// Number of cached entries correlated to this account. + pub entry_count: u64, + /// Sum of `size_bytes` across the returned entries. + pub total_size_bytes: u64, + /// The cached entries, sorted by most recent execution first. + pub entries: Vec, +} + #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] pub struct VersionResponse { pub name: String, diff --git a/lit-api-server/src/main.rs b/lit-api-server/src/main.rs index 025dbc82..a0c79c59 100644 --- a/lit-api-server/src/main.rs +++ b/lit-api-server/src/main.rs @@ -4,6 +4,7 @@ use lit_api_server::accounts::signer_pool::start_signer_pool; use lit_api_server::actions::grpc::GrpcClientPool; use lit_api_server::config; use lit_api_server::core; +use lit_api_server::core::cache_metadata::CacheMetadataIndex; use lit_api_server::core::v1::guards::cpu_overload::CpuOverloadMonitor; use lit_api_server::dstack; use lit_api_server::internal; @@ -174,11 +175,26 @@ async fn main() -> Result<(), rocket::Error> { // so on-chain changes made outside this process are reflected before TTL. lit_api_server::account_events::start_account_event_listener(); + // CPL-351: secondary metadata index correlating cached action code with the + // master account that ran it. Lives outside the restart loop (like the cache + // it shadows) so metadata survives Rocket rebuilds. + let cache_metadata_index = Arc::new(CacheMetadataIndex::new()); + // IPFS cache lives outside the restart loop so warm entries survive restarts. - let ipfs_cache: Cache> = Cache::builder() - .weigher(|_key, value: &Arc| -> u32 { value.len().try_into().unwrap_or(u32::MAX) }) - .max_capacity(1024 * 1024 * 1024) // 1 GB - .build(); + // The eviction listener keeps the metadata index consistent: when a binary + // is evicted (capacity), replaced, or removed, its metadata is dropped too. + let ipfs_cache: Cache> = { + let metadata_for_eviction = cache_metadata_index.clone(); + Cache::builder() + .weigher(|_key, value: &Arc| -> u32 { + value.len().try_into().unwrap_or(u32::MAX) + }) + .max_capacity(1024 * 1024 * 1024) // 1 GB + .eviction_listener(move |key: Arc, _value, _cause| { + metadata_for_eviction.remove_entry(&key); + }) + .build() + }; // Restart metrics: total restart count for logging. let mut restart_count: u64 = 0; @@ -211,6 +227,7 @@ async fn main() -> Result<(), rocket::Error> { internal_config.clone(), auth_resolver.clone(), ipfs_cache.clone(), + cache_metadata_index.clone(), ); let rocket = match r.ignite().await { @@ -333,6 +350,7 @@ fn build_rocket( internal_config: Option>, auth_resolver: Arc, ipfs_cache: Cache>, + cache_metadata_index: Arc, ) -> rocket::Rocket { let allowed_methods = HashSet::from([ Method::from_str("Get").expect("Invalid method: Get"), @@ -387,6 +405,7 @@ fn build_rocket( }), ) .manage(ipfs_cache) + .manage(cache_metadata_index) .manage(openapi_spec) .manage(default_http_client()) .manage(GrpcClientPool::::new()) From 40d179916d7f4ac0a68059f00fa81d456405a40c Mon Sep 17 00:00:00 2001 From: GTC6244 <95836911+GTC6244@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:51:24 -0400 Subject: [PATCH 2/4] =?UTF-8?q?refactor(cache):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20poison-safe=20locks,=20lighter=20lookup,=20no=20tru?= =?UTF-8?q?ncation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Recover poisoned RwLock guards in CacheMetadataIndex instead of panicking (record_execution runs on the hot path; remove_entry runs in the moka eviction listener; entries_for_account serves GET requests). - entries_for_account now returns a CacheEntrySnapshot projection so a widely-shared entry no longer clones its account_addresses HashSet per read. - system_time_to_millis saturates the u128→u64 conversion instead of truncating. - Allow clippy::too_many_arguments on build_rocket (now 8 args). Co-Authored-By: Claude Opus 4.8 --- lit-api-server/src/core/cache_metadata.rs | 55 +++++++++++++++++------ lit-api-server/src/core/core_features.rs | 7 +-- lit-api-server/src/main.rs | 1 + 3 files changed, 47 insertions(+), 16 deletions(-) diff --git a/lit-api-server/src/core/cache_metadata.rs b/lit-api-server/src/core/cache_metadata.rs index b1967fc7..8ad3aa2a 100644 --- a/lit-api-server/src/core/cache_metadata.rs +++ b/lit-api-server/src/core/cache_metadata.rs @@ -25,22 +25,36 @@ use std::time::SystemTime; /// Deliberately excludes the cached code/binary — this struct is safe to /// surface over the API. #[derive(Clone, Debug)] -pub struct CacheEntryMetadata { +struct CacheEntryMetadata { /// IPFS id (primary cache key) of the cached action code. - pub ipfs_id: String, + ipfs_id: String, /// Size of the cached code in bytes. - pub size_bytes: u64, + size_bytes: u64, /// When this entry was first recorded in the cache. - pub created_at: SystemTime, + created_at: SystemTime, /// When this entry was most recently executed. - pub last_run_at: SystemTime, + last_run_at: SystemTime, /// Total number of executions recorded against this entry (across every /// account that has run it). - pub run_count: u64, + run_count: u64, /// Account wallet addresses (master-account identities) that have executed /// this entry. Used both for the reverse lookup and to keep the secondary /// index consistent on eviction. - pub account_addresses: HashSet, + account_addresses: HashSet, +} + +/// A cheap, read-only projection of a cache entry's metadata for a single +/// account lookup. Excludes `account_addresses` so a widely-shared entry does +/// not force a clone of a large `HashSet` on every `GET /cache_metadata`. +#[derive(Clone, Debug)] +pub struct CacheEntrySnapshot { + pub ipfs_id: String, + pub size_bytes: u64, + pub created_at: SystemTime, + pub last_run_at: SystemTime, + pub run_count: u64, + /// Number of distinct accounts correlated with this entry. + pub account_count: usize, } #[derive(Default)] @@ -75,7 +89,10 @@ impl CacheMetadataIndex { account_address: &str, now: SystemTime, ) { - let mut st = self.state.write().expect("cache_metadata lock poisoned"); + // Recover from a poisoned lock rather than panic: this runs on the hot + // action-execution path, and a partially-written best-effort metadata + // entry is never a correctness or safety hazard. + let mut st = self.state.write().unwrap_or_else(|e| e.into_inner()); let entry = st .entries @@ -104,7 +121,9 @@ impl CacheMetadataIndex { /// Called from the primary cache's eviction listener when a binary is /// evicted (capacity pressure), replaced, or explicitly removed. pub fn remove_entry(&self, ipfs_id: &str) { - let mut st = self.state.write().expect("cache_metadata lock poisoned"); + // Runs inside the moka eviction listener, which must be panic-free — + // recover a poisoned lock instead of aborting the eviction. + let mut st = self.state.write().unwrap_or_else(|e| e.into_inner()); let Some(meta) = st.entries.remove(ipfs_id) else { return; }; @@ -123,13 +142,23 @@ impl CacheMetadataIndex { /// Secondary lookup: metadata for every cache entry `account_address` has /// executed. Returns an empty vec for an unknown account. - pub fn entries_for_account(&self, account_address: &str) -> Vec { - let st = self.state.read().expect("cache_metadata lock poisoned"); + pub fn entries_for_account(&self, account_address: &str) -> Vec { + // Recover a poisoned lock rather than panic a GET request — a metadata + // read must never be an availability risk. + let st = self.state.read().unwrap_or_else(|e| e.into_inner()); let Some(ids) = st.by_account.get(account_address) else { return Vec::new(); }; ids.iter() - .filter_map(|id| st.entries.get(id).cloned()) + .filter_map(|id| st.entries.get(id)) + .map(|m| CacheEntrySnapshot { + ipfs_id: m.ipfs_id.clone(), + size_bytes: m.size_bytes, + created_at: m.created_at, + last_run_at: m.last_run_at, + run_count: m.run_count, + account_count: m.account_addresses.len(), + }) .collect() } } @@ -193,7 +222,7 @@ mod tests { // Both accounts point at the same underlying entry. let a = &idx.entries_for_account(A)[0]; assert_eq!(a.run_count, 2); - assert_eq!(a.account_addresses.len(), 2); + assert_eq!(a.account_count, 2); } #[test] diff --git a/lit-api-server/src/core/core_features.rs b/lit-api-server/src/core/core_features.rs index 97d31bc4..ff0e2ec5 100644 --- a/lit-api-server/src/core/core_features.rs +++ b/lit-api-server/src/core/core_features.rs @@ -187,11 +187,12 @@ pub async fn get_cache_metadata( }) } -/// Convert a `SystemTime` to Unix-epoch milliseconds, saturating pre-epoch -/// times to 0. +/// Convert a `SystemTime` to Unix-epoch milliseconds. Pre-epoch times saturate +/// to 0; a value beyond `u64::MAX` ms (~year 584 million) saturates to +/// `u64::MAX` rather than silently truncating the `u128`. fn system_time_to_millis(t: std::time::SystemTime) -> u64 { t.duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as u64) + .map(|d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX)) .unwrap_or(0) } diff --git a/lit-api-server/src/main.rs b/lit-api-server/src/main.rs index a0c79c59..f765cd77 100644 --- a/lit-api-server/src/main.rs +++ b/lit-api-server/src/main.rs @@ -342,6 +342,7 @@ async fn await_server_handle( } } +#[allow(clippy::too_many_arguments)] fn build_rocket( signer_pool: Arc, chain_config: Arc, From f638227022b6f706381e9e658f6e9129b2a24054 Mon Sep 17 00:00:00 2001 From: GTC6244 <95836911+GTC6244@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:03:34 -0400 Subject: [PATCH 3/4] =?UTF-8?q?ci(deny):=20fix=20cargo-deny=200.20=20CLI?= =?UTF-8?q?=20break=20=E2=80=94=20root-level=20--config=20+=20pin=20versio?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cargo-deny 0.20 moved `--config` from the `check` subcommand to a root-level flag, so the unpinned taiki-e/install-action (which pulled 0.20.2) broke every Rust matrix job with `error: unexpected argument '--config' found`. Move `--config` before `check` and pin the tool to 0.20.2 so an unpinned upgrade can't silently flip the syntax again. Verified against the 0.20.2 binary for all six matrix crates (advisories ok, sources ok, exit 0). Co-Authored-By: Claude Opus 4.8 --- .github/workflows/rust-ci.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 658c038d..cdd621e9 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -107,14 +107,19 @@ jobs: # matrix job via taiki-e/install-action, which uses prebuilt binaries. # The pre-existing advisory backlog lives in deny.toml's [advisories.ignore] # block; new RUSTSEC IDs fail CI by default. + # Pin the version: cargo-deny 0.20 relocated `--config` from the `check` + # subcommand to a root-level flag, so an unpinned upgrade silently breaks + # the invocation below. The advisory DB is fetched at runtime, so pinning + # the binary does not stale the supply-chain data. - uses: taiki-e/install-action@v2 with: - tool: cargo-deny + tool: cargo-deny@0.20.2 - name: deny working-directory: ${{ matrix.crate }} + # `--config` is a root-level flag (before `check`) as of cargo-deny 0.20. # -W advisory-not-detected / unmatched-source: the deny.toml ignore + # allow-git lists are the union across all four workspaces, so each # individual workspace sees entries that don't apply — downgrade those # lints to warnings so they don't fail the build. - run: cargo deny check --config ${{ github.workspace }}/deny.toml -W advisory-not-detected -W unmatched-source advisories sources + run: cargo deny --config ${{ github.workspace }}/deny.toml check -W advisory-not-detected -W unmatched-source advisories sources From 7daccd7807c2ec851616717cf8df76bdbb5e4e4b Mon Sep 17 00:00:00 2001 From: GTC6244 <95836911+GTC6244@users.noreply.github.com> Date: Sat, 11 Jul 2026 20:52:14 -0400 Subject: [PATCH 4/4] =?UTF-8?q?fix(cache):=20address=20Claude=20review=20?= =?UTF-8?q?=E2=80=94=20Replaced-cause=20wipe,=20hot-path=20RPC,=20401=20(C?= =?UTF-8?q?PL-351)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MAJOR: the moka eviction listener treated RemovalCause::Replaced as a removal, so re-inserting an already-cached action (which lit_action does on every request) wiped its metadata — resetting run_count/created_at or making entries vanish. The listener now ignores Replaced (the key is still cached). Added a regression test driving the index through a real moka cache. - Moved the best-effort execution recording (which does an uncached on-chain get_account_wallet_address call) into a tokio::spawn so it no longer adds RPC latency to the POST /lit_action hot path. - get_cache_metadata now returns 401 for an unregistered key (matching the billing convention) and 500 for a genuine RPC/contract failure, instead of a blanket 400. - Documented the bounded (practically unreachable) orphan-metadata race and the internal-only CacheEntrySnapshot.account_count field. - Reverted the rust-ci.yml comment churn now that main carries the same fix. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/rust-ci.yml | 13 ++-- lit-api-server/src/core/cache_metadata.rs | 63 ++++++++++++++++++- lit-api-server/src/core/core_features.rs | 56 +++++++++++------ .../src/core/v1/endpoints/actions.rs | 2 +- lit-api-server/src/main.rs | 12 +++- 5 files changed, 117 insertions(+), 29 deletions(-) diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index c830cf31..0fb7ee8f 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -107,20 +107,21 @@ jobs: # matrix job via taiki-e/install-action, which uses prebuilt binaries. # The pre-existing advisory backlog lives in deny.toml's [advisories.ignore] # block; new RUSTSEC IDs fail CI by default. - # Pin the version: cargo-deny 0.20 relocated `--config` from the `check` - # subcommand to a root-level flag, so an unpinned upgrade silently breaks - # the invocation below (this bit CI when install-action pulled 0.20.2). - # The advisory DB is fetched fresh at runtime, so pinning the binary does - # not stale the supply-chain data — bump the pin deliberately. + # + # Pinned: an unpinned `cargo-deny` broke CI when 0.20.2 moved `--config` + # from the `check` subcommand to the top-level command. The advisory DB + # is fetched fresh at runtime, so pinning the tool does not freeze the + # advisory data — bump the pin deliberately. - uses: taiki-e/install-action@v2 with: tool: cargo-deny@0.20.2 - name: deny working-directory: ${{ matrix.crate }} - # `--config` is a root-level flag (before `check`) as of cargo-deny 0.20. # -W advisory-not-detected / unmatched-source: the deny.toml ignore + # allow-git lists are the union across all four workspaces, so each # individual workspace sees entries that don't apply — downgrade those # lints to warnings so they don't fail the build. + # NB: as of cargo-deny 0.20, --config is a top-level flag and must + # come before `check`. run: cargo deny --config ${{ github.workspace }}/deny.toml check -W advisory-not-detected -W unmatched-source advisories sources diff --git a/lit-api-server/src/core/cache_metadata.rs b/lit-api-server/src/core/cache_metadata.rs index 8ad3aa2a..0ccbab75 100644 --- a/lit-api-server/src/core/cache_metadata.rs +++ b/lit-api-server/src/core/cache_metadata.rs @@ -53,7 +53,10 @@ pub struct CacheEntrySnapshot { pub created_at: SystemTime, pub last_run_at: SystemTime, pub run_count: u64, - /// Number of distinct accounts correlated with this entry. + /// Number of distinct accounts correlated with this entry. Internal-only: + /// deliberately NOT surfaced in the API response, since exposing how many + /// other accounts run the same code would leak cross-account usage. Kept + /// for correlation invariants (verified in tests) and possible admin use. pub account_count: usize, } @@ -82,6 +85,15 @@ impl CacheMetadataIndex { /// Creates the entry on first sight (stamping `created_at`), and on every /// call refreshes `last_run_at`/`size_bytes`, increments `run_count`, and /// correlates the entry with the account in the secondary index. + /// + /// Callers invoke this just after inserting into the primary cache. If a + /// capacity/expiry eviction of that same key were to land in the tiny + /// window between the insert and this call, the eviction's `remove_entry` + /// runs as a no-op and this recreates a metadata entry with no backing + /// cache entry. That leak is bounded (one stale entry per raced key) and in + /// practice unreachable — the key was just inserted, so it is the + /// most-recently-used entry and cannot be size-evicted unless it alone + /// exceeds the 1 GB cap (impossible under `max_code_length`). pub fn record_execution( &self, ipfs_id: &str, @@ -258,4 +270,53 @@ mod tests { idx.remove_entry("QmDoesNotExist"); assert_eq!(idx.entries_for_account(A).len(), 1); } + + /// Regression test for the `RemovalCause::Replaced` bug: drives the index + /// through a real moka cache wired with the SAME listener guard as + /// `main.rs`, proving a re-insert (Replaced) preserves metadata while a + /// genuine removal drops it. Without the guard, `run_count`/`created_at` + /// reset on every re-run of a cached action. + #[tokio::test] + async fn replaced_preserves_metadata_but_real_removal_drops_it() { + use moka::future::Cache; + use moka::notification::RemovalCause; + use std::sync::Arc; + + let idx = Arc::new(CacheMetadataIndex::new()); + let idx_listener = idx.clone(); + let cache: Cache> = Cache::builder() + .eviction_listener(move |key: Arc, _v, cause| { + // Mirror the production guard in main.rs. + if cause != RemovalCause::Replaced { + idx_listener.remove_entry(&key); + } + }) + .build(); + + let code = Arc::new(String::from("code")); + + // First run: insert then record. + cache.insert("QmA".to_string(), code.clone()).await; + idx.record_execution("QmA", 4, A, t(1)); + cache.run_pending_tasks().await; + assert_eq!(idx.entries_for_account(A).len(), 1); + + // Re-run of the already-cached action: re-insert fires Replaced. + cache.insert("QmA".to_string(), code.clone()).await; + cache.run_pending_tasks().await; // deliver the Replaced notification + idx.record_execution("QmA", 4, A, t(2)); + + let entries = idx.entries_for_account(A); + assert_eq!(entries.len(), 1, "Replaced must not wipe metadata"); + assert_eq!(entries[0].run_count, 2, "run_count must accumulate"); + assert_eq!(entries[0].created_at, t(1), "created_at must be preserved"); + + // A genuine removal (explicit invalidate) drops the metadata. + cache.invalidate("QmA").await; + cache.run_pending_tasks().await; + assert!( + idx.entries_for_account(A).is_empty(), + "real removal must drop metadata" + ); + } } diff --git a/lit-api-server/src/core/core_features.rs b/lit-api-server/src/core/core_features.rs index ff0e2ec5..d81be141 100644 --- a/lit-api-server/src/core/core_features.rs +++ b/lit-api-server/src/core/core_features.rs @@ -32,7 +32,7 @@ pub async fn lit_action( api_key: &str, grpc_client_pool: &GrpcClientPool, ipfs_cache: &Cache>, - cache_metadata: &CacheMetadataIndex, + cache_metadata: Arc, http_client: &reqwest::Client, chain_config: Arc, stripe_state: Option>, @@ -69,19 +69,30 @@ pub async fn lit_action( .await; // CPL-351: correlate the cached binary with the caller's master account so - // its metadata can be surfaced by `GET /cache_metadata`. Best-effort — a - // failed on-chain wallet lookup must never fail action execution. - match crate::accounts::get_account_wallet_address(api_key).await { - Ok(account_address) => cache_metadata.record_execution( - &derived_ipfs_id, - code_to_run.len() as u64, - &account_address, - std::time::SystemTime::now(), - ), - Err(e) => tracing::debug!( - ipfs_id = %derived_ipfs_id, - "cache_metadata: skipped recording (wallet lookup failed): {e}" - ), + // its metadata can be surfaced by `GET /cache_metadata`. Spawned off the + // request path: resolving the account wallet is an uncached on-chain call, + // and this is best-effort bookkeeping that must add neither latency to nor + // failure modes for action execution. `record_execution` is eventually + // consistent — a slightly-late write only affects the metadata endpoint. + { + let cache_metadata = cache_metadata.clone(); + let api_key = api_key.to_string(); + let ipfs_id = derived_ipfs_id.clone(); + let size_bytes = code_to_run.len() as u64; + tokio::spawn(async move { + match crate::accounts::get_account_wallet_address(&api_key).await { + Ok(account_address) => cache_metadata.record_execution( + &ipfs_id, + size_bytes, + &account_address, + std::time::SystemTime::now(), + ), + Err(e) => tracing::debug!( + %ipfs_id, + "cache_metadata: skipped recording (wallet lookup failed): {e}" + ), + } + }); } let deno_execution_env = DenoExecutionEnv { @@ -153,10 +164,19 @@ pub async fn get_cache_metadata( let account_address = crate::accounts::get_account_wallet_address(api_key) .await .map_err(|e| { - ApiStatus::bad_request( - anyhow::anyhow!("failed to resolve account for API key: {e}"), - "Could not resolve the account for the provided API key.", - ) + // An unknown/unregistered key is a credential failure, not a bad + // request — map it to 401 to match the billing path's convention + // (see accounts::UnknownApiKey). Anything else (RPC/contract + // failure) is a transient 500. + let msg = format!("{e}"); + if msg.contains("no wallet address") || msg.contains("AccountDoesNotExist") { + ApiStatus::unauthorized("The provided API key is not registered.".to_string()) + } else { + ApiStatus::internal_server_error( + anyhow::anyhow!("failed to resolve account for API key: {e}"), + "Could not resolve the account for the provided API key.", + ) + } })?; let mut entries: Vec = cache_metadata diff --git a/lit-api-server/src/core/v1/endpoints/actions.rs b/lit-api-server/src/core/v1/endpoints/actions.rs index 21203e72..de869b2e 100644 --- a/lit-api-server/src/core/v1/endpoints/actions.rs +++ b/lit-api-server/src/core/v1/endpoints/actions.rs @@ -41,7 +41,7 @@ pub(super) async fn lit_action( api_key.0.as_str(), grpc_client_pool.inner(), ipfs_cache.inner(), - cache_metadata.inner(), + cache_metadata.inner().clone(), http_client.inner(), chain_config.inner().clone(), stripe_state.inner().clone(), diff --git a/lit-api-server/src/main.rs b/lit-api-server/src/main.rs index dc2592f9..6a7a5298 100644 --- a/lit-api-server/src/main.rs +++ b/lit-api-server/src/main.rs @@ -207,7 +207,11 @@ async fn main() -> Result<(), rocket::Error> { // IPFS cache lives outside the restart loop so warm entries survive restarts. // The eviction listener keeps the metadata index consistent: when a binary - // is evicted (capacity), replaced, or removed, its metadata is dropped too. + // leaves the cache (capacity, expiry, or explicit invalidation) its metadata + // is dropped too. `RemovalCause::Replaced` is explicitly NOT a removal — the + // key is still cached, only its value changed — and `lit_action` re-inserts + // on every request, so treating Replaced as a removal would wipe live + // metadata (run_count/created_at) on every re-run of a cached action. let ipfs_cache: Cache> = { let metadata_for_eviction = cache_metadata_index.clone(); Cache::builder() @@ -215,8 +219,10 @@ async fn main() -> Result<(), rocket::Error> { value.len().try_into().unwrap_or(u32::MAX) }) .max_capacity(1024 * 1024 * 1024) // 1 GB - .eviction_listener(move |key: Arc, _value, _cause| { - metadata_for_eviction.remove_entry(&key); + .eviction_listener(move |key: Arc, _value, cause| { + if cause != moka::notification::RemovalCause::Replaced { + metadata_for_eviction.remove_entry(&key); + } }) .build() };