Skip to main content

dynoxide/actions/
vector_lifecycle.rs

1//! Creation lifecycle for a vector index added to a live table.
2//!
3//! Real DynamoDB puts an index added through `UpdateTable` through a visible
4//! `CREATING` phase before reporting it `ACTIVE`, refuses a search for the
5//! whole of it, refuses to drop the table underneath it, and then keeps
6//! refusing searches for minutes after it reports `ACTIVE` (characterised in
7//! eu-west-2 on 2026-08-11 and 2026-08-21). Dynoxide's backfill is
8//! synchronous, so without this the index is searchable in the same breath as
9//! the call that added it, and a readiness check that polls for `ACTIVE` and
10//! then searches passes here while failing against AWS.
11//!
12//! Status is derived from when the index was armed rather than stored as a
13//! state that observation advances. That is what makes the `DeleteTable`
14//! refusal a property rather than a budget: AWS refuses for the whole
15//! backfill however many times you ask, and the documented readiness pattern
16//! polls `DescribeTable` an unbounded number of times. Nothing here mutates on
17//! read, so two surfaces sharing one engine, or two clients polling at once,
18//! get the same answer in any order at any concurrency.
19//!
20//! Time comes from the [`Clock`](crate::storage_backend::Clock) the backend
21//! already carries, so this repo's own tests drive it with a `ManualClock` and
22//! never wait. An out-of-process caller sees the real window below.
23//!
24//! The `CreateTable` path is deliberately not armed: measured in eu-west-2 on
25//! 2026-08-21, an index created with its table reaches `ACTIVE` in the same
26//! `DescribeTable` poll as the table itself, so a window there would be a new
27//! divergence rather than a fix.
28
29use std::collections::HashMap;
30use std::sync::Mutex;
31
32/// How long an index added through `UpdateTable` spends allocating resources
33/// before it starts backfilling.
34///
35/// `CREATING` is two phases on AWS, not one. For the first of them the table
36/// reports `UPDATING`, `Backfilling` reports `false`, and a `Delete` of the
37/// index is refused outright; DynamoDB says so in as many words, telling you to
38/// retry during backfilling. Measured at roughly thirty seconds in eu-west-2 on
39/// 2026-08-23; compressed here for the same reason the other windows are.
40pub const BACKFILLING_AFTER_SECS: f64 = 5.0;
41
42/// How long an index added through `UpdateTable` reports `CREATING` before it
43/// reports `ACTIVE`.
44///
45/// AWS took about seventeen minutes for a 25-item table. The shape and the
46/// ordering are what conformance asserts, not the magnitude, so this is chosen
47/// to be comfortably observable by an out-of-process poller and no longer than
48/// it needs to be. The conformance suite polls this walk at 1s and 5s against
49/// 300s and 2400s ceilings, so the margin is two orders of magnitude either
50/// side of any poll shape it might adopt.
51pub const ACTIVE_AFTER_SECS: f64 = 15.0;
52
53/// How long an index added through `UpdateTable` refuses a search for.
54///
55/// Longer than [`ACTIVE_AFTER_SECS`] on purpose: an index reports `ACTIVE`
56/// before it will answer, which is the trap a poll-until-ACTIVE readiness
57/// check falls into on AWS. Reproducing only the first threshold would leave
58/// that check passing here and failing there.
59pub const SEARCHABLE_AFTER_SECS: f64 = 30.0;
60
61/// Where an index sits in the creation lifecycle at one instant.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub(crate) enum VectorIndexPhase {
64    /// Allocating resources. The table reports `UPDATING`, the index
65    /// `CREATING` with `Backfilling` false, searches refuse, and the index
66    /// cannot be dropped yet.
67    Allocating,
68    /// Backfilling. The table is back to `ACTIVE`, the index still reports
69    /// `CREATING` with `Backfilling` true, searches refuse, and the index can
70    /// now be dropped, which is how a create is cancelled.
71    Backfilling,
72    /// Reports `ACTIVE` with `Backfilling` absent; searches still refuse.
73    ActiveNotSearchable,
74    /// Reports `ACTIVE` with `Backfilling` absent; searches are served.
75    Searchable,
76}
77
78impl VectorIndexPhase {
79    /// The `IndexStatus` string this phase reports.
80    pub(crate) fn index_status(self) -> &'static str {
81        match self {
82            Self::Allocating | Self::Backfilling => "CREATING",
83            Self::ActiveNotSearchable | Self::Searchable => "ACTIVE",
84        }
85    }
86
87    /// The `Backfilling` field this phase reports, `None` meaning the field is
88    /// absent rather than `false`.
89    pub(crate) fn backfilling(self) -> Option<bool> {
90        match self {
91            Self::Allocating => Some(false),
92            Self::Backfilling => Some(true),
93            Self::ActiveNotSearchable | Self::Searchable => None,
94        }
95    }
96
97    /// Whether a search is served.
98    pub(crate) fn is_searchable(self) -> bool {
99        matches!(self, Self::Searchable)
100    }
101
102    /// Whether the index still counts as being created, which is what holds a
103    /// `DeleteTable` off. True for both halves of `CREATING`.
104    pub(crate) fn is_creating(self) -> bool {
105        matches!(self, Self::Allocating | Self::Backfilling)
106    }
107
108    /// Whether this phase holds the base table at `UPDATING`. Only the
109    /// allocating half does; the table is back to `ACTIVE` for the backfill,
110    /// which is the state a readiness check should be written against.
111    pub(crate) fn holds_table_updating(self) -> bool {
112        matches!(self, Self::Allocating)
113    }
114
115    /// Whether an `UpdateTable` may drop this index. Refused while it is
116    /// allocating, which is what makes cancelling a create a two-step wait
117    /// rather than something you can do the instant the create returns.
118    pub(crate) fn accepts_delete(self) -> bool {
119        !matches!(self, Self::Allocating)
120    }
121}
122
123/// Derive the phase from when the index was armed and what the clock now says.
124///
125/// An index with no arming instant is `Searchable`: that is the `CreateTable`
126/// path, and it is also every index after a restart, since the map is session
127/// scoped. Both are harmless only because the backfill is synchronous, so the
128/// data behind the index is already complete when arming happens and the one
129/// thing lost is the wait. It would stop being harmless if backfill ever became
130/// incremental.
131///
132/// A clock that has gone backwards since arming yields a negative elapsed and
133/// so reports `Allocating`, which is the same answer as an index armed this
134/// instant.
135pub(crate) fn phase_of(armed_at: Option<f64>, now: f64) -> VectorIndexPhase {
136    let Some(armed_at) = armed_at else {
137        return VectorIndexPhase::Searchable;
138    };
139    let elapsed = now - armed_at;
140    if elapsed < BACKFILLING_AFTER_SECS {
141        VectorIndexPhase::Allocating
142    } else if elapsed < ACTIVE_AFTER_SECS {
143        VectorIndexPhase::Backfilling
144    } else if elapsed < SEARCHABLE_AFTER_SECS {
145        VectorIndexPhase::ActiveNotSearchable
146    } else {
147        VectorIndexPhase::Searchable
148    }
149}
150
151/// The phase every vector index of one table is in, resolved at one instant.
152///
153/// Taken as an input by [`build_table_description`](super::build_table_description)
154/// so two responses describing the same table cannot disagree about an index,
155/// and so a caller with no lifecycle to consult (the `CreateTable` path) says
156/// so by passing an empty set rather than by taking a different code path.
157#[derive(Debug, Clone, Default)]
158pub(crate) struct VectorIndexPhases(HashMap<String, VectorIndexPhase>);
159
160impl VectorIndexPhases {
161    /// The phase for one index. An index this set says nothing about is
162    /// `Searchable`, which is the `CreateTable` path.
163    pub(crate) fn get(&self, index_name: &str) -> VectorIndexPhase {
164        self.0
165            .get(index_name)
166            .copied()
167            .unwrap_or(VectorIndexPhase::Searchable)
168    }
169
170    /// Whether any index in the set is still allocating, which is what holds
171    /// the base table at `UPDATING`.
172    pub(crate) fn any_holding_table_updating(&self) -> bool {
173        self.0.values().any(|p| p.holds_table_updating())
174    }
175
176    /// Whether any index in the set is still creating, which is what a
177    /// `DeleteTable` refuses on.
178    ///
179    /// Only meaningful on a set built from the table's own definitions, as
180    /// [`VectorIndexLifecycle::phases_of`] builds one. Asked of an unfiltered
181    /// set it would let a stale entry, for an index the table has since
182    /// dropped, refuse the delete forever.
183    pub(crate) fn any_creating(&self) -> bool {
184        self.0.values().any(|p| p.is_creating())
185    }
186}
187
188/// When each vector index added through `UpdateTable` was armed, for one
189/// engine instance.
190///
191/// Held on [`Database`](crate::Database) beside the idempotency caches and
192/// shared by every clone of it, so the wire, MCP, and wasm surfaces all read
193/// one answer. Session scoped: it does not survive a restart, and an index
194/// armed before one reports `ACTIVE` afterwards.
195#[derive(Default)]
196pub struct VectorIndexLifecycle {
197    /// Table name to index name to the epoch second the index was armed.
198    armed: Mutex<HashMap<String, HashMap<String, f64>>>,
199}
200
201impl VectorIndexLifecycle {
202    /// An engine instance with nothing creating.
203    pub fn new() -> Self {
204        Self::default()
205    }
206
207    /// The armed map, recovering rather than propagating a poisoned lock.
208    ///
209    /// The map holds only `String` and `f64`, so nothing under this lock can
210    /// panic and leave it inconsistent. Treating a poisoning as fatal-but-silent
211    /// would be worse than recovering: every read would fall back to "nothing
212    /// armed", which is exactly the pre-lifecycle behaviour, so an engine would
213    /// quietly stop reporting the window rather than say anything had gone
214    /// wrong.
215    fn armed(&self) -> std::sync::MutexGuard<'_, HashMap<String, HashMap<String, f64>>> {
216        self.armed.lock().unwrap_or_else(|e| e.into_inner())
217    }
218
219    /// Record that `index_name` on `table_name` started creating now.
220    ///
221    /// Re-arming an index that already has an entry restarts its window, which
222    /// is what dropping and re-adding an index under the same name should do.
223    pub(crate) fn arm<S: crate::storage_backend::StorageBackend>(
224        &self,
225        storage: &S,
226        table_name: &str,
227        index_name: &str,
228    ) {
229        self.arm_at(table_name, index_name, now(storage));
230    }
231
232    /// Forget one index, whether or not it finished creating. Cancelling a
233    /// still-creating index has to clear its entry or the table it sits on
234    /// stays undeletable.
235    pub(crate) fn disarm(&self, table_name: &str, index_name: &str) {
236        let mut armed = self.armed();
237        if let Some(table) = armed.get_mut(table_name) {
238            table.remove(index_name);
239            if table.is_empty() {
240                armed.remove(table_name);
241            }
242        }
243    }
244
245    /// Forget every index on a table, for the path that drops it.
246    pub(crate) fn forget_table(&self, table_name: &str) {
247        self.armed().remove(table_name);
248    }
249
250    /// Forget every index on every table, for a restore that replaces the
251    /// tables the windows were armed on.
252    #[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
253    pub(crate) fn forget_all(&self) {
254        self.armed().clear();
255    }
256
257    /// Whether any index is armed on any table.
258    #[cfg(test)]
259    pub(crate) fn is_empty(&self) -> bool {
260        self.armed().is_empty()
261    }
262
263    /// The phase of every index this engine has armed on `table_name`, read at
264    /// the backend's own clock.
265    ///
266    /// Not filtered against the table's definitions. The caller that reports
267    /// these walks the definitions itself and asks by name, so an index the
268    /// table does not define is never looked up, and filtering would buy
269    /// nothing while costing a parse of the definition JSON on every describe.
270    /// For the same reason [`VectorIndexPhases::any_creating`] is not meaningful
271    /// on what this returns.
272    pub(crate) fn phases_armed_on<S: crate::storage_backend::StorageBackend>(
273        &self,
274        storage: &S,
275        table_name: &str,
276    ) -> VectorIndexPhases {
277        self.phases_armed_on_at(table_name, now(storage))
278    }
279
280    /// The phase of each index in `index_names`, for the caller that asks
281    /// whether *any* index is creating rather than about one it can name.
282    ///
283    /// Restricting it to the indexes the table still defines is what stops a
284    /// stale entry, for an index that has since gone, from refusing a
285    /// `DeleteTable`.
286    pub(crate) fn phases_of<'a, S: crate::storage_backend::StorageBackend>(
287        &self,
288        storage: &S,
289        table_name: &str,
290        index_names: impl Iterator<Item = &'a str>,
291    ) -> VectorIndexPhases {
292        self.phases_of_at(table_name, index_names, now(storage))
293    }
294
295    /// The phase of one index the caller has already resolved against the
296    /// table's definitions.
297    ///
298    /// For a path that holds the definition and would otherwise parse the
299    /// table's vector index JSON a second time to ask one question. Resolving
300    /// the name is all [`Self::phases_of`] wants the definitions for, so a
301    /// caller that has already resolved it loses nothing by skipping them.
302    pub(crate) fn phase_of_index<S: crate::storage_backend::StorageBackend>(
303        &self,
304        storage: &S,
305        table_name: &str,
306        index_name: &str,
307    ) -> VectorIndexPhase {
308        phase_of(self.armed_at(table_name, index_name), now(storage))
309    }
310
311    // The clock-free forms below carry the logic, so the unit tests can pin
312    // every edge of it without a backend.
313
314    fn arm_at(&self, table_name: &str, index_name: &str, now: f64) {
315        self.armed()
316            .entry(table_name.to_string())
317            .or_default()
318            .insert(index_name.to_string(), now);
319    }
320
321    /// When one index was armed, or `None` if it was not.
322    fn armed_at(&self, table_name: &str, index_name: &str) -> Option<f64> {
323        self.armed().get(table_name)?.get(index_name).copied()
324    }
325
326    fn phases_armed_on_at(&self, table_name: &str, now: f64) -> VectorIndexPhases {
327        VectorIndexPhases(
328            self.armed()
329                .get(table_name)
330                .into_iter()
331                .flatten()
332                .map(|(name, armed_at)| (name.clone(), phase_of(Some(*armed_at), now)))
333                .collect(),
334        )
335    }
336
337    fn phases_of_at<'a>(
338        &self,
339        table_name: &str,
340        index_names: impl Iterator<Item = &'a str>,
341        now: f64,
342    ) -> VectorIndexPhases {
343        let armed = self.armed();
344        let table = armed.get(table_name);
345        VectorIndexPhases(
346            index_names
347                .map(|name| {
348                    let armed_at = table.and_then(|t| t.get(name)).copied();
349                    (name.to_string(), phase_of(armed_at, now))
350                })
351                .collect(),
352        )
353    }
354}
355
356/// The backend's own clock, as fractional epoch seconds.
357fn now<S: crate::storage_backend::StorageBackend>(storage: &S) -> f64 {
358    storage.clock().now_unix_secs_f64()
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    const ARMED: f64 = 1_700_000_000.0;
366
367    #[test]
368    fn while_allocating_the_table_updates_and_the_index_refuses_everything() {
369        let phase = phase_of(Some(ARMED), ARMED + BACKFILLING_AFTER_SECS - 0.001);
370        assert_eq!(phase, VectorIndexPhase::Allocating);
371        assert_eq!(phase.index_status(), "CREATING");
372        assert_eq!(phase.backfilling(), Some(false));
373        assert!(!phase.is_searchable());
374        assert!(phase.is_creating());
375        assert!(phase.holds_table_updating());
376        assert!(!phase.accepts_delete());
377    }
378
379    #[test]
380    fn while_backfilling_the_table_is_active_and_the_index_takes_a_delete() {
381        let phase = phase_of(Some(ARMED), ARMED + ACTIVE_AFTER_SECS - 0.001);
382        assert_eq!(phase, VectorIndexPhase::Backfilling);
383        assert_eq!(phase.index_status(), "CREATING");
384        assert_eq!(phase.backfilling(), Some(true));
385        assert!(!phase.is_searchable());
386        assert!(phase.is_creating());
387        assert!(!phase.holds_table_updating());
388        assert!(phase.accepts_delete());
389    }
390
391    #[test]
392    fn between_the_thresholds_reports_active_and_still_refuses_a_search() {
393        let phase = phase_of(Some(ARMED), ARMED + ACTIVE_AFTER_SECS + 0.001);
394        assert_eq!(phase, VectorIndexPhase::ActiveNotSearchable);
395        assert_eq!(phase.index_status(), "ACTIVE");
396        assert_eq!(phase.backfilling(), None);
397        assert!(!phase.is_searchable());
398        assert!(!phase.is_creating());
399        assert!(!phase.holds_table_updating());
400        assert!(phase.accepts_delete());
401    }
402
403    #[test]
404    fn beyond_the_searchable_threshold_serves_the_search() {
405        let phase = phase_of(Some(ARMED), ARMED + SEARCHABLE_AFTER_SECS + 0.001);
406        assert_eq!(phase, VectorIndexPhase::Searchable);
407        assert_eq!(phase.index_status(), "ACTIVE");
408        assert_eq!(phase.backfilling(), None);
409        assert!(phase.is_searchable());
410    }
411
412    /// Both thresholds are exclusive lower bounds on the phase they end, so
413    /// exactly on one resolves to the later phase.
414    #[test]
415    fn exactly_on_a_threshold_resolves_to_the_later_phase() {
416        assert_eq!(
417            phase_of(Some(ARMED), ARMED + BACKFILLING_AFTER_SECS),
418            VectorIndexPhase::Backfilling
419        );
420        assert_eq!(
421            phase_of(Some(ARMED), ARMED + ACTIVE_AFTER_SECS),
422            VectorIndexPhase::ActiveNotSearchable
423        );
424        assert_eq!(
425            phase_of(Some(ARMED), ARMED + SEARCHABLE_AFTER_SECS),
426            VectorIndexPhase::Searchable
427        );
428    }
429
430    #[test]
431    fn an_index_with_no_entry_is_active_and_searchable() {
432        let phase = phase_of(None, ARMED);
433        assert_eq!(phase, VectorIndexPhase::Searchable);
434        assert_eq!(phase.index_status(), "ACTIVE");
435        assert_eq!(phase.backfilling(), None);
436        assert!(phase.is_searchable());
437    }
438
439    #[test]
440    fn a_clock_that_went_backwards_reads_as_just_armed() {
441        assert_eq!(
442            phase_of(Some(ARMED), ARMED - 60.0),
443            VectorIndexPhase::Allocating
444        );
445    }
446
447    #[test]
448    fn reading_does_not_change_what_a_later_read_returns() {
449        let lifecycle = VectorIndexLifecycle::new();
450        lifecycle.arm_at("t", "vix", ARMED);
451
452        for _ in 0..50 {
453            let phases = lifecycle.phases_of_at("t", ["vix"].into_iter(), ARMED + 1.0);
454            assert_eq!(phases.get("vix"), VectorIndexPhase::Allocating);
455            assert!(phases.any_creating());
456        }
457
458        let later = lifecycle.phases_of_at("t", ["vix"].into_iter(), ARMED + SEARCHABLE_AFTER_SECS);
459        assert_eq!(later.get("vix"), VectorIndexPhase::Searchable);
460        assert!(!later.any_creating());
461    }
462
463    /// The describe path asks by name from the table's own definitions, so the
464    /// unfiltered set may name an index the table has since dropped. It must
465    /// still answer correctly for the ones it does define, and must not report
466    /// another table's.
467    #[test]
468    fn the_unfiltered_set_carries_every_armed_index_on_that_table_alone() {
469        let lifecycle = VectorIndexLifecycle::new();
470        lifecycle.arm_at("t", "a", ARMED);
471        lifecycle.arm_at("t", "b", ARMED - SEARCHABLE_AFTER_SECS);
472        lifecycle.arm_at("other", "c", ARMED);
473
474        let phases = lifecycle.phases_armed_on_at("t", ARMED);
475        assert_eq!(phases.get("a"), VectorIndexPhase::Allocating);
476        assert_eq!(phases.get("b"), VectorIndexPhase::Searchable);
477        assert_eq!(phases.get("c"), VectorIndexPhase::Searchable);
478        assert!(phases.any_creating());
479
480        let none = lifecycle.phases_armed_on_at("nothing-armed-here", ARMED);
481        assert_eq!(none.get("a"), VectorIndexPhase::Searchable);
482        assert!(!none.any_creating());
483    }
484
485    #[test]
486    fn an_index_the_table_no_longer_defines_is_not_reported() {
487        let lifecycle = VectorIndexLifecycle::new();
488        lifecycle.arm_at("t", "gone", ARMED);
489
490        let phases = lifecycle.phases_of_at("t", ["still-here"].into_iter(), ARMED + 1.0);
491        assert_eq!(phases.get("still-here"), VectorIndexPhase::Searchable);
492        assert!(!phases.any_creating());
493    }
494
495    #[test]
496    fn disarming_forgets_one_index_and_leaves_its_neighbour() {
497        let lifecycle = VectorIndexLifecycle::new();
498        lifecycle.arm_at("t", "a", ARMED);
499        lifecycle.arm_at("t", "b", ARMED);
500        lifecycle.disarm("t", "a");
501
502        let phases = lifecycle.phases_of_at("t", ["a", "b"].into_iter(), ARMED + 1.0);
503        assert_eq!(phases.get("a"), VectorIndexPhase::Searchable);
504        assert_eq!(phases.get("b"), VectorIndexPhase::Allocating);
505    }
506
507    #[test]
508    fn forgetting_a_table_clears_every_index_on_it() {
509        let lifecycle = VectorIndexLifecycle::new();
510        lifecycle.arm_at("t", "a", ARMED);
511        lifecycle.arm_at("other", "a", ARMED);
512        lifecycle.forget_table("t");
513
514        let gone = lifecycle.phases_of_at("t", ["a"].into_iter(), ARMED + 1.0);
515        assert_eq!(gone.get("a"), VectorIndexPhase::Searchable);
516        let kept = lifecycle.phases_of_at("other", ["a"].into_iter(), ARMED + 1.0);
517        assert_eq!(kept.get("a"), VectorIndexPhase::Allocating);
518    }
519
520    #[test]
521    fn re_arming_restarts_the_window() {
522        let lifecycle = VectorIndexLifecycle::new();
523        lifecycle.arm_at("t", "vix", ARMED);
524        lifecycle.arm_at("t", "vix", ARMED + SEARCHABLE_AFTER_SECS);
525
526        let phases = lifecycle.phases_of_at(
527            "t",
528            ["vix"].into_iter(),
529            ARMED + SEARCHABLE_AFTER_SECS + 1.0,
530        );
531        assert_eq!(phases.get("vix"), VectorIndexPhase::Allocating);
532    }
533}