blob: 01fe3b653e6e4ec97dd557b09556f6d748e5e64e [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::*;
use crate::editor::{EditorTextRef, TextRange};
use maka_event_log::root::{ROOT_DATABASE, RootNamespaces, RootOwner};
use maka_packages::MANIFEST_FILE;
use maka_packages::Package;
use maka_plugin_core::deployment::DeploymentRef;
use maka_plugin_core::{
composition::{
Configuration, Environment, ExecutionLocation, Input, Node, PackageUse, PresentationTarget,
},
package::{Declaration, EntryDeclaration, EntryTarget, Implementation},
};
use serde_json::json;
use std::{collections::BTreeMap, time::Duration};
use uuid::Uuid;
async fn store() -> (tempfile::TempDir, Arc<EventLog>) {
let dir = tempfile::tempdir().unwrap();
let root =
Arc::new(RootOwner::create(&dir.path().join("root"), &namespaces(dir.path())).unwrap());
let store = Arc::new(EventLog::for_root(root).await.unwrap());
(dir, store)
}
fn namespaces(dir: &std::path::Path) -> RootNamespaces {
RootNamespaces {
ownership: dir.join("owners"),
control: dir.join("control"),
}
}
fn policy() -> EditorPersistence {
EditorPersistence {
key: "conversation".into(),
format: "test@1".into(),
data_source: json!({"host":"original"}),
metadata: json!({"attachments":["artifact"]}),
restore: None,
retry: 0,
}
}
async fn scope(store: &Arc<EventLog>, source: &DeploymentRef) -> Scope {
Scope {
store: Arc::downgrade(store),
source: store
.load_application_at(source)
.await
.unwrap()
.unwrap()
.snapshot(),
instance: instance(),
scheduler: tokio::runtime::Handle::current(),
}
}
fn snapshot(owner: &str, revision: u64, text: &str) -> EditorSnapshot {
EditorSnapshot {
text: EditorTextRef {
owner: owner.into(),
revision,
bytes: text.len(),
},
selection: TextRange { start: 1, end: 6 },
reversed: true,
marked: None,
}
}
async fn until(
editor: &mut EditorCheckpoint,
policy: &EditorPersistence,
check: impl Fn(&EditorCheckpoint) -> bool,
) {
tokio::time::timeout(Duration::from_secs(5), async {
loop {
editor.poll(policy);
if check(editor) {
break;
}
tokio::time::sleep(Duration::from_millis(2)).await;
}
})
.await
.unwrap();
}
async fn source(store: &EventLog, deployment: Uuid, expected: u64) -> DeploymentRef {
let package = Package::new(BTreeMap::from([(MANIFEST_FILE.into(),serde_json::to_vec(&json!({"schemaVersion":1,"id":"ui.checkpoint","application":Declaration {entries:BTreeMap::from([(EntryTarget::Tui,EntryDeclaration {scene:None,implementation:Implementation::Native {implementation:"test.checkpoint".into()},configuration:json!({"type":"object"}).into(),imports:BTreeMap::new(),exports:BTreeMap::new()})]),composition:None}})).unwrap())])).unwrap();
store
.commit_application(
deployment,
expected,
Input {
environment: Environment {
location: ExecutionLocation::Ui,
presentation: Some(PresentationTarget::Tui),
},
instances: BTreeMap::from([(
"editor".into(),
Node {
key: Uuid::from_u128(42),
package: package.application_ref().into(),
use_package: PackageUse::Entry,
configuration: Configuration::default(),
parameters: Configuration::default(),
bindings: BTreeMap::new(),
disabled: false,
overrides: vec![],
},
)]),
recovery: None,
launch: None,
},
vec![],
vec![package],
)
.await
.unwrap()
}
fn instance() -> InstanceId {
InstanceId(vec![Uuid::from_u128(42)])
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn final_coalesced_snapshot_flushes_before_cleanup_and_restores_after_root_reopen() {
let (dir, store) = store().await;
let source = source(&store, Uuid::new_v4(), 0).await;
let policy = policy();
let resources = Arc::new(Resources::default());
let budget = NativeBudget::default();
let recipe_scope = scope(&store, &source).await;
let mut editor = EditorCheckpoint::new(
Some(recipe_scope.clone()),
&policy,
&resources,
budget.clone(),
);
assert!(!editor.accepts_input());
until(&mut editor, &policy, EditorCheckpoint::writable).await;
assert!(
!editor.accepts_input(),
"new lineage must be committed before input"
);
let text = "甲👩‍💻z";
editor.save(&snapshot("old-owner", 1, text), text, &policy.metadata);
until(&mut editor, &policy, EditorCheckpoint::accepts_input).await;
let blocker = rusqlite::Connection::open(dir.path().join("root").join(ROOT_DATABASE)).unwrap();
blocker.execute_batch("BEGIN IMMEDIATE").unwrap();
let final_body = format!(
"{text}100\n{}FINAL COMPLETE DRAFT",
"保留中文😀 draft bytes.\n".repeat(50_000)
);
assert!(final_body.len() > 1024 * 1024);
for revision in 2..=100 {
let body = if revision == 100 {
final_body.clone()
} else {
format!("{text}{revision}")
};
editor.save(
&snapshot("old-owner", revision, &body),
&body,
&policy.metadata,
);
}
editor.close();
assert!(
!resources.seal().unwrap(),
"close must retain the actual pending writer"
);
drop(editor);
let retained = Arc::downgrade(&store);
drop(store);
assert!(retained.upgrade().is_some(), "worker retains original Root");
assert!(RootOwner::open(&dir.path().join("root"), &namespaces(dir.path())).is_err());
blocker.execute_batch("COMMIT").unwrap();
drop(blocker);
resources.finish().await.unwrap();
assert_eq!(budget.usage(), (0, 0, 0));
// EventLog drop starts asynchronous SQL shutdown. Reacquire the actual lease
// to prove that its independent connection owner has finished releasing it.
let root = tokio::time::timeout(Duration::from_secs(5), async {
loop {
match RootOwner::open(&dir.path().join("root"), &namespaces(dir.path())) {
Ok(root) => break Arc::new(root),
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
tokio::task::yield_now().await;
}
Err(error) => panic!("reopen original Root after checkpoint cleanup: {error}"),
}
}
})
.await
.unwrap();
assert!(retained.upgrade().is_none());
let store = Arc::new(EventLog::for_root(root).await.unwrap());
assert!(
recipe_scope.open(&store).is_err(),
"old evidence cannot cross a Root lease reopen"
);
let resources = Arc::new(Resources::default());
let mut editor = EditorCheckpoint::new(
Some(scope(&store, &source).await),
&policy,
&resources,
budget.clone(),
);
until(&mut editor, &policy, |e| {
matches!(e.phase, Phase::Restore(_))
})
.await;
let Phase::Restore(loaded) = &editor.phase else {
unreachable!()
};
assert!(
loaded.text.as_bytes() == final_body.as_bytes(),
"reopened checkpoint differs: got {} bytes, expected {}",
loaded.text.len(),
final_body.len()
);
assert_eq!(loaded.saved.metadata, policy.metadata);
assert_eq!(loaded.saved.snapshot.text.revision, 100);
assert_eq!(
selection_bytes(&loaded.text, Some(&loaded.saved.snapshot)),
Some((14, 3))
);
let mut policy = policy;
policy.restore = Some(EditorRestoration {
owner: "new-owner".into(),
version: loaded.version,
clear_text: false,
});
assert!(editor.restoration("other-owner", &policy).is_none());
assert!(
editor
.restoration("new-owner", &policy)
.unwrap()
.text()
.as_bytes()
== final_body.as_bytes(),
"restoration must retain every original draft byte"
);
editor.restored();
assert!(!editor.accepts_input());
editor.save(
&snapshot("new-owner", 1, &final_body),
&final_body,
&policy.metadata,
);
until(&mut editor, &policy, EditorCheckpoint::accepts_input).await;
editor.close();
drop(editor);
resources.finish().await.unwrap();
let (saved, ()) = store
.ui_checkpoints(&source, &instance())
.await
.unwrap()
.read(&policy.key, |_| Ok(()))
.await
.unwrap()
.unwrap();
assert_eq!(saved.metadata["snapshot"]["text"]["owner"], "new-owner");
assert!(
saved.content.as_slice() == final_body.as_bytes(),
"new owner must persist the complete restored draft"
);
assert_eq!(budget.usage(), (0, 0, 0));
store.shutdown().await.unwrap();
}
#[tokio::test]
async fn checkpoint_load_admits_actual_content_and_preserves_records_when_budget_is_exhausted() {
let (_dir, store) = store().await;
let source = source(&store, Uuid::new_v4(), 0).await;
let mut policy = policy();
policy.metadata = json!({"complete": "保留 metadata ".repeat(3000)});
policy.data_source = json!({"original": "source".repeat(1000)});
let checkpoints = store.ui_checkpoints(&source, &instance()).await.unwrap();
let large = format!("甲👩‍💻z{}COMPLETE END", "保留中文😀。\n".repeat(70_000));
assert!(large.len() > 1024 * 1024);
let mut version = None;
for (body, fits) in [("甲👩‍💻z", true), (large.as_str(), false)] {
let saved = Saved {
snapshot: snapshot("stored-owner", 1, body),
metadata: policy.metadata.clone(),
};
version = Some(
checkpoints
.write(
&policy.key,
version,
&policy.stored_format(),
&policy.data_source,
&serde_json::to_value(&saved).unwrap(),
body.as_bytes().to_vec(),
)
.await
.unwrap(),
);
let resources = Arc::new(Resources::default());
let budget = NativeBudget::new(crate::budget::BudgetLimits {
encoded_bytes: 128 * 1024,
..Default::default()
});
let mut editor = EditorCheckpoint::new(
Some(scope(&store, &source).await),
&policy,
&resources,
budget.clone(),
);
until(&mut editor, &policy, |e| !matches!(e.phase, Phase::Loading)).await;
let loaded = match &editor.phase {
Phase::Restore(value) => {
Some((value.text.as_bytes() == body.as_bytes(), value.version))
}
_ => None,
};
let failed = match &editor.phase {
Phase::Failed {
writing,
diagnostic,
} => Some((*writing, diagnostic.clone())),
_ => None,
};
let retained_bytes = budget.usage().0;
editor.close();
drop(editor);
resources.finish().await.unwrap();
assert_eq!(budget.usage(), (0, 0, 0));
if fits {
assert_eq!(
loaded,
Some((true, version.unwrap())),
"a small checkpoint must not reserve the former whole-content ceiling"
);
assert_eq!(failed, None);
assert_eq!(
retained_bytes,
body.len() + maka_plugin_core::json::encoded_size(&saved, usize::MAX).unwrap()
);
} else {
assert_eq!(loaded, None);
assert!(
matches!(failed, Some((false, ref diagnostic))
if diagnostic.contains("native resource budget exhausted")),
"large checkpoint load must fail its actual native admission: {failed:?}"
);
assert_eq!(retained_bytes, 0);
}
let (record, ()) = checkpoints
.read(&policy.key, |_| Ok(()))
.await
.unwrap()
.unwrap();
assert_eq!(record.revision, version.unwrap());
assert!(
record.content.as_slice() == body.as_bytes(),
"load admission changed stored draft bytes"
);
assert_eq!(record.metadata, serde_json::to_value(&saved).unwrap());
}
store.shutdown().await.unwrap();
}
#[tokio::test]
async fn an_older_ack_cannot_hide_a_newer_capture_failure_and_retry_accounts_for_it() {
let (_dir, store) = store().await;
let source = source(&store, Uuid::new_v4(), 0).await;
let mut policy = policy();
let resources = Arc::new(Resources::default());
let budget = NativeBudget::default();
let mut editor = EditorCheckpoint::new(
Some(scope(&store, &source).await),
&policy,
&resources,
budget.clone(),
);
until(&mut editor, &policy, EditorCheckpoint::writable).await;
let body = "甲👩‍💻z";
editor.save(&snapshot("owner", 1, body), body, &policy.metadata);
let mut invalid = snapshot("owner", 2, body);
invalid.text.bytes = body.len() + 1;
editor.save(&invalid, body, &policy.metadata);
until(&mut editor, &policy, |e| e.saved_sequence == 1).await;
assert!(!editor.accepts_input());
assert!(matches!(editor.phase, Phase::Failed { writing: true, .. }));
policy.retry = 1;
editor.poll(&policy);
editor.save(&snapshot("owner", 2, body), body, &policy.metadata);
until(&mut editor, &policy, |e| e.saved_sequence == 2).await;
editor.close();
drop(editor);
resources.finish().await.unwrap();
assert_eq!(budget.usage(), (0, 0, 0));
}
#[tokio::test]
async fn source_mismatch_and_conflicting_writers_cannot_overwrite_saved_content() {
let (_dir, store) = store().await;
let source = source(&store, Uuid::new_v4(), 0).await;
let policy = policy();
let a = Arc::new(Resources::default());
let b = Arc::new(Resources::default());
let mut first = EditorCheckpoint::new(
Some(scope(&store, &source).await),
&policy,
&a,
NativeBudget::default(),
);
let mut second = EditorCheckpoint::new(
Some(scope(&store, &source).await),
&policy,
&b,
NativeBudget::default(),
);
until(&mut first, &policy, EditorCheckpoint::writable).await;
until(&mut second, &policy, EditorCheckpoint::writable).await;
let body = "甲👩‍💻z";
first.save(&snapshot("first", 1, body), body, &policy.metadata);
until(&mut first, &policy, EditorCheckpoint::accepts_input).await;
second.save(&snapshot("second", 1, body), body, &policy.metadata);
until(&mut second, &policy, |e| {
matches!(e.phase, Phase::Failed { .. })
})
.await;
second.close();
drop(second);
assert!(b.finish().await.is_err());
first.close();
drop(first);
a.finish().await.unwrap();
let mut foreign = policy.clone();
foreign.data_source = json!({"host":"replacement"});
let c = Arc::new(Resources::default());
let mut replacement = EditorCheckpoint::new(
Some(scope(&store, &source).await),
&foreign,
&c,
NativeBudget::default(),
);
until(&mut replacement, &foreign, |e| {
matches!(e.phase, Phase::Failed { .. })
})
.await;
assert!(!replacement.accepts_input());
replacement.close();
drop(replacement);
c.finish().await.unwrap();
assert_eq!(
store
.ui_checkpoints(&source, &instance())
.await
.unwrap()
.read(&policy.key, |_| Ok(()))
.await
.unwrap()
.unwrap()
.0
.metadata["snapshot"]["text"]["owner"],
"first"
);
}
#[test]
fn restored_selections_require_exact_graphemes_and_preserve_direction() {
let body = "甲👩‍💻z";
let mut saved = snapshot("owner", 1, body);
assert_eq!(selection_bytes(body, Some(&saved)), Some((14, 3)));
saved.selection.start = 2;
assert_eq!(selection_bytes(body, Some(&saved)), None);
assert_eq!(selection_bytes("", None), Some((0, 0)));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn concurrent_preference_reads_are_not_rejected_by_a_worker_count() {
use super::value::{ValueCheckpoint, ValueCheckpointProps, ValueCheckpointState};
let (_dir, store) = store().await;
let source = source(&store, Uuid::new_v4(), 0).await;
let recipe_scope = scope(&store, &source).await;
let resources = Arc::new(Resources::default());
let budget = NativeBudget::new(crate::budget::BudgetLimits {
encoded_bytes: 128 * 1024,
..Default::default()
});
let mut preferences: Vec<_> = (0..16)
.map(|index| {
ValueCheckpoint::new(
Some(recipe_scope.clone()),
&ValueCheckpointProps {
key: format!("panel-{index}"),
format: "test/v1".into(),
command: None,
},
&resources,
budget.clone(),
)
})
.collect();
let mut ready = std::collections::BTreeSet::new();
tokio::time::timeout(Duration::from_secs(5), async {
while ready.len() < preferences.len() {
for (index, preference) in preferences.iter_mut().enumerate() {
if let Some(observed) = preference.observe() {
match observed.state {
ValueCheckpointState::Ready { .. } => {
ready.insert(index);
}
ValueCheckpointState::Failed { diagnostic } => panic!("{diagnostic}"),
_ => {}
}
}
}
tokio::time::sleep(Duration::from_millis(2)).await;
}
})
.await
.unwrap();
for preference in &mut preferences {
preference.close();
}
tokio::time::timeout(Duration::from_secs(5), async {
while !resources.seal().unwrap() {
tokio::time::sleep(Duration::from_millis(2)).await;
}
})
.await
.unwrap();
assert_eq!(budget.usage(), (0, 0, 0));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn preference_busy_keeps_the_original_write_result_and_retirement_drains_it() {
use super::value::{
ValueCheckpoint, ValueCheckpointCommand, ValueCheckpointProps, ValueCheckpointState,
};
let (dir, store) = store().await;
let source = source(&store, Uuid::new_v4(), 0).await;
let scope = scope(&store, &source).await;
let resources = Arc::new(Resources::default());
let budget = NativeBudget::default();
let mut props = ValueCheckpointProps {
key: "preferences".into(),
format: "test@1".into(),
command: None,
};
let mut preferences =
ValueCheckpoint::new(Some(scope.clone()), &props, &resources, budget.clone());
tokio::time::timeout(Duration::from_secs(5), async {
loop {
if preferences
.observe()
.is_some_and(|value| matches!(value.state, ValueCheckpointState::Ready { .. }))
{
break;
}
tokio::task::yield_now().await;
}
})
.await
.unwrap();
let original_payload = json!({"original": "完整 preference ".repeat(3000)});
let original_bytes = maka_plugin_core::json::value_size(&original_payload, usize::MAX).unwrap();
let blocker = rusqlite::Connection::open(dir.path().join("root").join(ROOT_DATABASE)).unwrap();
blocker.execute_batch("BEGIN IMMEDIATE").unwrap();
props.command = Some(ValueCheckpointCommand::Write {
sequence: 1,
expected_revision: None,
value: original_payload.clone(),
});
preferences.update(&props);
assert_eq!(
budget.usage().0,
original_bytes,
"accepted queued value retains its reservation"
);
let saving = preferences.observe().unwrap();
assert_eq!(saving.sequence, 1);
assert!(matches!(saving.state, ValueCheckpointState::Saving));
props.command = Some(ValueCheckpointCommand::Write {
sequence: 2,
expected_revision: None,
value: json!({"replacement": true}),
});
preferences.update(&props);
blocker.execute_batch("COMMIT").unwrap();
let busy = preferences.observe().unwrap();
assert_eq!(busy.sequence, 2);
assert!(
matches!(busy.state, ValueCheckpointState::Failed { diagnostic } if diagnostic.contains("busy"))
);
let original = tokio::time::timeout(Duration::from_secs(5), async {
loop {
if let Some(value) = preferences.observe() {
break value;
}
tokio::task::yield_now().await;
}
})
.await
.unwrap();
assert_eq!(original.sequence, 1);
let ValueCheckpointState::Ready {
revision: Some(revision),
value,
} = original.state
else {
panic!("the original accepted write must finish");
};
assert_eq!(value, Some(original_payload.clone()));
assert_eq!(
budget.usage().0,
original_bytes,
"ready observation retains its original reservation"
);
let checkpoints = scope.open(&store).unwrap();
let (saved, ()) = checkpoints
.read(&props.key, |_| Ok(()))
.await
.unwrap()
.unwrap();
assert_eq!(saved.revision, revision);
assert_eq!(
serde_json::from_slice::<Value>(&saved.content).unwrap(),
original_payload
);
blocker.execute_batch("BEGIN IMMEDIATE").unwrap();
props.command = Some(ValueCheckpointCommand::Write {
sequence: 3,
expected_revision: Some(revision),
value: json!({"retired": true}),
});
preferences.update(&props);
preferences.close();
assert!(!resources.seal().unwrap());
drop(preferences);
blocker.execute_batch("COMMIT").unwrap();
resources.finish().await.unwrap();
assert_eq!(budget.usage(), (0, 0, 0));
let (saved, ()) = checkpoints
.read(&props.key, |_| Ok(()))
.await
.unwrap()
.unwrap();
assert_eq!(
serde_json::from_slice::<Value>(&saved.content).unwrap(),
json!({"retired": true})
);
}