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.
@@ -0,0 +1,250 @@
1
+ # frozen_string_literal: true
2
+
3
+ ##########################################################################################
4
+ # JRuby Arrow IPC writer via Apache Arrow Java.
5
+ #
6
+ # JAR loading order:
7
+ # 1. GALAAZ_ARROW_JARS directory (all *.jar)
8
+ # 2. ~/arrow_jars if present
9
+ # 3. jar-dependencies require_jar for pinned Arrow 18.1.0 artifacts
10
+ ##########################################################################################
11
+
12
+ module Galaaz
13
+ module ArrowIpc
14
+ module JavaArrowBackend
15
+ ARROW_JAVA_VERSION = '18.1.0'
16
+
17
+ module_function
18
+
19
+ def available?
20
+ return false unless RUBY_ENGINE == 'jruby'
21
+
22
+ load_jars!
23
+ true
24
+ rescue LoadError, StandardError
25
+ false
26
+ end
27
+
28
+ def write_columns(columns_hash, path)
29
+ load_jars!
30
+ lengths = columns_hash.values.map(&:length).uniq
31
+ raise ArgumentError, "all columns must have the same length (got #{lengths.inspect})" if lengths.size != 1
32
+
33
+ n = lengths.first
34
+ alloc = org.apache.arrow.memory.RootAllocator.new(java.lang.Long::MAX_VALUE)
35
+ fields = []
36
+ kinds = {}
37
+
38
+ columns_hash.each do |name, values|
39
+ kind = infer_kind(values)
40
+ kinds[name] = kind
41
+ fields << org.apache.arrow.vector.types.pojo.Field.nullable(name, arrow_type(kind))
42
+ end
43
+
44
+ schema = org.apache.arrow.vector.types.pojo.Schema.new(fields)
45
+ root = org.apache.arrow.vector.VectorSchemaRoot.create(schema, alloc)
46
+
47
+ begin
48
+ columns_hash.each do |name, values|
49
+ fill_vector(root.getVector(name), kinds[name], values, n)
50
+ end
51
+ root.setRowCount(n)
52
+
53
+ fos = java.io.FileOutputStream.new(path)
54
+ begin
55
+ channel = java.nio.channels.Channels.newChannel(fos)
56
+ writer = org.apache.arrow.vector.ipc.ArrowFileWriter.new(root, nil, channel)
57
+ begin
58
+ writer.start
59
+ writer.writeBatch
60
+ writer.end
61
+ ensure
62
+ writer.close
63
+ end
64
+ ensure
65
+ fos.close
66
+ end
67
+ ensure
68
+ root.close
69
+ alloc.close
70
+ end
71
+ end
72
+
73
+ def read_columns(path)
74
+ load_jars!
75
+ alloc = org.apache.arrow.memory.RootAllocator.new(java.lang.Long::MAX_VALUE)
76
+ fis = java.io.FileInputStream.new(path)
77
+ columns = nil
78
+ begin
79
+ reader = org.apache.arrow.vector.ipc.ArrowFileReader.new(fis.getChannel, alloc)
80
+ begin
81
+ loop do
82
+ break unless reader.loadNextBatch
83
+
84
+ root = reader.getVectorSchemaRoot
85
+ columns ||= root.getSchema.getFields.map { |f| f.getName }.each_with_object({}) { |n, h| h[n] = [] }
86
+ n = root.getRowCount
87
+ root.getFieldVectors.each do |vec|
88
+ name = vec.getName
89
+ n.times { |i| columns[name] << java_value(vec, i) }
90
+ end
91
+ end
92
+ ensure
93
+ reader.close
94
+ end
95
+ ensure
96
+ fis.close
97
+ alloc.close
98
+ end
99
+ raise ArgumentError, "no record batches in #{path}" if columns.nil?
100
+
101
+ columns
102
+ end
103
+
104
+ def load_jars!
105
+ return if defined?(@jars_loaded) && @jars_loaded
106
+
107
+ unless RUBY_ENGINE == 'jruby'
108
+ raise LoadError, 'Galaaz::ArrowIpc::JavaArrowBackend requires JRuby'
109
+ end
110
+
111
+ loaded = load_jars_from_dir(ENV['GALAAZ_ARROW_JARS'])
112
+ loaded ||= load_jars_from_dir(File.expand_path('~/arrow_jars'))
113
+ loaded ||= load_jars_via_jar_dependencies
114
+
115
+ unless loaded
116
+ raise LoadError,
117
+ 'Apache Arrow Java JARs not found. Set GALAAZ_ARROW_JARS to a directory of ' \
118
+ "Arrow #{ARROW_JAVA_VERSION} JARs, place them in ~/arrow_jars, or install " \
119
+ 'jar-dependencies so require_jar can resolve them.'
120
+ end
121
+
122
+ @jars_loaded = true
123
+ end
124
+ private_class_method :load_jars!
125
+
126
+ def load_jars_from_dir(dir)
127
+ return false if dir.nil? || dir.empty?
128
+ return false unless File.directory?(dir)
129
+
130
+ jars = Dir[File.join(dir, '*.jar')]
131
+ return false if jars.empty?
132
+
133
+ jars.each { |jar| require jar }
134
+ true
135
+ end
136
+ private_class_method :load_jars_from_dir
137
+
138
+ def load_jars_via_jar_dependencies
139
+ require 'jar-dependencies'
140
+ require_jar 'org.apache.arrow', 'arrow-format', ARROW_JAVA_VERSION
141
+ require_jar 'org.apache.arrow', 'arrow-memory-core', ARROW_JAVA_VERSION
142
+ require_jar 'org.apache.arrow', 'arrow-memory-netty', ARROW_JAVA_VERSION
143
+ require_jar 'org.apache.arrow', 'arrow-memory-unsafe', ARROW_JAVA_VERSION
144
+ require_jar 'org.apache.arrow', 'arrow-vector', ARROW_JAVA_VERSION
145
+ true
146
+ rescue LoadError
147
+ false
148
+ end
149
+ private_class_method :load_jars_via_jar_dependencies
150
+
151
+ def arrow_type(kind)
152
+ case kind
153
+ when :float64
154
+ org.apache.arrow.vector.types.pojo.ArrowType::FloatingPoint.new(
155
+ org.apache.arrow.vector.types.FloatingPointPrecision::DOUBLE
156
+ )
157
+ when :int32
158
+ org.apache.arrow.vector.types.pojo.ArrowType::Int.new(32, true)
159
+ when :utf8
160
+ org.apache.arrow.vector.types.pojo.ArrowType::Utf8.new
161
+ else
162
+ raise ArgumentError, "unsupported kind #{kind.inspect}"
163
+ end
164
+ end
165
+ private_class_method :arrow_type
166
+
167
+ def fill_vector(vector, kind, values, n)
168
+ vector.allocateNew
169
+ case kind
170
+ when :float64
171
+ values.each_with_index do |v, i|
172
+ if v.nil?
173
+ vector.setNull(i)
174
+ else
175
+ vector.setSafe(i, v.to_f)
176
+ end
177
+ end
178
+ when :int32
179
+ values.each_with_index do |v, i|
180
+ if v.nil?
181
+ vector.setNull(i)
182
+ else
183
+ vector.setSafe(i, Integer(v))
184
+ end
185
+ end
186
+ when :utf8
187
+ values.each_with_index do |v, i|
188
+ if v.nil?
189
+ vector.setNull(i)
190
+ else
191
+ bytes = v.to_s.to_java_bytes
192
+ vector.setSafe(i, bytes)
193
+ end
194
+ end
195
+ end
196
+ vector.setValueCount(n)
197
+ end
198
+ private_class_method :fill_vector
199
+
200
+ def infer_kind(values)
201
+ sample = values.find { |v| !v.nil? }
202
+ return :float64 if sample.nil?
203
+
204
+ case sample
205
+ when Float
206
+ :float64
207
+ when Integer
208
+ values.any? { |v| v.is_a?(Float) } ? :float64 : :int32
209
+ when String, Symbol
210
+ :utf8
211
+ when TrueClass, FalseClass
212
+ raise ArgumentError, 'boolean columns are not supported in B1 (use int32/float64/utf8)'
213
+ else
214
+ if sample.is_a?(Numeric)
215
+ sample.is_a?(Integer) ? :int32 : :float64
216
+ else
217
+ :utf8
218
+ end
219
+ end
220
+ end
221
+ private_class_method :infer_kind
222
+
223
+ def java_value(vector, i)
224
+ return nil if vector.isNull(i)
225
+
226
+ minor = vector.getMinorType.toString
227
+ case minor
228
+ when 'FLOAT8', 'FLOAT4'
229
+ vector.get(i).to_f
230
+ when 'INT', 'SMALLINT', 'TINYINT', 'UINT1', 'UINT2', 'UINT4'
231
+ vector.get(i).to_i
232
+ when 'BIGINT', 'UINT8'
233
+ vector.get(i).to_i
234
+ when 'VARCHAR', 'VARBINARY'
235
+ bytes = vector.get(i)
236
+ bytes.nil? ? nil : String.from_java_bytes(bytes)
237
+ else
238
+ obj = vector.respond_to?(:getObject) ? vector.getObject(i) : vector.get(i)
239
+ return obj if obj.nil?
240
+ return obj.to_s if obj.is_a?(String) || obj.java_kind_of?(java.lang.CharSequence)
241
+ return obj.to_f if obj.is_a?(Float) || obj.java_kind_of?(java.lang.Double) || obj.java_kind_of?(java.lang.Float)
242
+ return obj.to_i if obj.is_a?(Integer) || obj.java_kind_of?(java.lang.Number)
243
+
244
+ obj.to_s
245
+ end
246
+ end
247
+ private_class_method :java_value
248
+ end
249
+ end
250
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rbconfig'
4
+
5
+ ##########################################################################################
6
+ # CRuby Arrow IPC writer via red-arrow (gem "arrow").
7
+ ##########################################################################################
8
+
9
+ module Galaaz
10
+ module ArrowIpc
11
+ module RedArrowBackend
12
+ module_function
13
+
14
+ def available?
15
+ load!
16
+ true
17
+ rescue LoadError, StandardError
18
+ false
19
+ end
20
+
21
+ def write_columns(columns_hash, path)
22
+ load!
23
+ lengths = columns_hash.values.map(&:length).uniq
24
+ raise ArgumentError, "all columns must have the same length (got #{lengths.inspect})" if lengths.size != 1
25
+
26
+ table_hash = {}
27
+ columns_hash.each do |name, values|
28
+ table_hash[name] = build_array(name, values)
29
+ end
30
+ table = ::Arrow::Table.new(table_hash)
31
+ table.save(path)
32
+ end
33
+
34
+ def read_columns(path)
35
+ load!
36
+ table = ::Arrow::Table.load(path)
37
+ table.schema.fields.each_with_object({}) do |field, out|
38
+ col = table[field.name]
39
+ values = col.respond_to?(:to_a) ? col.to_a : col.data.to_a
40
+ out[field.name] = values
41
+ end
42
+ end
43
+
44
+ def load!
45
+ return if defined?(@loaded) && @loaded
46
+
47
+ begin
48
+ require 'arrow'
49
+ rescue LoadError
50
+ prepend_user_red_arrow_load_path!
51
+ begin
52
+ require 'arrow'
53
+ rescue LoadError => e
54
+ raise LoadError,
55
+ "Galaaz::ArrowIpc on CRuby requires the red-arrow gem " \
56
+ "(gem install red-arrow, matching pkg-config arrow-glib). #{e.message}"
57
+ end
58
+ end
59
+ @loaded = true
60
+ end
61
+ private_class_method :load!
62
+
63
+ # bundle exec only exposes Gemfile gems; red-arrow is optional and often
64
+ # user-installed. Put its lib + native extension on $LOAD_PATH.
65
+ def prepend_user_red_arrow_load_path!
66
+ names = '{red-arrow,gobject-introspection,glib2,gio2,native-package-installer,pkg-config,extpp}'
67
+ roots = []
68
+ roots << Gem.user_dir if Gem.respond_to?(:user_dir)
69
+ rubylibdir = RbConfig::CONFIG['rubylibdir']
70
+ if rubylibdir
71
+ roots << File.join(File.dirname(rubylibdir), 'gems', RbConfig::CONFIG['ruby_version'])
72
+ end
73
+ roots.concat(Array(Gem.path))
74
+ roots.compact.uniq.each do |root|
75
+ next unless File.directory?(root)
76
+
77
+ Dir.glob(File.join(root, 'gems', "#{names}-*", 'lib')).sort.each do |lib|
78
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
79
+ end
80
+ Dir.glob(File.join(root, 'extensions', '**', "#{names}-*")).select { |p| File.directory?(p) }.sort.each do |ext|
81
+ $LOAD_PATH.unshift(ext) unless $LOAD_PATH.include?(ext)
82
+ end
83
+ end
84
+ end
85
+ private_class_method :prepend_user_red_arrow_load_path!
86
+
87
+ def build_array(name, values)
88
+ kind = infer_kind(values)
89
+ case kind
90
+ when :float64
91
+ ::Arrow::DoubleArray.new(values.map { |v| v.nil? ? nil : v.to_f })
92
+ when :int32
93
+ ::Arrow::Int32Array.new(values.map { |v| v.nil? ? nil : Integer(v) })
94
+ when :utf8
95
+ ::Arrow::StringArray.new(values.map { |v| v.nil? ? nil : v.to_s })
96
+ else
97
+ raise ArgumentError, "unsupported column type for #{name.inspect}"
98
+ end
99
+ end
100
+ private_class_method :build_array
101
+
102
+ def infer_kind(values)
103
+ sample = values.find { |v| !v.nil? }
104
+ return :float64 if sample.nil?
105
+
106
+ case sample
107
+ when Float
108
+ :float64
109
+ when Integer
110
+ values.any? { |v| v.is_a?(Float) } ? :float64 : :int32
111
+ when String, Symbol
112
+ :utf8
113
+ when TrueClass, FalseClass
114
+ raise ArgumentError, 'boolean columns are not supported in B1 (use int32/float64/utf8)'
115
+ else
116
+ if sample.respond_to?(:to_f) && sample.is_a?(Numeric)
117
+ sample.is_a?(Integer) ? :int32 : :float64
118
+ else
119
+ :utf8
120
+ end
121
+ end
122
+ end
123
+ private_class_method :infer_kind
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'securerandom'
5
+ require 'tmpdir'
6
+
7
+ ##########################################################################################
8
+ # Stage B: Ruby ↔ R Arrow IPC file handoff (B1 ingest, B2 export).
9
+ #
10
+ # NewBridge carries only the path. This is IPC/mmap file handoff — not zero-copy
11
+ # shared heap between Ruby and R.
12
+ ##########################################################################################
13
+
14
+ module Galaaz
15
+ class ArrowIpcError < StandardError; end
16
+
17
+ module ArrowIpc
18
+ module_function
19
+
20
+ # @return [Boolean] true when the current Ruby engine's Arrow backend can load
21
+ def available?
22
+ backend.available?
23
+ rescue StandardError
24
+ false
25
+ end
26
+
27
+ # Write columnar data to an Arrow IPC file.
28
+ #
29
+ # @param columns_hash [Hash{String,Symbol => Array}] column name → values
30
+ # @return [String] absolute path to the IPC file (fsync'd / closed)
31
+ def write(columns_hash)
32
+ raise ArgumentError, 'columns_hash must be a Hash' unless columns_hash.is_a?(Hash)
33
+ raise ArgumentError, 'columns_hash must not be empty' if columns_hash.empty?
34
+
35
+ normalized = normalize_columns(columns_hash)
36
+ path = next_path
37
+ backend.write_columns(normalized, path)
38
+ fsync_path(path)
39
+ path
40
+ end
41
+
42
+ # Convenience: Array of row Hashes → columnar write.
43
+ #
44
+ # @param row_hashes [Enumerable<Hash>]
45
+ # @return [String] path
46
+ def write_batches(row_hashes)
47
+ rows = []
48
+ row_hashes.each do |row|
49
+ raise ArgumentError, 'each row must be a Hash' unless row.is_a?(Hash)
50
+
51
+ rows << row
52
+ end
53
+ raise ArgumentError, 'write_batches requires at least one row' if rows.empty?
54
+
55
+ keys = rows.flat_map(&:keys).map(&:to_s).uniq
56
+ columns = keys.each_with_object({}) { |k, h| h[k] = [] }
57
+ rows.each do |row|
58
+ key_map = row.each_with_object({}) { |(k, v), h| h[k.to_s] = v }
59
+ keys.each { |k| columns[k] << key_map[k] }
60
+ end
61
+ write(columns)
62
+ end
63
+
64
+ # Read an Arrow IPC file into a column hash (String keys → Arrays).
65
+ # B2 types: float64, int32/int64, utf8 (nulls preserved).
66
+ #
67
+ # @param path [String]
68
+ # @return [Hash{String => Array}]
69
+ def read(path)
70
+ path = File.expand_path(path.to_s)
71
+ raise ArgumentError, "Arrow IPC file not found: #{path}" unless File.file?(path)
72
+
73
+ backend.read_columns(path)
74
+ end
75
+
76
+ # Read an Arrow IPC file as an Array of row Hashes (symbol keys).
77
+ #
78
+ # @param path [String]
79
+ # @return [Array<Hash>]
80
+ def read_batches(path)
81
+ columns = read(path)
82
+ raise ArgumentError, 'IPC file has no columns' if columns.empty?
83
+
84
+ n = columns.values.map(&:length).uniq
85
+ raise ArgumentError, "ragged columns after read: #{n.inspect}" if n.size != 1
86
+
87
+ n.first.times.map do |i|
88
+ columns.each_with_object({}) { |(name, values), row| row[name.to_sym] = values[i] }
89
+ end
90
+ end
91
+
92
+ # Unique scratch path for a new IPC file (used by R::Arrow.write_ipc when path omitted).
93
+ #
94
+ # @return [String]
95
+ def allocate_path
96
+ File.join(scratch_dir, "galaaz_ipc_#{Process.pid}_#{SecureRandom.hex(8)}.arrow")
97
+ end
98
+
99
+ # Unlink an IPC scratch file if present.
100
+ #
101
+ # @param path [String]
102
+ # @return [void]
103
+ def release(path)
104
+ return if path.nil? || path.to_s.empty?
105
+
106
+ File.unlink(path) if File.exist?(path)
107
+ rescue Errno::ENOENT
108
+ nil
109
+ end
110
+
111
+ # Prefer /dev/shm when writable; else Dir.tmpdir / gem tmp.
112
+ #
113
+ # @return [String]
114
+ def scratch_dir
115
+ @scratch_dir ||= begin
116
+ candidates = []
117
+ candidates << '/dev/shm' if File.directory?('/dev/shm')
118
+ candidates << File.join(Dir.tmpdir, 'galaaz_arrow_ipc')
119
+ candidates << File.expand_path('../../../tmp/galaaz_arrow_ipc', __dir__)
120
+
121
+ chosen = candidates.find do |dir|
122
+ begin
123
+ FileUtils.mkdir_p(dir)
124
+ File.writable?(dir)
125
+ rescue StandardError
126
+ false
127
+ end
128
+ end
129
+ raise ArrowIpcError, 'no writable scratch directory for Arrow IPC' unless chosen
130
+
131
+ chosen
132
+ end
133
+ end
134
+
135
+ # @return [Module] RedArrowBackend or JavaArrowBackend
136
+ def backend
137
+ @backend ||=
138
+ if RUBY_ENGINE == 'jruby'
139
+ require_relative 'arrow_ipc/java_arrow_backend'
140
+ JavaArrowBackend
141
+ else
142
+ require_relative 'arrow_ipc/red_arrow_backend'
143
+ RedArrowBackend
144
+ end
145
+ end
146
+
147
+ def normalize_columns(columns_hash)
148
+ columns_hash.each_with_object({}) do |(name, values), out|
149
+ key = name.to_s
150
+ raise ArgumentError, "column #{key.inspect} values must be an Array" unless values.is_a?(Array)
151
+
152
+ out[key] = values
153
+ end
154
+ end
155
+ private_class_method :normalize_columns
156
+
157
+ def next_path
158
+ allocate_path
159
+ end
160
+ private_class_method :next_path
161
+
162
+ def fsync_path(path)
163
+ File.open(path, 'rb') { |f| f.fsync }
164
+ end
165
+ private_class_method :fsync_path
166
+ end
167
+ end