snapshot_machines 0.1.5

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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 621669a04a2ca7d77dc8616a530150a8f57f9de1a3aee58f905e136838013436
4
+ data.tar.gz: 7b1c699b88f1f1b1e670e43617b6786c29707b06abed063ae546495dae38624f
5
+ SHA512:
6
+ metadata.gz: 2b41ad11cd2efe310af8476a009554398aed6a47decba23300c5a7d17538a31ee5b09df01ed43ea6b8023736b366a1e8818c8b5974fefcff62fc872a7e5690b7
7
+ data.tar.gz: 907cdae62c69da019daf4c5d4270b2dce0aacbc1be467785485cda06fce11a7476ad7afa6accb26b62f71dd17b7d9f02613313ac9ba7eed0c588568e07970537
data/CHANGELOG.md ADDED
@@ -0,0 +1,36 @@
1
+ # Changelog
2
+
3
+ ## [0.1.5](https://github.com/seuros/snapshot_machines/compare/snapshot_machines/v0.1.4...snapshot_machines/v0.1.5) (2026-11-06)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * extract catalog ownership check for commit/abort ([f501351](https://github.com/seuros/snapshot_machines/commit/f5013515b918dfb5627939ae2d06b191298b5a85))
9
+
10
+ ## [0.1.4](https://github.com/seuros/snapshot_machines/compare/snapshot_machines/v0.1.3...snapshot_machines/v0.1.4) (2026-11-06)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * crates.io keywords and categories for the core ([f544853](https://github.com/seuros/snapshot_machines/commit/f544853222f20d5e56b58199641258a676455f61))
16
+
17
+ ## [0.1.3](https://github.com/seuros/snapshot_machines/compare/snapshot_machines/v0.1.2...snapshot_machines/v0.1.3) (2026-11-06)
18
+
19
+
20
+ ### Bug Fixes
21
+
22
+ * keep test sources out of the packaged gem and crate ([002251a](https://github.com/seuros/snapshot_machines/commit/002251a711dd02f261ed4be6f255238fae2ef3ed))
23
+
24
+ ## [0.1.2](https://github.com/seuros/snapshot_machines/compare/snapshot_machines/v0.1.1...snapshot_machines/v0.1.2) (2026-11-06)
25
+
26
+
27
+ ### Bug Fixes
28
+
29
+ * cross builds no longer compile for the host ([e771bd5](https://github.com/seuros/snapshot_machines/commit/e771bd5f973692eef8035958143e29dc442237e7))
30
+
31
+ ## [0.1.1](https://github.com/seuros/snapshot_machines/compare/snapshot_machines-v0.1.0...snapshot_machines/v0.1.1) (2026-11-06)
32
+
33
+
34
+ ### Features
35
+
36
+ * initial extraction ([691a57e](https://github.com/seuros/snapshot_machines/commit/691a57e297015cf920bd3afc3ac9309c959c3af6))
data/Cargo.toml ADDED
@@ -0,0 +1,22 @@
1
+ [workspace]
2
+ members = ["ext/snapshot_machines_native/core", "ext/snapshot_machines_native/ffi"]
3
+ default-members = ["ext/snapshot_machines_native/core"]
4
+ resolver = "3"
5
+ [workspace.package]
6
+ edition = "2024"
7
+ rust-version = "1.99"
8
+ license = "MIT"
9
+ [workspace.dependencies]
10
+ snapshot-machines = { path = "ext/snapshot_machines_native/core" }
11
+ magnus = { version = "0.9.3", default-features = false }
12
+ rb-sys = { version = "0.9.130", default-features = false, features = ["stable-api-compiled-fallback"] }
13
+ [workspace.lints.rust]
14
+ unsafe_code = "deny"
15
+ unused_must_use = "warn"
16
+ missing_docs = "warn"
17
+ [workspace.lints.clippy]
18
+ all = { level = "warn", priority = -1 }
19
+ pedantic = { level = "warn", priority = -1 }
20
+ nursery = { level = "warn", priority = -1 }
21
+ unwrap_used = "deny"
22
+ expect_used = "deny"
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Abdelkader Boudih
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
9
+ of the Software, and to permit persons to whom the Software is furnished to do
10
+ so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,218 @@
1
+ # SnapshotMachines
2
+
3
+ > Every incremental backup is a promise that the full one still exists. 🗃️
4
+
5
+ A manifest catalog for Ruby. You tell it when a full snapshot starts and when it lands, and when each incremental one is taken on top. It remembers the chain, refuses to let you build on anything but the latest, keeps the old chain intact until a new full one has actually committed, and answers the only question that matters at 3 AM: to restore *this*, which manifests do I need, and in what order?
6
+
7
+ ## Quick Start
8
+
9
+ ```bash
10
+ gem 'snapshot_machines'
11
+ ```
12
+
13
+ ```ruby
14
+ catalog = SnapshotMachines::Catalog.new
15
+
16
+ full = catalog.begin(nil) # nil means "start a full snapshot"
17
+ catalog.commit(full, checksum_of(sunday_dump)) # => [1, nil, checksum]
18
+
19
+ monday = catalog.begin(1) # build on manifest 1
20
+ catalog.commit(monday, checksum_of(monday_delta)) # => [2, 1, checksum]
21
+
22
+ catalog.restore_plan(2)
23
+ # => [[1, nil, sunday_checksum], [2, 1, monday_checksum]]
24
+ ```
25
+
26
+ Each manifest is a three-element array: its id, the id it builds on, and the checksum you gave it. The restore plan is the list of manifests to replay, oldest first.
27
+
28
+ ## A Message to the Resistance
29
+
30
+ So you set up backups. Full dump every Sunday, incrementals Monday through Saturday, uploaded to a bucket with a lifecycle rule. Thirty days retention. Very responsible.
31
+
32
+ Then a Wednesday restore fails. The incrementals from Monday and Tuesday are right there. Wednesday's is there. Sunday's full dump expired at midnight because it was thirty-one days old, and nobody told the lifecycle rule that three other files depend on it.
33
+
34
+ The bytes were never the problem. The problem is that nothing in your system knew that Wednesday *needs* Sunday. The dependency lived in a naming convention and a vague memory of how the cron job works.
35
+
36
+ This gem holds that dependency. It won't take the bytes, won't upload anything, won't verify a checksum. It keeps the chain of who-builds-on-whom, and it makes a full replacement wait until the replacement has actually landed, so the old chain is never orphaned by an optimistic cleanup.
37
+
38
+ ## Chapter 1: Stardate 2025.186, the AMS Chandy
39
+
40
+ > **AMS Chandy**, NCC-4218, *Chandy*-class archive tender.
41
+ > Keep the full captain's log, then know exactly which later pages depend on it.
42
+
43
+ The tender doesn't carry the logs. It carries the index: this page is a complete log, these pages are amendments, each amendment names the page it amends. When a new complete log is being written, the old one stays on the shelf until the new one is bound. When the new one is bound, the old chain and all its amendments are struck from the index together.
44
+
45
+ An ensign with a disk-space alert suggests:
46
+
47
+ ```ruby
48
+ def prune_backups
49
+ Dir["backups/*.dump"].each do |file|
50
+ File.delete(file) if File.mtime(file) < 30.days.ago
51
+ end
52
+ end
53
+ ```
54
+
55
+ "That," says the archivist, "is how Wednesday becomes unrestorable."
56
+
57
+ ## The Flight Plan
58
+
59
+ ```mermaid
60
+ stateDiagram-v2
61
+ [*] --> empty
62
+ empty --> pending_full: begin(nil)
63
+ pending_full --> chained: commit, manifest 1 is the new base
64
+ pending_full --> empty: abort, nothing recorded
65
+ chained --> pending_incremental: begin(latest id)
66
+ pending_incremental --> chained: commit, chain grows by one
67
+ pending_incremental --> chained: abort, chain unchanged
68
+ chained --> pending_replacement: begin(nil)
69
+ pending_replacement --> chained: commit, old chain discarded
70
+ pending_replacement --> chained: abort, old chain kept
71
+ ```
72
+
73
+ And the week the full dump was replaced:
74
+
75
+ ```mermaid
76
+ sequenceDiagram
77
+ participant Cron
78
+ participant Catalog
79
+ participant Bucket
80
+
81
+ Cron->>Catalog: begin(nil)
82
+ Catalog-->>Cron: pending 1
83
+ Cron->>Bucket: upload sunday.dump
84
+ Cron->>Catalog: commit(pending 1, checksum)
85
+ Catalog-->>Cron: [1, nil, checksum]
86
+
87
+ Cron->>Catalog: begin(1)
88
+ Catalog-->>Cron: pending 2
89
+ Cron->>Bucket: upload monday.delta
90
+ Cron->>Catalog: commit(pending 2, checksum)
91
+ Catalog-->>Cron: [2, 1, checksum]
92
+
93
+ Note over Cron: next Sunday
94
+ Cron->>Catalog: begin(nil)
95
+ Catalog-->>Cron: pending 3
96
+ Cron->>Bucket: upload sunday2.dump
97
+ Note over Bucket: upload fails halfway
98
+ Cron->>Catalog: abort(pending 3)
99
+ Catalog-->>Cron: true
100
+ Cron->>Catalog: restore_plan(2)
101
+ Catalog-->>Cron: [[1, nil, ..], [2, 1, ..]]
102
+ Note over Catalog: old chain untouched. Monday is still restorable.
103
+ ```
104
+
105
+ ## How the Catalog Works
106
+
107
+ ### A full snapshot
108
+
109
+ Pass `nil` to `begin` and you get a `Pending` handle. Nothing is recorded yet. Record it with `commit` and the checksum you computed.
110
+
111
+ ```ruby
112
+ catalog = SnapshotMachines::Catalog.new
113
+ catalog.latest # => nil, nothing yet
114
+
115
+ pending = catalog.begin(nil)
116
+ pending.id # => 1
117
+ catalog.commit(pending, 42) # => [1, nil, 42]
118
+ catalog.latest # => [1, nil, 42]
119
+ ```
120
+
121
+ A full manifest has `nil` as its base. It depends on nothing.
122
+
123
+ ### An incremental snapshot
124
+
125
+ Pass the id of the latest manifest and the new one builds on it.
126
+
127
+ ```ruby
128
+ monday = catalog.begin(1)
129
+ catalog.commit(monday, 43) # => [2, 1, 43]
130
+
131
+ tuesday = catalog.begin(2)
132
+ catalog.commit(tuesday, 44) # => [3, 2, 44]
133
+ ```
134
+
135
+ Only the latest manifest can be built on. Ask to build on 1 now and the answer is `nil`. The chain is a line, not a tree.
136
+
137
+ ```ruby
138
+ catalog.begin(1) # => nil, manifest 1 is no longer the tip
139
+ catalog.begin(999) # => nil, never heard of it
140
+ ```
141
+
142
+ ### Restore
143
+
144
+ `restore_plan` walks back from the manifest you name to the full one it rests on, and hands you the list oldest first.
145
+
146
+ ```ruby
147
+ catalog.restore_plan(3)
148
+ # => [[1, nil, 42], [2, 1, 43], [3, 2, 44]]
149
+
150
+ catalog.restore_plan(2)
151
+ # => [[1, nil, 42], [2, 1, 43]]
152
+ ```
153
+
154
+ Replay them in that order and you're at Tuesday.
155
+
156
+ ### Replacing the full snapshot
157
+
158
+ A new full snapshot starts the same way, with `begin(nil)`. Until it commits, the old chain is exactly where it was.
159
+
160
+ ```ruby
161
+ replacement = catalog.begin(nil)
162
+ catalog.restore_plan(3) # => still [[1, nil, 42], [2, 1, 43], [3, 2, 44]]
163
+
164
+ catalog.commit(replacement, 50) # => [4, nil, 50]
165
+ catalog.restore_plan(3) # => nil, that chain is gone
166
+ catalog.restore_plan(4) # => [[4, nil, 50]]
167
+ ```
168
+
169
+ The moment the new full lands, every manifest of the old chain stops being restorable through this catalog. That's your signal that the old files can go. Not before.
170
+
171
+ ### Aborting
172
+
173
+ Upload failed, disk filled up, someone hit Ctrl-C. Tell the catalog.
174
+
175
+ ```ruby
176
+ attempt = catalog.begin(nil)
177
+ catalog.abort(attempt) # => true
178
+ catalog.abort(attempt) # => false, already dealt with
179
+ catalog.latest # => [4, nil, 50], unchanged
180
+ ```
181
+
182
+ An aborted full attempt leaves the old chain alone. An aborted incremental leaves the chain one link shorter than it would have been. Nothing is ever half-recorded.
183
+
184
+ ### One at a time
185
+
186
+ While a snapshot is pending, no other can begin. Commit it or abort it first.
187
+
188
+ ```ruby
189
+ busy = catalog.begin(4)
190
+ catalog.begin(nil) # => nil
191
+ catalog.begin(4) # => nil
192
+ catalog.abort(busy) # => true
193
+ ```
194
+
195
+ ## The Rules of the Tender
196
+
197
+ - **Checksums and ids are Integers** in `0..2**64-1`. The catalog stores the checksum; it never computes or verifies one. Floats and strings raise `TypeError`, negatives raise `RangeError`.
198
+ - **A chain holds 32 links.** At capacity, `begin(latest)` returns `nil`. `begin(nil)` still works, so a new full snapshot is always possible.
199
+ - **Handles belong to one catalog.** Hand a `Pending` from one catalog to another and you get `ArgumentError`. Commit the same handle twice and the second returns `nil`.
200
+ - **Rejections return nil or false.** Only malformed input raises.
201
+ - **Frozen means frozen.** `freeze` the catalog and `begin`, `commit`, and `abort` all raise `FrozenError`. Reads still work.
202
+
203
+ ## What You Think You're Doing vs Reality
204
+
205
+ ### You Think: "The retention policy keeps thirty days of backups."
206
+ ### Reality: It keeps thirty days of files. Some of those files are useless alone.
207
+
208
+ An incremental backup is not a backup. It's a diff. Its value is exactly zero without the full snapshot it was taken against, and a retention rule counting days has no idea which file that is. The catalog knows. Ask it before you delete anything.
209
+
210
+ ## What's Yours
211
+
212
+ The catalog is the index, not the archive. Take the snapshot, compute the checksum, store the bytes somewhere durable, and only then `commit`. Persist the catalog's state alongside the bytes, because a catalog that lives only in memory forgets the chain with the process. When `restore_plan` says a manifest is gone, that's when the corresponding files are safe to remove.
213
+
214
+ ## License
215
+
216
+ MIT License. See [LICENSE.txt](LICENSE.txt) for details.
217
+
218
+ **Remember: Sunday's dump was deleted on schedule. Wednesday's restore was not.**
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ ## [0.2.2](https://github.com/seuros/snapshot_machines/compare/snapshot-machines-v0.2.1...snapshot-machines-v0.2.2) (2026-11-06)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * crates.io keywords and categories for the core ([f544853](https://github.com/seuros/snapshot_machines/commit/f544853222f20d5e56b58199641258a676455f61))
9
+
10
+ ## [0.2.1](https://github.com/seuros/snapshot_machines/compare/snapshot-machines-v0.2.0...snapshot-machines-v0.2.1) (2026-11-06)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * keep test sources out of the packaged gem and crate ([002251a](https://github.com/seuros/snapshot_machines/commit/002251a711dd02f261ed4be6f255238fae2ef3ed))
16
+
17
+ ## [0.2.0](https://github.com/seuros/snapshot_machines/compare/snapshot-machines-v0.1.0...snapshot-machines-v0.2.0) (2026-11-06)
18
+
19
+
20
+ ### Features
21
+
22
+ * initial extraction ([691a57e](https://github.com/seuros/snapshot_machines/commit/691a57e297015cf920bd3afc3ac9309c959c3af6))
@@ -0,0 +1,13 @@
1
+ [package]
2
+ name = "snapshot-machines"
3
+ version = "0.2.2"
4
+ edition.workspace = true
5
+ rust-version.workspace = true
6
+ license.workspace = true
7
+ description = "Bounded full/incremental checkpoint manifest lifecycle"
8
+ readme = "README.md"
9
+ exclude = ["src/**/tests.rs", "tests/"]
10
+ keywords = ["snapshot", "checkpoint", "incremental", "backup", "no-std"]
11
+ categories = ["no-std", "algorithms"]
12
+ [lints]
13
+ workspace = true
@@ -0,0 +1,21 @@
1
+ # snapshot-machines
2
+
3
+ > **AMS Chandy**, NCC-4218, *Chandy*-class archive tender.
4
+
5
+ Engine-room flight manual: chart checkpoint manifest ancestry, not snapshot bytes.
6
+
7
+ Allocation-free `no_std` linear checkpoint manifest planning. Begin a full or
8
+ incremental checkpoint, write/validate its data externally, then commit its
9
+ manifest. Incrementals must name the latest committed checkpoint as parent.
10
+ Aborted attempts consume IDs so stale completions cannot commit a replacement.
11
+
12
+ A committed full checkpoint replaces the old manifest chain; a pending/aborted
13
+ full checkpoint does not. Capacity exhaustion rejects incrementals and can be
14
+ resolved by successfully committing a full checkpoint. Restore plans return
15
+ the required full-to-target chain, not missing parent references.
16
+
17
+ The supplied checksum is opaque metadata: this core does not calculate or verify
18
+ it. There is no byte-diff engine, storage consistency boundary, serialization,
19
+ data garbage collection, or crash recovery adapter. Persist manifests and data
20
+ with a deployment-specific atomic publish protocol before deleting old data.
21
+ IDs and pending handles are scoped to one catalog lifetime.
@@ -0,0 +1,122 @@
1
+ #![no_std]
2
+ #![doc = include_str!("../README.md")]
3
+
4
+ /// Catalog-scoped checkpoint identity.
5
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
6
+ pub struct SnapshotId(pub u64);
7
+ /// An in-progress checkpoint, issued by one catalog.
8
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
9
+ pub struct Pending {
10
+ id: SnapshotId,
11
+ base: Option<SnapshotId>,
12
+ }
13
+ impl Pending {
14
+ /// Identity for external data storage.
15
+ #[must_use]
16
+ pub const fn id(self) -> SnapshotId {
17
+ self.id
18
+ }
19
+ }
20
+ /// A committed full checkpoint or one incremental link.
21
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
22
+ pub struct Manifest {
23
+ /// Checkpoint identity.
24
+ pub id: SnapshotId,
25
+ /// Parent, or `None` for a full checkpoint.
26
+ pub base: Option<SnapshotId>,
27
+ /// Caller-computed data integrity metadata.
28
+ pub checksum: u64,
29
+ }
30
+ /// A bounded linear full-plus-incremental manifest chain.
31
+ #[derive(Debug)]
32
+ pub struct Catalog<const N: usize> {
33
+ manifests: [Option<Manifest>; N],
34
+ len: usize,
35
+ sequence: u64,
36
+ pending: Option<Pending>,
37
+ }
38
+ impl<const N: usize> Default for Catalog<N> {
39
+ fn default() -> Self {
40
+ Self::new()
41
+ }
42
+ }
43
+ impl<const N: usize> Catalog<N> {
44
+ /// Start an empty catalog in a fresh identity namespace.
45
+ #[must_use]
46
+ pub const fn new() -> Self {
47
+ Self {
48
+ manifests: [None; N],
49
+ len: 0,
50
+ sequence: 0,
51
+ pending: None,
52
+ }
53
+ }
54
+ /// Begin a full checkpoint, or an incremental of the latest committed one.
55
+ pub fn begin(&mut self, base: Option<SnapshotId>) -> Option<Pending> {
56
+ if N == 0 || self.pending.is_some() {
57
+ return None;
58
+ }
59
+ if let Some(base) = base
60
+ && (self.len == N || self.latest().map(|entry| entry.id) != Some(base))
61
+ {
62
+ return None;
63
+ }
64
+ let sequence = self.sequence.checked_add(1)?;
65
+ let pending = Pending {
66
+ id: SnapshotId(sequence),
67
+ base,
68
+ };
69
+ self.sequence = sequence;
70
+ self.pending = Some(pending);
71
+ Some(pending)
72
+ }
73
+ /// Publish a matching attempt after its data is durably written and verified.
74
+ pub fn commit(&mut self, pending: Pending, checksum: u64) -> Option<Manifest> {
75
+ if self.pending != Some(pending) {
76
+ return None;
77
+ }
78
+ let manifest = Manifest {
79
+ id: pending.id,
80
+ base: pending.base,
81
+ checksum,
82
+ };
83
+ if pending.base.is_none() {
84
+ self.manifests = [None; N];
85
+ self.len = 0;
86
+ }
87
+ self.manifests[self.len] = Some(manifest);
88
+ self.len += 1;
89
+ self.pending = None;
90
+ Some(manifest)
91
+ }
92
+ /// Abort without deleting committed manifests or reusing the attempt ID.
93
+ #[must_use]
94
+ pub fn abort(&mut self, pending: Pending) -> bool {
95
+ if self.pending != Some(pending) {
96
+ return false;
97
+ }
98
+ self.pending = None;
99
+ true
100
+ }
101
+ /// Latest committed checkpoint.
102
+ #[must_use]
103
+ pub const fn latest(&self) -> Option<Manifest> {
104
+ if self.len == 0 {
105
+ None
106
+ } else {
107
+ self.manifests[self.len - 1]
108
+ }
109
+ }
110
+ /// Copy a complete full-to-target restore chain; unused slots are `None`.
111
+ #[must_use]
112
+ pub fn restore_plan(&self, target: SnapshotId) -> Option<[Option<Manifest>; N]> {
113
+ let end = self.manifests[..self.len]
114
+ .iter()
115
+ .position(|entry| entry.is_some_and(|entry| entry.id == target))?;
116
+ let mut plan = [None; N];
117
+ plan[..=end].copy_from_slice(&self.manifests[..=end]);
118
+ Some(plan)
119
+ }
120
+ }
121
+ #[cfg(test)]
122
+ mod tests;
@@ -0,0 +1,2 @@
1
+ # frozen_string_literal: true
2
+ require_relative 'ffi/extconf'
@@ -0,0 +1,20 @@
1
+ [package]
2
+ name = "snapshot_machines_native"
3
+ version = "0.1.3"
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
+ snapshot-machines.workspace = true
16
+ magnus.workspace = true
17
+ rb-sys.workspace = true
18
+
19
+ [lints]
20
+ workspace = true
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+ require 'mkmf'
3
+ require 'pathname'
4
+ require 'rb_sys/mkmf'
5
+ create_rust_makefile('snapshot_machines_native/snapshot_machines_native') do |extension|
6
+ extension.ext_dir = Pathname(__dir__).relative_path_from(Pathname(Dir.pwd)).to_s
7
+ extension.profile = ENV.fetch('RB_SYS_CARGO_PROFILE', :release).to_sym
8
+ end
9
+ makefile = File.join(Dir.pwd, 'Makefile')
10
+ File.write(makefile, File.read(makefile).gsub(
11
+ /^RB_SYS_CARGO_MANIFEST_DIR \?=.*$/,
12
+ "RB_SYS_CARGO_MANIFEST_DIR ?= #{File.expand_path(__dir__)}"
13
+ ))
@@ -0,0 +1,19 @@
1
+ //! Private initialization boundary.
2
+ use magnus::{Error, Ruby, function, method, prelude::*};
3
+
4
+ #[magnus::init]
5
+ fn init(ruby: &Ruby) -> Result<(), Error> {
6
+ let module = ruby.define_module("SnapshotMachines")?;
7
+ let pending = module.define_class("Pending", ruby.class_object())?;
8
+ pending.undef_default_alloc_func();
9
+ pending.define_method("id", method!(crate::Attempt::id, 0))?;
10
+ let class = module.define_class("Catalog", ruby.class_object())?;
11
+ class.undef_default_alloc_func();
12
+ class.define_singleton_method("new", function!(crate::Wrapper::new, 0))?;
13
+ class.define_method("capacity", method!(crate::Wrapper::capacity, 0))?;
14
+ class.define_method("begin", method!(crate::Wrapper::begin, 1))?;
15
+ class.define_method("commit", method!(crate::Wrapper::commit, 2))?;
16
+ class.define_method("abort", method!(crate::Wrapper::abort, 1))?;
17
+ class.define_method("latest", method!(crate::Wrapper::latest, 0))?;
18
+ class.define_method("restore_plan", method!(crate::Wrapper::restore_plan, 1))
19
+ }
@@ -0,0 +1,109 @@
1
+ //! Thin bindings for bounded manifest planning, never snapshot bytes.
2
+ mod init;
3
+
4
+ use magnus::{Error, Integer, RArray, Ruby, TryConvert, Value, prelude::*, typed_data::Obj};
5
+ use snapshot_machines::{Catalog, Manifest, Pending, SnapshotId};
6
+ use std::{cell::RefCell, sync::Arc};
7
+
8
+ const CAPACITY: usize = 32;
9
+
10
+ #[derive(Clone, Copy)]
11
+ struct Id(u64);
12
+ impl TryConvert for Id {
13
+ fn try_convert(value: Value) -> Result<Self, Error> {
14
+ if Integer::from_value(value).is_none() {
15
+ return Err(Error::new(
16
+ Ruby::get_with(value).exception_type_error(),
17
+ "expected an Integer",
18
+ ));
19
+ }
20
+ u64::try_convert(value).map(Self)
21
+ }
22
+ }
23
+
24
+ #[magnus::wrap(class = "SnapshotMachines::Pending", free_immediately, size)]
25
+ struct Attempt {
26
+ owner: Arc<()>,
27
+ pending: Pending,
28
+ }
29
+ impl Attempt {
30
+ const fn id(&self) -> u64 {
31
+ self.pending.id().0
32
+ }
33
+ }
34
+
35
+ #[magnus::wrap(class = "SnapshotMachines::Catalog", free_immediately, size)]
36
+ struct Wrapper {
37
+ owner: Arc<()>,
38
+ catalog: RefCell<Catalog<CAPACITY>>,
39
+ }
40
+
41
+ fn manifest(entry: Manifest) -> (u64, Option<u64>, u64) {
42
+ (entry.id.0, entry.base.map(|id| id.0), entry.checksum)
43
+ }
44
+
45
+ impl Wrapper {
46
+ fn new() -> Self {
47
+ Self {
48
+ owner: Arc::new(()),
49
+ catalog: RefCell::new(Catalog::new()),
50
+ }
51
+ }
52
+ #[allow(clippy::unused_self)] // Ruby instance query exposes the fixed bridge bound.
53
+ const fn capacity(&self) -> usize {
54
+ CAPACITY
55
+ }
56
+ /// Rejects a pending handle that some other catalog issued.
57
+ fn owns(&self, ruby: &Ruby, attempt: &Attempt) -> Result<(), Error> {
58
+ if Arc::ptr_eq(&self.owner, &attempt.owner) {
59
+ Ok(())
60
+ } else {
61
+ Err(Error::new(
62
+ ruby.exception_arg_error(),
63
+ "pending handle belongs to another catalog",
64
+ ))
65
+ }
66
+ }
67
+ fn begin(rb_self: Obj<Self>, base: Option<Id>) -> Result<Option<Attempt>, Error> {
68
+ rb_self.check_frozen()?;
69
+ let pending = rb_self
70
+ .catalog
71
+ .borrow_mut()
72
+ .begin(base.map(|id| SnapshotId(id.0)));
73
+ Ok(pending.map(|pending| Attempt {
74
+ owner: Arc::clone(&rb_self.owner),
75
+ pending,
76
+ }))
77
+ }
78
+ fn commit(
79
+ ruby: &Ruby,
80
+ rb_self: Obj<Self>,
81
+ attempt: &Attempt,
82
+ checksum: Id,
83
+ ) -> Result<Option<(u64, Option<u64>, u64)>, Error> {
84
+ rb_self.check_frozen()?;
85
+ rb_self.owns(ruby, attempt)?;
86
+ let result = rb_self
87
+ .catalog
88
+ .borrow_mut()
89
+ .commit(attempt.pending, checksum.0);
90
+ Ok(result.map(manifest))
91
+ }
92
+ fn abort(ruby: &Ruby, rb_self: Obj<Self>, attempt: &Attempt) -> Result<bool, Error> {
93
+ rb_self.check_frozen()?;
94
+ rb_self.owns(ruby, attempt)?;
95
+ Ok(rb_self.catalog.borrow_mut().abort(attempt.pending))
96
+ }
97
+ fn latest(&self) -> Option<(u64, Option<u64>, u64)> {
98
+ self.catalog.borrow().latest().map(manifest)
99
+ }
100
+ fn restore_plan(ruby: &Ruby, rb_self: &Self, target: Id) -> Result<Option<RArray>, Error> {
101
+ let plan = rb_self.catalog.borrow().restore_plan(SnapshotId(target.0));
102
+ let Some(plan) = plan else { return Ok(None) };
103
+ let result = ruby.ary_new();
104
+ for entry in plan.into_iter().flatten() {
105
+ result.push(manifest(entry))?;
106
+ }
107
+ Ok(Some(result))
108
+ }
109
+ }
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+ module SnapshotMachines
3
+ VERSION = '0.1.5'
4
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+ require_relative 'snapshot_machines/version'
3
+ require 'snapshot_machines_native/snapshot_machines_native'
@@ -0,0 +1,15 @@
1
+ module SnapshotMachines
2
+ VERSION: String
3
+ class Pending
4
+ def id: () -> Integer
5
+ end
6
+ class Catalog
7
+ def self.new: () -> Catalog
8
+ def capacity: () -> Integer
9
+ def begin: (Integer?) -> Pending?
10
+ def commit: (Pending, Integer) -> [Integer, Integer?, Integer]?
11
+ def abort: (Pending) -> bool
12
+ def latest: () -> [Integer, Integer?, Integer]?
13
+ def restore_plan: (Integer) -> Array[[Integer, Integer?, Integer]]?
14
+ end
15
+ end
metadata ADDED
@@ -0,0 +1,83 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: snapshot_machines
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.5
5
+ platform: ruby
6
+ authors:
7
+ - Abdelkader Boudih
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rb_sys
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.9'
19
+ - - ">="
20
+ - !ruby/object:Gem::Version
21
+ version: 0.9.130
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - "~>"
27
+ - !ruby/object:Gem::Version
28
+ version: '0.9'
29
+ - - ">="
30
+ - !ruby/object:Gem::Version
31
+ version: 0.9.130
32
+ description: An allocation-free Rust manifest model with a thin Ruby bridge. No snapshot
33
+ bytes or durable storage.
34
+ email:
35
+ - oss@seuros.com
36
+ executables: []
37
+ extensions:
38
+ - ext/snapshot_machines_native/extconf.rb
39
+ extra_rdoc_files: []
40
+ files:
41
+ - CHANGELOG.md
42
+ - Cargo.toml
43
+ - LICENSE.txt
44
+ - README.md
45
+ - ext/snapshot_machines_native/core/CHANGELOG.md
46
+ - ext/snapshot_machines_native/core/Cargo.toml
47
+ - ext/snapshot_machines_native/core/README.md
48
+ - ext/snapshot_machines_native/core/src/lib.rs
49
+ - ext/snapshot_machines_native/extconf.rb
50
+ - ext/snapshot_machines_native/ffi/Cargo.toml
51
+ - ext/snapshot_machines_native/ffi/extconf.rb
52
+ - ext/snapshot_machines_native/ffi/src/init.rs
53
+ - ext/snapshot_machines_native/ffi/src/lib.rs
54
+ - lib/snapshot_machines.rb
55
+ - lib/snapshot_machines/version.rb
56
+ - sig/snapshot_machines.rbs
57
+ homepage: https://github.com/seuros/snapshot_machines
58
+ licenses:
59
+ - MIT
60
+ metadata:
61
+ source_code_uri: https://github.com/seuros/snapshot_machines
62
+ bug_tracker_uri: https://github.com/seuros/snapshot_machines/issues
63
+ rubygems_mfa_required: 'true'
64
+ cargo_crate_name: snapshot_machines_native
65
+ cargo_manifest_path: ext/snapshot_machines_native/ffi/Cargo.toml
66
+ rdoc_options: []
67
+ require_paths:
68
+ - lib
69
+ required_ruby_version: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: 4.0.0
74
+ required_rubygems_version: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - ">="
77
+ - !ruby/object:Gem::Version
78
+ version: '0'
79
+ requirements: []
80
+ rubygems_version: 4.0.20
81
+ specification_version: 4
82
+ summary: Bounded native snapshot manifest planning for Ruby
83
+ test_files: []