blob: 2b5862b0f65f895146f89fba22749e405edc2962 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
use super::Application;
use super::{support, timer};
use maka_event_log::{
EventLog,
applications::intents::CallerIntent,
root::{ROOT_DATABASE, RootNamespaces, RootOwner},
};
use maka_packages::MANIFEST_FILE;
use maka_packages::Package;
use maka_plugin_core::{
composition::{Input, InstanceId, PresentationTarget, Target},
contract::{
Availability, Cardinality, Contract, Export, Import, Interface, Method, MethodMode,
Transport,
},
deployment::DeploymentRef,
operation::{RecoveryProtocol, RecoveryRef},
package::{Declaration, EntryDeclaration, EntryTarget, Implementation},
};
use maka_ui_runtime::operations::{CALLER_HISTORY, caller_history_contract};
use serde_json::{Value, json};
use std::{collections::BTreeMap, future::Future, sync::Arc, task::Poll, time::Duration};
use uuid::Uuid;
fn identity(n: u128) -> InstanceId {
InstanceId(vec![Uuid::from_u128(n)])
}
fn fixture() -> (Input, Vec<Package>) {
let handler = "if(event.kind==='action')return frame({...state,outcome:event.event,revision:state.revision+1});const out=frame({...state,outcome:null,count:state.count+1,revision:state.revision+1});out.actions.push({key:'history',binding:'history',member:0,method:'page',input:event.event,resources:{}});return out;";
let (mut input, mut packages) = support::fixture(handler);
let index = packages
.iter()
.position(|package| package.manifest().id == "dynamic.app")
.unwrap();
let old = packages.remove(index);
let mut declaration = old
.manifest()
.application
.as_ref()
.unwrap()
.declaration()
.clone();
declaration
.entries
.get_mut(&EntryTarget::Tui)
.unwrap()
.imports
.insert(
"history".into(),
Import {
contract: caller_history_contract(),
cardinality: Cardinality::One,
},
);
let source = std::str::from_utf8(old.file("index.mjs").unwrap())
.unwrap()
.replace(
"count:props.count??0,revision:0",
"count:props.count??0,revision:0,padding:props.padding??''",
);
let mut files = old.files().clone();
files.insert("index.mjs".into(), source.into_bytes());
files.insert(
MANIFEST_FILE.into(),
serde_json::to_vec(
&json!({"schemaVersion":1,"id":"dynamic.app","application":declaration}),
)
.unwrap(),
);
let package = Package::new(files).unwrap();
input.instances.get_mut("app").unwrap().package = package.application_ref().into();
packages.push(package);
let history = support::package(
"native.history",
Declaration {
entries: [EntryTarget::Tui]
.into_iter()
.map(|target| {
(
target,
EntryDeclaration {
scene: None,
implementation: Implementation::Native {
implementation: CALLER_HISTORY.into(),
},
configuration: json!({"type":"object","additionalProperties":false})
.into(),
imports: BTreeMap::new(),
exports: ["History", "DeploymentHistory"]
.into_iter()
.map(|name| {
(
name.into(),
Export {
contract: caller_history_contract(),
availability: Availability::Required,
},
)
})
.collect(),
},
)
})
.collect(),
composition: None,
},
None,
);
input
.instances
.insert("history".into(), support::node(&history, 3));
let app = input.instances.get_mut("app").unwrap();
app.bindings.insert(
"history".into(),
vec![Target::Instance {
path: vec!["history".into()],
export: "History".into(),
}],
);
let mut other = app.clone();
other.key = Uuid::from_u128(4);
input.instances.insert("other".into(), other);
packages.push(history);
(input, packages)
}
fn reference() -> RecoveryRef {
RecoveryRef {
source: DeploymentRef {
state_domain: "a".repeat(64),
deployment: Uuid::from_u128(700),
revision: 1,
},
namespace: identity(99),
export: "Service".into(),
method: "change".into(),
operation: Uuid::new_v4(),
fingerprint: format!("sha256-{}", "b".repeat(64)),
recovery: RecoveryProtocol::JournalV1,
contract: maka_plugin_core::operation::RecoveryContract::for_method(
&Contract {
name: "test.Change".into(),
version: 1,
transport: Transport::Remote,
interface: Interface::Service {
methods: BTreeMap::from([(
"change".into(),
Method {
progress: None,
mode: MethodMode::Mutation,
input: json!({}).into(),
output: json!({}).into(),
resources: BTreeMap::new(),
},
)]),
},
},
"change",
)
.unwrap(),
}
}
fn namespaces(dir: &std::path::Path) -> RootNamespaces {
RootNamespaces {
ownership: dir.join("owners"),
control: dir.join("control"),
}
}
fn store(timer: &tokio::runtime::Runtime, dir: &std::path::Path, create: bool) -> Arc<EventLog> {
let owner = if create {
RootOwner::create(&dir.join("root"), &namespaces(dir))
} else {
RootOwner::open(&dir.join("root"), &namespaces(dir))
}
.unwrap();
Arc::new(timer.block_on(EventLog::for_root(Arc::new(owner))).unwrap())
}
fn start(
timer: &tokio::runtime::Runtime,
store: Arc<EventLog>,
deployment: Uuid,
target: PresentationTarget,
) -> (Application, maka_plugin_core::runtime::Mount) {
let source = timer
.block_on(store.load_application(deployment))
.unwrap()
.unwrap()
.into_parts()
.0
.reference;
let mut app = timer
.block_on(Application::start_from_store(
store,
source,
target,
timer.handle().clone(),
maka_ui_js::execution(maka_js_runtime::plugin::Limits {
synchronous_slice: Duration::from_millis(100),
..Default::default()
}),
crate::support::scene::registry(target),
vec![],
))
.unwrap();
let mount = app
.mount_launch(json!({"padding":"x".repeat(40_000)}))
.unwrap();
(app, mount)
}
fn request(
app: &mut Application,
mount: &maka_plugin_core::runtime::Mount,
after: u64,
limit: usize,
) -> maka_ui_runtime::actions::ActionTask {
let revision = app.frame(mount).unwrap().revision;
app.dispatch(mount, revision, "inc", json!({"after":after,"limit":limit}))
.unwrap();
app.capture_actions(mount, |_| {
unreachable!("history has no resource attachments")
})
.unwrap();
app.start_task(mount).unwrap().unwrap()
}
fn page(
timer: &tokio::runtime::Runtime,
app: &mut Application,
mount: &maka_plugin_core::runtime::Mount,
after: u64,
limit: usize,
) -> Value {
let task = request(app, mount, after, limit);
let completion = timer.block_on(task.future);
assert!(app.finish_task(task.id, completion).unwrap().is_none());
assert_eq!(
app.deliver_action_results(mount, |_, _, _| Ok(false))
.unwrap(),
1
);
assert!(!app.is_retiring(mount));
app.frame(mount).unwrap().state["outcome"].clone()
}
fn close(app: &mut Application, mount: &maka_plugin_core::runtime::Mount) {
app.retire(mount).unwrap();
for mount in app.retiring_mounts() {
app.close(&mount, Ok(())).unwrap();
}
app.shutdown().unwrap();
}
#[test]
fn explicit_deployment_history_reads_original_consumers_but_never_another_deployment() {
let timer = timer();
let (mut input, packages) = fixture();
input.instances.get_mut("app").unwrap().bindings.insert(
"history".into(),
vec![Target::Instance {
path: vec!["history".into()],
export: "DeploymentHistory".into(),
}],
);
let dir = tempfile::tempdir().unwrap();
let store = store(&timer, dir.path(), true);
let deployment = Uuid::new_v4();
let original = timer
.block_on(store.commit_application(deployment, 0, input.clone(), vec![], packages.clone()))
.unwrap();
let other_deployment = timer
.block_on(store.commit_application(Uuid::new_v4(), 0, input, vec![], packages))
.unwrap();
let a = timer
.block_on(store.record_application_intent(
original.clone(),
identity(2),
reference(),
serde_json::Value::Null,
))
.unwrap()
.intent()
.clone();
timer
.block_on(store.record_application_intent(
other_deployment,
identity(2),
reference(),
serde_json::Value::Null,
))
.unwrap();
let b = timer
.block_on(store.record_application_intent(
original,
identity(4),
reference(),
serde_json::Value::Null,
))
.unwrap()
.intent()
.clone();
let (mut app, mount) = start(&timer, store.clone(), deployment, PresentationTarget::Tui);
let first = page(&timer, &mut app, &mount, 0, 1);
assert_eq!(
serde_json::from_value::<Vec<CallerIntent>>(first["value"].clone())
.unwrap()
.as_slice(),
std::slice::from_ref(&a)
);
let next = page(&timer, &mut app, &mount, a.cursor, 1);
assert_eq!(
serde_json::from_value::<Vec<CallerIntent>>(next["value"].clone())
.unwrap()
.as_slice(),
std::slice::from_ref(&b)
);
let end = page(&timer, &mut app, &mount, b.cursor, 128);
assert_eq!(end["value"], json!([]));
close(&mut app, &mount);
timer.block_on(store.shutdown()).unwrap();
}
#[test]
fn terminal_reads_original_caller_history_after_root_reopen_and_source_replacement() {
let timer = timer();
let (template, packages) = fixture();
{
let target = PresentationTarget::Tui;
let dir = tempfile::tempdir().unwrap();
let store1 = store(&timer, dir.path(), true);
let deployment = Uuid::new_v4();
let mut input = template.clone();
input.environment.presentation = Some(target);
let first = timer
.block_on(store1.commit_application(
deployment,
0,
input.clone(),
vec![],
packages.clone(),
))
.unwrap();
let original = reference();
let mine = timer
.block_on(store1.record_application_intent(
first.clone(),
identity(2),
original.clone(),
json!({"format":"test.draft@1","editor":{"owner":"original","revision":7},"attachments":["unsent"]}),
))
.unwrap()
.intent()
.clone();
let other = timer
.block_on(store1.record_application_intent(
first.clone(),
identity(4),
reference(),
serde_json::Value::Null,
))
.unwrap()
.intent()
.clone();
let (mut app, mount) = start(&timer, store1.clone(), deployment, target);
let out = page(&timer, &mut app, &mount, 0, 128);
assert_eq!(out["status"], "succeeded");
let read: Vec<CallerIntent> = serde_json::from_value(out["value"].clone()).unwrap();
assert_eq!(read.as_slice(), std::slice::from_ref(&mine));
let revision = app.frame(&mount).unwrap().revision;
assert!(
app.dispatch(
&mount,
revision,
"inc",
json!({"after":0,"limit":128,"consumer":identity(4)})
)
.is_err()
);
assert_eq!(app.frame(&mount).unwrap().revision, revision);
assert_eq!(app.pending_action_count(), 0);
close(&mut app, &mount);
timer.block_on(store1.shutdown()).unwrap();
drop(store1);
let store2 = store(&timer, dir.path(), false);
input.launch = Some(Target::Instance {
path: vec!["other".into()],
export: "App".into(),
});
let second = timer
.block_on(store2.commit_application(
deployment,
1,
input.clone(),
vec![],
packages.clone(),
))
.unwrap();
assert_ne!(second, first);
let (mut app, mount) = start(&timer, store2.clone(), deployment, target);
let out = page(&timer, &mut app, &mount, 0, 128);
assert_eq!(
serde_json::from_value::<Vec<CallerIntent>>(out["value"].clone()).unwrap(),
[other]
);
close(&mut app, &mount);
input.launch = template.launch.clone();
timer
.block_on(store2.commit_application(deployment, 2, input, vec![], packages.clone()))
.unwrap();
let (mut app, mount) = start(&timer, store2.clone(), deployment, target);
let out = page(&timer, &mut app, &mount, 0, 128);
assert_eq!(
serde_json::from_value::<Vec<CallerIntent>>(out["value"].clone()).unwrap(),
[mine]
);
assert_eq!(
out["value"][0]["reference"],
serde_json::to_value(original).unwrap()
);
close(&mut app, &mount);
timer.block_on(store2.shutdown()).unwrap();
}
}
#[test]
fn history_requires_persisted_source_and_rejects_configuration_selecting_another_namespace() {
let timer = timer();
let (mut input, packages) = fixture();
assert!(
matches!(Application::<()>::new(&input,packages.clone(),timer.handle().clone(),maka_ui_js::execution(Default::default()),crate::support::scene::registry(PresentationTarget::Tui),Default::default()),Err(maka_ui_runtime::Error::Invalid(reason)) if reason.contains(CALLER_HISTORY))
);
// Even if a package declaration permits these keys, the actual factory
// rejects attempts to turn caller observations into another namespace.
let mut packages = packages;
let i = packages
.iter()
.position(|p| p.manifest().id == "native.history")
.unwrap();
let old = packages.remove(i);
let mut declaration = old
.manifest()
.application
.as_ref()
.unwrap()
.declaration()
.clone();
declaration
.entries
.get_mut(&EntryTarget::Tui)
.unwrap()
.configuration = json!({"type":"object"}).into();
let package = support::package("native.history", declaration, None);
let node = input.instances.get_mut("history").unwrap();
node.package = package.application_ref().into();
node.configuration.values.insert(
"consumer".into(),
serde_json::to_value(identity(4)).unwrap(),
);
packages.push(package);
let dir = tempfile::tempdir().unwrap();
let store = store(&timer, dir.path(), true);
let deployment = Uuid::new_v4();
let source = timer
.block_on(store.commit_application(deployment, 0, input, vec![], packages))
.unwrap();
assert!(
matches!(timer.block_on(Application::<()>::start_from_store(store.clone(),source,PresentationTarget::Tui,timer.handle().clone(),maka_ui_js::execution(Default::default()),crate::support::scene::registry(PresentationTarget::Tui),vec![])),Err(maka_ui_runtime::Error::Invalid(reason)) if reason.contains("unknown field"))
);
timer.block_on(store.shutdown()).unwrap();
}
#[test]
fn accepted_history_waiting_behind_real_sql_writer_retains_root_after_surface_exit() {
let timer = timer();
let dir = tempfile::tempdir().unwrap();
let store = store(&timer, dir.path(), true);
let deployment = Uuid::new_v4();
let (input, packages) = fixture();
let source = timer
.block_on(store.commit_application(deployment, 0, input.clone(), vec![], packages.clone()))
.unwrap();
let original = timer
.block_on(store.record_application_intent(
source.clone(),
identity(2),
reference(),
serde_json::Value::Null,
))
.unwrap()
.intent()
.clone();
let (mut app, mount) = start(&timer, store.clone(), deployment, PresentationTarget::Tui);
let weak = Arc::downgrade(&store);
let sql = rusqlite::Connection::open(dir.path().join("root").join(ROOT_DATABASE)).unwrap();
sql.execute_batch("BEGIN IMMEDIATE").unwrap();
let queued = Arc::new(tokio::sync::Semaphore::new(0));
let observed = queued.clone();
let writer_store = store.clone();
let write = timer.spawn(async move {
let future = writer_store.commit_application(deployment, 1, input, vec![], packages);
tokio::pin!(future);
std::future::poll_fn(|cx| {
let result = future.as_mut().poll(cx);
if result.is_pending() {
observed.add_permits(1);
}
result
})
.await
});
timer.block_on(async {
tokio::time::timeout(Duration::from_secs(3), queued.acquire())
.await
.unwrap()
.unwrap()
.forget();
});
let task = request(&mut app, &mount, 0, 128);
let id = task.id;
// Poll the actual History future through its enqueue, without completing it.
let mut future = task.future;
timer.block_on(std::future::poll_fn(|cx| match future.as_mut().poll(cx) {
Poll::Pending => Poll::Ready(()),
Poll::Ready(_) => panic!("history passed the blocked SQL writer"),
}));
app.retire(&mount).unwrap();
for mount in app.retiring_mounts() {
app.close(&mount, Ok(())).unwrap();
}
assert!(!app.can_shutdown());
assert_eq!(app.accepted_action_count(), 1);
drop(store);
assert_eq!(
RootOwner::open(&dir.path().join("root"), &namespaces(dir.path()))
.err()
.unwrap()
.kind(),
std::io::ErrorKind::WouldBlock
);
sql.execute_batch("COMMIT").unwrap();
drop(sql);
let desired = timer.block_on(write).unwrap().unwrap();
assert_ne!(desired, source);
let completion = timer.block_on(future);
let maka_ui_runtime::actions::TaskCompletion::Query(ref query) = completion else {
panic!("expected history query")
};
assert_eq!(
serde_json::from_value::<Vec<CallerIntent>>(query.result.as_ref().unwrap().clone())
.unwrap(),
[original]
);
assert!(query.cleanup.is_ok());
assert!(RootOwner::open(&dir.path().join("root"), &namespaces(dir.path())).is_err());
app.finish_task(id, completion).unwrap();
assert_eq!(
app.deliver_action_results(&mount, |_, _, _| Ok(false))
.unwrap(),
0
);
assert!(app.can_shutdown());
let observer = weak.upgrade().unwrap();
timer.block_on(observer.shutdown()).unwrap();
drop(observer);
app.shutdown().unwrap();
assert!(weak.upgrade().is_none());
assert!(RootOwner::open(&dir.path().join("root"), &namespaces(dir.path())).is_ok());
}
#[test]
fn live_adoption_keeps_queued_history_source_and_retained_history_serves_new_consumer_epochs() {
let timer = timer();
let (template, packages) = fixture();
{
let target = PresentationTarget::Tui;
let dir = tempfile::tempdir().unwrap();
let store = store(&timer, dir.path(), true);
let deployment = Uuid::new_v4();
let mut input = template.clone();
input.environment.presentation = Some(target);
let first = timer
.block_on(store.commit_application(
deployment,
0,
input.clone(),
vec![],
packages.clone(),
))
.unwrap();
let mine = timer
.block_on(store.record_application_intent(
first.clone(),
identity(2),
reference(),
serde_json::Value::Null,
))
.unwrap()
.intent()
.clone();
let (mut app, root) = start(&timer, store.clone(), deployment, target);
let history = app
.deployment_status()
.instances
.into_iter()
.find(|s| s.activation.instance() == &identity(3))
.unwrap()
.activation;
app.dispatch(&root, 0, "inc", json!({"after":0,"limit":128}))
.unwrap();
app.capture_actions(&root, |_| unreachable!()).unwrap();
let mut unrelated = input.clone();
unrelated
.instances
.get_mut("other")
.unwrap()
.configuration
.values
.insert("unrelated".into(), json!(true));
let next = timer
.block_on(store.commit_application(
deployment,
1,
unrelated.clone(),
vec![],
packages.clone(),
))
.unwrap();
timer
.block_on(app.adopt_committed(next.clone(), vec![]))
.unwrap();
app.advance_deployment().unwrap();
assert!(!app.is_retiring(&root));
let task = app.start_task(&root).unwrap().unwrap();
let completion = timer.block_on(task.future);
app.finish_task(task.id, completion).unwrap();
assert_eq!(
app.deliver_action_results(&root, |_, _, _| Ok(false))
.unwrap(),
1
);
let read: Vec<CallerIntent> =
serde_json::from_value(app.frame(&root).unwrap().state["outcome"]["value"].clone())
.unwrap();
assert_eq!(read, vec![mine.clone()]);
assert_eq!(app.deployment_status().effective, Some(next));
unrelated
.instances
.get_mut("app")
.unwrap()
.configuration
.values
.insert("new_epoch".into(), json!(true));
let latest = timer
.block_on(store.commit_application(deployment, 2, unrelated, vec![], packages.clone()))
.unwrap();
timer
.block_on(app.adopt_committed(latest.clone(), vec![]))
.unwrap();
app.advance_deployment().unwrap();
assert!(app.is_retiring(&root));
app.close(&root, Ok(())).unwrap();
app.advance_deployment().unwrap();
assert_eq!(app.deployment_status().effective, Some(latest));
assert!(
app.deployment_status()
.instances
.iter()
.any(|s| s.activation == history)
);
let fresh = app.mount_launch(json!({})).unwrap();
let out = page(&timer, &mut app, &fresh, 0, 128);
assert_eq!(out["status"], "succeeded");
let read: Vec<CallerIntent> = serde_json::from_value(out["value"].clone()).unwrap();
assert_eq!(read, vec![mine]);
close(&mut app, &fresh);
drop(app);
timer
.block_on(Arc::try_unwrap(store).ok().unwrap().close())
.unwrap();
}
}