docs(console): clarify API and persistence boundaries
This commit is contained in:
@@ -1,4 +1,14 @@
|
||||
//! Axum management and public gateway protocol adapters.
|
||||
//!
|
||||
//! The management router accepts JSON and multipart packages and exposes
|
||||
//! lifecycle operations. The gateway router is intentionally smaller: it
|
||||
//! accepts raw `application/octet-stream` input and returns raw bytes plus
|
||||
//! `x-wasmeld-revision` and `x-wasmeld-latency-ms` response headers.
|
||||
//!
|
||||
//! Neither router installs authentication or authorization middleware. A
|
||||
//! production deployment must place the appropriate identity, authorization,
|
||||
//! TLS, rate-limit, and request-size policy in front of these routers. CORS is
|
||||
//! a browser access policy and must not be treated as authentication.
|
||||
|
||||
use super::*;
|
||||
use axum::{
|
||||
@@ -21,6 +31,11 @@ use wasmeld_package::wit_package::WitPackageError;
|
||||
///
|
||||
/// The returned router exposes Runtime control, component registration,
|
||||
/// invocation, events, and WIT Registry endpoints.
|
||||
///
|
||||
/// `allowed_origins` only affects browser CORS responses. Passing an empty
|
||||
/// vector does not make the API private; it merely omits allowed origins.
|
||||
/// Multipart bodies receive one MiB of framing headroom above the larger
|
||||
/// configured artifact limit.
|
||||
pub fn app(console: Arc<Console>, allowed_origins: Vec<HeaderValue>) -> Router {
|
||||
let max_body_bytes = console
|
||||
.max_artifact_bytes()
|
||||
@@ -89,7 +104,18 @@ pub fn app(console: Arc<Console>, allowed_origins: Vec<HeaderValue>) -> Router {
|
||||
/// Builds the public data-plane API around the same resident Runtime.
|
||||
///
|
||||
/// This router intentionally contains no registration, lifecycle, Registry,
|
||||
/// or deployment management routes.
|
||||
/// or deployment management routes. Callers must send
|
||||
/// `Content-Type: application/octet-stream`; the stable service ID is resolved
|
||||
/// to its active immutable revision at the start of each request.
|
||||
///
|
||||
/// # Wire example
|
||||
///
|
||||
/// ```text
|
||||
/// POST /v1/services/image-resize/invoke
|
||||
/// Content-Type: application/octet-stream
|
||||
///
|
||||
/// <component-specific bytes>
|
||||
/// ```
|
||||
pub fn gateway_app(console: Arc<Console>) -> Router {
|
||||
let max_input_bytes = console.max_gateway_input_bytes();
|
||||
Router::new()
|
||||
@@ -126,6 +152,9 @@ async fn gateway_invoke(
|
||||
let input = body.map_err(|_| GatewayError::PayloadTooLarge {
|
||||
limit: console.max_gateway_input_bytes(),
|
||||
})?;
|
||||
// Deployment resolution and Actor enqueue happen in the same blocking
|
||||
// closure. The returned revision header therefore identifies the exact
|
||||
// revision that received this request, even if deployment changes later.
|
||||
let invocation = run_blocking(console, move |console| {
|
||||
console.gateway_invoke(&service_id, input.to_vec())
|
||||
})
|
||||
@@ -262,6 +291,9 @@ async fn publish_wit_package(
|
||||
State(console): State<Arc<Console>>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<(StatusCode, Json<WitPackageMetadata>), ApiError> {
|
||||
// Axum materializes this one field in memory. DefaultBodyLimit above is
|
||||
// only a transport guard; WitRegistry repeats the exact artifact limit and
|
||||
// validates the package identity before committing it to disk.
|
||||
let mut package = None;
|
||||
while let Some(field) = multipart
|
||||
.next_field()
|
||||
@@ -453,6 +485,9 @@ where
|
||||
// an operation may have changed state before returning its error.
|
||||
let operation_console = Arc::clone(&console);
|
||||
let result = run_blocking(operation_console, operation).await;
|
||||
// The flush marker orders previously accepted metric deltas before the
|
||||
// full snapshot transaction. It means "attempted", not "durably written";
|
||||
// the telemetry worker logs write failures independently.
|
||||
console.flush_invocation_telemetry().await;
|
||||
let persisted = console.persist_snapshot().await;
|
||||
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
//! Bounded, asynchronous persistence for data-plane invocation telemetry.
|
||||
//!
|
||||
//! Invocation responses must not wait for libSQL, so producers use
|
||||
//! [`tokio::sync::mpsc::Sender::try_send`] and one task writes ordered batches.
|
||||
//! When the queue is full or closed, the update is deliberately dropped and a
|
||||
//! warning is emitted. These counters and events are operational telemetry;
|
||||
//! they are not suitable as a billing ledger or audit log.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -10,6 +16,10 @@ const QUEUE_CAPACITY: usize = 1024;
|
||||
const MAX_BATCH_SIZE: usize = 64;
|
||||
|
||||
/// Non-blocking producer used by Wasmtime invocation threads.
|
||||
///
|
||||
/// All producers share one FIFO queue. Queue order is preserved for accepted
|
||||
/// records, but a process crash can lose records that have not yet reached
|
||||
/// libSQL.
|
||||
pub(crate) struct InvocationPersistence {
|
||||
sender: mpsc::Sender<Command>,
|
||||
}
|
||||
@@ -21,6 +31,10 @@ enum Command {
|
||||
|
||||
impl InvocationPersistence {
|
||||
/// Starts the single ordered writer on the current Tokio runtime.
|
||||
///
|
||||
/// `sync` is shared with full Console snapshot persistence. Taking it
|
||||
/// around each batch prevents a snapshot transaction from racing a metric
|
||||
/// delta transaction.
|
||||
pub(crate) fn start(persistence: Persistence, sync: Arc<Mutex<()>>) -> Self {
|
||||
let (sender, mut receiver) = mpsc::channel(QUEUE_CAPACITY);
|
||||
tokio::spawn(async move {
|
||||
@@ -45,6 +59,10 @@ impl InvocationPersistence {
|
||||
tracing::error!(%error, "failed to persist invocation telemetry");
|
||||
}
|
||||
}
|
||||
// Flush is an ordering barrier, not a durability receipt. A
|
||||
// database error above is logged rather than returned, and a
|
||||
// flush may wait for records dequeued after the marker when
|
||||
// those records fit in the same batch.
|
||||
for flush in flushes {
|
||||
let _ = flush.send(());
|
||||
}
|
||||
@@ -54,6 +72,9 @@ impl InvocationPersistence {
|
||||
}
|
||||
|
||||
/// Enqueues telemetry without extending API response latency.
|
||||
///
|
||||
/// Saturation drops this update instead of applying backpressure to the
|
||||
/// Component invocation path.
|
||||
pub(crate) fn record(&self, update: InvocationUpdate) {
|
||||
if let Err(error) = self.sender.try_send(Command::Record(update)) {
|
||||
tracing::warn!(%error, "invocation telemetry queue is unavailable; update dropped");
|
||||
@@ -61,6 +82,10 @@ impl InvocationPersistence {
|
||||
}
|
||||
|
||||
/// Waits until all telemetry queued before this call has been attempted.
|
||||
///
|
||||
/// This method is used before a control-plane snapshot so the snapshot
|
||||
/// reads the newest in-memory counters after older delta writes. It does
|
||||
/// not report persistence failures; those are visible through tracing.
|
||||
pub(crate) async fn flush(&self) {
|
||||
let (sender, receiver) = oneshot::channel();
|
||||
if self.sender.send(Command::Flush(sender)).await.is_ok() {
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
//! Bounded bridge between synchronous Component Host calls and async Toasty.
|
||||
//!
|
||||
//! Wasmtime calls [`wasmeld_runtime::KvBackend`] synchronously on an Actor
|
||||
//! thread, while Toasty and libSQL are asynchronous. This adapter moves every
|
||||
//! database operation onto one dedicated current-thread Tokio runtime. The
|
||||
//! bounded channel prevents a slow database from accumulating unbounded work
|
||||
//! or blocking every Actor while waiting for queue capacity.
|
||||
//!
|
||||
//! # Timeout semantics
|
||||
//!
|
||||
//! A timeout only stops the caller from waiting. It does not remove a command
|
||||
//! that is already queued or cancel a libSQL operation that has started.
|
||||
//! Consequently, `set` and `delete` may commit after the Component observes
|
||||
//! `KvBackendError::Timeout`. Components should make retries idempotent and
|
||||
//! must not interpret a timeout as proof that no mutation occurred.
|
||||
|
||||
use std::{
|
||||
fmt, io,
|
||||
@@ -15,6 +29,9 @@ use wasmeld_runtime::{KvBackend, KvBackendError};
|
||||
use crate::persistence::Persistence;
|
||||
|
||||
const COMMAND_CAPACITY: usize = 64;
|
||||
// Keep Host calls responsive even when a Component requests a larger timeout.
|
||||
// The Component-facing deadline is an upper bound, not permission to occupy an
|
||||
// Actor thread indefinitely.
|
||||
const MAX_RESPONSE_TIMEOUT: Duration = Duration::from_millis(400);
|
||||
|
||||
pub(crate) struct ToastyKvBackend {
|
||||
@@ -47,6 +64,11 @@ enum KvCommand {
|
||||
}
|
||||
|
||||
impl ToastyKvBackend {
|
||||
/// Starts the database worker and waits until its Tokio runtime is ready.
|
||||
///
|
||||
/// Waiting for the ready signal makes construction fail deterministically
|
||||
/// instead of returning a backend whose first operation discovers that
|
||||
/// the worker could not initialize.
|
||||
pub(crate) fn start(persistence: Persistence) -> io::Result<Self> {
|
||||
let (sender, receiver) = mpsc::sync_channel(COMMAND_CAPACITY);
|
||||
let (ready_sender, ready_receiver) = mpsc::sync_channel(1);
|
||||
@@ -78,6 +100,8 @@ impl ToastyKvBackend {
|
||||
build: impl FnOnce(SyncSender<Result<T, KvBackendError>>) -> KvCommand,
|
||||
) -> Result<T, KvBackendError> {
|
||||
let (response, receiver) = mpsc::sync_channel(1);
|
||||
// `try_send` is intentional: waiting for queue capacity here would
|
||||
// stall the Actor and defeat the Runtime's invocation deadline.
|
||||
match self.inner.sender.try_send(build(response)) {
|
||||
Ok(()) => {}
|
||||
Err(TrySendError::Full(_)) => return Err(KvBackendError::Busy),
|
||||
@@ -149,6 +173,9 @@ impl KvBackend for ToastyKvBackend {
|
||||
|
||||
impl Drop for BackendInner {
|
||||
fn drop(&mut self) {
|
||||
// Only the final Arc owner reaches this Drop. Joining guarantees the
|
||||
// worker no longer holds the Persistence handle when Console shuts
|
||||
// down. If commands precede Shutdown, the FIFO channel drains them.
|
||||
let _ = self.sender.send(KvCommand::Shutdown);
|
||||
if let Ok(worker) = self.worker.get_mut()
|
||||
&& let Some(worker) = worker.take()
|
||||
@@ -163,6 +190,8 @@ fn run_worker(
|
||||
receiver: Receiver<KvCommand>,
|
||||
ready: SyncSender<io::Result<()>>,
|
||||
) {
|
||||
// Toasty is driven from one thread so synchronous Host calls never need to
|
||||
// enter or block the Console server's multi-thread Tokio runtime.
|
||||
let runtime = match tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
@@ -175,6 +204,9 @@ fn run_worker(
|
||||
};
|
||||
let _ = ready.send(Ok(()));
|
||||
|
||||
// A single consumer also defines the ordering of mutations submitted to
|
||||
// this backend. Ordering across different Wasmeld processes is delegated
|
||||
// to libSQL and is not guaranteed by this queue.
|
||||
while let Ok(command) = receiver.recv() {
|
||||
match command {
|
||||
KvCommand::Get {
|
||||
|
||||
@@ -5,6 +5,32 @@
|
||||
//! to libSQL through Toasty. It also hosts an immutable filesystem-backed WIT
|
||||
//! package Registry. Actor memory remains process-local and is never presented
|
||||
//! as durable state.
|
||||
//!
|
||||
//! # Ownership model
|
||||
//!
|
||||
//! * [`Console`] owns the in-process Runtime and serializes control-plane
|
||||
//! mutations with a lifecycle mutex.
|
||||
//! * libSQL stores desired service/deployment state and bounded telemetry.
|
||||
//! * Component and WIT bytes are immutable filesystem artifacts addressed by
|
||||
//! identity and digest.
|
||||
//! * On restart, Actors are recreated from persisted manifests; their linear
|
||||
//! memory is intentionally reset.
|
||||
//!
|
||||
//! # Example
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use wasmeld_console::{Console, ConsoleConfig};
|
||||
//!
|
||||
//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
|
||||
//! let console = Console::new(ConsoleConfig::default()).await?;
|
||||
//! for service in console.services()? {
|
||||
//! println!("{}@{}: {:?}", service.id, service.revision, service.status);
|
||||
//! }
|
||||
//! # Ok(())
|
||||
//! # }
|
||||
//! ```
|
||||
|
||||
#![warn(missing_docs)]
|
||||
|
||||
mod api;
|
||||
mod invocation_persistence;
|
||||
@@ -82,6 +108,10 @@ impl Default for ConsoleConfig {
|
||||
/// registered service metadata, artifacts, metrics, and events remain managed
|
||||
/// by the Console.
|
||||
pub struct Console {
|
||||
// Lock order for lifecycle mutations is `lifecycle` -> `runtime` ->
|
||||
// `state`. Invocation does not take `lifecycle`; it briefly reads Runtime
|
||||
// and then updates state after the Actor call. Keep new code on this order
|
||||
// to avoid deadlocks between HTTP control operations.
|
||||
runtime: RwLock<Option<Runtime>>,
|
||||
runtime_config: RuntimeConfig,
|
||||
artifact_dir: PathBuf,
|
||||
@@ -124,26 +154,37 @@ struct DeploymentRecord {
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ServiceStatus {
|
||||
/// Actor is accepting invocations for this revision.
|
||||
Running,
|
||||
/// Revision is registered but has no active Actor.
|
||||
Stopped,
|
||||
/// Its most recent Actor trapped and must be started again.
|
||||
Faulted,
|
||||
}
|
||||
|
||||
/// API projection of one public service deployment.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct DeploymentView {
|
||||
/// Stable public service ID used by the Gateway route.
|
||||
pub service_id: String,
|
||||
/// Immutable revision currently selected for the service.
|
||||
pub active_revision: String,
|
||||
/// Current lifecycle status of the selected revision.
|
||||
pub status: ServiceStatus,
|
||||
/// Unix timestamp of the most recent deployment change.
|
||||
pub updated_at_ms: u64,
|
||||
}
|
||||
|
||||
/// Exact versioned host interface imported by a registered Component.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
pub struct CapabilityView {
|
||||
/// Fully qualified versioned WIT interface.
|
||||
pub interface: String,
|
||||
/// WIT package identity without the version suffix.
|
||||
pub package: String,
|
||||
/// Interface name inside the package.
|
||||
pub name: String,
|
||||
/// Exact semantic version linked by the Runtime.
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
@@ -161,16 +202,27 @@ impl From<CapabilityDescriptor> for CapabilityView {
|
||||
/// API projection of a registered service and its current metrics.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct ServiceView {
|
||||
/// Stable service ID.
|
||||
pub id: String,
|
||||
/// Immutable Component revision.
|
||||
pub revision: String,
|
||||
/// Canonical path of the stored Component artifact.
|
||||
pub artifact: String,
|
||||
/// Fully versioned world declared by the package.
|
||||
pub world: String,
|
||||
/// Exact Host interfaces discovered from Component imports.
|
||||
pub capabilities: Vec<CapabilityView>,
|
||||
/// Current Actor status for this revision.
|
||||
pub status: ServiceStatus,
|
||||
/// Unix timestamp of the most recent lifecycle change.
|
||||
pub updated_at_ms: u64,
|
||||
/// Platform limits injected during registration.
|
||||
pub limits: ResourceLimits,
|
||||
/// Persisted number of completed invocation attempts.
|
||||
pub calls: u64,
|
||||
/// Persisted number of failed invocation attempts.
|
||||
pub errors: u64,
|
||||
/// Latency of the most recently persisted invocation.
|
||||
pub last_latency_ms: Option<u64>,
|
||||
}
|
||||
|
||||
@@ -195,11 +247,17 @@ impl ServiceRecord {
|
||||
/// One persisted Console lifecycle or invocation event.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct EventView {
|
||||
/// Process-independent monotonically increasing event identity.
|
||||
pub id: u64,
|
||||
/// Event creation time as Unix milliseconds.
|
||||
pub timestamp_ms: u64,
|
||||
/// Stable category used by API consumers.
|
||||
pub kind: EventKind,
|
||||
/// Related service ID, or `None` for Runtime-wide events.
|
||||
pub service_id: Option<String>,
|
||||
/// Related immutable revision when the event targets one version.
|
||||
pub revision: Option<String>,
|
||||
/// Human-readable operational detail; not a machine protocol.
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
@@ -207,12 +265,19 @@ pub struct EventView {
|
||||
#[derive(Clone, Copy, Debug, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum EventKind {
|
||||
/// A Component package was registered.
|
||||
Registered,
|
||||
/// An inactive revision and its artifacts were removed.
|
||||
Unregistered,
|
||||
/// A public deployment switched to a revision.
|
||||
Deployed,
|
||||
/// Runtime or Actor startup completed.
|
||||
Started,
|
||||
/// Runtime or Actor shutdown completed.
|
||||
Stopped,
|
||||
/// One data-plane or management invocation completed.
|
||||
Invoked,
|
||||
/// A lifecycle or invocation operation failed.
|
||||
Failed,
|
||||
}
|
||||
|
||||
@@ -348,55 +413,76 @@ struct GatewayInvocation {
|
||||
/// Errors raised by Console state, persistence, Registry, or Runtime operations.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ConsoleError {
|
||||
/// API input failed semantic validation.
|
||||
#[error("{0}")]
|
||||
InvalidRequest(String),
|
||||
|
||||
/// The requested registered service revision does not exist.
|
||||
#[error("service revision {0} is not managed by Wasmeld")]
|
||||
ServiceNotFound(ServiceKey),
|
||||
|
||||
/// The public service has no selected revision.
|
||||
#[error("service {0} has no active deployment")]
|
||||
DeploymentNotFound(String),
|
||||
|
||||
/// Unregister was attempted on the revision currently serving traffic.
|
||||
#[error("service revision {0} is the active deployment")]
|
||||
ActiveDeployment(ServiceKey),
|
||||
|
||||
/// An operation requiring Wasmtime was attempted while Runtime is stopped.
|
||||
#[error("Wasmeld Runtime is not running")]
|
||||
RuntimeNotRunning,
|
||||
|
||||
/// Uploaded package bytes exceed the configured control-plane limit.
|
||||
#[error("artifact exceeds the {limit}-byte limit")]
|
||||
ArtifactTooLarge { limit: usize },
|
||||
ArtifactTooLarge {
|
||||
/// Maximum accepted upload bytes.
|
||||
limit: usize,
|
||||
},
|
||||
|
||||
/// Component package decoding or integrity validation failed.
|
||||
#[error(transparent)]
|
||||
Package(#[from] PackageError),
|
||||
|
||||
/// WIT Registry storage or integrity validation failed.
|
||||
#[error(transparent)]
|
||||
WitRegistry(#[from] WitRegistryError),
|
||||
|
||||
/// Component artifact or manifest filesystem access failed.
|
||||
#[error("failed to access {path}: {source}")]
|
||||
Storage {
|
||||
/// Path involved in the failed operation.
|
||||
path: PathBuf,
|
||||
/// Underlying filesystem failure.
|
||||
#[source]
|
||||
source: io::Error,
|
||||
},
|
||||
|
||||
/// A generated Runtime manifest could not be encoded.
|
||||
#[error("failed to serialize manifest: {0}")]
|
||||
ManifestSerialize(#[from] toml::ser::Error),
|
||||
|
||||
/// Toasty or libSQL control-state persistence failed.
|
||||
#[error("database operation failed: {0}")]
|
||||
Database(#[from] toasty::Error),
|
||||
|
||||
/// The synchronous-to-async KV worker thread could not start.
|
||||
#[error("failed to start persistent KV backend: {0}")]
|
||||
KvBackendStart(#[source] io::Error),
|
||||
|
||||
/// Persisted rows contain an unknown enum, invalid key, or invalid revision.
|
||||
#[error("database contains invalid console data: {0}")]
|
||||
InvalidDatabaseData(String),
|
||||
|
||||
/// Component compilation, Actor lifecycle, or invocation failed.
|
||||
#[error(transparent)]
|
||||
Runtime(#[from] RuntimeError),
|
||||
|
||||
/// A blocking Runtime operation panicked or was cancelled.
|
||||
#[error("blocking runtime task failed: {0}")]
|
||||
Join(#[from] tokio::task::JoinError),
|
||||
|
||||
/// An internal lifecycle, state, or Runtime lock was poisoned.
|
||||
#[error("console state lock was poisoned")]
|
||||
LockPoisoned,
|
||||
}
|
||||
@@ -793,6 +879,11 @@ impl Console {
|
||||
};
|
||||
manifest.validate()?;
|
||||
|
||||
// Artifacts are committed before Runtime registration so
|
||||
// `register_from_file` can recover the same bytes after restart.
|
||||
// Every later fallible step removes files it created until the Runtime
|
||||
// owns the registration. The HTTP wrapper persists the state snapshot
|
||||
// after this method returns.
|
||||
write_atomic(&component_path, &component_bytes)?;
|
||||
let manifest_toml = toml::to_string_pretty(&manifest)?;
|
||||
if let Err(error) = write_atomic(&manifest_path, manifest_toml.as_bytes()) {
|
||||
@@ -950,6 +1041,10 @@ impl Console {
|
||||
let result = runtime.invoke(key, input);
|
||||
let latency_ms = duration_ms(started_at.elapsed());
|
||||
|
||||
// Update counters synchronously so management reads immediately
|
||||
// observe this call. Durable telemetry is queued below and is
|
||||
// intentionally best-effort: queue saturation or a process crash can
|
||||
// lose recent counters/events. Do not use these values for billing.
|
||||
let mut state = self.state()?;
|
||||
let record = state
|
||||
.services
|
||||
@@ -995,6 +1090,9 @@ impl Console {
|
||||
service_id: &str,
|
||||
input: Vec<u8>,
|
||||
) -> Result<GatewayInvocation, ConsoleError> {
|
||||
// Clone the selected revision while holding state, then release the
|
||||
// lock before entering Runtime. An activation racing this call affects
|
||||
// only subsequent resolutions; this call remains pinned to `revision`.
|
||||
let revision = {
|
||||
let state = self.state()?;
|
||||
state
|
||||
@@ -1139,7 +1237,9 @@ impl Console {
|
||||
|
||||
async fn persist_snapshot(&self) -> Result<(), ConsoleError> {
|
||||
// Serialize snapshots so a slower earlier write cannot overwrite a
|
||||
// newer state captured by a concurrent HTTP operation.
|
||||
// newer state captured by a concurrent HTTP operation. This mutex is
|
||||
// also shared with the invocation delta writer, which prevents the two
|
||||
// transaction styles from interleaving.
|
||||
let _sync = self.persistence_sync.lock().await;
|
||||
let (services, events, deployments) = self.persistence_snapshot()?;
|
||||
self.persistence.sync(services, events, deployments).await?;
|
||||
|
||||
@@ -1,4 +1,25 @@
|
||||
//! Standalone Wasmeld control-plane and data-plane server entry point.
|
||||
//!
|
||||
//! Configuration is supplied through environment variables:
|
||||
//!
|
||||
//! - `WASMELD_ADDR` binds the management API (default `127.0.0.1:8080`).
|
||||
//! - `WASMELD_GATEWAY_ADDR` binds the public byte API (default `0.0.0.0:8081`).
|
||||
//! - `WASMELD_ARTIFACT_DIR`, `WASMELD_WIT_REGISTRY_DIR`, and
|
||||
//! `WASMELD_DATABASE_PATH` select durable local storage.
|
||||
//! - `WASMELD_ALLOWED_ORIGINS` is a comma-separated management UI CORS list.
|
||||
//! - `RUST_LOG` configures tracing.
|
||||
//!
|
||||
//! For example:
|
||||
//!
|
||||
//! ```text
|
||||
//! WASMELD_ADDR=127.0.0.1:8080 \
|
||||
//! WASMELD_GATEWAY_ADDR=0.0.0.0:8081 \
|
||||
//! RUST_LOG=wasmeld_console=debug \
|
||||
//! cargo run -p wasmeld-console
|
||||
//! ```
|
||||
//!
|
||||
//! The defaults expose only the gateway on non-loopback interfaces.
|
||||
//! Authentication and TLS must be supplied by the deployment environment.
|
||||
|
||||
use std::{env, net::SocketAddr, path::PathBuf, sync::Arc};
|
||||
|
||||
@@ -45,6 +66,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let gateway = gateway_app(Arc::clone(&console));
|
||||
let management_listener = tokio::net::TcpListener::bind(management_address).await?;
|
||||
let gateway_listener = tokio::net::TcpListener::bind(gateway_address).await?;
|
||||
// Both servers share one shutdown edge so neither listener remains alive
|
||||
// after Ctrl+C has begun process termination.
|
||||
let (shutdown_sender, shutdown_receiver) = tokio::sync::watch::channel(false);
|
||||
let signal_task = tokio::spawn(async move {
|
||||
shutdown_signal().await;
|
||||
@@ -61,6 +84,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
);
|
||||
signal_task.abort();
|
||||
result?;
|
||||
// Axum has stopped accepting requests at this point. Flush accepted
|
||||
// telemetry before dropping Console; persistence failures are traced by
|
||||
// the worker because telemetry is an operational, best-effort stream.
|
||||
console.flush_invocation_telemetry().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -3,6 +3,15 @@
|
||||
//! Component bytes and WIT packages remain filesystem artifacts. This module
|
||||
//! persists service manifests, deployments, metrics, the bounded event
|
||||
//! history, and service-scoped Host KV entries.
|
||||
//!
|
||||
//! There are two write paths:
|
||||
//!
|
||||
//! - [`Persistence::sync`] stores a complete control-plane snapshot.
|
||||
//! - [`Persistence::record_invocations`] applies ordered metric deltas.
|
||||
//!
|
||||
//! Callers serialize these paths with the Console persistence mutex. Existing
|
||||
//! service counters are intentionally owned by the delta path; a snapshot
|
||||
//! updates their manifest and timestamp without replacing those counters.
|
||||
|
||||
use std::{
|
||||
collections::BTreeSet,
|
||||
@@ -55,6 +64,10 @@ pub(crate) struct StoredKvEntry {
|
||||
}
|
||||
|
||||
/// One invocation's durable metric delta and event.
|
||||
///
|
||||
/// The caller assigns the event ID before enqueueing the update. A missing
|
||||
/// service row is tolerated because unregister may race queued telemetry; the
|
||||
/// event is still retained for diagnostics.
|
||||
pub(crate) struct InvocationUpdate {
|
||||
pub service_key: String,
|
||||
pub failed: bool,
|
||||
@@ -64,6 +77,10 @@ pub(crate) struct InvocationUpdate {
|
||||
}
|
||||
|
||||
/// Serialized access to the Toasty database connection.
|
||||
///
|
||||
/// This mutex protects a single Toasty [`Db`] handle. It is separate from the
|
||||
/// higher-level Console persistence mutex, which coordinates whole snapshot
|
||||
/// and invocation-delta operations before they acquire this connection.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Persistence {
|
||||
db: Arc<Mutex<Db>>,
|
||||
@@ -131,6 +148,11 @@ impl Persistence {
|
||||
}
|
||||
|
||||
/// Atomically upserts a Console snapshot and prunes expired events.
|
||||
///
|
||||
/// The input is authoritative for services and deployments: rows omitted
|
||||
/// from those collections are removed. Events older than the oldest
|
||||
/// supplied retained event are pruned. Existing invocation counters are
|
||||
/// preserved because [`Self::record_invocations`] owns counter changes.
|
||||
pub async fn sync(
|
||||
&self,
|
||||
services: Vec<StoredService>,
|
||||
@@ -178,6 +200,8 @@ impl Persistence {
|
||||
}
|
||||
}
|
||||
|
||||
// Console stores events newest-first, so the last element is the
|
||||
// oldest ID that should survive snapshot pruning.
|
||||
if let Some(oldest_event) = events.last() {
|
||||
StoredEvent::filter(StoredEvent::fields().id().lt(oldest_event.id))
|
||||
.delete()
|
||||
@@ -217,6 +241,9 @@ impl Persistence {
|
||||
}
|
||||
|
||||
/// Atomically records invocation deltas and their bounded event history.
|
||||
///
|
||||
/// All supplied updates commit together. Metrics use saturating arithmetic
|
||||
/// so corrupted or extremely old databases cannot wrap counters.
|
||||
pub(crate) async fn record_invocations(
|
||||
&self,
|
||||
updates: Vec<InvocationUpdate>,
|
||||
@@ -268,6 +295,8 @@ impl Persistence {
|
||||
service_id: &str,
|
||||
entry_key: &str,
|
||||
) -> toasty::Result<Option<Vec<u8>>> {
|
||||
// The namespace is the stable service ID rather than its revision.
|
||||
// Deploying a new revision therefore preserves application state.
|
||||
let mut db = self.db.lock().await;
|
||||
Ok(
|
||||
StoredKvEntry::filter_by_service_id_and_entry_key(service_id, entry_key)
|
||||
@@ -284,6 +313,8 @@ impl Persistence {
|
||||
entry_key: String,
|
||||
value: Vec<u8>,
|
||||
) -> toasty::Result<()> {
|
||||
// Upsert makes a retry after an ambiguous Host timeout idempotent for
|
||||
// the same `(service_id, entry_key, value)`.
|
||||
let mut db = self.db.lock().await;
|
||||
StoredKvEntry::upsert_by_service_id_and_entry_key(service_id, entry_key)
|
||||
.value(value)
|
||||
|
||||
@@ -37,28 +37,40 @@ struct StoredWitPackage {
|
||||
/// Errors produced by WIT Registry storage and integrity checks.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum WitRegistryError {
|
||||
/// Uploaded binary WIT exceeds the configured byte limit.
|
||||
#[error("WIT package exceeds the {limit}-byte limit")]
|
||||
TooLarge { limit: usize },
|
||||
TooLarge {
|
||||
/// Maximum accepted artifact bytes.
|
||||
limit: usize,
|
||||
},
|
||||
|
||||
/// The immutable `name@version` already exists.
|
||||
#[error("WIT package {0} is already published and cannot be overwritten")]
|
||||
AlreadyExists(String),
|
||||
|
||||
/// No indexed artifact matches the requested identity.
|
||||
#[error("WIT package {0} was not found")]
|
||||
NotFound(String),
|
||||
|
||||
/// Uploaded or persisted bytes are not a valid versioned binary WIT package.
|
||||
#[error(transparent)]
|
||||
InvalidPackage(#[from] WitPackageError),
|
||||
|
||||
/// Directory layout and decoded artifact identity disagree.
|
||||
#[error("invalid WIT registry data: {0}")]
|
||||
InvalidStorage(String),
|
||||
|
||||
/// Registry directory or artifact filesystem access failed.
|
||||
#[error("failed to access {path}: {source}")]
|
||||
Storage {
|
||||
/// Path involved in the failed operation.
|
||||
path: PathBuf,
|
||||
/// Underlying filesystem failure.
|
||||
#[source]
|
||||
source: io::Error,
|
||||
},
|
||||
|
||||
/// The process-local Registry index lock was poisoned.
|
||||
#[error("WIT registry lock was poisoned")]
|
||||
LockPoisoned,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user