blob: f61cbbc9bc2d5d7238c12766eecef057714e1d95 [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.
*/
//! Plugin-owned UI preferences using the existing instance checkpoint store.
//! Accepted writes drain before native retirement; paint never performs storage I/O.
use super::Scope;
use crate::budget::{Kind, NativeBudget, Permit};
use maka_capabilities::call::Resources;
use maka_plugin_core::contract::{Contract, Interface, Transport};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::{collections::BTreeMap, sync::Arc};
use tokio::sync::mpsc;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ValueCheckpointProps {
#[schemars(length(min = 1))]
pub key: String,
#[schemars(length(min = 1))]
pub format: String,
pub command: Option<ValueCheckpointCommand>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)]
pub enum ValueCheckpointCommand {
Read {
sequence: u64,
},
Write {
sequence: u64,
#[serde(rename = "expectedRevision")]
expected_revision: Option<u64>,
value: Value,
},
}
impl ValueCheckpointCommand {
fn sequence(&self) -> u64 {
match self {
Self::Read { sequence } | Self::Write { sequence, .. } => *sequence,
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ValueCheckpointObservation {
pub sequence: u64,
pub state: ValueCheckpointState,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum ValueCheckpointState {
Loading,
Saving,
Ready {
revision: Option<u64>,
value: Option<Value>,
},
Failed {
diagnostic: String,
},
}
pub fn contract() -> Contract {
Contract {
name: "maka.ui.ValueCheckpoint".into(),
version: 1,
transport: Transport::Local,
interface: Interface::Component {
props: serde_json::to_value(schemars::schema_for!(ValueCheckpointProps))
.unwrap()
.into(),
events: BTreeMap::from([(
"changed".into(),
serde_json::to_value(schemars::schema_for!(ValueCheckpointObservation))
.unwrap()
.into(),
)]),
captures: BTreeMap::new(),
},
}
}
pub struct ValueCheckpoint {
key: String,
format: String,
command: Option<u64>,
pending: Option<u64>,
sender: Option<mpsc::Sender<(ValueCheckpointCommand, Option<Arc<Permit>>)>>,
receiver: mpsc::Receiver<(ValueCheckpointObservation, Option<Arc<Permit>>)>,
current: ValueCheckpointObservation,
announced: Option<ValueCheckpointObservation>,
current_memory: Option<Arc<Permit>>,
announced_memory: Option<Arc<Permit>>,
budget: NativeBudget,
}
fn failure(sequence: u64, error: impl std::fmt::Display) -> ValueCheckpointObservation {
ValueCheckpointObservation {
sequence,
state: ValueCheckpointState::Failed {
diagnostic: error.to_string().chars().take(1024).collect(),
},
}
}
impl ValueCheckpoint {
pub fn new(
scope: Option<Scope>,
props: &ValueCheckpointProps,
resources: &Arc<Resources>,
budget: NativeBudget,
) -> Self {
let initial = ValueCheckpointObservation {
sequence: 0,
state: ValueCheckpointState::Loading,
};
let (reply, receiver) = mpsc::channel(2);
let (sender, requests) = mpsc::channel(1);
let worker_budget = budget.clone();
let started = (|| {
let scope = scope.ok_or("UI preferences require an owned UI Root")?;
let store = scope
.store
.upgrade()
.ok_or("original UI Root is unavailable")?;
let mut ticket = resources.reserve().map_err(|e| e.to_string())?;
let key = props.key.clone();
let format = props.format.clone();
let wake = resources.clone();
ticket.start();
scope.scheduler.clone().spawn(async move {
let mut requests = requests;
let mut checkpoints = None;
let mut command = ValueCheckpointCommand::Read { sequence: 0 };
let mut memory = None;
let budget = worker_budget;
loop {
let sequence = command.sequence();
let result: Result<(ValueCheckpointState, Option<Arc<Permit>>), String> =
async {
if checkpoints.is_none() {
checkpoints = Some(scope.open(&store)?);
}
let checkpoints = checkpoints.as_ref().unwrap();
match command {
ValueCheckpointCommand::Read { .. } => {
let budget = budget.clone();
let saved = checkpoints
.read(&key, move |total_bytes| {
budget
.reserve(Kind::Encoded, total_bytes)
.map(Arc::new)
.map_err(|error| {
maka_event_log::StoreError::InvalidTransition(
error.to_string(),
)
})
})
.await
.map_err(|e| e.to_string())?;
match saved {
None => Ok((
ValueCheckpointState::Ready {
revision: None,
value: None,
},
None,
)),
Some((saved, bytes)) => {
if saved.format != format
|| saved.data_source != Value::Null
{
return Err(
"UI preference format is incompatible".into()
);
}
let value = serde_json::from_slice(&saved.content)
.map_err(|e| e.to_string())?;
Ok((
ValueCheckpointState::Ready {
revision: Some(saved.revision),
value: Some(value),
},
Some(bytes),
))
}
}
}
ValueCheckpointCommand::Write {
expected_revision,
value,
..
} => {
let bytes = match memory.take() {
Some(bytes) => bytes,
None => {
let size = maka_plugin_core::json::value_size(
&value,
usize::MAX,
)?;
Arc::new(
budget
.reserve(Kind::Encoded, size)
.map_err(|e| e.to_string())?,
)
}
};
let content =
serde_json::to_vec(&value).map_err(|e| e.to_string())?;
let revision = checkpoints
.write(
&key,
expected_revision,
&format,
&Value::Null,
&Value::Null,
content,
)
.await
.map_err(|e| e.to_string())?;
Ok((
ValueCheckpointState::Ready {
revision: Some(revision),
value: Some(value),
},
Some(bytes),
))
}
}
}
.await;
let _ = reply
.send(match result {
Ok((state, memory)) => {
(ValueCheckpointObservation { sequence, state }, memory)
}
Err(error) => (failure(sequence, error), None),
})
.await;
wake.notify();
match requests.recv().await {
Some((next, next_memory)) => {
command = next;
memory = next_memory;
}
None => break,
}
}
ticket.complete(Ok(()));
});
Ok::<_, String>(())
})();
Self {
key: props.key.clone(),
format: props.format.clone(),
command: None,
pending: None,
sender: started.is_ok().then_some(sender),
receiver,
current: started.err().map_or(initial, |error| failure(0, error)),
announced: None,
current_memory: None,
announced_memory: None,
budget,
}
}
pub fn publication_capacity(&self, props: &ValueCheckpointProps) -> usize {
let command = props.key == self.key
&& props.format == self.format
&& props.command.as_ref().is_some_and(|command| {
self.command
.as_ref()
.is_none_or(|prior| command.sequence() > *prior)
});
2 * (usize::from(self.announced.is_none()) + usize::from(command))
}
pub fn update(&mut self, props: &ValueCheckpointProps) {
if props.key != self.key || props.format != self.format {
self.current_memory = None;
self.current = failure(
self.current.sequence,
"UI preference identity cannot change on a live mount",
);
return;
}
let Some(command) = props.command.as_ref() else {
return;
};
let sequence = command.sequence();
if self.command.is_some_and(|prior| sequence <= prior) {
return;
}
self.command = Some(sequence);
if self.pending.is_some() {
self.current_memory = None;
self.current = failure(command.sequence(), "UI preferences are busy");
return;
}
let memory = match command {
ValueCheckpointCommand::Write { value, .. } => {
let reservation =
maka_plugin_core::json::value_size(value, usize::MAX).and_then(|size| {
self.budget
.reserve(Kind::Encoded, size)
.map(Arc::new)
.map_err(|e| e.to_string())
});
match reservation {
Ok(memory) => Some(memory),
Err(error) => {
self.current = failure(sequence, error);
self.current_memory = None;
return;
}
}
}
ValueCheckpointCommand::Read { .. } => None,
};
self.current_memory = None;
self.current = match self
.sender
.as_ref()
.ok_or("UI preferences are unavailable")
.and_then(|sender| {
sender
.try_send((command.clone(), memory))
.map_err(|_| "UI preferences are busy")
}) {
Ok(()) => {
self.pending = Some(command.sequence());
ValueCheckpointObservation {
sequence: command.sequence(),
state: if matches!(command, ValueCheckpointCommand::Write { .. }) {
ValueCheckpointState::Saving
} else {
ValueCheckpointState::Loading
},
}
}
Err(error) => failure(command.sequence(), error),
};
}
fn poll(&mut self) {
if let Ok((reply, memory)) = self.receiver.try_recv() {
if self.pending == Some(reply.sequence) {
self.pending = None;
}
self.current = reply;
self.current_memory = memory;
}
}
pub fn observe(&mut self) -> Option<ValueCheckpointObservation> {
// Transfer a command's immediate outcome before polling the original
// worker. A ready reply must not overwrite an unannounced busy/error.
if self.announced.as_ref() != Some(&self.current) {
self.announced = Some(self.current.clone());
self.announced_memory = self.current_memory.clone();
return Some(self.current.clone());
}
self.poll();
if self.announced.as_ref() == Some(&self.current) {
return None;
}
self.announced = Some(self.current.clone());
self.announced_memory = self.current_memory.clone();
Some(self.current.clone())
}
pub fn close(&mut self) {
self.sender.take();
// Accepted store operations still drain. Retired observation custody
// must not leave that original worker blocked on a full result queue.
self.receiver.close();
}
}
pub const IMPLEMENTATION: &str = "maka.ui.checkpoint";
pub fn native() -> maka_plugin_core::package::NativeDefinition {
maka_plugin_core::package::NativeDefinition::new(
IMPLEMENTATION,
serde_json::json!({"type":"object","properties":{},"required":[],"additionalProperties":false}),
[("Checkpoint", contract())],
)
}