lora-ruby 0.2.0 → 0.4.0

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.
Files changed (5) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +27 -3
  3. data/lib/lora_ruby/version.rb +1 -1
  4. data/src/lib.rs +139 -32
  5. metadata +2 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cc78ad82af9cdeddda40129742a043a0a2671a4671ace27e6ed250f10bb81f47
4
- data.tar.gz: 409372914f30e7c4d69ad3a4fb70dc328020a0b903c2a0c1d69b64aa61c2358f
3
+ metadata.gz: 9f225172245d7983494a2d5d498edc749e31629b811453667be8517d7624fde0
4
+ data.tar.gz: 36e8d24e24003755b36abcf34354df4c7af42125d969c27c2e76de551d2cb06d
5
5
  SHA512:
6
- metadata.gz: 7fb025acfebc7f57a98cb07f745b9ad9ede8cae1532abc4a157d2fd8c0cfc875754299e714b4456dc4f93bed76b10954edb8bc6f5b41012d02f2076510a71ed0
7
- data.tar.gz: 884ffd034cd6b174b936bc0c02019e073c4d3106822db1f9f8fa067fd3903a8c9dc619648508d855ee2ab26f522dab6b619c89f35be2d5c9f5fcbbfb2c5496dc
6
+ metadata.gz: 48356ff0802ae5949f2d000580779c865b793dc562d8068deb92e72eefc7a2409fa9890b7a69ff336ef8c0ba2d67c27da57f253e6db2674920715b71320f7c07
7
+ data.tar.gz: 4446b8aac9ac26302ccb688784c6bd20f3e6f4ca80d6fb66525505c7022964f672cf9272876584672ce8b34a183708a50f9cc54e785ae75420394b365ecc43f2
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # lora-ruby
2
2
 
