diff --git a/k6/litApiServer.ts b/k6/litApiServer.ts index 838bf1fe..6ec00a68 100644 --- a/k6/litApiServer.ts +++ b/k6/litApiServer.ts @@ -413,6 +413,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; +} + /** * Returned by `/get_supported_languages` — the node's language capability surface (see `actions::languages`). */ @@ -891,6 +947,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 GetSupportedLanguagesDefault = | SupportedLanguagesResponse | ErrMessage; @@ -2406,6 +2471,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", + }; + } + /** * Advertises the node's language capability surface: which languages, runtimes, and execution methods this node admits. No guards — like `get_lit_action_client_config`, it exists so clients can discover capability before uploading anything. */ 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..0ccbab75 --- /dev/null +++ b/lit-api-server/src/core/cache_metadata.rs @@ -0,0 +1,322 @@ +//! 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)] +struct CacheEntryMetadata { + /// IPFS id (primary cache key) of the cached action code. + ipfs_id: String, + /// Size of the cached code in bytes. + size_bytes: u64, + /// When this entry was first recorded in the cache. + created_at: SystemTime, + /// When this entry was most recently executed. + last_run_at: SystemTime, + /// Total number of executions recorded against this entry (across every + /// account that has run it). + 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. + 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. 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, +} + +#[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. + /// + /// 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, + size_bytes: u64, + account_address: &str, + now: SystemTime, + ) { + // 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 + .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) { + // 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; + }; + 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 { + // 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)) + .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() + } +} + +#[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_count, 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); + } + + /// 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 3de64fae..b37423ff 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, LitBinaryActionRequest}; -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; @@ -32,6 +35,7 @@ pub async fn lit_action( api_key: &str, grpc_client_pool: &GrpcClientPool, ipfs_cache: &Cache>, + cache_metadata: Arc, http_client: &reqwest::Client, chain_config: Arc, stripe_state: Option>, @@ -67,6 +71,33 @@ 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`. 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 { ipfs_cache: Some(moka::future::Cache::clone(ipfs_cache)), http_client: Some(reqwest::Client::clone(http_client)), @@ -126,6 +157,69 @@ 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| { + // 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 + .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. 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| u64::try_from(d.as_millis()).unwrap_or(u64::MAX)) + .unwrap_or(0) +} + /// Execute an any-language action **bundle** on the gVisor runner. /// /// Mirrors [`lit_action`] but targets a different backend socket and carries a 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 7ab28717..5b289f73 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; @@ -28,6 +29,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>>, @@ -40,6 +42,7 @@ pub(super) async fn lit_action( api_key.0.as_str(), grpc_client_pool.inner(), ipfs_cache.inner(), + cache_metadata.inner().clone(), 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 1bdef047..2a173c8a 100644 --- a/lit-api-server/src/core/v1/endpoints/configuration.rs +++ b/lit-api-server/src/core/v1/endpoints/configuration.rs @@ -3,10 +3,13 @@ use std::sync::Arc; use crate::accounts::chain_config::ChainConfig; use crate::actions::languages::SupportedLanguages; 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::SupportedLanguagesResponse; use crate::core::v1::models::response::VersionResponse; @@ -60,6 +63,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 a622d4eb..d89dca69 100644 --- a/lit-api-server/src/core/v1/endpoints/mod.rs +++ b/lit-api-server/src/core/v1/endpoints/mod.rs @@ -50,6 +50,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_supported_languages, get_api_payers, get_admin_api_payer, diff --git a/lit-api-server/src/core/v1/models/response.rs b/lit-api-server/src/core/v1/models/response.rs index 0ac20dcb..20575711 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 8d0f91c0..d1d53d47 100644 --- a/lit-api-server/src/main.rs +++ b/lit-api-server/src/main.rs @@ -5,6 +5,7 @@ use lit_api_server::actions::grpc::GrpcClientPool; use lit_api_server::actions::languages::SupportedLanguages; 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; @@ -244,11 +245,32 @@ 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 + // 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() + .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| { + if cause != moka::notification::RemovalCause::Replaced { + metadata_for_eviction.remove_entry(&key); + } + }) + .build() + }; // Restart metrics: total restart count for logging. let mut restart_count: u64 = 0; @@ -303,6 +325,7 @@ async fn main() -> Result<(), rocket::Error> { internal_config.clone(), auth_resolver.clone(), ipfs_cache.clone(), + cache_metadata_index.clone(), supported_languages.clone(), ); @@ -427,6 +450,7 @@ fn build_rocket( internal_config: Option>, auth_resolver: Arc, ipfs_cache: Cache>, + cache_metadata_index: Arc, supported_languages: Arc, ) -> rocket::Rocket { let allowed_methods = HashSet::from([ @@ -482,6 +506,7 @@ fn build_rocket( }), ) .manage(ipfs_cache) + .manage(cache_metadata_index) .manage(openapi_spec) .manage(default_http_client()) .manage(GrpcClientPool::::new()) diff --git a/spec.json b/spec.json index 12cfc22d..bdc23cca 100644 --- a/spec.json +++ b/spec.json @@ -410,6 +410,58 @@ } } }, + "/lit_binary_action": { + "post": { + "tags": [ + "Actions" + ], + "description": "Execute an any-language action bundle on the gVisor runner. Same billing, CPU-gating, and response shape as `/lit_action`; differs only in payload (a tar bundle instead of JS) and backend socket.", + "operationId": "lit_binary_action", + "parameters": [ + { + "name": "X-Api-Key", + "in": "header", + "description": "Account or usage API key. Alternatively use Authorization: Bearer .", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LitBinaryActionRequest" + } + } + }, + "required": true + }, + "responses": { + "default": { + "description": "", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/LitActionResponse" + }, + { + "$ref": "#/components/schemas/ErrMessage" + } + ] + } + } + } + }, + "429": { + "description": "Too Many Requests — the node is CPU-overloaded and shedding load. Clients receiving this response should retry the request up to five times with exponential backoff." + } + } + } + }, "/get_lit_action_ipfs_id": { "post": { "tags": [ @@ -1499,6 +1551,45 @@ } } }, + "/cache_metadata": { + "get": { + "tags": [ + "Configuration" + ], + "description": "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.", + "operationId": "get_cache_metadata", + "parameters": [ + { + "name": "X-Api-Key", + "in": "header", + "description": "Account or usage API key. Alternatively use Authorization: Bearer .", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "default": { + "description": "", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/CacheMetadataResponse" + }, + { + "$ref": "#/components/schemas/ErrMessage" + } + ] + } + } + } + } + } + } + }, "/get_supported_languages": { "get": { "tags": [ @@ -2054,6 +2145,28 @@ } } }, + "LitBinaryActionRequest": { + "description": "POST /lit_binary_action\n\nExecutes an any-language action **bundle** in the gVisor runner. Provide either `bundle` (a base64-encoded tar/tar.gz containing a `lit.json` manifest at its root) or `checksum` (the content id of a bundle the runner already cached). When `bundle` is supplied the server derives the checksum from the decoded tar bytes and authorizes on that derived value — a client-supplied `checksum` is only a hint and is ignored if it disagrees.", + "type": "object", + "properties": { + "bundle": { + "description": "Base64-encoded tar or tar.gz bundle. Optional when `checksum` refers to a previously-submitted bundle the runner still has cached.", + "default": null, + "type": "string", + "nullable": true + }, + "checksum": { + "description": "Content id (IPFS CID) of the bundle. Required when `bundle` is omitted; when `bundle` is present it is only a hint, validated against the value derived from the bundle bytes.", + "default": null, + "type": "string", + "nullable": true + }, + "js_params": { + "description": "Parameters passed to the action (exposed to guest code via `lit params`).", + "nullable": true + } + } + }, "AddGroupResponse": { "description": "Response for add_group, includes the on-chain group ID.", "type": "object", @@ -2666,6 +2779,89 @@ } } }, + "CacheMetadataResponse": { + "description": "GET /cache_metadata — metadata for the cached action code correlated to the authenticated master account. Excludes the cached binaries themselves.", + "type": "object", + "required": [ + "account_address", + "entries", + "entry_count", + "total_size_bytes" + ], + "properties": { + "account_address": { + "description": "On-chain account wallet address the caller's key resolves to.", + "type": "string" + }, + "entry_count": { + "description": "Number of cached entries correlated to this account.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "total_size_bytes": { + "description": "Sum of `size_bytes` across the returned entries.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "entries": { + "description": "The cached entries, sorted by most recent execution first.", + "type": "array", + "items": { + "$ref": "#/components/schemas/CacheEntryMetadataItem" + } + } + } + }, + "CacheEntryMetadataItem": { + "description": "One cached action-code entry in a `GET /cache_metadata` response (CPL-351).\n\nDescribes the cached data only — never the code/binary itself.", + "type": "object", + "required": [ + "created_at_ms", + "ipfs_id", + "last_run_at_ms", + "run_count", + "size_bytes" + ], + "properties": { + "ipfs_id": { + "description": "IPFS id (cache key) of the cached action code.", + "type": "string" + }, + "size_bytes": { + "description": "Size of the cached code in bytes.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "created_at_ms": { + "description": "Unix-epoch milliseconds when the entry was first cached.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "last_run_at_ms": { + "description": "Unix-epoch milliseconds of the most recent execution.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "run_count": { + "description": "Number of executions recorded against this entry.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "ttl_seconds": { + "description": "Time-to-live of the entry, in seconds. `None` for the API-server IPFS cache, which is capacity-bounded (LRU) rather than time-expired.", + "type": "integer", + "format": "uint64", + "minimum": 0.0, + "nullable": true + } + } + }, "SupportedLanguagesResponse": { "description": "Returned by `/get_supported_languages` — the node's language capability surface (see `actions::languages`).", "type": "object",