The asset store now uses libs/sqlite_query as its ONLY engine — not a feature
flag, not a fallback. That closes the Windows gap (the embedded store starts
there now, and a SHARED->EXCLUSIVE upgrade is handled rather than assumed
free) and takes the C dependency out of the build everywhere else.
Around it:
- store: a garbage collector, catalogued content that is referenced in place
instead of copied, the `vjeffect` kind, and host/chat routes that keep up
with the chat wire below.
- importer: the unified map contract reaches quake2, quake3, doom and duke —
world placement, nav, welding, prelit maps and glTF node handling shared
rather than reimplemented per game. Music import, billboards and stateful
props move to the data crate so readers stop linking the importer.
- ai: the serving side of multi-lane chat — per-lane conversations, honest
progress and acceptance reporting, penalties and a watchdog, context as a
per-box number that compacts instead of erupting, a realtime session mode,
and inpaint/flux2 backends. `chat_bench` measures the rate the way the
client meter computes it.
- client / chat / chat_ui: a publication can NAME a file instead of carrying
it; the wire says whether a turn is warm and whether it is thinking, so a
client stops guessing; transcript and feed widgets render history the way
the model wrote it. `SessionConfig::catalog_runtime` lets a host size the
catalog runtime's lanes itself — a browsing UI puts every listing, every
per-tile resolve and every thumbnail blob through that one runtime and
wants a wider fast lane than the shared default, while media lanes keep
it (a few big transfers, not a thousand small ones).
- widgets: the shared asset widgets — one video view (knobbed seek,
transport, bracket trim, rail playback) used everywhere, plus thumb,
preview, scene view, walk-world and the lyric reader.
366 lines
14 KiB
Rust
366 lines
14 KiB
Rust
//! The deletion contract over real sockets: the real `makepad-asset-client`
|
|
//! against a real `AssetServer`, publishing, deleting, and collecting.
|
|
//!
|
|
//! This is the proof the Asset UI's "Delete from store" and "Collect
|
|
//! garbage" buttons stand on: after `retire_asset` the alias 404s, search
|
|
//! and the listing no longer carry the asset, detail says `retired`, a
|
|
//! subscriber is told, and a GC run reclaims exactly the bytes that
|
|
//! retirement unlinked — over HTTP, with the same DTOs the UI parses.
|
|
|
|
use makepad_asset_client::{
|
|
Api, ApiEndpoints, AssetClient, CandidateStateDto, CatalogEventKind, CatalogQuery,
|
|
CatalogSubscriberConfig, CatalogSubscriptionEvent, ClientConfig, ClientError, GcPhaseDto,
|
|
GcRequest, HttpLimits, PublishFile, PublishRequest, PublishThumbnail,
|
|
};
|
|
use makepad_asset_data::{AssetAlias, AssetKind, FileRole, MediaType, ThumbnailMedia};
|
|
use makepad_asset_store::{AssetServer, ServerConfig};
|
|
use std::path::PathBuf;
|
|
use std::str::FromStr;
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
use std::time::{Duration, Instant};
|
|
|
|
static DIR_COUNTER: AtomicU64 = AtomicU64::new(0);
|
|
|
|
fn test_root(name: &str) -> PathBuf {
|
|
let n = DIR_COUNTER.fetch_add(1, Ordering::Relaxed);
|
|
std::env::temp_dir().join(format!("mp_asset_del_{}_{}_{}", std::process::id(), n, name))
|
|
}
|
|
|
|
fn start_server(name: &str) -> (AssetServer, String) {
|
|
let root = test_root(name);
|
|
let mut cfg = ServerConfig::new(root.clone());
|
|
cfg.control_addr = "127.0.0.1:0".parse().unwrap();
|
|
cfg.data_addr = "127.0.0.1:0".parse().unwrap();
|
|
cfg.bootstrap_admin = true;
|
|
cfg.log = false;
|
|
// Tests drive GC explicitly; a background janitor advancing the same run
|
|
// would make the step accounting non-deterministic.
|
|
cfg.gc_janitor_steps = 0;
|
|
cfg.gc_grace_ms = 0;
|
|
let server = AssetServer::start(cfg).expect("server start");
|
|
let token = std::fs::read_to_string(root.join("admin-token"))
|
|
.expect("admin token")
|
|
.trim()
|
|
.to_string();
|
|
(server, token)
|
|
}
|
|
|
|
fn connect(server: &AssetServer, token: &str, cache: &str) -> AssetClient {
|
|
let mut cfg = ClientConfig::new(test_root(cache));
|
|
cfg.token = Some(token.to_string());
|
|
let endpoints = ApiEndpoints { control: server.control_addr(), data: server.data_addr() };
|
|
AssetClient::connect(cfg, endpoints, Some(server.server_id())).expect("connect")
|
|
}
|
|
|
|
fn publish(client: &mut AssetClient, alias: &str, title: &str, fill: u8, len: usize)
|
|
-> makepad_asset_client::Published
|
|
{
|
|
publish_rev(client, alias, title, fill, len, None)
|
|
}
|
|
|
|
/// Publish a new REVISION of an existing asset when `asset_id` is given
|
|
/// (that is what makes a superseded revision to retire).
|
|
fn publish_rev(
|
|
client: &mut AssetClient,
|
|
alias: &str,
|
|
title: &str,
|
|
fill: u8,
|
|
len: usize,
|
|
asset_id: Option<makepad_asset_data::AssetId>,
|
|
) -> makepad_asset_client::Published {
|
|
let artifact = vec![fill; len];
|
|
let thumb = vec![fill ^ 0xFF; 1_024];
|
|
let mut request = PublishRequest::new(
|
|
"gen",
|
|
AssetKind::Video,
|
|
title,
|
|
PublishFile {
|
|
bytes: artifact,
|
|
media: MediaType::Mp4,
|
|
role: FileRole::Video,
|
|
media_millis: 1_000,
|
|
dims: None,
|
|
},
|
|
PublishThumbnail {
|
|
bytes: thumb,
|
|
media: ThumbnailMedia::Png,
|
|
width: 512,
|
|
height: 512,
|
|
views: Vec::new(),
|
|
},
|
|
);
|
|
request.alias = Some(AssetAlias::from_str(alias).unwrap());
|
|
request.asset_id = asset_id;
|
|
client.publish_artifact(&request).expect("publish")
|
|
}
|
|
|
|
/// Drive a GC run to completion through the HTTP route, exactly as a UI
|
|
/// polling the button would.
|
|
fn gc_to_completion(client: &AssetClient, request: GcRequest) -> makepad_asset_client::GcStatusDto {
|
|
let mut status = client.gc_blobs(&request).expect("gc start");
|
|
let deadline = Instant::now() + Duration::from_secs(30);
|
|
while !status.done {
|
|
assert!(Instant::now() < deadline, "gc did not converge: {status:?}");
|
|
status = client.gc_blobs(&request).expect("gc advance");
|
|
}
|
|
status
|
|
}
|
|
|
|
#[test]
|
|
fn delete_from_store_then_collect_garbage_over_http() {
|
|
let (mut server, token) = start_server("delete_e2e");
|
|
let mut client = connect(&server, &token, "delete_e2e_cache");
|
|
|
|
let mut sub_cfg = CatalogSubscriberConfig::default_v1();
|
|
sub_cfg.wait_ms = 1_000;
|
|
let mut subscriber = client.subscribe_catalog(sub_cfg).expect("subscribe");
|
|
|
|
let keep = publish(&mut client, "gen/keeper", "Keeper clip", 0x11, 4_000);
|
|
let drop = publish(&mut client, "gen/doomed", "Doomed clip", 0x22, 6_000);
|
|
|
|
// Both are visible: listing, search, alias.
|
|
let page = client.assets_page(Some("gen"), None, 10).expect("listing");
|
|
assert_eq!(page.assets.len(), 2);
|
|
let hits = client
|
|
.catalog_search(&CatalogQuery::browse(10), None)
|
|
.expect("browse");
|
|
assert_eq!(hits.hits.len(), 2);
|
|
let doomed_alias = AssetAlias::from_str("gen/doomed").unwrap();
|
|
assert_eq!(
|
|
client.resolve_alias(&doomed_alias).expect("alias").asset_id,
|
|
drop.asset_id
|
|
);
|
|
|
|
// ---- delete ----------------------------------------------------------
|
|
let report = client.retire_asset(&drop.asset_id).expect("retire");
|
|
assert!(!report.already_retired);
|
|
assert_eq!(report.revisions_retired, 1);
|
|
assert_eq!(report.aliases_dropped, 1);
|
|
assert!(report.annotation_cleared);
|
|
// Idempotent over the wire too.
|
|
assert!(client.retire_asset(&drop.asset_id).expect("retire again").already_retired);
|
|
|
|
// The alias is gone (indistinguishable from never existing).
|
|
assert!(matches!(
|
|
client.resolve_alias(&doomed_alias),
|
|
Err(ClientError::NotFound { .. })
|
|
));
|
|
// Search and listing carry only the survivor.
|
|
let hits = client
|
|
.catalog_search(&CatalogQuery::browse(10), None)
|
|
.expect("browse after delete");
|
|
assert_eq!(hits.hits.len(), 1);
|
|
assert_eq!(hits.hits[0].asset_id, keep.asset_id);
|
|
let page = client.assets_page(Some("gen"), None, 10).expect("listing after delete");
|
|
assert_eq!(page.assets.len(), 1);
|
|
assert_eq!(page.assets[0].asset_id, keep.asset_id);
|
|
// Detail still answers, and says deleted.
|
|
let detail = client.asset_detail(&drop.asset_id).expect("detail after delete");
|
|
assert!(detail.retired);
|
|
assert!(detail.retired_ms.is_some());
|
|
assert_eq!(detail.candidates.len(), 1);
|
|
assert_eq!(detail.candidates[0].state, CandidateStateDto::Retired);
|
|
assert!(detail.candidates[0].retired_ms.is_some());
|
|
assert!(detail.latest_published().is_none());
|
|
// The manifest of a deleted revision reads exactly like an absent one.
|
|
assert!(matches!(
|
|
client.fetch_asset_manifest(&drop.revision),
|
|
Err(ClientError::NotFound { .. })
|
|
));
|
|
|
|
// The subscriber is told, with the retirement kind (this client asks for
|
|
// the vocabulary that has one).
|
|
let deadline = Instant::now() + Duration::from_secs(10);
|
|
let mut saw_retire = false;
|
|
while Instant::now() < deadline && !saw_retire {
|
|
for event in subscriber.poll() {
|
|
if let CatalogSubscriptionEvent::Events { events, .. } = event {
|
|
for ev in events {
|
|
if ev.asset_id == Some(drop.asset_id)
|
|
&& ev.kind == CatalogEventKind::AssetRetired
|
|
{
|
|
assert!(ev.kind.removes_content());
|
|
saw_retire = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
std::thread::sleep(Duration::from_millis(25));
|
|
}
|
|
assert!(saw_retire, "asset_retired must reach the subscriber");
|
|
subscriber.shutdown();
|
|
|
|
// ---- collect garbage -------------------------------------------------
|
|
// Dry run first: the exact bytes the real run will free.
|
|
let dry = gc_to_completion(&client, GcRequest::dry_run());
|
|
assert!(dry.dry_run);
|
|
assert_eq!(dry.phase, GcPhaseDto::Done);
|
|
assert_eq!(dry.unreferenced_blobs, 2, "the deleted asset's file + thumbnail");
|
|
assert_eq!(dry.unreferenced_bytes, 6_000 + 1_024);
|
|
assert_eq!(dry.deleted_blobs, 0);
|
|
// Nothing was deleted by the preview.
|
|
assert!(client.blob_head(&drop.artifact_blob).is_ok());
|
|
|
|
let real = gc_to_completion(&client, GcRequest::collect());
|
|
assert!(!real.dry_run);
|
|
assert_eq!(real.deleted_blobs, dry.unreferenced_blobs);
|
|
assert_eq!(real.deleted_bytes, dry.unreferenced_bytes);
|
|
|
|
// The deleted asset's bytes are unreachable; the survivor's are intact.
|
|
assert!(matches!(
|
|
client.blob_head(&drop.artifact_blob),
|
|
Err(ClientError::NotFound { .. })
|
|
));
|
|
assert_eq!(
|
|
client
|
|
.fetch_blob_bytes(&keep.artifact_blob, Some(4_000))
|
|
.expect("survivor bytes")
|
|
.len(),
|
|
4_000
|
|
);
|
|
|
|
// Status without starting a run reports the finished one; a further
|
|
// collection finds nothing.
|
|
let status = client.gc_status().expect("gc status");
|
|
assert!(status.done);
|
|
assert_eq!(status.deleted_blobs, 2);
|
|
let again = gc_to_completion(&client, GcRequest::collect());
|
|
assert_eq!(again.deleted_blobs, 0);
|
|
assert!(!client.gc_cancel().expect("cancel idle"));
|
|
|
|
server.shutdown();
|
|
}
|
|
|
|
#[test]
|
|
fn retiring_a_superseded_revision_keeps_the_head_serving() {
|
|
let (mut server, token) = start_server("retire_revision_e2e");
|
|
let mut client = connect(&server, &token, "retire_revision_cache");
|
|
|
|
let v1 = publish(&mut client, "gen/clip", "Clip v1", 0x33, 3_000);
|
|
let v2 = publish_rev(&mut client, "gen/clip", "Clip v2", 0x44, 3_500, Some(v1.asset_id));
|
|
assert_eq!(v1.asset_id, v2.asset_id);
|
|
assert_ne!(v1.revision, v2.revision);
|
|
let alias = AssetAlias::from_str("gen/clip").unwrap();
|
|
assert_eq!(client.resolve_alias(&alias).expect("alias").head_revision, v2.revision);
|
|
|
|
let report = client.retire_revision(&v1.asset_id, &v1.revision).expect("retire v1");
|
|
assert!(!report.already_retired);
|
|
assert!(client
|
|
.retire_revision(&v1.asset_id, &v1.revision)
|
|
.expect("retire v1 again")
|
|
.already_retired);
|
|
|
|
// The head still serves and the asset is still listed.
|
|
assert_eq!(client.resolve_alias(&alias).expect("alias").head_revision, v2.revision);
|
|
let page = client.assets_page(Some("gen"), None, 10).expect("listing");
|
|
assert_eq!(page.assets.len(), 1);
|
|
let detail = client.asset_detail(&v1.asset_id).expect("detail");
|
|
assert!(!detail.retired);
|
|
let states: Vec<_> = detail
|
|
.candidates
|
|
.iter()
|
|
.map(|c| (c.revision, c.state))
|
|
.collect();
|
|
assert!(states.contains(&(v1.revision, CandidateStateDto::Retired)));
|
|
assert!(states.contains(&(v2.revision, CandidateStateDto::Published)));
|
|
|
|
// Collection frees the superseded revision's bytes only.
|
|
let status = gc_to_completion(&client, GcRequest::collect());
|
|
assert_eq!(status.deleted_blobs, 2, "v1's file + thumbnail");
|
|
assert!(matches!(
|
|
client.blob_head(&v1.artifact_blob),
|
|
Err(ClientError::NotFound { .. })
|
|
));
|
|
assert!(client.blob_head(&v2.artifact_blob).is_ok());
|
|
|
|
server.shutdown();
|
|
}
|
|
|
|
#[test]
|
|
fn retention_over_http_trims_history_without_touching_heads() {
|
|
let (mut server, token) = start_server("retention_e2e");
|
|
let mut client = connect(&server, &token, "retention_cache");
|
|
let mut published = Vec::new();
|
|
for i in 0..3u8 {
|
|
let asset_id = published.last().map(|p: &makepad_asset_client::Published| p.asset_id);
|
|
published.push(publish_rev(
|
|
&mut client,
|
|
"gen/series",
|
|
&format!("Series v{i}"),
|
|
0x50 + i,
|
|
2_000 + i as usize,
|
|
asset_id,
|
|
));
|
|
}
|
|
let request = GcRequest { retain_per_asset: Some(1), ..GcRequest::default() };
|
|
let status = gc_to_completion(&client, request);
|
|
// Two superseded revisions retired; the head survives.
|
|
assert_eq!(status.retired_revisions, 2);
|
|
assert_eq!(status.deleted_blobs, 4);
|
|
let head = published.last().unwrap();
|
|
assert!(client.blob_head(&head.artifact_blob).is_ok());
|
|
for old in &published[..2] {
|
|
assert!(matches!(
|
|
client.blob_head(&old.artifact_blob),
|
|
Err(ClientError::NotFound { .. })
|
|
));
|
|
}
|
|
let alias = AssetAlias::from_str("gen/series").unwrap();
|
|
assert_eq!(client.resolve_alias(&alias).expect("alias").head_revision, head.revision);
|
|
|
|
// Retention retires revisions inside the core, without any route being
|
|
// called: detail must still report the real lifecycle (it reads the
|
|
// catalog, not a mirror).
|
|
let detail = client.asset_detail(&head.asset_id).expect("detail");
|
|
assert!(!detail.retired);
|
|
let retired: Vec<_> = detail
|
|
.candidates
|
|
.iter()
|
|
.filter(|c| c.state == CandidateStateDto::Retired)
|
|
.map(|c| c.revision)
|
|
.collect();
|
|
assert_eq!(retired.len(), 2);
|
|
assert!(!retired.contains(&head.revision));
|
|
assert_eq!(
|
|
detail.latest_published().expect("head candidate").revision,
|
|
head.revision
|
|
);
|
|
|
|
server.shutdown();
|
|
}
|
|
|
|
#[test]
|
|
fn deletion_and_collection_require_credentials() {
|
|
let (mut server, token) = start_server("delete_auth");
|
|
let mut client = connect(&server, &token, "delete_auth_cache");
|
|
let published = publish(&mut client, "gen/guarded", "Guarded", 0x66, 2_000);
|
|
|
|
// A well-shaped but unknown token is refused uniformly on every one of
|
|
// the new routes — no oracle, and nothing is deleted.
|
|
let stranger = Api::new(
|
|
ApiEndpoints { control: server.control_addr(), data: server.data_addr() },
|
|
HttpLimits::default_v1(),
|
|
Some(format!("mpat_{}", "ab".repeat(32))),
|
|
)
|
|
.expect("api");
|
|
assert!(matches!(
|
|
stranger.retire_asset(&published.asset_id),
|
|
Err(ClientError::Unauthenticated)
|
|
));
|
|
assert!(matches!(
|
|
stranger.retire_revision(&published.asset_id, &published.revision),
|
|
Err(ClientError::Unauthenticated)
|
|
));
|
|
assert!(matches!(
|
|
stranger.gc_blobs(&GcRequest::dry_run()),
|
|
Err(ClientError::Unauthenticated)
|
|
));
|
|
assert!(matches!(stranger.gc_status(), Err(ClientError::Unauthenticated)));
|
|
assert!(matches!(stranger.gc_cancel(), Err(ClientError::Unauthenticated)));
|
|
|
|
// The asset is untouched.
|
|
let detail = client.asset_detail(&published.asset_id).expect("detail");
|
|
assert!(!detail.retired);
|
|
|
|
server.shutdown();
|
|
}
|