3
- Ruby bindings for the [Lora](../../README.md) in-memory graph engine.
3
+ Ruby bindings for the [Lora](../../README.md) graph engine.
4
4
  Ships a native extension built with [Magnus](https://github.com/matsadler/magnus)
5
5
  on top of [`rb-sys`](https://github.com/oxidize-rb/rb-sys) so the Rust
6
6
  engine runs in-process — no separate server, no socket hop.
@@ -39,6 +39,16 @@ result["rows"].each do |row|
39
39
  end
40
40
  ```
41
41
 
42
+ Initialization rule:
43
+
44
+ ```ruby
45
+ scratch = LoraRuby::Database.create # in-memory
46
+ persistent = LoraRuby::Database.create("./app") # persistent: directory string
47
+ ```
48
+
49
+ If you want persistence, pass a directory string to
50
+ `LoraRuby::Database.create(...)` or `LoraRuby::Database.new(...)`.
51
+
42
52
  ### Params
43
53
 
44
54
  `execute` accepts a second argument — either `nil` or a `Hash` keyed by
@@ -66,8 +76,8 @@ binding's `lora_python.Database` and because it mirrors the
66
76
  ## Public API
67
77
 
68
78
  ```ruby
69
- LoraRuby::Database.create # -> Database
70
- LoraRuby::Database.new # -> Database (alias of .create)
79
+ LoraRuby::Database.create(wal_dir = nil) # -> Database
80
+ LoraRuby::Database.new(wal_dir = nil) # -> Database (alias of .create)
71
81
 
72
82
  db.execute(query, params = nil) # -> { "columns" => [...], "rows" => [...] }
73
83
  db.clear # -> nil
@@ -136,6 +146,20 @@ db.execute(
136
146
  - `LoraRuby::QueryError` — parse / analyze / execute failure.
137
147
  - `LoraRuby::InvalidParamsError` — a parameter value couldn't be mapped.
138
148
 
149
+ ## Persistence
150
+
151
+ `LoraRuby::Database.create("./app")` and
152
+ `LoraRuby::Database.new("./app")` open or create a WAL-backed
153
+ persistent database rooted at that directory. Reopening the same path
154
+ replays committed writes before returning the handle.
155
+
156
+ Call `db.close` before reopening the same WAL directory inside one
157
+ process.
158
+
159
+ This first Ruby persistence slice intentionally stays small: the
160
+ binding exposes WAL-backed initialization plus the existing snapshot
161
+ APIs, but not checkpoint, truncate, status, or sync-mode controls.
162
+
139
163
  ## Concurrency (GVL release)
140
164
 
141
165
  `Database#execute` calls `rb_thread_call_without_gvl`, so other Ruby
@@ -10,5 +10,5 @@ module LoraRuby
10
10
  # Guard against redefinition so re-requiring this file (or loading
11
11
  # both paths) doesn't emit a "warning: already initialized constant"
12
12
  # when the native extension loads second with the identical value.
13
- VERSION = "0.2.0" unless const_defined?(:VERSION)
13
+ VERSION = "0.4.0" unless const_defined?(:VERSION)
14
14
  end
data/src/lib.rs CHANGED
@@ -25,10 +25,11 @@ use magnus::{
25
25
 
26
26
  use lora_database::{
27
27
  Database as InnerDatabase, ExecuteOptions, InMemoryGraph, LoraValue, QueryResult, ResultFormat,
28
+ WalConfig,
28
29
  };
29
30
  use lora_store::{
30
- GraphStorage, LoraDate, LoraDateTime, LoraDuration, LoraLocalDateTime, LoraLocalTime,
31
- LoraPoint, LoraTime, LoraVector, RawCoordinate, VectorCoordinateType, VectorValues,
31
+ LoraDate, LoraDateTime, LoraDuration, LoraLocalDateTime, LoraLocalTime, LoraPoint, LoraTime,
32
+ LoraVector, RawCoordinate, VectorCoordinateType, VectorValues,
32
33
  };
33
34
 
34
35
  // ============================================================================
@@ -60,10 +61,11 @@ fn init(ruby: &Ruby) -> Result<(), MagnusError> {
60
61
  lora_ruby.define_class("InvalidParamsError", error)?;
61
62
 
62
63
  let database = lora_ruby.define_class("Database", ruby.class_object())?;
63
- database.define_singleton_method("create", function!(database_create, 0))?;
64
- database.define_singleton_method("new", function!(database_new, 0))?;
64
+ database.define_singleton_method("create", function!(database_create, -1))?;
65
+ database.define_singleton_method("new", function!(database_new, -1))?;
65
66
  database.define_method("execute", method!(database_execute, -1))?;
66
67
  database.define_method("clear", method!(database_clear, 0))?;
68
+ database.define_method("close", method!(database_close, 0))?;
67
69
  database.define_method("node_count", method!(database_node_count, 0))?;
68
70
  database.define_method(
69
71
  "relationship_count",
@@ -71,6 +73,8 @@ fn init(ruby: &Ruby) -> Result<(), MagnusError> {
71
73
  )?;
72
74
  database.define_method("inspect", method!(database_inspect, 0))?;
73
75
  database.define_method("to_s", method!(database_inspect, 0))?;
76
+ database.define_method("save_snapshot", method!(database_save_snapshot, 1))?;
77
+ database.define_method("load_snapshot", method!(database_load_snapshot, 1))?;
74
78
 
75
79
  // `LoraRuby::VERSION` is owned by `lib/lora_ruby/version.rb` so the
76
80
  // gem can expose a version before the native extension compiles
@@ -112,20 +116,20 @@ fn invalid_params(ruby: &Ruby, msg: impl Into<String>) -> MagnusError {
112
116
  // Database
113
117
  // ============================================================================
114
118
 
115
- /// In-memory Lora graph database handle exposed to Ruby.
119
+ /// Lora graph database handle exposed to Ruby.
116
120
  ///
117
- /// `Arc<Mutex<InMemoryGraph>>` gives us cheap cloning so the mutex guard
118
- /// can be sent across the GVL-release boundary without borrowing any Ruby
119
- /// state.
121
+ /// Wraps an `Arc<Database<InMemoryGraph>>`; the same handle is cloned
122
+ /// across the GVL-release boundary for query execution without borrowing
123
+ /// any Ruby state.
120
124
  #[magnus::wrap(class = "LoraRuby::Database", free_immediately, size)]
121
125
  struct Database {
122
- store: Arc<Mutex<InMemoryGraph>>,
126
+ db: Mutex<Option<Arc<InnerDatabase<InMemoryGraph>>>>,
123
127
  }
124
128
 
125
129
  impl Database {
126
- fn empty() -> Self {
130
+ fn from_db(db: Arc<InnerDatabase<InMemoryGraph>>) -> Self {
127
131
  Self {
128
- store: Arc::new(Mutex::new(InMemoryGraph::new())),
132
+ db: Mutex::new(Some(db)),
129
133
  }
130
134
  }
131
135
  }
@@ -133,36 +137,140 @@ impl Database {
133
137
  // Constructors — we expose `Database.create` and `Database.new` as
134
138
  // singletons so callers can use whichever idiom they prefer; both are
135
139
  // cost-equivalent.
136
- fn database_new() -> Database {
137
- Database::empty()
140
+ fn database_new(ruby: &Ruby, args: &[Value]) -> Result<Database, MagnusError> {
141
+ let wal_dir = optional_wal_dir_arg(ruby, args)?;
142
+ let db = without_gvl(move || open_database(wal_dir)).map_err(|e| query_error(ruby, e))?;
143
+ Ok(Database::from_db(db))
138
144
  }
139
145
 
140
- fn database_create() -> Database {
141
- Database::empty()
146
+ fn database_create(ruby: &Ruby, args: &[Value]) -> Result<Database, MagnusError> {
147
+ database_new(ruby, args)
142
148
  }
143
149
 
144
- fn database_clear(rb_self: &Database) {
145
- let mut guard = rb_self.store.lock().unwrap_or_else(|p| p.into_inner());
146
- *guard = InMemoryGraph::new();
150
+ fn database_clear(ruby: &Ruby, rb_self: &Database) -> Result<(), MagnusError> {
151
+ database_inner(ruby, rb_self)?.clear();
152
+ Ok(())
147
153
  }
148
154
 
149
- fn database_node_count(rb_self: &Database) -> u64 {
150
- let guard = rb_self.store.lock().unwrap_or_else(|p| p.into_inner());
151
- guard.node_count() as u64
155
+ fn database_close(ruby: &Ruby, rb_self: &Database) -> Result<(), MagnusError> {
156
+ let mut slot = rb_self
157
+ .db
158
+ .lock()
159
+ .map_err(|_| query_error(ruby, "database lock poisoned"))?;
160
+ slot.take();
161
+ Ok(())
152
162
  }
153
163
 
154
- fn database_relationship_count(rb_self: &Database) -> u64 {
155
- let guard = rb_self.store.lock().unwrap_or_else(|p| p.into_inner());
156
- guard.relationship_count() as u64
164
+ fn database_node_count(ruby: &Ruby, rb_self: &Database) -> Result<u64, MagnusError> {
165
+ Ok(database_inner(ruby, rb_self)?.node_count() as u64)
166
+ }
167
+
168
+ fn database_relationship_count(ruby: &Ruby, rb_self: &Database) -> Result<u64, MagnusError> {
169
+ Ok(database_inner(ruby, rb_self)?.relationship_count() as u64)
157
170
  }
158
171
 
159
172
  fn database_inspect(rb_self: &Database) -> String {
160
- let guard = rb_self.store.lock().unwrap_or_else(|p| p.into_inner());
161
- format!(
162
- "#<LoraRuby::Database nodes={} relationships={}>",
163
- guard.node_count(),
164
- guard.relationship_count(),
165
- )
173
+ match rb_self
174
+ .db
175
+ .lock()
176
+ .ok()
177
+ .and_then(|slot| slot.as_ref().cloned())
178
+ {
179
+ Some(db) => format!(
180
+ "#<LoraRuby::Database nodes={} relationships={}>",
181
+ db.node_count(),
182
+ db.relationship_count(),
183
+ ),
184
+ None => "#<LoraRuby::Database closed>".to_string(),
185
+ }
186
+ }
187
+
188
+ fn database_save_snapshot(
189
+ ruby: &Ruby,
190
+ rb_self: &Database,
191
+ path: RString,
192
+ ) -> Result<RHash, MagnusError> {
193
+ let path = path.to_string()?;
194
+ let db = database_inner(ruby, rb_self)?;
195
+ let meta = without_gvl(move || db.save_snapshot_to(&path))
196
+ .map_err(|e| query_error(ruby, format!("{e}")))?;
197
+ snapshot_meta_to_rhash(ruby, meta)
198
+ }
199
+
200
+ fn database_load_snapshot(
201
+ ruby: &Ruby,
202
+ rb_self: &Database,
203
+ path: RString,
204
+ ) -> Result<RHash, MagnusError> {
205
+ let path = path.to_string()?;
206
+ let db = database_inner(ruby, rb_self)?;
207
+ let meta = without_gvl(move || db.load_snapshot_from(&path))
208
+ .map_err(|e| query_error(ruby, format!("{e}")))?;
209
+ snapshot_meta_to_rhash(ruby, meta)
210
+ }
211
+
212
+ fn snapshot_meta_to_rhash(
213
+ ruby: &Ruby,
214
+ meta: lora_database::SnapshotMeta,
215
+ ) -> Result<RHash, MagnusError> {
216
+ let h = ruby.hash_new();
217
+ h.aset(
218
+ ruby.str_new("formatVersion"),
219
+ ruby.integer_from_i64(meta.format_version as i64),
220
+ )?;
221
+ h.aset(
222
+ ruby.str_new("nodeCount"),
223
+ ruby.integer_from_i64(meta.node_count as i64),
224
+ )?;
225
+ h.aset(
226
+ ruby.str_new("relationshipCount"),
227
+ ruby.integer_from_i64(meta.relationship_count as i64),
228
+ )?;
229
+ match meta.wal_lsn {
230
+ Some(lsn) => h.aset(ruby.str_new("walLsn"), ruby.integer_from_i64(lsn as i64))?,
231
+ None => h.aset(ruby.str_new("walLsn"), ruby.qnil())?,
232
+ }
233
+ Ok(h)
234
+ }
235
+
236
+ fn optional_wal_dir_arg(ruby: &Ruby, args: &[Value]) -> Result<Option<String>, MagnusError> {
237
+ match args.len() {
238
+ 0 => Ok(None),
239
+ 1 => {
240
+ if args[0].is_nil() {
241
+ Ok(None)
242
+ } else {
243
+ Ok(Some(RString::try_convert(args[0])?.to_string()?))
244
+ }
245
+ }
246
+ n => Err(MagnusError::new(
247
+ ruby.exception_arg_error(),
248
+ format!("wrong number of arguments (given {n}, expected 0..1)"),
249
+ )),
250
+ }
251
+ }
252
+
253
+ fn open_database(wal_dir: Option<String>) -> Result<Arc<InnerDatabase<InMemoryGraph>>, String> {
254
+ let db = match wal_dir {
255
+ Some(dir) => {
256
+ InnerDatabase::open_with_wal(WalConfig::enabled(dir)).map_err(|e| e.to_string())?
257
+ }
258
+ None => InnerDatabase::in_memory(),
259
+ };
260
+ Ok(Arc::new(db))
261
+ }
262
+
263
+ fn database_inner(
264
+ ruby: &Ruby,
265
+ rb_self: &Database,
266
+ ) -> Result<Arc<InnerDatabase<InMemoryGraph>>, MagnusError> {
267
+ let slot = rb_self
268
+ .db
269
+ .lock()
270
+ .map_err(|_| query_error(ruby, "database lock poisoned"))?;
271
+ slot.as_ref()
272
+ .cloned()
273
+ .ok_or_else(|| query_error(ruby, "database is closed"))
166
274
  }
167
275
 
168
276
  /// `execute(query, params = nil)` — `-1` arity so `params` is optional and
@@ -202,9 +310,8 @@ fn database_execute(ruby: &Ruby, rb_self: &Database, args: &[Value]) -> Result<R
202
310
  // Run the engine with the GVL released. Everything inside the closure
203
311
  // is pure Rust — no Ruby values cross the boundary — which keeps this
204
312
  // sound.
205
- let store = Arc::clone(&rb_self.store);
313
+ let db = database_inner(ruby, rb_self)?;
206
314
  let exec_result = without_gvl(move || {
207
- let db = InnerDatabase::new(store);
208
315
  let options = ExecuteOptions {
209
316
  format: ResultFormat::RowArrays,
210
317
  };
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lora-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - LoraDB, Inc.
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-04-24 00:00:00.000000000 Z
11
+ date: 2026-04-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rb_sys