lora-ruby 0.3.0 → 0.5.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 +111 -26
  5. metadata +2 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c27b39620c0d4484f27a0d1d80ca4bfe85fbf4a7cc9ee85866cdbc15a1f028f0
4
- data.tar.gz: 6474c3321d2fef98b602deb92d184778861b4330217ebb882569cd3b828790cc
3
+ metadata.gz: 93108d86cc62dc1e96f8f4f486696b9b422a4ccf62119e56c79131df476a8144
4
+ data.tar.gz: a2d547e01de27be476b969c8cecfdaaf940eda38b58ea39a930a16bf57001c98
5
5
  SHA512:
6
- metadata.gz: adad8d60ecf6a6df5f7156d0176d43b2981170a57bf7d2239554b94666dd06dcb8ff551c3487025cf2f3812d4465c0f4e5ecd912ed70af97f98474104e742e99
7
- data.tar.gz: 63d8a74a82179146ef6dc0c9fc7ff2b30902cee5cda9a44d157953fd61a36b74c4a89a466d4a6f1b4336b2bd6ebcf6ea5dbc14c87a7e527ca999660497f2a90f
6
+ metadata.gz: 53a6ef06bc8d0e456d53100fde0ee2aeae43085d4923b65842f70b014360d7ab18025f70f7ac09a101fb7916cfabe0f385fa632cea1b8105440a24011ef657f7
7
+ data.tar.gz: 90f93b470012d15fb981e79db8ae47913396f95de2a7379e3258749ef5bbaab70b9712d480f268759c96a4e0996bbd1bed044b1406e1dac4ef76edf1aba9ad90
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.3.0" unless const_defined?(:VERSION)
13
+ VERSION = "0.5.0" unless const_defined?(:VERSION)
14
14
  end
data/src/lib.rs CHANGED
@@ -16,7 +16,7 @@
16
16
  use std::collections::BTreeMap;
17
17
  use std::ffi::c_void;
18
18
  use std::mem::MaybeUninit;
19
- use std::sync::Arc;
19
+ use std::sync::{Arc, Mutex};
20
20
 
