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            Err(e) if e.to_string().contains("not found") => (CounterManifest::default(), None),
61            Err(e) => return Err(e),
62        };
63
64        // Start allocating from where the last batch ended
65        let current_vid = manifest.next_vid_batch;
66        let current_eid = manifest.next_eid_batch;
67
68        Ok(Self {
69            store,
70            path,
71            state: Mutex::new(AllocatorState {
72                manifest,
73                manifest_version: version,
74                current_vid,
75                current_eid,
76            }),
77            batch_size,
78        })
79    }
80
81    /// Allocates a new VID.
82    ///
83    /// Returns a globally unique, auto-incrementing vertex ID.
84    pub async fn allocate_vid(&self) -> Result<Vid> {
85        let mut state = self.state.lock().await;
86
87        // Check if we've exhausted our current batch
88        if state.current_vid >= state.manifest.next_vid_batch {
89            // Reserve a new batch. `checked_add` guards against u64
90            // exhaustion (defense-in-depth; ~1.8e19 ids — physically
91            // unreachable, but wrapping silently would be a correctness
92            // disaster). L13.
93            let reserved = state
94                .current_vid
95                .checked_add(self.batch_size)
96                .ok_or_else(|| anyhow::anyhow!("VID space exhausted"))?;
97            let prev = state.manifest.next_vid_batch;
98            state.manifest.next_vid_batch = reserved;
99            if let Err(e) = self.persist_manifest(&mut state).await {
100                // Roll back the in-memory advance so a retry re-attempts the
101                // DURABLE reservation. Leaving it advanced would let a retry skip
102                // the reservation and hand out ids from a batch a crash would lose
103                // (the on-disk manifest still points at `prev`) — risking id reuse.
104                state.manifest.next_vid_batch = prev;
105                return Err(e);
106            }
107        }
108
109        let vid = Vid::new(state.current_vid);
110        state.current_vid += 1;
111        Ok(vid)
112    }
113
114    /// Allocates multiple VIDs at once.
115    pub async fn allocate_vids(&self, count: usize) -> Result<Vec<Vid>> {
116        let mut state = self.state.lock().await;
117        let needed = count as u64;
118
119        // Check if we need to expand our batch (L13: checked).
120        let want = state
121            .current_vid
122            .checked_add(needed)
123            .ok_or_else(|| anyhow::anyhow!("VID space exhausted"))?;
124        if want > state.manifest.next_vid_batch {
125            // Reserve enough for the request plus a full batch
126            let reserved = want
127                .checked_add(self.batch_size)
128                .ok_or_else(|| anyhow::anyhow!("VID space exhausted"))?;
129            let prev = state.manifest.next_vid_batch;
130            state.manifest.next_vid_batch = reserved;
131            if let Err(e) = self.persist_manifest(&mut state).await {
132                // Roll back so a retry re-attempts the durable reservation.
133                state.manifest.next_vid_batch = prev;
134                return Err(e);
135            }
136        }
137
138        let vids: Vec<Vid> = (0..count)
139            .map(|i| Vid::new(state.current_vid + i as u64))
140            .collect();
141        state.current_vid += needed;
142        Ok(vids)
143    }
144
145    /// Allocates a new EID.
146    ///
147    /// Returns a globally unique, auto-incrementing edge ID.
148    pub async fn allocate_eid(&self) -> Result<Eid> {
149        let mut state = self.state.lock().await;
150
151        // Check if we've exhausted our current batch (L13: checked).
152        if state.current_eid >= state.manifest.next_eid_batch {
153            // Reserve a new batch
154            let reserved = state
155                .current_eid
156                .checked_add(self.batch_size)
157                .ok_or_else(|| anyhow::anyhow!("EID space exhausted"))?;
158            let prev = state.manifest.next_eid_batch;
159            state.manifest.next_eid_batch = reserved;
160            if let Err(e) = self.persist_manifest(&mut state).await {
161                // Roll back so a retry re-attempts the durable reservation.
162                state.manifest.next_eid_batch = prev;
163                return Err(e);
164            }
165        }
166
167        let eid = Eid::new(state.current_eid);
168        state.current_eid += 1;
169        Ok(eid)
170    }
171
172    /// Allocates multiple EIDs at once.
173    pub async fn allocate_eids(&self, count: usize) -> Result<Vec<Eid>> {
174        let mut state = self.state.lock().await;
175        let needed = count as u64;
176
177        // Check if we need to expand our batch (L13: checked).
178        let want = state
179            .current_eid
180            .checked_add(needed)
181            .ok_or_else(|| anyhow::anyhow!("EID space exhausted"))?;
182        if want > state.manifest.next_eid_batch {
183            // Reserve enough for the request plus a full batch
184            let reserved = want
185                .checked_add(self.batch_size)
186                .ok_or_else(|| anyhow::anyhow!("EID space exhausted"))?;
187            let prev = state.manifest.next_eid_batch;
188            state.manifest.next_eid_batch = reserved;
189            if let Err(e) = self.persist_manifest(&mut state).await {
190                // Roll back so a retry re-attempts the durable reservation.
191                state.manifest.next_eid_batch = prev;
192                return Err(e);
193            }
194        }
195
196        let eids: Vec<Eid> = (0..count)
197            .map(|i| Eid::new(state.current_eid + i as u64))
198            .collect();
199        state.current_eid += needed;
200        Ok(eids)
201    }
202
203    /// Returns the current VID counter value (next VID that would be allocated).
204    pub async fn current_vid(&self) -> u64 {
205        self.state.lock().await.current_vid
206    }
207
208    /// Returns the current EID counter value (next EID that would be allocated).
209    pub async fn current_eid(&self) -> u64 {
210        self.state.lock().await.current_eid
211    }
212
213    /// Snapshot the current high-water-marks for VID and EID.
214    ///
215    /// Returns `(next_vid, next_eid)` — the values the next
216    /// allocations would produce if not constrained by batch
217    /// reservation. Used by Phase 2 fork-creation to bootstrap a
218    /// fork's allocator above primary's range without going through
219    /// disk (the primary and fork allocators may live on different
220    /// `ObjectStore` instances, making file-copy bootstrap fragile).
221    pub async fn current_hwm(&self) -> (u64, u64) {
222        let state = self.state.lock().await;
223        (state.current_vid, state.current_eid)
224    }
225
226    /// Force a checkpoint of the in-memory state to the underlying
227    /// object store.
228    ///
229    /// Used by Phase 2 fork creation to bootstrap a fork's allocator
230    /// from primary's *current* HWM. Without this, primary's allocator
231    /// has an in-memory state that the on-disk manifest doesn't yet
232    /// reflect (the disk file is only updated on batch-boundary
233    /// crossings), and the fork would start at VID 0 — colliding with
234    /// primary rows visible through the `base_paths` chain.
235    ///
236    /// Idempotent and safe to call frequently; the persisted manifest
237    /// reflects the same state if nothing has changed.
238    ///
239    /// # Errors
240    ///
241    /// Returns the underlying [`anyhow::Error`] from `persist_manifest`
242    /// (object-store put failure).
243    pub async fn checkpoint(&self) -> Result<()> {
244        let mut state = self.state.lock().await;
245        // Advance the persisted batch HWM to at least the current
246        // allocation cursor so reloads start above any allocated VIDs.
247        if state.manifest.next_vid_batch < state.current_vid {
248            state.manifest.next_vid_batch = state.current_vid;
249        }
250        if state.manifest.next_eid_batch < state.current_eid {
251            state.manifest.next_eid_batch = state.current_eid;
252        }
253        self.persist_manifest(&mut state).await
254    }
255
256    /// Persists the counter manifest to object store with optimistic locking.
257    async fn persist_manifest(&self, state: &mut AllocatorState) -> Result<()> {
258        let json = serde_json::to_vec_pretty(&state.manifest)?;
259        let bytes = Bytes::from(json);
260
261        // Try conditional put first, fall back to unconditional if not supported
262        // (LocalFileSystem doesn't support ETag-based conditional puts)
263        let put_result = if let Some(version) = &state.manifest_version {
264            let opts: PutOptions = PutMode::Update(UpdateVersion {
265                e_tag: Some(version.clone()),
266                version: None,
267            })
268            .into();
269            match tokio::time::timeout(
270                DEFAULT_TIMEOUT,
271                self.store.put_opts(&self.path, bytes.clone().into(), opts),
272            )
273            .await
274            {
275                Ok(Ok(result)) => result,
276                Ok(Err(e))
277                    if e.to_string().contains("not yet implemented")
278                        || e.to_string().contains("not supported") =>
279                {
280                    // LocalFileSystem doesn't support conditional puts, use regular put
281                    put_with_timeout(&self.store, &self.path, bytes, DEFAULT_TIMEOUT).await?
282                }
283                Ok(Err(e)) => return Err(e.into()),
284                Err(_) => {
285                    return Err(anyhow::anyhow!(
286                        "Object store put_opts timed out after {:?}",
287                        DEFAULT_TIMEOUT
288                    ));
289                }
290            }
291        } else {
292            // No version yet, try create mode, fall back to regular put
293            let opts: PutOptions = PutMode::Create.into();
294            match tokio::time::timeout(
295                DEFAULT_TIMEOUT,
296                self.store.put_opts(&self.path, bytes.clone().into(), opts),
297            )
298            .await
299            {
300                Ok(Ok(result)) => result,
301                Ok(Err(object_store::Error::AlreadyExists { .. })) => {
302                    // Another process created it, just overwrite
303                    put_with_timeout(&self.store, &self.path, bytes, DEFAULT_TIMEOUT).await?
304                }
305                Ok(Err(e)) if e.to_string().contains("not yet implemented") => {
306                    put_with_timeout(&self.store, &self.path, bytes, DEFAULT_TIMEOUT).await?
307                }
308                Ok(Err(e)) => return Err(e.into()),
309                Err(_) => {
310                    return Err(anyhow::anyhow!(
311                        "Object store put_opts timed out after {:?}",
312                        DEFAULT_TIMEOUT
313                    ));
314                }
315            }
316        };
317
318        state.manifest_version = put_result.e_tag;
319        Ok(())
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326    use object_store::memory::InMemory;
327
328    #[tokio::test]
329    async fn test_allocate_vid() {
330        let store = Arc::new(InMemory::new());
331        let path = Path::from("id_counters.json");
332        let allocator = IdAllocator::new(store, path, 100).await.unwrap();
333
334        let vid1 = allocator.allocate_vid().await.unwrap();
335        let vid2 = allocator.allocate_vid().await.unwrap();
336        let vid3 = allocator.allocate_vid().await.unwrap();
337
338        assert_eq!(vid1.as_u64(), 0);
339        assert_eq!(vid2.as_u64(), 1);
340        assert_eq!(vid3.as_u64(), 2);
341    }
342
343    #[tokio::test]
344    async fn test_allocate_eid() {
345        let store = Arc::new(InMemory::new());
346        let path = Path::from("id_counters.json");
347        let allocator = IdAllocator::new(store, path, 100).await.unwrap();
348
349        let eid1 = allocator.allocate_eid().await.unwrap();
350        let eid2 = allocator.allocate_eid().await.unwrap();
351
352        assert_eq!(eid1.as_u64(), 0);
353        assert_eq!(eid2.as_u64(), 1);
354    }
355
356    #[tokio::test]
357    async fn test_allocate_many() {
358        let store = Arc::new(InMemory::new());
359        let path = Path::from("id_counters.json");
360        let allocator = IdAllocator::new(store, path, 100).await.unwrap();
361
362        let vids = allocator.allocate_vids(5).await.unwrap();
363        assert_eq!(vids.len(), 5);
364        for (i, vid) in vids.iter().enumerate() {
365            assert_eq!(vid.as_u64(), i as u64);
366        }
367
368        // Next allocation should continue from 5
369        let next = allocator.allocate_vid().await.unwrap();
370        assert_eq!(next.as_u64(), 5);
371    }
372
373    #[tokio::test]
374    async fn test_persistence() {
375        let store = Arc::new(InMemory::new());
376        let path = Path::from("id_counters.json");
377
378        // Allocate some IDs
379        {
380            let allocator = IdAllocator::new(store.clone(), path.clone(), 10)
381                .await
382                .unwrap();
383            for _ in 0..15 {
384                allocator.allocate_vid().await.unwrap();
385            }
386        }
387
388        // Re-open and verify continuation
389        {
390            let allocator = IdAllocator::new(store, path, 10).await.unwrap();
391            // After allocating 15 IDs with batch size 10, we reserved up to 20
392            // So next allocation should be 20 (start of new batch after reload)
393            let vid = allocator.allocate_vid().await.unwrap();
394            assert_eq!(vid.as_u64(), 20);
395        }
396    }
397}