Skip to main content

artifactize_core/
changes.rs

1//! Best-effort, state-scoped invalidation hints. SQLite and session files remain authoritative.
2//! The first subscriber temporarily hosts a hub; there is no daemon and writers never elect one.
3mod probe;
4#[cfg(test)]
5mod tests;
6mod transport;
7
8use crate::types::SessionId;
9use serde::{Deserialize, Serialize};
10use std::{
11    collections::{BTreeMap, BTreeSet},
12    path::Path,
13    sync::{Arc, Mutex, OnceLock, Weak},
14    time::Duration,
15};
16use tokio::{
17    io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt},
18    sync::{Notify, broadcast, oneshot},
19    task::JoinSet,
20};
21use tokio_util::sync::CancellationToken;
22use transport::Endpoint;
23
24/// Reconcile missed commits and database replacement without reloading idle snapshots.
25pub const RECONCILE: Duration = Duration::from_secs(5);
26/// A dead hub is replaced promptly, without a spin loop or a permanent process.
27const RECONNECT: Duration = Duration::from_millis(500);
28/// Bound startup and short-command shutdown; notification availability cannot block correctness.
29const DELIVERY_TIMEOUT: Duration = Duration::from_millis(300);
30/// Bound hostile frames before allocation (IDs are at most 200 bytes).
31const MAX_FRAME_BYTES: usize = 1024;
32/// Bound per-process publishers, per-hub connections, broadcast backlog and session dirty sets.
33const MAX_STATES: usize = 32;
34const MAX_CLIENTS: usize = 64;
35const MAX_BACKLOG: usize = 64;
36const MAX_DIRTY_SESSIONS: usize = 64;
37/// Version 1 is the first ephemeral invalidation wire format, independent of the state
38/// database schema and durable session-event format. The Hello handshake rejects any
39/// unsupported version instead of interpreting incompatible hints; readers reconcile safely.
40const VERSION: u32 = 1;
41
42/// Hints carry no records, credentials, scheduler decisions or remote-store events.
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44pub enum Change {
45    StateInvalidated,
46    SessionInvalidated(SessionId),
47    /// Initial registration, reconnect, gap, overflow or database replacement: read afresh.
48    Resync,
49}
50
51/// Changes not yet delivered: a resync replaces everything else pending.
52enum Dirty {
53    Pending {
54        state: bool,
55        sessions: BTreeSet<SessionId>,
56    },
57    Resync,
58}
59impl Default for Dirty {
60    fn default() -> Self {
61        Self::Pending {
62            state: false,
63            sessions: BTreeSet::new(),
64        }
65    }
66}
67impl Dirty {
68    fn add(&mut self, change: Change) {
69        let Self::Pending { state, sessions } = self else {
70            return;
71        };
72        match change {
73            Change::Resync => *self = Self::Resync,
74            Change::StateInvalidated => *state = true,
75            Change::SessionInvalidated(id) => {
76                sessions.insert(id);
77                if sessions.len() > MAX_DIRTY_SESSIONS {
78                    *self = Self::Resync;
79                }
80            }
81        }
82    }
83    fn pop(&mut self) -> Option<Change> {
84        match self {
85            Self::Resync => {
86                *self = Self::default();
87                Some(Change::Resync)
88            }
89            Self::Pending { state, sessions } => {
90                if std::mem::take(state) {
91                    Some(Change::StateInvalidated)
92                } else {
93                    sessions.pop_first().map(Change::SessionInvalidated)
94                }
95            }
96        }
97    }
98    fn is_empty(&self) -> bool {
99        matches!(self, Self::Pending { state: false, sessions } if sessions.is_empty())
100    }
101}
102struct Inbox {
103    #[cfg(test)]
104    registration: tokio::sync::watch::Sender<Option<crate::config::HubEpoch>>,
105    dirty: Mutex<Dirty>,
106    wake: Notify,
107}
108impl Default for Inbox {
109    fn default() -> Self {
110        Self {
111            #[cfg(test)]
112            registration: tokio::sync::watch::channel(None).0,
113            dirty: Mutex::default(),
114            wake: Notify::new(),
115        }
116    }
117}
118impl Inbox {
119    fn add(&self, change: Change) {
120        self.dirty.lock().unwrap().add(change);
121        self.wake.notify_one();
122    }
123    async fn next(&self) -> Change {
124        loop {
125            // Register before checking: a hint arriving between check and await is retained.
126            let wake = self.wake.notified();
127            if let Some(change) = self.dirty.lock().unwrap().pop() {
128                return change;
129            }
130            wake.await;
131        }
132    }
133}
134
135/// Register before reading the baseline. Dirties arriving during that read stay queued.
136/// Dropping the subscription stops its connection, probe and any hub it owns.
137pub struct Subscription {
138    inbox: Arc<Inbox>,
139    cancel: CancellationToken,
140}
141impl Subscription {
142    pub async fn new(state: &Path) -> Self {
143        let inbox = Arc::new(Inbox::default());
144        let cancel = CancellationToken::new();
145        let (ready, registered) = oneshot::channel();
146        let state = state.to_path_buf();
147        tokio::spawn(watch(state, inbox.clone(), cancel.clone(), ready));
148        // Keep cancellation ownership even if the caller abandons registration midway.
149        let subscription = Self { inbox, cancel };
150        // An unavailable or unsafe endpoint degrades, never prevents a baseline read.
151        let _ = registered.await;
152        subscription
153    }
154    pub async fn next(&mut self) -> Change {
155        self.inbox.next().await
156    }
157    /// Schedulers and non-session UIs can ignore session-only hints without reloading state.
158    pub async fn next_state(&mut self) -> Change {
159        loop {
160            let change = self.next().await;
161            if !matches!(change, Change::SessionInvalidated(_)) {
162                return change;
163            }
164        }
165    }
166}
167impl Drop for Subscription {
168    fn drop(&mut self) {
169        self.cancel.cancel();
170    }
171}
172
173#[derive(Default)]
174struct Outbox {
175    dirty: Dirty,
176    submitted: u64,
177    delivered: u64,
178}
179struct Publishing {
180    endpoint: Endpoint,
181    pending: Mutex<Outbox>,
182    wake: Notify,
183    drained: Notify,
184}
185/// Cloneable synchronous producer, also safe to invoke on the SQLite worker thread.
186#[derive(Clone, Default)]
187pub(crate) struct Publisher(Option<Arc<Publishing>>);
188static PUBLISHERS: OnceLock<Mutex<BTreeMap<crate::config::EndpointId, Weak<Publishing>>>> =
189    OnceLock::new();
190impl Publisher {
191    pub(crate) fn new(state: &Path) -> Self {
192        let Ok(runtime) = tokio::runtime::Handle::try_current() else {
193            return Self::default();
194        };
195        let Ok(endpoint) = Endpoint::new(state) else {
196            return Self::default();
197        };
198        let mut publishers = PUBLISHERS.get_or_init(Default::default).lock().unwrap();
199        publishers.retain(|_, publisher| publisher.strong_count() != 0);
200        if let Some(publisher) = publishers.get(&endpoint.identity).and_then(Weak::upgrade) {
201            return Self(Some(publisher));
202        }
203        if publishers.len() >= MAX_STATES {
204            return Self::default();
205        }
206        let publisher = Arc::new(Publishing {
207            endpoint,
208            pending: Mutex::new(Outbox::default()),
209            wake: Notify::new(),
210            drained: Notify::new(),
211        });
212        publishers.insert(
213            publisher.endpoint.identity.clone(),
214            Arc::downgrade(&publisher),
215        );
216        runtime.spawn(publish(publisher.clone()));
217        Self(Some(publisher))
218    }
219    pub(crate) fn notify(&self, change: Change) {
220        if let Some(publisher) = &self.0 {
221            let mut pending = publisher.pending.lock().unwrap();
222            pending.dirty.add(change);
223            pending.submitted = pending.submitted.wrapping_add(1);
224            drop(pending);
225            publisher.wake.notify_one();
226        }
227    }
228}
229
230/// Drain short-lived commands centrally. Only bounded hints are sent; a failed delivery is safe.
231pub async fn drain() {
232    let publishers: Vec<_> = PUBLISHERS
233        .get()
234        .map(|publishers| {
235            publishers
236                .lock()
237                .unwrap()
238                .values()
239                .filter_map(Weak::upgrade)
240                .collect()
241        })
242        .unwrap_or_default();
243    let wait = async {
244        for publisher in publishers {
245            let target = publisher.pending.lock().unwrap().submitted;
246            loop {
247                let drained = publisher.drained.notified();
248                if publisher.pending.lock().unwrap().delivered >= target {
249                    break;
250                }
251                drained.await;
252            }
253        }
254    };
255    let _ = tokio::time::timeout(DELIVERY_TIMEOUT, wait).await;
256}
257
258#[derive(Serialize, Deserialize)]
259enum Frame {
260    Hello {
261        version: u32,
262        identity: crate::config::EndpointId,
263        subscriber: bool,
264    },
265    Registered {
266        epoch: crate::config::HubEpoch,
267    },
268    Publish(Change),
269    Delivered,
270    Hint {
271        epoch: crate::config::HubEpoch,
272        sequence: u64,
273        change: Change,
274    },
275}
276async fn read_frame<R: AsyncRead + Unpin>(reader: &mut R) -> std::io::Result<Frame> {
277    let bytes = reader.read_u32().await? as usize;
278    if bytes == 0 || bytes > MAX_FRAME_BYTES {
279        return Err(std::io::ErrorKind::InvalidData.into());
280    }
281    let mut data = vec![0; bytes];
282    reader.read_exact(&mut data).await?;
283    serde_json::from_slice(&data).map_err(std::io::Error::other)
284}
285async fn write_frame<W: AsyncWrite + Unpin>(writer: &mut W, frame: &Frame) -> std::io::Result<()> {
286    let data = serde_json::to_vec(frame).map_err(std::io::Error::other)?;
287    if data.len() > MAX_FRAME_BYTES {
288        return Err(std::io::ErrorKind::InvalidData.into());
289    }
290    writer.write_u32(data.len() as u32).await?;
291    writer.write_all(&data).await?;
292    writer.flush().await
293}
294async fn connect(
295    endpoint: &Endpoint,
296    subscriber: bool,
297) -> std::io::Result<(transport::Stream, crate::config::HubEpoch)> {
298    let mut stream = endpoint.connect().await?;
299    write_frame(
300        &mut stream,
301        &Frame::Hello {
302            version: VERSION,
303            identity: endpoint.identity.clone(),
304            subscriber,
305        },
306    )
307    .await?;
308    match read_frame(&mut stream).await? {
309        Frame::Registered { epoch } if epoch.len() <= crate::types::MAX_ID_BYTES => {
310            Ok((stream, epoch))
311        }
312        _ => Err(std::io::ErrorKind::InvalidData.into()),
313    }
314}
315async fn publish(publisher: Arc<Publishing>) {
316    loop {
317        let wake = publisher.wake.notified();
318        if publisher.pending.lock().unwrap().dirty.is_empty() {
319            tokio::select! {
320                _ = wake => {},
321                _ = tokio::time::sleep(RECONNECT) => {
322                    // The task must not retain a publisher forever after its writers disappear.
323                    if Arc::strong_count(&publisher) == 1 {
324                        break;
325                    }
326                    continue;
327                }
328            }
329        }
330        let (mut dirty, target) = {
331            let mut pending = publisher.pending.lock().unwrap();
332            (std::mem::take(&mut pending.dirty), pending.submitted)
333        };
334        // No retries when no reader exists: future registration and the probe recover all state.
335        let delivery = async {
336            let (mut stream, _) = connect(&publisher.endpoint, false).await?;
337            while let Some(change) = dirty.pop() {
338                write_frame(&mut stream, &Frame::Publish(change)).await?;
339                if !matches!(read_frame(&mut stream).await?, Frame::Delivered) {
340                    return Err(std::io::ErrorKind::InvalidData.into());
341                }
342            }
343            Ok::<_, std::io::Error>(())
344        };
345        let _ = tokio::time::timeout(DELIVERY_TIMEOUT, delivery).await;
346        publisher.pending.lock().unwrap().delivered = target;
347        publisher.drained.notify_one();
348    }
349}
350
351async fn watch(
352    state: std::path::PathBuf,
353    inbox: Arc<Inbox>,
354    cancel: CancellationToken,
355    ready: oneshot::Sender<()>,
356) {
357    let mut ready = Some(ready);
358    let endpoint = Endpoint::new(&state).ok();
359    let mut hub = None;
360    let mut probe = probe::Probe::new(state);
361    // The baseline probe below already covers startup. An immediate interval tick
362    // could obscure a racing publication with a redundant Resync.
363    let mut reconcile =
364        tokio::time::interval_at(tokio::time::Instant::now() + RECONCILE, RECONCILE);
365    reconcile.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
366    // Establish the cheap persistent baseline before the caller reads its snapshot.
367    let _ = probe.changed().await;
368    loop {
369        if cancel.is_cancelled() {
370            break;
371        }
372        if let Some(endpoint) = &endpoint {
373            if hub
374                .as_ref()
375                .is_some_and(|task: &tokio::task::JoinHandle<()>| task.is_finished())
376            {
377                hub = None;
378            }
379            if hub.is_none()
380                && let Ok(Some(owner)) = endpoint.elect()
381                && let Ok(listener) = endpoint.listen()
382            {
383                let endpoint = endpoint.clone();
384                let token = cancel.child_token();
385                hub = Some(tokio::spawn(async move {
386                    let _ = serve(endpoint, listener, owner, token).await;
387                }));
388            }
389            let connection = tokio::time::timeout(DELIVERY_TIMEOUT, connect(endpoint, true));
390            tokio::select! {
391                _ = cancel.cancelled() => break,
392                result = connection => {
393                    if let Ok(Ok((stream, epoch))) = result {
394                        inbox.add(Change::Resync);
395                        #[cfg(test)]
396                        inbox.registration.send_replace(Some(epoch.clone()));
397                        if let Some(ready) = ready.take() {
398                            let _ = ready.send(());
399                        }
400                        let receive = receive(stream, epoch, inbox.clone());
401                        tokio::pin!(receive);
402                        loop {
403                            tokio::select! {
404                                _ = cancel.cancelled() => break,
405                                _ = &mut receive => {
406                                    #[cfg(test)]
407                                    inbox.registration.send_replace(None);
408                                    inbox.add(Change::Resync);
409                                    break;
410                                }
411                                _ = reconcile.tick() => {
412                                    if probe.changed().await {
413                                        inbox.add(Change::Resync);
414                                    }
415                                }
416                            }
417                        }
418                    }
419                }
420            }
421        }
422        if let Some(ready) = ready.take() {
423            inbox.add(Change::Resync);
424            let _ = ready.send(());
425        }
426        tokio::select! {
427            _ = cancel.cancelled() => break,
428            _ = reconcile.tick() => {
429                let _ = probe.changed().await;
430                // No secure transport: low-frequency authoritative reconciliation also
431                // recovers session-only writes, which SQLite data_version cannot observe.
432                inbox.add(Change::Resync);
433            },
434            _ = tokio::time::sleep(RECONNECT) => {},
435        }
436    }
437    if let Some(task) = hub {
438        task.abort();
439        let _ = crate::task::joined(task.await);
440    }
441}
442async fn receive(
443    mut stream: transport::Stream,
444    epoch: crate::config::HubEpoch,
445    inbox: Arc<Inbox>,
446) -> std::io::Result<()> {
447    let mut previous = None;
448    loop {
449        match read_frame(&mut stream).await? {
450            Frame::Hint {
451                epoch: incoming,
452                sequence,
453                change,
454            } if incoming == epoch => {
455                if previous.is_some_and(|previous: u64| sequence != previous.wrapping_add(1)) {
456                    inbox.add(Change::Resync);
457                }
458                previous = Some(sequence);
459                inbox.add(change);
460            }
461            _ => return Err(std::io::ErrorKind::InvalidData.into()),
462        }
463    }
464}
465
466#[derive(Clone)]
467struct Hint {
468    sequence: u64,
469    change: Change,
470}
471async fn serve(
472    endpoint: Endpoint,
473    mut listener: transport::Listener,
474    _owner: std::fs::File,
475    cancel: CancellationToken,
476) -> std::io::Result<()> {
477    let epoch: crate::config::HubEpoch = crate::agent::uuid()
478        .map_err(std::io::Error::other)?
479        .parse()
480        .expect("UUID hub epoch");
481    let (sender, _) = broadcast::channel(MAX_BACKLOG);
482    let sequence = Arc::new(Mutex::new(0_u64));
483    let mut clients = JoinSet::new();
484    loop {
485        tokio::select! {
486            _ = cancel.cancelled() => break,
487            Some(result) = clients.join_next(), if !clients.is_empty() => {
488                let _ = crate::task::joined(result);
489            },
490            result = listener.accept(), if clients.len() < MAX_CLIENTS => {
491                let stream = result?;
492                let (identity, epoch, sender, sequence) = (
493                    endpoint.identity.clone(),
494                    epoch.clone(),
495                    sender.clone(),
496                    sequence.clone(),
497                );
498                clients.spawn(async move {
499                    let _ = client(stream, identity, epoch, sender, sequence).await;
500                });
501            }
502        }
503    }
504    clients.abort_all();
505    while let Some(result) = clients.join_next().await {
506        let _ = crate::task::joined(result);
507    }
508    Ok(())
509}
510async fn client(
511    mut stream: transport::Stream,
512    identity: crate::config::EndpointId,
513    epoch: crate::config::HubEpoch,
514    sender: broadcast::Sender<Hint>,
515    sequence: Arc<Mutex<u64>>,
516) -> std::io::Result<()> {
517    // Peer lookup can fail after a short-lived connection closes (Darwin ENOTCONN).
518    // Reject only that client, never tear down the listener or other subscribers.
519    transport::validate_peer(&stream)?;
520    let hello = tokio::time::timeout(DELIVERY_TIMEOUT, read_frame(&mut stream)).await??;
521    let Frame::Hello {
522        version: VERSION,
523        identity: incoming,
524        subscriber,
525    } = hello
526    else {
527        return Err(std::io::ErrorKind::InvalidData.into());
528    };
529    if incoming != identity {
530        return Err(std::io::ErrorKind::PermissionDenied.into());
531    }
532    // Register before acknowledging. Broadcast retains any hint that races the baseline read.
533    let mut receiver = sender.subscribe();
534    tokio::time::timeout(
535        DELIVERY_TIMEOUT,
536        write_frame(
537            &mut stream,
538            &Frame::Registered {
539                epoch: epoch.clone(),
540            },
541        ),
542    )
543    .await??;
544    if subscriber {
545        let (mut reader, mut writer) = tokio::io::split(stream);
546        let outgoing = async {
547            loop {
548                let hint = match receiver.recv().await {
549                    Ok(hint) => hint,
550                    Err(broadcast::error::RecvError::Lagged(_)) => Hint {
551                        sequence: *sequence.lock().unwrap(),
552                        change: Change::Resync,
553                    },
554                    Err(_) => return Ok::<_, std::io::Error>(()),
555                };
556                // A slow client loses only hints, not memory or the hub's other clients.
557                tokio::time::timeout(
558                    DELIVERY_TIMEOUT,
559                    write_frame(
560                        &mut writer,
561                        &Frame::Hint {
562                            epoch: epoch.clone(),
563                            sequence: hint.sequence,
564                            change: hint.change,
565                        },
566                    ),
567                )
568                .await??;
569            }
570        };
571        tokio::select! { result = outgoing => result, _ = reader.read_u8() => Ok(()) }
572    } else {
573        loop {
574            let frame = tokio::time::timeout(DELIVERY_TIMEOUT, read_frame(&mut stream)).await??;
575            let Frame::Publish(change) = frame else {
576                return Err(std::io::ErrorKind::InvalidData.into());
577            };
578            {
579                // Serialize sequence assignment and broadcast, including concurrent writers.
580                let mut sequence = sequence.lock().unwrap();
581                *sequence = sequence.wrapping_add(1);
582                let _ = sender.send(Hint {
583                    sequence: *sequence,
584                    change,
585                });
586            }
587            tokio::time::timeout(
588                DELIVERY_TIMEOUT,
589                write_frame(&mut stream, &Frame::Delivered),
590            )
591            .await??;
592        }
593    }
594}