21
21
  use magnus::{
22
22
  function, method, prelude::*, r_hash::ForEach, value::ReprValue, Error as MagnusError,
@@ -24,7 +24,8 @@ use magnus::{
24
24
  };
25
25
 
26
26
  use lora_database::{
27
- Database as InnerDatabase, ExecuteOptions, InMemoryGraph, LoraValue, QueryResult, ResultFormat,
27
+ Database as InnerDatabase, DatabaseOpenOptions, ExecuteOptions, InMemoryGraph, LoraValue,
28
+ QueryResult, ResultFormat,
28
29
  };
29
30
  use lora_store::{
30
31
  LoraDate, LoraDateTime, LoraDuration, LoraLocalDateTime, LoraLocalTime, LoraPoint, LoraTime,
@@ -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",
@@ -114,20 +116,20 @@ fn invalid_params(ruby: &Ruby, msg: impl Into<String>) -> MagnusError {
114
116
  // Database
115
117
  // ============================================================================
116
118
 
117
- /// In-memory Lora graph database handle exposed to Ruby.
119
+ /// Lora graph database handle exposed to Ruby.
118
120
  ///
119
121
  /// Wraps an `Arc<Database<InMemoryGraph>>`; the same handle is cloned
120
122
  /// across the GVL-release boundary for query execution without borrowing
121
123
  /// any Ruby state.
122
124
  #[magnus::wrap(class = "LoraRuby::Database", free_immediately, size)]
123
125
  struct Database {
124
- db: Arc<InnerDatabase<InMemoryGraph>>,
126
+ db: Mutex<Option<Arc<InnerDatabase<InMemoryGraph>>>>,
125
127
  }
126
128
 
127
129
  impl Database {
128
- fn empty() -> Self {
130
+ fn from_db(db: Arc<InnerDatabase<InMemoryGraph>>) -> Self {
129
131
  Self {
130
- db: Arc::new(InnerDatabase::in_memory()),
132
+ db: Mutex::new(Some(db)),
131
133
  }
132
134
  }
133
135
  }
@@ -135,32 +137,53 @@ impl Database {
135
137
  // Constructors — we expose `Database.create` and `Database.new` as
136
138
  // singletons so callers can use whichever idiom they prefer; both are
137
139
  // cost-equivalent.
138
- fn database_new() -> Database {
139
- Database::empty()
140
+ fn database_new(ruby: &Ruby, args: &[Value]) -> Result<Database, MagnusError> {
141
+ let (database_name, options) = database_open_args(ruby, args)?;
142
+ let db = without_gvl(move || open_database(database_name, options))
143
+ .map_err(|e| query_error(ruby, e))?;
144
+ Ok(Database::from_db(db))
140
145
  }
141
146
 
142
- fn database_create() -> Database {
143
- Database::empty()
147
+ fn database_create(ruby: &Ruby, args: &[Value]) -> Result<Database, MagnusError> {
148
+ database_new(ruby, args)
144
149
  }
145
150
 
146
- fn database_clear(rb_self: &Database) {
147
- rb_self.db.clear();
151
+ fn database_clear(ruby: &Ruby, rb_self: &Database) -> Result<(), MagnusError> {
152
+ database_inner(ruby, rb_self)?.clear();
153
+ Ok(())
154
+ }
155
+
156
+ fn database_close(ruby: &Ruby, rb_self: &Database) -> Result<(), MagnusError> {
157
+ let mut slot = rb_self
158
+ .db
159
+ .lock()
160
+ .map_err(|_| query_error(ruby, "database lock poisoned"))?;
161
+ slot.take();
162
+ Ok(())
148
163
  }
149
164
 
150
- fn database_node_count(rb_self: &Database) -> u64 {
151
- rb_self.db.node_count() as u64
165
+ fn database_node_count(ruby: &Ruby, rb_self: &Database) -> Result<u64, MagnusError> {
166
+ Ok(database_inner(ruby, rb_self)?.node_count() as u64)
152
167
  }
153
168
 
154
- fn database_relationship_count(rb_self: &Database) -> u64 {
155
- rb_self.db.relationship_count() as u64
169
+ fn database_relationship_count(ruby: &Ruby, rb_self: &Database) -> Result<u64, MagnusError> {
170
+ Ok(database_inner(ruby, rb_self)?.relationship_count() as u64)
156
171
  }
157
172
 
158
173
  fn database_inspect(rb_self: &Database) -> String {
159
- format!(
160
- "#<LoraRuby::Database nodes={} relationships={}>",
161
- rb_self.db.node_count(),
162
- rb_self.db.relationship_count(),
163
- )
174
+ match rb_self
175
+ .db
176
+ .lock()
177
+ .ok()
178
+ .and_then(|slot| slot.as_ref().cloned())
179
+ {
180
+ Some(db) => format!(
181
+ "#<LoraRuby::Database nodes={} relationships={}>",
182
+ db.node_count(),
183
+ db.relationship_count(),
184
+ ),
185
+ None => "#<LoraRuby::Database closed>".to_string(),
186
+ }
164
187
  }
165
188
 
166
189
  fn database_save_snapshot(
@@ -169,7 +192,7 @@ fn database_save_snapshot(
169
192
  path: RString,
170
193
  ) -> Result<RHash, MagnusError> {
171
194
  let path = path.to_string()?;
172
- let db = Arc::clone(&rb_self.db);
195
+ let db = database_inner(ruby, rb_self)?;
173
196
  let meta = without_gvl(move || db.save_snapshot_to(&path))
174
197
  .map_err(|e| query_error(ruby, format!("{e}")))?;
175
198
  snapshot_meta_to_rhash(ruby, meta)
@@ -181,7 +204,7 @@ fn database_load_snapshot(
181
204
  path: RString,
182
205
  ) -> Result<RHash, MagnusError> {
183
206
  let path = path.to_string()?;
184
- let db = Arc::clone(&rb_self.db);
207
+ let db = database_inner(ruby, rb_self)?;
185
208
  let meta = without_gvl(move || db.load_snapshot_from(&path))
186
209
  .map_err(|e| query_error(ruby, format!("{e}")))?;
187
210
  snapshot_meta_to_rhash(ruby, meta)
@@ -211,6 +234,68 @@ fn snapshot_meta_to_rhash(
211
234
  Ok(h)
212
235
  }
213
236
 
237
+ fn database_open_args(
238
+ ruby: &Ruby,
239
+ args: &[Value],
240
+ ) -> Result<(Option<String>, DatabaseOpenOptions), MagnusError> {
241
+ match args.len() {
242
+ 0 => Ok((None, DatabaseOpenOptions::default())),
243
+ 1 => {
244
+ if args[0].is_nil() {
245
+ Ok((None, DatabaseOpenOptions::default()))
246
+ } else {
247
+ Ok((
248
+ Some(RString::try_convert(args[0])?.to_string()?),
249
+ DatabaseOpenOptions::default(),
250
+ ))
251
+ }
252
+ }
253
+ 2 => {
254
+ let database_name = if args[0].is_nil() {
255
+ None
256
+ } else {
257
+ Some(RString::try_convert(args[0])?.to_string()?)
258
+ };
259
+ let mut options = DatabaseOpenOptions::default();
260
+ let hash = RHash::try_convert(args[1])?;
261
+ if let Some(dir) = hash_get_either(ruby, hash, "database_dir")
262
+ .or_else(|| hash_get_either(ruby, hash, "databaseDir"))
263
+ {
264
+ options.database_dir = RString::try_convert(dir)?.to_string()?.into();
265
+ }
266
+ Ok((database_name, options))
267
+ }
268
+ n => Err(MagnusError::new(
269
+ ruby.exception_arg_error(),
270
+ format!("wrong number of arguments (given {n}, expected 0..2)"),
271
+ )),
272
+ }
273
+ }
274
+
275
+ fn open_database(
276
+ database_name: Option<String>,
277
+ options: DatabaseOpenOptions,
278
+ ) -> Result<Arc<InnerDatabase<InMemoryGraph>>, String> {
279
+ let db = match database_name {
280
+ Some(name) => InnerDatabase::open_named(name, options).map_err(|e| e.to_string())?,
281
+ None => InnerDatabase::in_memory(),
282
+ };
283
+ Ok(Arc::new(db))
284
+ }
285
+
286
+ fn database_inner(
287
+ ruby: &Ruby,
288
+ rb_self: &Database,
289
+ ) -> Result<Arc<InnerDatabase<InMemoryGraph>>, MagnusError> {
290
+ let slot = rb_self
291
+ .db
292
+ .lock()
293
+ .map_err(|_| query_error(ruby, "database lock poisoned"))?;
294
+ slot.as_ref()
295
+ .cloned()
296
+ .ok_or_else(|| query_error(ruby, "database is closed"))
297
+ }
298
+
214
299
  /// `execute(query, params = nil)` — `-1` arity so `params` is optional and
215
300
  /// we can distinguish "not passed" from `nil`/`{}` (both map to empty
216
301
  /// params). Everything that touches Ruby values happens under the GVL;
@@ -248,7 +333,7 @@ fn database_execute(ruby: &Ruby, rb_self: &Database, args: &[Value]) -> Result<R
248
333
  // Run the engine with the GVL released. Everything inside the closure
249
334
  // is pure Rust — no Ruby values cross the boundary — which keeps this
250
335
  // sound.
251
- let db = Arc::clone(&rb_self.db);
336
+ let db = database_inner(ruby, rb_self)?;
252
337
  let exec_result = without_gvl(move || {
253
338
  let options = ExecuteOptions {
254
339
  format: ResultFormat::RowArrays,
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.3.0
4
+ version: 0.5.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-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rb_sys