Skip to main content

uni_store/runtime/
id_allocator.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2026 Dragonscale Team
3
4//! ID allocation for vertices and edges using pure auto-increment counters.
5//!
6//! VIDs and EIDs are simple auto-incrementing u64 values. Unlike the previous
7//! design, they no longer embed label/type information - that's now handled
8//! by the VidLabelsIndex and edge tables.
9
10use crate::store_utils::{DEFAULT_TIMEOUT, get_with_timeout, put_with_timeout};
11use anyhow::Result;
12use bytes::Bytes;
13use object_store::path::Path;
14use object_store::{ObjectStore, PutMode, PutOptions, UpdateVersion};
15use serde::{Deserialize, Serialize};
16use std::sync::Arc;
17use tokio::sync::Mutex;
18use uni_common::core::id::{Eid, Vid};
19
20/// Persisted counter manifest - stores the reserved counter ranges.
21#[derive(Serialize, Deserialize, Default, Clone)]
22struct CounterManifest {
23    /// Next VID value that needs to be reserved (end of current batch)
24    next_vid_batch: u64,
25    /// Next EID value that needs to be reserved (end of current batch)
26    next_eid_batch: u64,
27}
28
29/// Internal allocator state - tracks current position within reserved batch.
30struct AllocatorState {
31    manifest: CounterManifest,
32    manifest_version: Option<String>, // ETag for optimistic locking
33    current_vid: u64,
34    current_eid: u64,
35}
36
37/// Allocates globally unique VIDs and EIDs using auto-increment counters.
38///
39/// This allocator uses batch reservation to minimize object store writes:
40/// - Reserves a batch of IDs (e.g., 1000) from the object store
41/// - Allocates from the local batch until exhausted
42/// - Reserves a new batch when needed
43pub struct IdAllocator {
44    store: Arc<dyn ObjectStore>,
45    path: Path,
46    state: Mutex<AllocatorState>,
47    batch_size: u64,
48}
49
50impl IdAllocator {
51    /// Creates a new ID allocator, loading existing state from object store.
52    pub async fn new(store: Arc<dyn ObjectStore>, path: Path, batch_size: u64) -> Result<Self> {
53        let (manifest, version) = match get_with_timeout(&store, &path, DEFAULT_TIMEOUT).await {
54            Ok(get_result) => {
55                let version = get_result.meta.e_tag.clone();
56                let bytes = get_result.bytes().await?;
57                let manifest: CounterManifest = serde_json::from_slice(&bytes)?;
58                (manifest, version)
59            }
60            // Typed, not substring: a transient failure whose message merely
61            // reads like a missing object (proxy 404, wrapped S3 "bucket not
62            // found") would otherwise start the allocator from a defaulted
63            // manifest against a populated database and re-issue live VIDs.
64            // Same rationale as `fork/registry.rs` and `snapshot/manager.rs`.
65            Err(e) if crate::store_utils::is_not_found(&e) => (CounterManifest::default(), None),
66            Err(e) => return Err(e),
67        };
68
69        // Start allocating from where the last batch ended
70        let current_vid = manifest.next_vid_batch;
71        let current_eid = manifest.next_eid_batch;
72
73        Ok(Self {
74            store,
75            path,
76            state: Mutex::new(AllocatorState {
77                manifest,
78                manifest_version: version,
79                current_vid,
80                current_eid,
81            }),
82            batch_size,
83        })
84    }
85
86    /// Allocates a new VID.
87    ///
88    /// Returns a globally unique, auto-incrementing vertex ID.
89    pub async fn allocate_vid(&self) -> Result<Vid> {
90        let mut state = self.state.lock().await;
91
92        // Check if we've exhausted our current batch
93        if state.current_vid >= state.manifest.next_vid_batch {
94            // Reserve a new batch. `checked_add` guards against u64
95            // exhaustion (defense-in-depth; ~1.8e19 ids — physically
96            // unreachable, but wrapping silently would be a correctness
97            // disaster). L13.
98            let reserved = state
99                .current_vid
100                .checked_add(self.batch_size)
101                .ok_or_else(|| anyhow::anyhow!("VID space exhausted"))?;
102            let prev = state.manifest.next_vid_batch;
103            state.manifest.next_vid_batch = reserved;
104            if let Err(e) = self.persist_manifest(&mut state).await {
105                // Roll back the in-memory advance so a retry re-attempts the
106                // DURABLE reservation. Leaving it advanced would let a retry skip
107                // the reservation and hand out ids from a batch a crash would lose
108                // (the on-disk manifest still points at `prev`) — risking id reuse.
109                state.manifest.next_vid_batch = prev;
110                return Err(e);
111            }
112        }
113
114        let vid = Vid::new(state.current_vid);
115        state.current_vid += 1;
116        Ok(vid)
117    }
118
119    /// Allocates multiple VIDs at once.
120    pub async fn allocate_vids(&self, count: usize) -> Result<Vec<Vid>> {
121        let mut state = self.state.lock().await;
122        let needed = count as u64;
123
124        // Check if we need to expand our batch (L13: checked).
125        let want = state
126            .current_vid
127            .checked_add(needed)
128            .ok_or_else(|| anyhow::anyhow!("VID space exhausted"))?;
129        if want > state.manifest.next_vid_batch {
130            // Reserve enough for the request plus a full batch
131            let reserved = want
132                .checked_add(self.batch_size)
133                .ok_or_else(|| anyhow::anyhow!("VID space exhausted"))?;
134            let prev = state.manifest.next_vid_batch;
135            state.manifest.next_vid_batch = reserved;
136            if let Err(e) = self.persist_manifest(&mut state).await {
137                // Roll back so a retry re-attempts the durable reservation.
138                state.manifest.next_vid_batch = prev;
139                return Err(e);
140            }
141        }
142
143        let vids: Vec<Vid> = (0..count)
144            .map(|i| Vid::new(state.current_vid + i as u64))
145            .collect();
146        state.current_vid += needed;
147        Ok(vids)
148    }
149
150    /// Allocates a new EID.
151    ///
152    /// Returns a globally unique, auto-incrementing edge ID.
153    pub async fn allocate_eid(&self) -> Result<Eid> {
154        let mut state = self.state.lock().await;
155
156        // Check if we've exhausted our current batch (L13: checked).
157        if state.current_eid >= state.manifest.next_eid_batch {
158            // Reserve a new batch
159            let reserved = state
160                .current_eid
161                .checked_add(self.batch_size)
162                .ok_or_else(|| anyhow::anyhow!("EID space exhausted"))?;
163            let prev = state.manifest.next_eid_batch;
164            state.manifest.next_eid_batch = reserved;
165            if let Err(e) = self.persist_manifest(&mut state).await {
166                // Roll back so a retry re-attempts the durable reservation.
167                state.manifest.next_eid_batch = prev;
168                return Err(e);
169            }
170        }
171
172        let eid = Eid::new(state.current_eid);
173        state.current_eid += 1;
174        Ok(eid)
175    }
176
177    /// Allocates multiple EIDs at once.
178    pub async fn allocate_eids(&self, count: usize) -> Result<Vec<Eid>> {
179        let mut state = self.state.lock().await;
180        let needed = count as u64;
181
182        // Check if we need to expand our batch (L13: checked).
183        let want = state
184            .current_eid
185            .checked_add(needed)
186            .ok_or_else(|| anyhow::anyhow!("EID space exhausted"))?;
187        if want > state.manifest.next_eid_batch {
188            // Reserve enough for the request plus a full batch
189            let reserved = want
190                .checked_add(self.batch_size)
191                .ok_or_else(|| anyhow::anyhow!("EID space exhausted"))?;
192            let prev = state.manifest.next_eid_batch;
193            state.manifest.next_eid_batch = reserved;
194            if let Err(e) = self.persist_manifest(&mut state).await {
195                // Roll back so a retry re-attempts the durable reservation.
196                state.manifest.next_eid_batch = prev;
197                return Err(e);
198            }
199        }
200
201        let eids: Vec<Eid> = (0..count)
202            .map(|i| Eid::new(state.current_eid + i as u64))
203            .collect();
204        state.current_eid += needed;
205        Ok(eids)
206    }
207
208    /// Returns the current VID counter value (next VID that would be allocated).
209    pub async fn current_vid(&self) -> u64 {
210        self.state.lock().await.current_vid
211    }
212
213    /// Returns the current EID counter value (next EID that would be allocated).
214    pub async fn current_eid(&self) -> u64 {
215        self.state.lock().await.current_eid
216    }
217
218    /// Snapshot the current high-water-marks for VID and EID.
219    ///
220    /// Returns `(next_vid, next_eid)` — the values the next
221    /// allocations would produce if not constrained by batch
222    /// reservation. Used by Phase 2 fork-creation to bootstrap a
223    /// fork's allocator above primary's range without going through
224    /// disk (the primary and fork allocators may live on different
225    /// `ObjectStore` instances, making file-copy bootstrap fragile).
226    pub async fn current_hwm(&self) -> (u64, u64) {
227        let state = self.state.lock().await;
228        (state.current_vid, state.current_eid)
229    }
230
231    /// Creates a throwaway in-memory `IdAllocator` that allocates ids *above*
232    /// `(vid_hwm, eid_hwm)`, backed by an `InMemory` object store discarded with
233    /// the allocator.
234    ///
235    /// This is the id source for an ephemeral / scratch transaction
236    /// (`Session::scratch`): it hands out vids/eids that cannot collide with the
237    /// primary's live rows (all `< hwm`, pinned in the scratch's read base), and
238    /// it never touches the primary's durable `id_allocator.json` — so thousands
239    /// of open-write-discard rollouts advance no global counter and do no catalog
240    /// I/O (G8/E2).
241    pub async fn in_memory_seeded(vid_hwm: u64, eid_hwm: u64, batch_size: u64) -> Result<Self> {
242        let store: Arc<dyn ObjectStore> = Arc::new(object_store::memory::InMemory::new());
243        let path = Path::from("scratch_id_allocator.json");
244        let manifest = CounterManifest {
245            next_vid_batch: vid_hwm,
246            next_eid_batch: eid_hwm,
247        };
248        let bytes = Bytes::from(serde_json::to_vec(&manifest)?);
249        put_with_timeout(&store, &path, bytes, DEFAULT_TIMEOUT).await?;
250        Self::new(store, path, batch_size).await
251    }
252
253    /// Force a checkpoint of the in-memory state to the underlying
254    /// object store.
255    ///
256    /// Used by Phase 2 fork creation to bootstrap a fork's allocator
257    /// from primary's *current* HWM. Without this, primary's allocator
258    /// has an in-memory state that the on-disk manifest doesn't yet
259    /// reflect (the disk file is only updated on batch-boundary
260    /// crossings), and the fork would start at VID 0 — colliding with
261    /// primary rows visible through the `base_paths` chain.
262    ///
263    /// Idempotent and safe to call frequently; the persisted manifest
264    /// reflects the same state if nothing has changed.
265    ///
266    /// # Errors
267    ///
268    /// Returns the underlying [`anyhow::Error`] from `persist_manifest`
269    /// (object-store put failure).
270    pub async fn checkpoint(&self) -> Result<()> {
271        let mut state = self.state.lock().await;
272        // Advance the persisted batch HWM to at least the current
273        // allocation cursor so reloads start above any allocated VIDs.
274        if state.manifest.next_vid_batch < state.current_vid {
275            state.manifest.next_vid_batch = state.current_vid;
276        }
277        if state.manifest.next_eid_batch < state.current_eid {
278            state.manifest.next_eid_batch = state.current_eid;
279        }
280        self.persist_manifest(&mut state).await
281    }
282
283    /// Persists the counter manifest to object store with optimistic locking.
284    async fn persist_manifest(&self, state: &mut AllocatorState) -> Result<()> {
285        let json = serde_json::to_vec_pretty(&state.manifest)?;
286        let bytes = Bytes::from(json);
287
288        // Try conditional put first, fall back to unconditional if not supported
289        // (LocalFileSystem doesn't support ETag-based conditional puts)
290        let put_result = if let Some(version) = &state.manifest_version {
291            let opts: PutOptions = PutMode::Update(UpdateVersion {
292                e_tag: Some(version.clone()),
293                version: None,
294            })
295            .into();
296            match tokio::time::timeout(
297                DEFAULT_TIMEOUT,
298                self.store.put_opts(&self.path, bytes.clone().into(), opts),
299            )
300            .await
301            {
302                Ok(Ok(result)) => result,
303                Ok(Err(e))
304                    if e.to_string().contains("not yet implemented")
305                        || e.to_string().contains("not supported") =>
306                {
307                    // LocalFileSystem doesn't support conditional puts, use regular put
308                    put_with_timeout(&self.store, &self.path, bytes, DEFAULT_TIMEOUT).await?
309                }
310                Ok(Err(e)) => return Err(e.into()),
311                Err(_) => {
312                    return Err(anyhow::anyhow!(
313                        "Object store put_opts timed out after {:?}",
314                        DEFAULT_TIMEOUT
315                    ));
316                }
317            }
318        } else {
319            // No version yet, try create mode, fall back to regular put
320            let opts: PutOptions = PutMode::Create.into();
321            match tokio::time::timeout(
322                DEFAULT_TIMEOUT,
323                self.store.put_opts(&self.path, bytes.clone().into(), opts),
324            )
325            .await
326            {
327                Ok(Ok(result)) => result,
328                Ok(Err(object_store::Error::AlreadyExists { .. })) => {
329                    // Another process created it, just overwrite
330                    put_with_timeout(&self.store, &self.path, bytes, DEFAULT_TIMEOUT).await?
331                }
332                Ok(Err(e)) if e.to_string().contains("not yet implemented") => {
333                    put_with_timeout(&self.store, &self.path, bytes, DEFAULT_TIMEOUT).await?
334                }
335                Ok(Err(e)) => return Err(e.into()),
336                Err(_) => {
337                    return Err(anyhow::anyhow!(
338                        "Object store put_opts timed out after {:?}",
339                        DEFAULT_TIMEOUT
340                    ));
341                }
342            }
343        };
344
345        state.manifest_version = put_result.e_tag;
346        Ok(())
347    }
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353    use object_store::memory::InMemory;
354
355    #[tokio::test]
356    async fn test_allocate_vid() {
357        let store = Arc::new(InMemory::new());
358        let path = Path::from("id_counters.json");
359        let allocator = IdAllocator::new(store, path, 100).await.unwrap();
360
361        let vid1 = allocator.allocate_vid().await.unwrap();
362        let vid2 = allocator.allocate_vid().await.unwrap();
363        let vid3 = allocator.allocate_vid().await.unwrap();
364
365        assert_eq!(vid1.as_u64(), 0);
366        assert_eq!(vid2.as_u64(), 1);
367        assert_eq!(vid3.as_u64(), 2);
368    }
369
370    #[tokio::test]
371    async fn test_allocate_eid() {
372        let store = Arc::new(InMemory::new());
373        let path = Path::from("id_counters.json");
374        let allocator = IdAllocator::new(store, path, 100).await.unwrap();
375
376        let eid1 = allocator.allocate_eid().await.unwrap();
377        let eid2 = allocator.allocate_eid().await.unwrap();
378
379        assert_eq!(eid1.as_u64(), 0);
380        assert_eq!(eid2.as_u64(), 1);
381    }
382
383    #[tokio::test]
384    async fn test_allocate_many() {
385        let store = Arc::new(InMemory::new());
386        let path = Path::from("id_counters.json");
387        let allocator = IdAllocator::new(store, path, 100).await.unwrap();
388
389        let vids = allocator.allocate_vids(5).await.unwrap();
390        assert_eq!(vids.len(), 5);
391        for (i, vid) in vids.iter().enumerate() {
392            assert_eq!(vid.as_u64(), i as u64);
393        }
394
395        // Next allocation should continue from 5
396        let next = allocator.allocate_vid().await.unwrap();
397        assert_eq!(next.as_u64(), 5);
398    }
399
400    #[tokio::test]
401    async fn test_persistence() {
402        let store = Arc::new(InMemory::new());
403        let path = Path::from("id_counters.json");
404
405        // Allocate some IDs
406        {
407            let allocator = IdAllocator::new(store.clone(), path.clone(), 10)
408                .await
409                .unwrap();
410            for _ in 0..15 {
411                allocator.allocate_vid().await.unwrap();
412            }
413        }
414
415        // Re-open and verify continuation
416        {
417            let allocator = IdAllocator::new(store, path, 10).await.unwrap();
418            // After allocating 15 IDs with batch size 10, we reserved up to 20
419            // So next allocation should be 20 (start of new batch after reload)
420            let vid = allocator.allocate_vid().await.unwrap();
421            assert_eq!(vid.as_u64(), 20);
422        }
423    }
424}