galaaz 2.1.0 → 2.1.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.
data/lib/galaaz/cli.rb ADDED
@@ -0,0 +1,552 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'rbconfig'
5
+ require 'open3'
6
+ require 'time'
7
+
8
+ module Galaaz
9
+ module CLI
10
+ BLOG_NAMES = %w[oh_my gknit galaaz_ggplot manual nse_dplyr ruby_plot].freeze
11
+ PROFILES = %w[knit arrow tex bio examples ledger demo].freeze
12
+ CONFIG_DIR = File.join(Dir.home, '.config', 'galaaz')
13
+ PROFILES_DIR = File.join(CONFIG_DIR, 'profiles')
14
+ DEFAULT_BLOGS_DIR = File.join(Dir.home, 'galaaz-blogs')
15
+ DEFAULT_EXAMPLES_DIR = File.join(Dir.home, 'galaaz-examples')
16
+ DEFAULT_LEDGER_DIR = File.join(Dir.home, 'r_on_rails_ledger')
17
+ LEDGER_REPO = 'https://github.com/rbotafogo/r_on_rails_ledger.git'
18
+ BLOGS_MARKER = '.galaaz-blogs'
19
+ EXAMPLES_MARKER = '.galaaz-examples'
20
+ CRAN = 'https://cloud.r-project.org'
21
+
22
+ module_function
23
+
24
+ def root
25
+ return ENV['GALAAZ_ROOT'] if ENV['GALAAZ_ROOT'] && !ENV['GALAAZ_ROOT'].empty?
26
+
27
+ File.expand_path('../..', __dir__)
28
+ end
29
+
30
+ def run(argv)
31
+ cmd = argv[0]
32
+ case cmd
33
+ when nil, '-h', '--help', 'help'
34
+ print_help
35
+ 0
36
+ when 'setup'
37
+ cmd_setup
38
+ when 'blogs'
39
+ cmd_blogs(argv[1..])
40
+ when 'doctor'
41
+ cmd_doctor
42
+ when 'add'
43
+ cmd_add(argv[1..])
44
+ else
45
+ # Legacy: `galaaz some:rake_task` forwarded to rake (see README / blogs).
46
+ Dir.chdir(root) do
47
+ ok = system('rake', *argv)
48
+ ok ? 0 : 1
49
+ end
50
+ end
51
+ rescue CliError => e
52
+ warn "galaaz: #{e.message}"
53
+ 1
54
+ rescue SystemCallError => e
55
+ warn "galaaz: #{e.class}: #{e.message}"
56
+ 1
57
+ end
58
+
59
+ def print_help
60
+ puts <<~HELP
61
+ Usage: galaaz <command> [args]
62
+
63
+ Commands:
64
+ setup Build the NewBridge gatekeeper; ensure Rcpp
65
+ blogs init [DIR] Copy blog sources (default: ~/galaaz-blogs)
66
+ Options: --force
67
+ doctor Report Ruby, R, gatekeeper, Rcpp, profiles
68
+ add PROFILE Install an add-on (idempotent)
69
+ Profiles: #{PROFILES.join(', ')}
70
+
71
+ Legacy: any other argument is passed to rake in the Galaaz root
72
+ (e.g. galaaz master_list:scatter_plot).
73
+ HELP
74
+ end
75
+
76
+ # ---- setup ----
77
+
78
+ def cmd_setup
79
+ need_cmd!('R')
80
+ need_cmd!('Rscript')
81
+ need_cmd!('make')
82
+ ensure_rcpp!
83
+ bridge = File.join(root, 'ext', 'new_bridge')
84
+ abort_unless(File.directory?(bridge), "missing #{bridge}")
85
+ puts "galaaz setup: make -C #{bridge} all"
86
+ ok = system('make', '-C', bridge, 'all')
87
+ abort_unless(ok, 'make failed building ext/new_bridge')
88
+ so = File.join(bridge, 'galaaz_gatekeeper.so')
89
+ abort_unless(File.file?(so), "gatekeeper missing after build: #{so}")
90
+ mark_profile!('core')
91
+ puts 'galaaz setup: OK'
92
+ puts "You can now run: galaaz doctor"
93
+ puts 'Omarchy menu: Install → Development → Galaaz (add-ons appear after core)'
94
+ 0
95
+ end
96
+
97
+ # ---- blogs ----
98
+
99
+ def cmd_blogs(argv)
100
+ sub = argv[0]
101
+ case sub
102
+ when 'init'
103
+ blogs_init(argv[1..])
104
+ when nil, '-h', '--help'
105
+ puts 'Usage: galaaz blogs init [DIR] [--force]'
106
+ 0
107
+ else
108
+ raise CliError, "unknown blogs subcommand: #{sub.inspect} (try: init)"
109
+ end
110
+ end
111
+
112
+ def blogs_init(argv)
113
+ force = argv.delete('--force')
114
+ dest = File.expand_path(argv[0] || DEFAULT_BLOGS_DIR)
115
+ src_root = File.join(root, 'blogs')
116
+ abort_unless(File.directory?(src_root), "blogs not found in #{root}")
117
+
118
+ if File.exist?(dest)
119
+ entries = Dir.children(dest).reject { |n| n.start_with?('.') }
120
+ if !entries.empty? && !force
121
+ raise CliError, "#{dest} exists and is not empty (use --force to replace)"
122
+ end
123
+ FileUtils.rm_rf(dest) if force
124
+ end
125
+ FileUtils.mkdir_p(dest)
126
+
127
+ BLOG_NAMES.each do |name|
128
+ src = File.join(src_root, name)
129
+ abort_unless(File.directory?(src), "missing blog source: #{src}")
130
+ FileUtils.cp_r(src, File.join(dest, name))
131
+ rmd = File.join(dest, name, "#{name}.Rmd")
132
+ abort_unless(File.file?(rmd), "expected #{rmd}")
133
+ end
134
+
135
+ File.write(File.join(dest, BLOGS_MARKER), "galaaz blogs init\n")
136
+ puts "galaaz blogs init: #{dest}"
137
+ puts "You can now knit with gknit (after: galaaz add knit)"
138
+ 0
139
+ end
140
+
141
+ # ---- doctor ----
142
+
143
+ def cmd_doctor
144
+ puts "galaaz doctor"
145
+ puts " root: #{root}"
146
+ print_ruby_engines
147
+
148
+ r_ok = command_present?('R')
149
+ rs_ok = command_present?('Rscript')
150
+ puts " R: #{r_ok ? `R --version 2>/dev/null`.lines.first.to_s.strip : 'MISSING'}"
151
+ puts " Rscript: #{rs_ok ? 'OK' : 'MISSING'}"
152
+
153
+ so = File.join(root, 'ext', 'new_bridge', 'galaaz_gatekeeper.so')
154
+ puts " gatekeeper: #{File.file?(so) ? so : 'MISSING (run: galaaz setup)'}"
155
+
156
+ rcpp = rs_ok && r_namespace?('Rcpp')
157
+ puts " Rcpp: #{rcpp ? 'OK' : 'MISSING'}"
158
+
159
+ blogs = detect_blogs_dir
160
+ puts " blogs: #{blogs || '(not initialized)'}"
161
+
162
+ profiles = listed_profiles
163
+ puts " profiles: #{profiles.empty? ? '(none)' : profiles.join(', ')}"
164
+
165
+ setup_ok = r_ok && rs_ok && File.file?(so) && rcpp
166
+ puts setup_ok ? 'galaaz doctor: setup OK' : 'galaaz doctor: setup INCOMPLETE'
167
+ setup_ok ? 0 : 1
168
+ end
169
+
170
+ def print_ruby_engines
171
+ puts " this: #{RUBY_DESCRIPTION}"
172
+ puts " engine: #{RUBY_ENGINE} (interpreter running doctor)"
173
+
174
+ cruby = probe_cruby
175
+ jruby = probe_jruby
176
+ puts " cruby: #{cruby || 'MISSING (Omarchy / mise: ruby@3.3+)'}"
177
+ puts " jruby: #{jruby || 'MISSING'}"
178
+ if RUBY_ENGINE != 'ruby' && cruby
179
+ puts " tip: run under CRuby with: GALAAZ_RUBY=ruby bin/galaaz-ruby …"
180
+ elsif RUBY_ENGINE == 'ruby' && jruby
181
+ puts " tip: run under JRuby with: GALAAZ_RUBY=jruby bin/galaaz-ruby …"
182
+ end
183
+ end
184
+
185
+ # MRI description if available (PATH ruby may be JRuby via mise).
186
+ def probe_cruby
187
+ return RUBY_DESCRIPTION if RUBY_ENGINE == 'ruby'
188
+
189
+ if ENV['GALAAZ_RUBY'] && !ENV['GALAAZ_RUBY'].empty?
190
+ desc = ruby_description_for(ENV['GALAAZ_RUBY'])
191
+ return desc if desc && desc_engine(desc) == 'ruby'
192
+ end
193
+
194
+ if command_present?('mise')
195
+ mise_ruby_versions.each do |ver|
196
+ next if ver.include?('jruby')
197
+ desc = ruby_description_for('mise', 'exec', "ruby@#{ver}", '--', 'ruby')
198
+ return desc if desc
199
+ end
200
+ end
201
+
202
+ # Last resort: a binary named something other than the jruby-backed `ruby` shim.
203
+ %w[ruby3.3 ruby3.2 ruby3.4].each do |bin|
204
+ desc = ruby_description_for(bin)
205
+ return desc if desc && desc_engine(desc) == 'ruby'
206
+ end
207
+ nil
208
+ end
209
+
210
+ def probe_jruby
211
+ return RUBY_DESCRIPTION if RUBY_ENGINE == 'jruby'
212
+
213
+ desc = ruby_description_for('jruby')
214
+ return desc if desc
215
+
216
+ if command_present?('mise')
217
+ mise_ruby_versions.each do |ver|
218
+ next unless ver.include?('jruby')
219
+ desc = ruby_description_for('mise', 'exec', "ruby@#{ver}", '--', 'ruby')
220
+ return desc if desc
221
+ end
222
+ end
223
+ nil
224
+ end
225
+
226
+ def mise_ruby_versions
227
+ out, status = Open3.capture2e('mise', 'ls', 'ruby')
228
+ return [] unless status.success?
229
+
230
+ out.lines.filter_map do |line|
231
+ # e.g. "ruby 3.3.12" or "ruby jruby-10.1.1.0 …"
232
+ m = line.strip.match(/\Aruby\s+(\S+)/)
233
+ m && m[1]
234
+ end.uniq
235
+ end
236
+
237
+ def ruby_description_for(*cmd)
238
+ out, status = Open3.capture2e(*cmd, '-e', 'print RUBY_DESCRIPTION')
239
+ return nil unless status.success?
240
+ s = out.to_s.strip
241
+ s.empty? ? nil : s
242
+ rescue Errno::ENOENT
243
+ nil
244
+ end
245
+
246
+ def desc_engine(description)
247
+ return 'jruby' if description.downcase.start_with?('jruby')
248
+ return 'truffleruby' if description.downcase.start_with?('truffleruby')
249
+ 'ruby'
250
+ end
251
+
252
+ # ---- add ----
253
+
254
+ def cmd_add(argv)
255
+ profile = argv[0]
256
+ raise CliError, "usage: galaaz add <#{PROFILES.join('|')}>" if profile.nil? || profile.empty?
257
+ raise CliError, "unknown profile: #{profile.inspect}" unless PROFILES.include?(profile)
258
+
259
+ require_core_for_add! unless profile == 'demo'
260
+ return add_demo if profile == 'demo'
261
+
262
+ if profile_marked?(profile) && profile != 'demo'
263
+ puts "galaaz add #{profile}: already installed (marker present)"
264
+ return 0
265
+ end
266
+
267
+ case profile
268
+ when 'knit' then add_knit
269
+ when 'arrow' then add_arrow
270
+ when 'tex' then add_tex
271
+ when 'bio' then add_bio
272
+ when 'examples' then add_examples
273
+ when 'ledger' then add_ledger
274
+ end
275
+ end
276
+
277
+ def add_demo
278
+ %w[knit arrow ledger].each do |p|
279
+ code = cmd_add([p])
280
+ return code unless code.zero?
281
+ end
282
+ mark_profile!('demo')
283
+ puts 'galaaz add demo: OK'
284
+ 0
285
+ end
286
+
287
+ def add_knit
288
+ pkgs = read_pkg_list('knit.txt')
289
+ install_cran!(pkgs)
290
+ extras_path = File.join(root, 'r_requires', 'knit-extras.txt')
291
+ if File.file?(extras_path)
292
+ extras = read_pkg_list('knit-extras.txt')
293
+ unless install_cran_soft!(extras)
294
+ warn 'galaaz add knit: some optional packages are missing (see R messages above)'
295
+ end
296
+ end
297
+ mark_profile!('knit')
298
+ puts 'galaaz add knit: OK'
299
+ puts 'You can now run: gknit ~/galaaz-blogs/oh_my/oh_my.Rmd'
300
+ 0
301
+ end
302
+
303
+ def add_arrow
304
+ pkgs = read_pkg_list('arrow.txt')
305
+ install_cran!(pkgs)
306
+ if RUBY_ENGINE == 'ruby'
307
+ puts 'galaaz add arrow: gem install red-arrow (CRuby Stage B writer)'
308
+ unless system('gem', 'install', 'red-arrow', '--no-document')
309
+ warn 'galaaz add arrow: red-arrow gem install failed (need Apache Arrow GLib / libarrow-glib). R arrow is installed; Ruby IPC writer may be unavailable.'
310
+ end
311
+ else
312
+ warn 'galaaz add arrow: on JRuby, set GALAAZ_ARROW_JARS (or ~/arrow_jars) and JAVA_OPTS nio opens for Arrow Java'
313
+ end
314
+ mark_profile!('arrow')
315
+ puts 'galaaz add arrow: OK'
316
+ 0
317
+ end
318
+
319
+ def add_tex
320
+ unless command_present?('pandoc')
321
+ warn 'galaaz add tex: pandoc not on PATH — install via your package manager (e.g. pacman -S pandoc)'
322
+ end
323
+ script = File.join(root, 'bin', 'install-tinytex')
324
+ abort_unless(File.file?(script), "missing #{script}")
325
+ puts 'galaaz add tex: running bin/install-tinytex'
326
+ ok = system(script)
327
+ abort_unless(ok, 'TinyTeX install failed')
328
+ mark_profile!('tex')
329
+ puts 'galaaz add tex: OK'
330
+ 0
331
+ end
332
+
333
+ def add_bio
334
+ warn 'galaaz add bio: Bioconductor DESeq2 + airway — this can take a long time'
335
+ script = <<~R
336
+ repos <- '#{CRAN}'
337
+ if (!requireNamespace('BiocManager', quietly = TRUE))
338
+ install.packages('BiocManager', repos = repos)
339
+ BiocManager::install(c('DESeq2', 'airway'), update = FALSE, ask = FALSE)
340
+ stopifnot(requireNamespace('DESeq2', quietly = TRUE))
341
+ stopifnot(requireNamespace('airway', quietly = TRUE))
342
+ R
343
+ ok = system('Rscript', '-e', script)
344
+ abort_unless(ok, 'Bioconductor install failed')
345
+ mark_profile!('bio')
346
+ puts 'galaaz add bio: OK'
347
+ 0
348
+ end
349
+
350
+ def add_examples
351
+ dest = DEFAULT_EXAMPLES_DIR
352
+ src = File.join(root, 'examples')
353
+ abort_unless(File.directory?(src), "examples not found in #{root}")
354
+ if File.exist?(dest) && !Dir.children(dest).reject { |n| n.start_with?('.') }.empty?
355
+ if profile_marked?('examples')
356
+ puts "galaaz add examples: already at #{dest}"
357
+ return 0
358
+ end
359
+ raise CliError, "#{dest} exists and is not empty"
360
+ end
361
+ FileUtils.mkdir_p(File.dirname(dest))
362
+ FileUtils.rm_rf(dest) if File.exist?(dest)
363
+ FileUtils.cp_r(src, dest)
364
+ File.write(File.join(dest, EXAMPLES_MARKER), "galaaz add examples\n")
365
+ mark_profile!('examples')
366
+ puts "galaaz add examples: #{dest}"
367
+ 0
368
+ end
369
+
370
+ def add_ledger
371
+ unless profile_marked?('arrow') || r_namespace?('arrow')
372
+ puts 'galaaz add ledger: installing arrow profile first'
373
+ code = add_arrow
374
+ return code unless code.zero?
375
+ end
376
+
377
+ dest = DEFAULT_LEDGER_DIR
378
+ if File.directory?(dest) && File.file?(File.join(dest, 'Gemfile'))
379
+ puts "galaaz add ledger: using existing #{dest}"
380
+ else
381
+ need_cmd!('git')
382
+ abort_unless(!File.exist?(dest) || Dir.empty?(dest), "#{dest} exists but is not an empty/ledger dir")
383
+ FileUtils.rm_rf(dest) if File.exist?(dest)
384
+ puts "galaaz add ledger: git clone #{LEDGER_REPO}"
385
+ ok = system('git', 'clone', '--depth', '1', LEDGER_REPO, dest)
386
+ abort_unless(ok, 'git clone failed (is the ledger repo public/reachable?)')
387
+ end
388
+
389
+ gemfile = File.join(dest, 'Gemfile')
390
+ text = File.read(gemfile)
391
+ rewritten = text.gsub(/gem\s+["']galaaz["']\s*,\s*path:\s*["'][^"']+["']/, 'gem "galaaz"')
392
+ if rewritten != text
393
+ File.write(gemfile, rewritten)
394
+ puts 'galaaz add ledger: Gemfile now uses RubyGems galaaz (no path:)'
395
+ end
396
+
397
+ Dir.chdir(dest) do
398
+ need_cmd!('bundle')
399
+ abort_unless(system('bundle', 'install'), 'bundle install failed')
400
+ # Ensure gatekeeper for the bundled/installed gem path when possible.
401
+ system('galaaz', 'setup') || warn('galaaz add ledger: galaaz setup returned non-zero; check gatekeeper')
402
+ abort_unless(system('bin/rails', 'db:prepare'), 'rails db:prepare failed')
403
+ abort_unless(system({ 'SEED_PROFILE' => 'fast' }, 'bin/rails', 'db:seed'), 'rails db:seed failed')
404
+ end
405
+
406
+ mark_profile!('ledger')
407
+ puts 'galaaz add ledger: OK'
408
+ puts "You can now run: cd #{dest} && bin/dev"
409
+ puts 'Then open http://localhost:3000 — portfolio → Run stress test'
410
+ 0
411
+ end
412
+
413
+ # ---- helpers ----
414
+
415
+ class CliError < StandardError; end
416
+
417
+ def abort_unless(cond, msg)
418
+ raise CliError, msg unless cond
419
+ end
420
+
421
+ def need_cmd!(name)
422
+ abort_unless(command_present?(name), "missing command: #{name}")
423
+ end
424
+
425
+ def command_present?(name)
426
+ system('bash', '-c', "command -v #{shell_escape(name)} >/dev/null 2>&1")
427
+ end
428
+
429
+ def shell_escape(s)
430
+ s.gsub("'", "'\\''")
431
+ end
432
+
433
+ def ensure_rcpp!
434
+ return if r_namespace?('Rcpp')
435
+
436
+ puts 'galaaz setup: installing Rcpp from CRAN'
437
+ install_cran!(['Rcpp'])
438
+ end
439
+
440
+ # Omarchy/Arch: /usr/lib/R/library is not writable for normal users.
441
+ def r_lib_bootstrap
442
+ <<~R
443
+ lib <- Sys.getenv("R_LIBS_USER", unset = "")
444
+ if (!nzchar(lib)) {
445
+ lib <- file.path(Sys.getenv("HOME"), ".local", "lib", "R", "library")
446
+ Sys.setenv(R_LIBS_USER = lib)
447
+ }
448
+ dir.create(lib, recursive = TRUE, showWarnings = FALSE)
449
+ .libPaths(c(lib, .libPaths()))
450
+ renviron <- file.path(Sys.getenv("HOME"), ".Renviron")
451
+ line <- paste0("R_LIBS_USER=", lib)
452
+ if (!file.exists(renviron) || !any(grepl("^R_LIBS_USER=", readLines(renviron, warn = FALSE)))) {
453
+ cat(line, "\\n", file = renviron, append = TRUE)
454
+ }
455
+ R
456
+ end
457
+
458
+ def r_namespace?(pkg)
459
+ script = "#{r_lib_bootstrap}\nquit(status=if (requireNamespace('#{pkg}', quietly=TRUE)) 0 else 1)"
460
+ _out, status = Open3.capture2e('Rscript', '-e', script)
461
+ status.success?
462
+ rescue Errno::ENOENT
463
+ false
464
+ end
465
+
466
+ def install_cran!(pkgs)
467
+ list = pkgs.map { |p| "'#{p}'" }.join(', ')
468
+ script = <<~R
469
+ #{r_lib_bootstrap}
470
+ repos <- '#{CRAN}'
471
+ pkgs <- c(#{list})
472
+ inst <- rownames(installed.packages())
473
+ need <- pkgs[!pkgs %in% inst]
474
+ if (length(need)) {
475
+ message('Installing into ', lib, ': ', paste(need, collapse=', '))
476
+ install.packages(need, lib = lib, repos = repos)
477
+ }
478
+ missing <- pkgs[!pkgs %in% rownames(installed.packages())]
479
+ if (length(missing)) {
480
+ message('Still missing: ', paste(missing, collapse=', '))
481
+ quit(status = 1)
482
+ }
483
+ R
484
+ ok = system('Rscript', '-e', script)
485
+ abort_unless(ok, "failed to install CRAN packages: #{pkgs.join(', ')}")
486
+ end
487
+
488
+ def install_cran_soft!(pkgs)
489
+ return if pkgs.empty?
490
+
491
+ list = pkgs.map { |p| "'#{p}'" }.join(', ')
492
+ script = <<~R
493
+ #{r_lib_bootstrap}
494
+ repos <- '#{CRAN}'
495
+ pkgs <- c(#{list})
496
+ inst <- rownames(installed.packages())
497
+ need <- pkgs[!pkgs %in% inst]
498
+ if (length(need)) {
499
+ message('Installing (optional) into ', lib, ': ', paste(need, collapse=', '))
500
+ tryCatch(
501
+ install.packages(need, lib = lib, repos = repos),
502
+ error = function(e) message(e)
503
+ )
504
+ }
505
+ missing <- pkgs[!pkgs %in% rownames(installed.packages())]
506
+ if (length(missing)) {
507
+ message('Optional still missing: ', paste(missing, collapse=', '))
508
+ quit(status = 2)
509
+ }
510
+ quit(status = 0)
511
+ R
512
+ status = system('Rscript', '-e', script)
513
+ status
514
+ end
515
+
516
+ def read_pkg_list(name)
517
+ path = File.join(root, 'r_requires', name)
518
+ abort_unless(File.file?(path), "missing #{path}")
519
+ File.readlines(path).map(&:strip).reject { |l| l.empty? || l.start_with?('#') }
520
+ end
521
+
522
+ def require_core_for_add!
523
+ so = File.join(root, 'ext', 'new_bridge', 'galaaz_gatekeeper.so')
524
+ unless File.file?(so) && r_namespace?('Rcpp')
525
+ raise CliError, 'core Galaaz incomplete (need gatekeeper + Rcpp). Run: galaaz setup'
526
+ end
527
+ end
528
+
529
+ def profile_marked?(name)
530
+ File.file?(File.join(PROFILES_DIR, name))
531
+ end
532
+
533
+ def mark_profile!(name)
534
+ FileUtils.mkdir_p(PROFILES_DIR)
535
+ File.write(File.join(PROFILES_DIR, name), "#{Time.now.utc.iso8601}\n")
536
+ end
537
+
538
+ def listed_profiles
539
+ return [] unless File.directory?(PROFILES_DIR)
540
+
541
+ Dir.children(PROFILES_DIR).sort
542
+ end
543
+
544
+ def detect_blogs_dir
545
+ candidates = [DEFAULT_BLOGS_DIR]
546
+ candidates.each do |d|
547
+ return d if File.file?(File.join(d, BLOGS_MARKER))
548
+ end
549
+ nil
550
+ end
551
+ end
552
+ end
data/lib/galaaz.rb CHANGED
@@ -23,6 +23,12 @@
23
23
 
24
24
  $LOAD_PATH << File.dirname(File.expand_path('..', __FILE__)) + "/r_requires"
25
25
 
26
+ if RUBY_ENGINE == 'jruby'
27
+ require_relative 'galaaz_jruby'
28
+ GalaazJRuby.apply_java_opts_env!
29
+ end
30
+
31
+ require_relative 'galaaz/arrow_ipc'
26
32
  require_relative 'R_interface/r'
27
33
  require_relative 'util/exec_ruby'
28
34
  require_relative 'util/inline_file'
data/lib/galaaz_jruby.rb CHANGED
@@ -3,14 +3,20 @@
3
3
  # JVM arguments required for every JRuby process that loads Galaaz (Apache Arrow memory
4
4
  # on Java 9+). Rake and Ruby launchers should use GalaazJRuby.shell_j_arg_string.
5
5
  #
6
+ # The opens flag must be on the *JVM at startup*. `jruby -J--add-opens=... -S bundle exec`
7
+ # does not pass it to the child rspec JVM — export JAVA_OPTS instead (see
8
+ # +apply_java_opts_env!+ and bin/galaaz_jruby_env.inc.sh).
9
+ #
6
10
  # Optional extra flags (e.g. -J-Xmx4g): set environment variable GALAAZ_JRUBY_OPTS
7
11
  # (space-separated; passed through to the shell unquoted).
8
12
  #
9
13
  # Bash entrypoints (bin/run_rspec, etc.) source bin/galaaz_jruby_env.inc.sh — keep the
10
14
  # -J list there identical to REQUIRED_JRUBY_J_ARGS below.
11
15
  module GalaazJRuby
12
- REQUIRED_JRUBY_J_ARGS = %w[
13
- -J--add-opens=java.base/java.nio=ALL-UNNAMED
16
+ JAVA_NIO_ADD_OPENS = '--add-opens=java.base/java.nio=ALL-UNNAMED'
17
+
18
+ REQUIRED_JRUBY_J_ARGS = %W[
19
+ -J#{JAVA_NIO_ADD_OPENS}
14
20
  ].freeze
15
21
 
16
22
  OPTIONAL_ENV = 'GALAAZ_JRUBY_OPTS'
@@ -19,4 +25,13 @@ module GalaazJRuby
19
25
  extra = ENV[OPTIONAL_ENV].to_s.strip
20
26
  [REQUIRED_JRUBY_J_ARGS.join(' '), extra].reject(&:empty?).join(' ')
21
27
  end
28
+
29
+ # For subprocess JVMs (and so launchers that exec JRuby after requiring this file
30
+ # pick up the flag). Does not change the already-running JVM.
31
+ def self.apply_java_opts_env!
32
+ opts = ENV['JAVA_OPTS'].to_s
33
+ return if opts.split.include?(JAVA_NIO_ADD_OPENS)
34
+
35
+ ENV['JAVA_OPTS'] = [JAVA_NIO_ADD_OPENS, opts].reject(&:empty?).join(' ')
36
+ end
22
37
  end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Async synchronization for Stage B1 Arrow IPC handoff.
4
+ # Run: bin/run_all_rspec new_bridge_specs/arrow_ipc_async_spec.rb
5
+
6
+ require 'thread'
7
+ require 'galaaz'
8
+
9
+ RSpec.describe 'Arrow IPC async handoff' do
10
+ before(:all) do
11
+ skip 'R not on PATH' unless system('command -v R >/dev/null 2>&1')
12
+ skip 'Galaaz::ArrowIpc writer backend not available' unless Galaaz::ArrowIpc.available?
13
+ ok = R::Support.eval("requireNamespace('arrow', quietly=TRUE) && requireNamespace('dplyr', quietly=TRUE)")
14
+ skip 'R packages arrow/dplyr are not available' unless ok == true
15
+ @paths = []
16
+ end
17
+
18
+ after(:each) do
19
+ Array(@paths).each { |p| Galaaz::ArrowIpc.release(p) }
20
+ @paths = []
21
+ end
22
+
23
+ def track(path)
24
+ @paths << path
25
+ path
26
+ end
27
+
28
+ it 'happens-before: write+fsync then async open returns correct nrow' do
29
+ values = [1.0, 2.0, 3.0, 4.0]
30
+ path = track(Galaaz::ArrowIpc.write(value: values))
31
+
32
+ done = Queue.new
33
+ # Path-only over the bridge; R reads the IPC file.
34
+ R.eval_r_async("nrow(arrow::read_ipc_file(#{path.inspect}))", timeout: 30) do |result|
35
+ done.push(result)
36
+ end
37
+ r = done.pop
38
+ expect(r).to be_ok
39
+ expect(r.value).to match(/4/)
40
+ end
41
+
42
+ it 'async open then sync R reduction on the proxy' do
43
+ path = track(Galaaz::ArrowIpc.write(value: [10.0, 20.0, 30.0]))
44
+
45
+ done = Queue.new
46
+ R::Async.arrow___read_ipc_file(path, as_data_frame: false, timeout: 30) do |result|
47
+ done.push(result)
48
+ end
49
+ r = done.pop
50
+ expect(r).to be_ok
51
+ expect(r.value).to be_a(R::Object)
52
+
53
+ tbl = r.value
54
+ mean = R.mean(R.dplyr___pull(tbl, :value))
55
+ mean_v = mean.respond_to?(:>>) ? (mean >> 0) : mean
56
+ expect(mean_v).to eq(20.0)
57
+ end
58
+
59
+ it 'two concurrent handoffs do not cross-talk' do
60
+ path_a = track(Galaaz::ArrowIpc.write(tag: ['A'], n: [1]))
61
+ path_b = track(Galaaz::ArrowIpc.write(tag: ['B', 'B'], n: [2, 2]))
62
+
63
+ done = Queue.new
64
+ R.eval_r_async("nrow(arrow::read_ipc_file(#{path_a.inspect}))", timeout: 30) do |result|
65
+ done.push([:a, result])
66
+ end
67
+ R.eval_r_async("nrow(arrow::read_ipc_file(#{path_b.inspect}))", timeout: 30) do |result|
68
+ done.push([:b, result])
69
+ end
70
+
71
+ results = {}
72
+ 2.times do
73
+ key, res = done.pop
74
+ expect(res).to be_ok
75
+ results[key] = res.value
76
+ end
77
+ expect(results[:a]).to match(/1/)
78
+ expect(results[:b]).to match(/2/)
79
+ end
80
+
81
+ it 'missing path fails without hanging' do
82
+ done = Queue.new
83
+ missing = File.join(Galaaz::ArrowIpc.scratch_dir, 'galaaz_missing_async.arrow')
84
+ R.eval_r_async("arrow::read_ipc_file(#{missing.inspect})", timeout: 10) do |result|
85
+ done.push(result)
86
+ end
87
+ r = done.pop
88
+ expect(r).not_to be_ok
89
+ end
90
+ end