dedup_machines 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,470 @@
1
+ #![no_std]
2
+ #![doc = include_str!("../README.md")]
3
+
4
+ #[cfg(feature = "alloc")]
5
+ extern crate alloc;
6
+
7
+ #[cfg(feature = "alloc")]
8
+ mod hashed;
9
+
10
+ use core::time::Duration;
11
+
12
+ #[cfg(feature = "alloc")]
13
+ pub use hashed::{HashDedup, Hashed};
14
+
15
+ /// Opaque generation-checked claim, scoped to one ledger lifetime.
16
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
17
+ pub struct ClaimId {
18
+ slot: usize,
19
+ generation: u64,
20
+ }
21
+
22
+ /// Claim outcome.
23
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
24
+ #[non_exhaustive]
25
+ pub enum Claim<'a, V> {
26
+ /// Caller owns a new pending claim.
27
+ Acquired(ClaimId),
28
+ /// Another live attempt owns this key.
29
+ InProgress,
30
+ /// This key completed within its retention interval.
31
+ Completed(&'a V),
32
+ /// A live or completed record for this key carries a different fingerprint.
33
+ Conflict,
34
+ /// All slots contain unexpired records.
35
+ Full,
36
+ /// The attempt's partition holds its quota of unexpired records.
37
+ PartitionFull,
38
+ /// Invalid clock/TTL or exhausted generation space.
39
+ Rejected,
40
+ }
41
+
42
+ /// A key plus the optional fingerprint and partition it is claimed under.
43
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
44
+ pub struct Attempt<K> {
45
+ key: K,
46
+ fingerprint: Option<u64>,
47
+ partition: Option<u64>,
48
+ }
49
+
50
+ impl<K> Attempt<K> {
51
+ /// Plain attempt: no fingerprint, no partition.
52
+ #[must_use]
53
+ pub const fn new(key: K) -> Self {
54
+ Self {
55
+ key,
56
+ fingerprint: None,
57
+ partition: None,
58
+ }
59
+ }
60
+
61
+ /// Payload identity; a record under the same key with another fingerprint conflicts.
62
+ #[must_use]
63
+ pub const fn fingerprint(mut self, fingerprint: u64) -> Self {
64
+ self.fingerprint = Some(fingerprint);
65
+ self
66
+ }
67
+
68
+ /// Quota bucket counted against the ledger's partition cap.
69
+ #[must_use]
70
+ pub const fn partition(mut self, partition: u64) -> Self {
71
+ self.partition = Some(partition);
72
+ self
73
+ }
74
+ }
75
+
76
+ #[derive(Debug, Clone, PartialEq, Eq)]
77
+ enum State<V> {
78
+ Pending,
79
+ Completed(V),
80
+ }
81
+
82
+ /// One record in a [`Storage`] backend: a claimed key, its generation and
83
+ /// expiry, and whether the claim is still pending or carries a completed value.
84
+ ///
85
+ /// A [`Ledger`] builds entries and hands them to its storage; a backend that
86
+ /// persists them elsewhere reads them back through the accessors and rebuilds
87
+ /// them with [`Entry::new`].
88
+ #[derive(Debug, Clone, PartialEq, Eq)]
89
+ pub struct Entry<K, V> {
90
+ key: K,
91
+ fingerprint: Option<u64>,
92
+ partition: Option<u64>,
93
+ generation: u64,
94
+ expires: Duration,
95
+ state: State<V>,
96
+ }
97
+
98
+ impl<K, V> Entry<K, V> {
99
+ /// Rebuild a stored record; `value` is `None` while the claim is pending.
100
+ #[must_use]
101
+ pub fn new(
102
+ key: K,
103
+ fingerprint: Option<u64>,
104
+ partition: Option<u64>,
105
+ generation: u64,
106
+ expires: Duration,
107
+ value: Option<V>,
108
+ ) -> Self {
109
+ Self {
110
+ key,
111
+ fingerprint,
112
+ partition,
113
+ generation,
114
+ expires,
115
+ state: value.map_or(State::Pending, State::Completed),
116
+ }
117
+ }
118
+
119
+ /// The claimed key.
120
+ #[must_use]
121
+ pub const fn key(&self) -> &K {
122
+ &self.key
123
+ }
124
+
125
+ /// Payload fingerprint the key was claimed under, if any.
126
+ #[must_use]
127
+ pub const fn fingerprint(&self) -> Option<u64> {
128
+ self.fingerprint
129
+ }
130
+
131
+ /// Partition the record is counted against, if any.
132
+ #[must_use]
133
+ pub const fn partition(&self) -> Option<u64> {
134
+ self.partition
135
+ }
136
+
137
+ /// Generation the claim was issued with; stale [`ClaimId`]s carry an older one.
138
+ #[must_use]
139
+ pub const fn generation(&self) -> u64 {
140
+ self.generation
141
+ }
142
+
143
+ /// Instant after which the record is dropped on the next observation.
144
+ #[must_use]
145
+ pub const fn expires(&self) -> Duration {
146
+ self.expires
147
+ }
148
+
149
+ /// `true` while the claim is live and uncompleted.
150
+ #[must_use]
151
+ pub const fn is_pending(&self) -> bool {
152
+ matches!(self.state, State::Pending)
153
+ }
154
+
155
+ /// The completed value, once there is one.
156
+ #[must_use]
157
+ pub const fn value(&self) -> Option<&V> {
158
+ match &self.state {
159
+ State::Completed(value) => Some(value),
160
+ State::Pending => None,
161
+ }
162
+ }
163
+ }
164
+
165
+ /// Where a [`Ledger`] keeps its records.
166
+ ///
167
+ /// [`Fixed`] and [`Hashed`] are in-memory. Implement this to put the ledger
168
+ /// over something else, such as a database table or a shared-memory arena;
169
+ /// the ledger only ever addresses records by the slot `insert` returned.
170
+ ///
171
+ /// Slots are opaque: a backend may reuse one after `remove`, because the
172
+ /// ledger's generation check tells a stale [`ClaimId`] from a fresh record.
173
+ pub trait Storage {
174
+ /// Claimed key type.
175
+ type Key: Eq;
176
+ /// Value stored on completion.
177
+ type Value;
178
+
179
+ /// The record at `slot`, if one is live.
180
+ fn get(&self, slot: usize) -> Option<&Entry<Self::Key, Self::Value>>;
181
+ /// Mutable access to the record at `slot`, if one is live.
182
+ fn get_mut(&mut self, slot: usize) -> Option<&mut Entry<Self::Key, Self::Value>>;
183
+ /// The slot holding `key`, if any.
184
+ fn find(&self, key: &Self::Key) -> Option<usize>;
185
+ /// Store `entry` in a free slot, or `None` when the backend is full.
186
+ fn insert(&mut self, entry: Entry<Self::Key, Self::Value>) -> Option<usize>;
187
+ /// Drop the record at `slot`; `false` when there was none.
188
+ fn remove(&mut self, slot: usize) -> bool;
189
+ /// Drop every record `keep` rejects, returning how many were dropped.
190
+ fn retain(&mut self, keep: impl FnMut(&Entry<Self::Key, Self::Value>) -> bool) -> usize;
191
+ /// Every live record, in no particular order.
192
+ fn iter(&self) -> impl Iterator<Item = &Entry<Self::Key, Self::Value>>;
193
+ }
194
+
195
+ /// Fixed-capacity inline storage.
196
+ #[derive(Debug, PartialEq, Eq)]
197
+ pub struct Fixed<K, V, const N: usize> {
198
+ entries: [Option<Entry<K, V>>; N],
199
+ }
200
+
201
+ impl<K: Eq, V, const N: usize> Storage for Fixed<K, V, N> {
202
+ type Key = K;
203
+ type Value = V;
204
+
205
+ fn get(&self, slot: usize) -> Option<&Entry<K, V>> {
206
+ self.entries.get(slot)?.as_ref()
207
+ }
208
+
209
+ fn get_mut(&mut self, slot: usize) -> Option<&mut Entry<K, V>> {
210
+ self.entries.get_mut(slot)?.as_mut()
211
+ }
212
+
213
+ fn find(&self, key: &K) -> Option<usize> {
214
+ self.entries
215
+ .iter()
216
+ .position(|entry| entry.as_ref().is_some_and(|entry| entry.key == *key))
217
+ }
218
+
219
+ fn insert(&mut self, entry: Entry<K, V>) -> Option<usize> {
220
+ let slot = self.entries.iter().position(Option::is_none)?;
221
+ self.entries[slot] = Some(entry);
222
+ Some(slot)
223
+ }
224
+
225
+ fn remove(&mut self, slot: usize) -> bool {
226
+ self.entries.get_mut(slot).and_then(Option::take).is_some()
227
+ }
228
+
229
+ fn retain(&mut self, mut keep: impl FnMut(&Entry<K, V>) -> bool) -> usize {
230
+ let mut removed = 0;
231
+ for slot in &mut self.entries {
232
+ if slot.as_ref().is_some_and(|entry| !keep(entry)) {
233
+ *slot = None;
234
+ removed += 1;
235
+ }
236
+ }
237
+ removed
238
+ }
239
+
240
+ fn iter(&self) -> impl Iterator<Item = &Entry<K, V>> {
241
+ self.entries.iter().flatten()
242
+ }
243
+ }
244
+
245
+ /// Exact-key claim ledger over a storage backend.
246
+ #[derive(Debug, PartialEq, Eq)]
247
+ pub struct Ledger<S> {
248
+ store: S,
249
+ generation: u64,
250
+ observed: Duration,
251
+ partition_cap: Option<usize>,
252
+ }
253
+
254
+ /// Fixed-capacity exact-key ledger.
255
+ pub type Dedup<K, const N: usize, V = ()> = Ledger<Fixed<K, V, N>>;
256
+
257
+ impl<K: Eq, V, const N: usize> Default for Dedup<K, N, V> {
258
+ fn default() -> Self {
259
+ Self::new()
260
+ }
261
+ }
262
+
263
+ impl<K: Eq, V, const N: usize> Dedup<K, N, V> {
264
+ /// Start an empty ledger with a fresh handle namespace.
265
+ #[must_use]
266
+ pub const fn new() -> Self {
267
+ Self::over(Fixed {
268
+ entries: [const { None }; N],
269
+ })
270
+ }
271
+ }
272
+
273
+ impl<S: Storage> Ledger<S> {
274
+ /// Start an empty ledger over `store` with a fresh handle namespace.
275
+ ///
276
+ /// The store is expected to be empty: records it already holds would
277
+ /// outrank every [`ClaimId`] this ledger issues. Use [`Ledger::restore`]
278
+ /// to continue a persisted one.
279
+ #[must_use]
280
+ pub const fn over(store: S) -> Self {
281
+ Self::restore(store, 0, Duration::ZERO)
282
+ }
283
+
284
+ /// Continue a ledger whose records live in `store`, from the highest
285
+ /// `generation` it ever issued and the last clock value it `observed`.
286
+ ///
287
+ /// Both must come from the same committed state as the records, so no
288
+ /// stored [`ClaimId`] can be reissued.
289
+ #[must_use]
290
+ pub const fn restore(store: S, generation: u64, observed: Duration) -> Self {
291
+ Self {
292
+ store,
293
+ generation,
294
+ observed,
295
+ partition_cap: None,
296
+ }
297
+ }
298
+
299
+ /// Highest generation issued so far; persist it with the records.
300
+ #[must_use]
301
+ pub const fn generation(&self) -> u64 {
302
+ self.generation
303
+ }
304
+
305
+ /// Latest clock value accepted; persist it with the records.
306
+ #[must_use]
307
+ pub const fn observed(&self) -> Duration {
308
+ self.observed
309
+ }
310
+
311
+ /// The storage backend, for a persisting adapter to read through.
312
+ #[must_use]
313
+ pub const fn store(&self) -> &S {
314
+ &self.store
315
+ }
316
+
317
+ /// Give the storage back, to persist or hand to [`Ledger::restore`].
318
+ #[must_use]
319
+ pub fn into_store(self) -> S {
320
+ self.store
321
+ }
322
+
323
+ /// Cap unexpired records per partition; attempts without a partition are uncounted.
324
+ #[must_use]
325
+ pub const fn with_partition_cap(mut self, cap: usize) -> Self {
326
+ self.partition_cap = Some(cap);
327
+ self
328
+ }
329
+
330
+ /// Claim a key without evicting any unexpired entry.
331
+ #[must_use]
332
+ pub fn claim(&mut self, key: S::Key, now: Duration, ttl: Duration) -> Claim<'_, S::Value> {
333
+ self.admit(Attempt::new(key), now, ttl)
334
+ }
335
+
336
+ /// Claim a key under a payload fingerprint; a mismatching record conflicts.
337
+ #[must_use]
338
+ pub fn claim_with(
339
+ &mut self,
340
+ key: S::Key,
341
+ fingerprint: u64,
342
+ now: Duration,
343
+ ttl: Duration,
344
+ ) -> Claim<'_, S::Value> {
345
+ self.admit(Attempt::new(key).fingerprint(fingerprint), now, ttl)
346
+ }
347
+
348
+ /// Claim an attempt without evicting any unexpired entry.
349
+ #[must_use]
350
+ pub fn admit(
351
+ &mut self,
352
+ attempt: Attempt<S::Key>,
353
+ now: Duration,
354
+ ttl: Duration,
355
+ ) -> Claim<'_, S::Value> {
356
+ if !self.observe(now) || ttl.is_zero() {
357
+ return Claim::Rejected;
358
+ }
359
+ let Some(expires) = now.checked_add(ttl) else {
360
+ return Claim::Rejected;
361
+ };
362
+ self.store.retain(|entry| now < entry.expires);
363
+ let found = self.store.find(&attempt.key);
364
+ if let Some(slot) = found {
365
+ return self.revisit(slot, attempt.fingerprint);
366
+ }
367
+ if let (Some(cap), Some(partition)) = (self.partition_cap, attempt.partition)
368
+ && self
369
+ .store
370
+ .iter()
371
+ .filter(|entry| entry.partition == Some(partition))
372
+ .count()
373
+ >= cap
374
+ {
375
+ return Claim::PartitionFull;
376
+ }
377
+ let Some(generation) = self.generation.checked_add(1) else {
378
+ return Claim::Rejected;
379
+ };
380
+ let Some(slot) = self.store.insert(Entry {
381
+ key: attempt.key,
382
+ fingerprint: attempt.fingerprint,
383
+ partition: attempt.partition,
384
+ generation,
385
+ expires,
386
+ state: State::Pending,
387
+ }) else {
388
+ return Claim::Full;
389
+ };
390
+ self.generation = generation;
391
+ Claim::Acquired(ClaimId { slot, generation })
392
+ }
393
+
394
+ /// Complete a live pending claim. Repeated or stale completions return `false`.
395
+ #[must_use]
396
+ pub fn complete(&mut self, claim: ClaimId, now: Duration) -> bool
397
+ where
398
+ S::Value: Default,
399
+ {
400
+ self.complete_with(claim, S::Value::default(), now).is_ok()
401
+ }
402
+
403
+ /// Complete a live pending claim, storing `value` for duplicate attempts.
404
+ ///
405
+ /// # Errors
406
+ ///
407
+ /// Repeated, stale, or expired completions hand `value` back.
408
+ pub fn complete_with(
409
+ &mut self,
410
+ claim: ClaimId,
411
+ value: S::Value,
412
+ now: Duration,
413
+ ) -> Result<(), S::Value> {
414
+ if !self.observe(now) {
415
+ return Err(value);
416
+ }
417
+ let Some(entry) = self.store.get_mut(claim.slot) else {
418
+ return Err(value);
419
+ };
420
+ if entry.generation != claim.generation
421
+ || !matches!(entry.state, State::Pending)
422
+ || now >= entry.expires
423
+ {
424
+ return Err(value);
425
+ }
426
+ entry.state = State::Completed(value);
427
+ Ok(())
428
+ }
429
+
430
+ /// Free a matching pending claim after a known failure, not an uncertain effect.
431
+ #[must_use]
432
+ pub fn abandon(&mut self, claim: ClaimId) -> bool {
433
+ let pending = self.store.get(claim.slot).is_some_and(|entry| {
434
+ entry.generation == claim.generation && matches!(entry.state, State::Pending)
435
+ });
436
+ pending && self.store.remove(claim.slot)
437
+ }
438
+
439
+ /// Drop every record, pending or completed, whose key `dead` marks as ownerless.
440
+ /// Returns how many were dropped.
441
+ pub fn expire_where(&mut self, mut dead: impl FnMut(&S::Key) -> bool) -> usize {
442
+ self.store.retain(|entry| !dead(&entry.key))
443
+ }
444
+
445
+ fn revisit(&self, slot: usize, fingerprint: Option<u64>) -> Claim<'_, S::Value> {
446
+ let Some(entry) = self.store.get(slot) else {
447
+ return Claim::Rejected;
448
+ };
449
+ if entry.fingerprint != fingerprint {
450
+ return Claim::Conflict;
451
+ }
452
+ match &entry.state {
453
+ State::Pending => Claim::InProgress,
454
+ State::Completed(value) => Claim::Completed(value),
455
+ }
456
+ }
457
+
458
+ fn observe(&mut self, now: Duration) -> bool {
459
+ if now < self.observed {
460
+ return false;
461
+ }
462
+ self.observed = now;
463
+ true
464
+ }
465
+ }
466
+
467
+ #[cfg(test)]
468
+ mod test_support;
469
+ #[cfg(test)]
470
+ mod tests;
@@ -0,0 +1,16 @@
1
+ //! Helpers shared by the crate's test modules.
2
+
3
+ use core::time::Duration;
4
+
5
+ use crate::{Claim, ClaimId};
6
+
7
+ pub fn at(seconds: u64) -> Duration {
8
+ Duration::from_secs(seconds)
9
+ }
10
+
11
+ pub fn acquired<V>(claim: &Claim<'_, V>) -> ClaimId {
12
+ let Claim::Acquired(id) = claim else {
13
+ panic!("expected acquired claim");
14
+ };
15
+ *id
16
+ }
@@ -0,0 +1,2 @@
1
+ # frozen_string_literal: true
2
+ require_relative 'ffi/extconf'
@@ -0,0 +1,20 @@
1
+ [package]
2
+ name = "dedup_machines_native"
3
+ version = "0.1.6"
4
+ edition.workspace = true
5
+ rust-version.workspace = true
6
+ license.workspace = true
7
+ publish = false
8
+
9
+ [lib]
10
+ crate-type = ["cdylib"]
11
+ test = false
12
+ doctest = false
13
+
14
+ [dependencies]
15
+ dedup-machines.workspace = true
16
+ magnus.workspace = true
17
+ rb-sys.workspace = true
18
+
19
+ [lints]
20
+ workspace = true
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+ require 'mkmf'
3
+ require 'pathname'
4
+ require 'rb_sys/mkmf'
5
+
6
+ create_rust_makefile('dedup_machines_native/dedup_machines_native') do |e|
7
+ e.ext_dir = Pathname(__dir__).relative_path_from(Pathname(Dir.pwd)).to_s
8
+ e.profile = ENV.fetch('RB_SYS_CARGO_PROFILE', :release).to_sym
9
+ end
10
+ makefile = File.join(Dir.pwd, 'Makefile')
11
+ File.write(makefile, File.read(makefile).gsub(
12
+ /^RB_SYS_CARGO_MANIFEST_DIR \?=.*$/,
13
+ "RB_SYS_CARGO_MANIFEST_DIR ?= #{File.expand_path(__dir__)}"
14
+ ))
@@ -0,0 +1,18 @@
1
+ //! Private native entry point.
2
+ use crate::Ledger;
3
+ use magnus::{Error, Ruby, function, method, prelude::*};
4
+
5
+ #[magnus::init]
6
+ fn init(ruby: &Ruby) -> Result<(), Error> {
7
+ let module = ruby.define_module("DedupMachines")?;
8
+ let handle = module.define_class("Claim", ruby.class_object())?;
9
+ handle.undef_default_alloc_func();
10
+ let class = module.define_class("Ledger", ruby.class_object())?;
11
+ class.undef_default_alloc_func();
12
+ class.define_singleton_method("new", function!(Ledger::new, 0))?;
13
+ class.define_method("claim", method!(Ledger::claim, 3))?;
14
+ class.define_method("complete", method!(Ledger::complete, 2))?;
15
+ class.define_method("abandon", method!(Ledger::abandon, 1))?;
16
+ class.define_method("capacity", function!(Ledger::capacity, 0))?;
17
+ Ok(())
18
+ }
@@ -0,0 +1,96 @@
1
+ //! Thin binding to the exact bounded claim ledger.
2
+ mod init;
3
+
4
+ use std::{cell::RefCell, sync::Arc, time::Duration};
5
+
6
+ use dedup_machines::{Claim, ClaimId, Dedup};
7
+ use magnus::{Error, Integer, Ruby, TryConvert, Value, prelude::*, typed_data::Obj};
8
+
9
+ const CAPACITY: usize = 64;
10
+
11
+ #[derive(Clone, Copy)]
12
+ struct Number(u64);
13
+
14
+ impl TryConvert for Number {
15
+ fn try_convert(value: Value) -> Result<Self, Error> {
16
+ if Integer::from_value(value).is_none() {
17
+ return Err(Error::new(
18
+ Ruby::get_with(value).exception_type_error(),
19
+ "expected an Integer (unsigned 64-bit)",
20
+ ));
21
+ }
22
+ u64::try_convert(value).map(Self)
23
+ }
24
+ }
25
+
26
+ #[magnus::wrap(class = "DedupMachines::Claim", free_immediately, size)]
27
+ struct Handle {
28
+ owner: Arc<()>,
29
+ id: ClaimId,
30
+ }
31
+
32
+ #[magnus::wrap(class = "DedupMachines::Ledger", free_immediately, size)]
33
+ struct Ledger {
34
+ owner: Arc<()>,
35
+ core: RefCell<Dedup<u64, CAPACITY>>,
36
+ }
37
+
38
+ impl Ledger {
39
+ fn new() -> Self {
40
+ Self {
41
+ owner: Arc::new(()),
42
+ core: RefCell::new(Dedup::new()),
43
+ }
44
+ }
45
+
46
+ fn claim(
47
+ ruby: &Ruby,
48
+ this: Obj<Self>,
49
+ key: Number,
50
+ now_ns: Number,
51
+ ttl_ns: Number,
52
+ ) -> Result<Value, Error> {
53
+ this.check_frozen()?;
54
+ let mut core = this.core.borrow_mut();
55
+ let outcome = core.claim(
56
+ key.0,
57
+ Duration::from_nanos(now_ns.0),
58
+ Duration::from_nanos(ttl_ns.0),
59
+ );
60
+ Ok(match outcome {
61
+ Claim::Acquired(id) => ruby
62
+ .obj_wrap(Handle {
63
+ owner: Arc::clone(&this.owner),
64
+ id,
65
+ })
66
+ .as_value(),
67
+ Claim::InProgress => ruby.to_symbol("in_progress").as_value(),
68
+ Claim::Completed(()) => ruby.to_symbol("completed").as_value(),
69
+ Claim::Full => ruby.to_symbol("full").as_value(),
70
+ _ => ruby.to_symbol("rejected").as_value(),
71
+ })
72
+ }
73
+
74
+ fn complete(this: Obj<Self>, handle: Obj<Handle>, now_ns: Number) -> Result<bool, Error> {
75
+ this.check_frozen()?;
76
+ if !Arc::ptr_eq(&this.owner, &handle.owner) {
77
+ return Ok(false);
78
+ }
79
+ Ok(this
80
+ .core
81
+ .borrow_mut()
82
+ .complete(handle.id, Duration::from_nanos(now_ns.0)))
83
+ }
84
+
85
+ fn abandon(this: Obj<Self>, handle: Obj<Handle>) -> Result<bool, Error> {
86
+ this.check_frozen()?;
87
+ if !Arc::ptr_eq(&this.owner, &handle.owner) {
88
+ return Ok(false);
89
+ }
90
+ Ok(this.core.borrow_mut().abandon(handle.id))
91
+ }
92
+
93
+ const fn capacity() -> usize {
94
+ CAPACITY
95
+ }
96
+ }
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DedupMachines
4
+ VERSION = '0.2.2'
5
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'dedup_machines/version'
4
+ # Native is required: no algorithm fallback.
5
+ require 'dedup_machines_native/dedup_machines_native'
@@ -0,0 +1,12 @@
1
+ module DedupMachines
2
+ VERSION: String
3
+ class Claim
4
+ end
5
+ class Ledger
6
+ def self.new: () -> Ledger
7
+ def capacity: () -> Integer
8
+ def claim: (Integer key, Integer now_ns, Integer ttl_ns) -> (Claim | :in_progress | :completed | :full | :rejected)
9
+ def complete: (Claim, Integer now_ns) -> bool
10
+ def abandon: (Claim) -> bool
11
+ end
12
+ end