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}