Files
oak-editor/crates/oakstorage/src/writethrough.rs
T
Mike-Solar b36cbd6b6f refactor: purge CHandle from module internals (M14 R5)
Module-internal object references are Rust types now (values, Arc,
Mutex); CHandle remains only at the oakengine C-ABI boundary:

- oakundo: the global stack holds UndoStack/UndoCommand values
  directly (stack token is the static's address)
- oaktimeline: marker/workarea boxes carry Arc<Mutex<T>>; commands
  share the same allocation through Arc clones (readers in oakengine
  stubs and the app's graphops updated to lock)
- oaktask/oakstorage: sessions, write-through bindings and the
  database backend pass ProjectArc; the Session drops its manual
  release bookkeeping; nodeutil keeps the CHandle<->Arc boundary
  conversion (release_project restored for the app)
- oakcodec: handle.rs deleted outright (no facade entry needed it);
  texture/block placeholders are unit structs
- oakrender: copier's project handle is an identity u64; alive-count
  machinery removed; handle.rs is make_owned/get/get_mut only
- oakplugin: the instance registry is gone (its unregister key never
  matched, leaking weak entries); handle.rs is the RefBox boundary type
- oaknode/oakcommon: only dead guard/borrow helpers removed; external
  payload handles (texture/processor) documented as the boundary

Flake hunts landed along the way: the audio recording test serializes
on the shared manager lock with a normalized state; the autocacher
cancel test uses a slow producer so cancellation is deterministic.
2026-08-17 16:40:15 +08:00

569 lines
20 KiB
Rust

// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Live write-through to the project library (plan M13 §2/§3; M14 R1:
//! sunk from the engine facade's `storage.rs`).
//!
//! Every opened project is bound to a database session — a
//! `(library uri, project uuid)` pair — and re-saved after every
//! successful undo-path operation. The module subscribes to the oakundo
//! process-wide stack's **command-success observers**
//! ([`oakundo::global::add_observer`]), so each command's diff lands in
//! the journal transactionally (the oakstorage database backend does the
//! diff itself) with no facade round-trip.
//!
//! ## Binding model
//!
//! The map is keyed by the project box's identity (`Arc::as_ptr` — one
//! per in-memory project instance), so several projects can be bound at
//! once (multi-project, plan §3) without confusing their library rows.
//! Every undo-path operation re-saves ALL bound projects
//! ([`note_command`]): the oakstorage backend diffs each project against
//! its own library head, so untouched projects are no-op touches and
//! only the project the command actually changed advances its journal.
//! (The plan's "current project" phrasing maps to this — the undo stack
//! is cleared on every project switch, so at most one project's graph
//! changes per command; a bound-but-untouched project can gain its
//! import row this way, which reflects its true state.) Closing a
//! project ([`unbind_project`], hooked from the facade's `project_free`)
//! flushes its pending writes and drops the binding. The binding holds
//! the boxed project ([`ProjectArc`]) directly — the `CHandle` facade
//! entries convert at the boundary — so a bound project outlives the
//! caller's own handle reference.
//!
//! The library is selected from the `Storage` config group (all defaults
//! are config-driven, plan §5):
//!
//! - `Storage/Backend` — `"sqlite"` (the documented default value),
//! `"database"` or `"pg"` enable the write-through; any other value
//! (e.g. `"off"`) disables it. When the key is absent, no library is
//! configured: projects stay unbound and the undo path runs without
//! touching a database (graceful degradation — this is what keeps
//! headless consumers and the test suite from writing to the user's
//! default library).
//! - `Storage/SqlitePath` — the SQLite library file; default (used when
//! storage is enabled) `<system data directory>/library.db` (the same
//! location `FileFunctions::get_configuration_location` derives,
//! honoring `OAK_CONFIG_DIR` and portable mode).
//! - `Storage/PgUrl` — the PostgreSQL connection string (plan D3), used
//! when `Storage/Backend` is `"pg"`: `user:pass@host:5432/dbname`
//! (libpq URL form; an optional `postgres://`/`postgresql://` scheme is
//! accepted and stripped). The resolved library URI is
//! `oakdb+pg://<PgUrl>`. When `Backend = "pg"` but `PgUrl` is absent
//! or empty, no library is configured (same graceful degradation).
//!
//! ## Snapshot thread and exit flush
//!
//! A background thread re-snapshots every *dirty* binding every
//! `Storage/SnapshotIntervalSec` seconds (default 600; ≤ 0 acts every
//! wake). Snapshots are latest-wins: the backend writes the full payload
//! at the current head seq and prunes to the newest three. The thread is
//! notified on bind and stops on the exit flush. [`flush_all`] (exported
//! by the facade as `oakengine_storage_flush`) is the exit path: it stops
//! the thread, then writes through and snapshots every still-bound
//! project (save + snapshot drain). A write-through failure never
//! propagates to the caller — it is recorded in the binding's
//! `last_error` ([`last_error`]) and the project keeps working (graceful
//! degradation, plan §5).
use std::collections::HashMap;
use std::panic::{catch_unwind, AssertUnwindSafe};
use std::sync::{Arc, Condvar, Mutex, OnceLock};
use std::time::Duration;
use oakundo::global;
use crate::backends::database::DatabaseBackend;
use crate::handle::CHandle;
use crate::nodeutil::ProjectArc;
use crate::uri::StorageUri;
/// One bound project: its session (library uri + row uuid) plus the
/// write state.
struct Binding {
/// The boxed project (kept alive here past the caller's own handle
/// reference).
project: ProjectArc,
/// Library uri (`oakdb+sqlite:///…`).
uri: String,
/// Library row uuid.
uuid: String,
/// Whether a write-through happened since the last successful
/// snapshot (the snapshot thread and the flush drain on this).
dirty: bool,
/// Monotonic counter bumped on every write-through; a snapshot only
/// clears `dirty` when the generation still matches, so a write that
/// lands while the snapshot runs is not lost.
write_gen: u64,
/// Last write-through / snapshot error (graceful degradation).
last_error: Option<String>,
}
/// The process-wide oakstorage backend (shared by the write-throughs, the
/// snapshot thread and the library-manager exports of the facade; the
/// backend serializes its own operations).
pub fn backend() -> &'static DatabaseBackend {
static BACKEND: OnceLock<DatabaseBackend> = OnceLock::new();
BACKEND.get_or_init(DatabaseBackend::new)
}
/// project identity (boxed project allocation) -> binding.
fn bindings() -> &'static Mutex<HashMap<usize, Binding>> {
static BINDINGS: OnceLock<Mutex<HashMap<usize, Binding>>> = OnceLock::new();
BINDINGS.get_or_init(|| Mutex::new(HashMap::new()))
}
/// Snapshot-thread runtime state.
struct SnapshotRuntime {
/// The background thread (None after the exit flush joined it).
handle: Option<std::thread::JoinHandle<()>>,
/// Exit request (set by [`flush_all`]).
stop: bool,
}
static SNAPSHOT: Mutex<SnapshotRuntime> = Mutex::new(SnapshotRuntime {
handle: None,
stop: false,
});
/// Wakes the snapshot thread on bind/flush/stop.
static SNAPSHOT_CV: Condvar = Condvar::new();
// ---------------------------------------------------------------------------
// Command-success subscription
// ---------------------------------------------------------------------------
/// Subscribe this module's [`note_command`] to the oakundo process-wide
/// stack's command-success observers (idempotent; the callback is a
/// `fn()`, so a single registration covers every future command).
fn ensure_command_observer() {
static ONCE: OnceLock<()> = OnceLock::new();
ONCE.get_or_init(|| {
global::add_observer(note_command);
});
}
// ---------------------------------------------------------------------------
// Binding
// ---------------------------------------------------------------------------
/// Bind `project` to the configured default library. No-op when the
/// backend is disabled by config, the project cannot be addressed (no
/// uuid), or it is already bound. No database write happens here — the
/// first undo-path operation creates the library row.
///
/// Facade entry: takes the project as a `CHandle` (the engine's project
/// handle form) and converts it to the boxed project at the boundary;
/// everything from here on works with the [`ProjectArc`].
pub fn bind_project(project: CHandle) {
let _ = catch_unwind(AssertUnwindSafe(|| {
if project.is_null() {
return;
}
// SAFETY: facade project handles box a `ProjectArc`.
let Ok(arc) = (unsafe { crate::nodeutil::project_arc(&project) }) else {
return;
};
let key = Arc::as_ptr(&arc) as usize;
{
let g = bindings().lock().unwrap_or_else(|e| e.into_inner());
if g.contains_key(&key) {
return; // Already bound (re-bind): nothing to refresh.
}
}
if !storage_enabled() {
return;
}
let Some(uri) = library_uri() else {
return;
};
let uuid = {
let guard = arc.lock().unwrap_or_else(|e| e.into_inner());
guard.uuid.clone()
};
if uuid.is_empty() {
return;
}
bindings()
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(
key,
Binding {
project: arc,
uri,
uuid,
dirty: false,
write_gen: 0,
last_error: None,
},
);
ensure_command_observer();
ensure_thread();
}));
}
/// Flush `project`'s pending writes and drop its binding (closing the
/// project). No-op when not bound.
pub fn unbind_project(project: CHandle) {
let _ = catch_unwind(AssertUnwindSafe(|| {
// SAFETY: facade project handles box a `ProjectArc`.
let Ok(arc) = (unsafe { crate::nodeutil::project_arc(&project) }) else {
return;
};
let key = Arc::as_ptr(&arc) as usize;
flush_one(key);
let mut g = bindings().lock().unwrap_or_else(|e| e.into_inner());
// Dropping the binding releases the project reference.
g.remove(&key);
}));
}
/// Whether `project` currently has a binding (status-bar / D5 surface).
pub fn is_bound(project: CHandle) -> bool {
if project.is_null() {
return false;
}
// SAFETY: facade project handles box a `ProjectArc`.
let Ok(arc) = (unsafe { crate::nodeutil::project_arc(&project) }) else {
return false;
};
bindings()
.lock()
.unwrap_or_else(|e| e.into_inner())
.contains_key(&(Arc::as_ptr(&arc) as usize))
}
/// The last write-through / snapshot error of `project` (empty when none
/// or not bound).
pub fn last_error(project: CHandle) -> Option<String> {
if project.is_null() {
return None;
}
// SAFETY: facade project handles box a `ProjectArc`.
let Ok(arc) = (unsafe { crate::nodeutil::project_arc(&project) }) else {
return None;
};
bindings()
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(&(Arc::as_ptr(&arc) as usize))
.and_then(|b| b.last_error.clone())
}
// ---------------------------------------------------------------------------
// Write-through
// ---------------------------------------------------------------------------
/// Persist every bound project after a successful undo-path operation
/// (push / group_end / jump). Registered as the oakundo global-stack
/// command-success observer ([`ensure_command_observer`]); the oakstorage
/// backend diffs each project against its own head, so unchanged projects
/// are no-op touches. No-op when nothing is bound.
pub fn note_command() {
let _ = catch_unwind(AssertUnwindSafe(|| {
let keys: Vec<usize> = {
let g = bindings().lock().unwrap_or_else(|e| e.into_inner());
g.keys().copied().collect()
};
for key in keys {
write_through(key);
}
}));
}
/// Save the project of `key` through the database backend (the backend
/// diffs and journals internally). Failures are recorded, never returned.
fn write_through(key: usize) {
let (project, uri, _uuid) = {
let mut g = bindings().lock().unwrap_or_else(|e| e.into_inner());
match g.get_mut(&key) {
Some(b) => (b.project.clone(), b.uri.clone(), b.uuid.clone()),
None => return,
}
};
let parsed = match StorageUri::parse(&uri) {
Ok(u) => u,
Err(_) => {
record_error(key, "invalid storage uri");
return;
}
};
match backend().save_project(&project, &parsed, 0) {
Ok(()) => {
let mut g = bindings().lock().unwrap_or_else(|e| e.into_inner());
if let Some(b) = g.get_mut(&key) {
b.dirty = true;
b.write_gen = b.write_gen.wrapping_add(1);
b.last_error = None;
}
}
Err(e) => record_error(key, &format!("{e}")),
}
}
fn record_error(key: usize, message: &str) {
let mut g = bindings().lock().unwrap_or_else(|e| e.into_inner());
if let Some(b) = g.get_mut(&key) {
b.last_error = Some(message.to_string());
}
}
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
/// Whether the write-through backend is enabled (config-driven, plan §5):
/// `Storage/Backend` set to `"sqlite"`, `"database"` or `"pg"` enables
/// it; any other explicit value (e.g. `"off"`) disables it. When NO
/// `Storage` configuration is present the backend is NOT enabled — "no
/// library configured" degrades gracefully to plain unbound projects,
/// which keeps headless consumers (the CLI) and the test suite from ever
/// writing to the user's default library. The documented default *values*
/// are `Backend = "sqlite"` and `SqlitePath = <system data dir>/library.db`
/// (used once storage is enabled without an explicit path).
pub fn storage_enabled() -> bool {
let store = oakcommon::configstore::ConfigStore::instance();
match store.get(Some("Storage"), "Backend") {
Ok(b) => b == "sqlite" || b == "database" || b == "pg",
Err(_) => false,
}
}
/// The configured SQLite library path (empty value = the default).
fn configured_sqlite_path() -> Option<String> {
let store = oakcommon::configstore::ConfigStore::instance();
match store.get(Some("Storage"), "SqlitePath") {
Ok(p) if !p.trim().is_empty() => Some(p.trim().to_string()),
_ => None,
}
}
/// The configured PostgreSQL connection string (`Storage/PgUrl`; empty
/// value = not configured).
fn configured_pg_url() -> Option<String> {
let store = oakcommon::configstore::ConfigStore::instance();
match store.get(Some("Storage"), "PgUrl") {
Ok(u) if !u.trim().is_empty() => Some(u.trim().to_string()),
_ => None,
}
}
/// The `oakdb+pg://…` uri of the configured PostgreSQL library (None
/// when `Storage/PgUrl` is absent). A `postgres://`/`postgresql://`
/// scheme on the config value is stripped — the oakdb uri body is the
/// bare connection string (`user:pass@host:5432/dbname`).
fn pg_library_uri() -> Option<String> {
let url = configured_pg_url()?;
let body = url
.strip_prefix("postgres://")
.or_else(|| url.strip_prefix("postgresql://"))
.unwrap_or(&url);
Some(format!("oakdb+pg://{body}"))
}
/// The default library file: `<system data directory>/library.db`, where
/// the data directory is the standard per-user location
/// (`FileFunctions::get_configuration_location`: macOS Application
/// Support / XDG config, honoring `OAK_CONFIG_DIR` and portable mode).
pub fn default_library_path() -> String {
let dir = oakcommon::filefunctions::FileFunctions::new()
.get_configuration_location()
.unwrap_or_else(|_| std::env::temp_dir().to_string_lossy().into_owned());
format!("{}/library.db", dir)
}
/// The `oakdb+…` uri of the configured library (None when the path
/// cannot be made absolute, or the PG url is missing). Shared with the
/// facade's library-manager exports.
pub fn library_uri() -> Option<String> {
let store = oakcommon::configstore::ConfigStore::instance();
if store.get(Some("Storage"), "Backend").ok().as_deref() == Some("pg") {
return pg_library_uri();
}
let path = match configured_sqlite_path() {
Some(p) => p,
None => default_library_path(),
};
let abs = std::path::absolute(&path).ok()?;
Some(format!("oakdb+sqlite://{}", abs.display()))
}
// ---------------------------------------------------------------------------
// Snapshot thread
// ---------------------------------------------------------------------------
/// Spawn the snapshot thread (a no-op when one is already running). The
/// thread is (re)startable after an exit flush.
fn ensure_thread() {
let mut rt = SNAPSHOT.lock().unwrap_or_else(|e| e.into_inner());
let alive = rt
.handle
.as_ref()
.map(|h| !h.is_finished())
.unwrap_or(false);
if !alive {
rt.stop = false;
rt.handle = Some(
std::thread::Builder::new()
.name("oak-storage-snapshot".into())
.spawn(snapshot_loop)
.expect("snapshot thread spawn"),
);
}
}
/// The thread loop: sleep a tick, then snapshot every dirty binding.
/// The condvar makes the sleep interruptible (bind notifications and the
/// exit flush's stop signal wake it immediately).
fn snapshot_loop() {
loop {
let interval = snapshot_tick();
let mut rt = SNAPSHOT.lock().unwrap_or_else(|e| e.into_inner());
let (guard, _) = SNAPSHOT_CV
.wait_timeout(rt, interval)
.unwrap_or_else(|e| e.into_inner());
rt = guard;
if rt.stop {
break;
}
drop(rt);
snapshot_dirty();
}
}
/// The sleep between snapshot passes: `Storage/SnapshotIntervalSec`
/// seconds (default 600), with ≤ 0 treated as "every wake" and a 100 ms
/// floor so the thread stays responsive to bind/stop signals.
fn snapshot_tick() -> Duration {
let secs = oakcommon::configstore::ConfigStore::instance()
.get_int(Some("Storage"), "SnapshotIntervalSec", 600);
if secs <= 0 {
Duration::from_millis(100)
} else {
Duration::from_secs(secs as u64)
}
}
/// Snapshot every dirty binding at its head seq (latest-wins: a project
/// edited since the last pass snapshots at the newer head; an unchanged
/// head is a backend no-op).
fn snapshot_dirty() {
let jobs: Vec<(usize, String, String, u64)> = {
let g = bindings().lock().unwrap_or_else(|e| e.into_inner());
g.iter()
.filter(|(_, b)| b.dirty)
.map(|(k, b)| (*k, b.uri.clone(), b.uuid.clone(), b.write_gen))
.collect()
};
for (key, uri, uuid, gen) in jobs {
let res = match StorageUri::parse(&uri) {
Ok(u) => backend().snapshot(&u, &uuid),
Err(_) => {
record_error(key, "invalid storage uri");
continue;
}
};
match res {
Ok(()) => {
let mut g = bindings().lock().unwrap_or_else(|e| e.into_inner());
if let Some(b) = g.get_mut(&key) {
// Only clear when no write landed since the snapshot
// started (the generation counter detects it).
if b.write_gen == gen {
b.dirty = false;
}
}
}
Err(e) => record_error(key, &format!("{e}")),
}
}
}
// ---------------------------------------------------------------------------
// Exit flush
// ---------------------------------------------------------------------------
/// The exit path: stop the snapshot thread, then save + snapshot every
/// still-bound project (plan §2 "退出前 flush"). Idempotent; safe to call
/// again after new projects are bound (the thread restarts on the next
/// bind).
pub fn flush_all() {
let _ = catch_unwind(AssertUnwindSafe(|| {
// 1. Stop the thread and wait for it to finish its pass.
{
let mut rt = SNAPSHOT.lock().unwrap_or_else(|e| e.into_inner());
rt.stop = true;
}
SNAPSHOT_CV.notify_all();
{
let mut rt = SNAPSHOT.lock().unwrap_or_else(|e| e.into_inner());
if let Some(h) = rt.handle.take() {
drop(rt);
let _ = h.join();
}
}
// 2. Drain every binding (write-through + snapshot).
let keys: Vec<usize> = {
let g = bindings().lock().unwrap_or_else(|e| e.into_inner());
g.keys().copied().collect()
};
for key in keys {
flush_one(key);
}
}));
}
/// Save + snapshot one binding when it has pending writes (nothing to do
/// for a clean project — flush must not invent library rows).
fn flush_one(key: usize) {
let dirty = {
let g = bindings().lock().unwrap_or_else(|e| e.into_inner());
g.get(&key).map(|b| b.dirty).unwrap_or(false)
};
if !dirty {
return;
}
write_through(key);
// Snapshot the head the write-through just reached (capture the
// generation AFTER the write so the drain clears the dirty flag).
let job = {
let g = bindings().lock().unwrap_or_else(|e| e.into_inner());
match g.get(&key) {
Some(b) => (b.uri.clone(), b.uuid.clone(), b.write_gen),
None => return,
}
};
let res = match StorageUri::parse(&job.0) {
Ok(u) => backend().snapshot(&u, &job.1),
Err(_) => {
record_error(key, "invalid storage uri");
return;
}
};
match res {
Ok(()) => {
let mut g = bindings().lock().unwrap_or_else(|e| e.into_inner());
if let Some(b) = g.get_mut(&key) {
if b.write_gen == job.2 {
b.dirty = false;
}
}
}
Err(e) => record_error(key, &format!("{e}")),
}
}