tg_geometry 0.1.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +103 -0
- data/Gemfile +3 -0
- data/LICENSE.txt +21 -0
- data/README.md +385 -0
- data/Rakefile +129 -0
- data/benchmark/_support.rb +115 -0
- data/benchmark/batch_packed_vs_loop.rb +27 -0
- data/benchmark/falcon_concurrency.rb +25 -0
- data/benchmark/flat_vs_rtree.rb +27 -0
- data/benchmark/gvl_threshold.rb +41 -0
- data/benchmark/objectspace_memsize.rb +17 -0
- data/benchmark/parse_throughput.rb +38 -0
- data/benchmark/rss_stability.rb +70 -0
- data/docs/ACTIVE_RECORD.md +26 -0
- data/docs/ARCHITECTURE.md +130 -0
- data/docs/AUTO_STRATEGY.md +15 -0
- data/docs/BENCHMARKING.md +75 -0
- data/docs/CASUAL_EXAMPLE.md +618 -0
- data/docs/CONCURRENCY.md +65 -0
- data/docs/ERROR_HANDLING.md +55 -0
- data/docs/EXPANSION_E_TO_H_STATUS.md +51 -0
- data/docs/FORMAT_COVERAGE.md +23 -0
- data/docs/FULL_TG_API_COVERAGE.md +109 -0
- data/docs/LIMITATIONS.md +61 -0
- data/docs/LOW_LEVEL_GEOMETRY.md +121 -0
- data/docs/MEMORY_OWNERSHIP.md +94 -0
- data/docs/RACTOR.md +40 -0
- data/docs/REGISTRY.md +37 -0
- data/docs/RELEASE_CHECKLIST.md +39 -0
- data/ext/tg_geometry/extconf.rb +91 -0
- data/ext/tg_geometry/tg_geometry_ext.c +3054 -0
- data/ext/tg_geometry/tg_geometry_vendor_rtree.c +1 -0
- data/ext/tg_geometry/tg_geometry_vendor_tg.c +24 -0
- data/ext/tg_geometry/vendor/.vendored +16 -0
- data/ext/tg_geometry/vendor/rtree/LICENSE +20 -0
- data/ext/tg_geometry/vendor/rtree/README.md +202 -0
- data/ext/tg_geometry/vendor/rtree/VERSION +3 -0
- data/ext/tg_geometry/vendor/rtree/rtree.c +840 -0
- data/ext/tg_geometry/vendor/rtree/rtree.h +105 -0
- data/ext/tg_geometry/vendor/tg/LICENSE +19 -0
- data/ext/tg_geometry/vendor/tg/README.md +197 -0
- data/ext/tg_geometry/vendor/tg/VERSION +3 -0
- data/ext/tg_geometry/vendor/tg/tg.c +16010 -0
- data/ext/tg_geometry/vendor/tg/tg.h +359 -0
- data/lib/tg/geometry/active_record_source.rb +57 -0
- data/lib/tg/geometry/registry.rb +119 -0
- data/lib/tg/geometry/version.rb +7 -0
- data/lib/tg/geometry.rb +6 -0
- data/lib/tg_geometry.rb +3 -0
- data/script/vendor_libs.rb +264 -0
- data/spec/block_10_rtree_strategy_spec.rb +82 -0
- data/spec/block_11_rtree_order_spec.rb +53 -0
- data/spec/block_12_batch_packed_spec.rb +55 -0
- data/spec/block_13_error_hardening_spec.rb +65 -0
- data/spec/block_14_memory_gc_hardening_spec.rb +116 -0
- data/spec/block_1_skeleton_spec.rb +45 -0
- data/spec/block_20_concurrency_spec.rb +157 -0
- data/spec/block_20_fuzz_spec.rb +145 -0
- data/spec/block_2_vendor_spec.rb +79 -0
- data/spec/block_3_geom_parse_spec.rb +89 -0
- data/spec/block_4_geom_api_spec.rb +90 -0
- data/spec/block_5_rect_api_spec.rb +96 -0
- data/spec/block_6_index_build_spec.rb +111 -0
- data/spec/block_7_index_owned_geometry_spec.rb +143 -0
- data/spec/block_8_index_borrowed_geometry_spec.rb +106 -0
- data/spec/block_9_flat_query_spec.rb +65 -0
- data/spec/expansion_a_auto_strategy_spec.rb +14 -0
- data/spec/expansion_b_registry_spec.rb +47 -0
- data/spec/expansion_c_active_record_source_spec.rb +42 -0
- data/spec/expansion_d_format_coverage_spec.rb +30 -0
- data/spec/expansion_e_low_level_geometry_spec.rb +82 -0
- data/spec/expansion_i_ractor_spec.rb +25 -0
- data/spec/expansion_j_full_tg_api_coverage_spec.rb +114 -0
- data/spec/spec_helper.rb +15 -0
- metadata +157 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "digest"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "open3"
|
|
7
|
+
require "optparse"
|
|
8
|
+
require "tmpdir"
|
|
9
|
+
|
|
10
|
+
VENDOR_DIR = File.expand_path("../ext/tg_geometry/vendor", __dir__)
|
|
11
|
+
MANIFEST_PATH = File.join(VENDOR_DIR, ".vendored")
|
|
12
|
+
|
|
13
|
+
PINS = {
|
|
14
|
+
tg: {
|
|
15
|
+
repo: "https://github.com/tidwall/tg.git",
|
|
16
|
+
ref: "main",
|
|
17
|
+
commit: "caf840504eaab4563280cf4ab16d618f69a23720",
|
|
18
|
+
sha256: "f8e0d904055c209b2a23a2200456f9374ab95cadb69e61b5721d7f8e2500e705",
|
|
19
|
+
target: "tg",
|
|
20
|
+
files: %w[tg.c tg.h LICENSE README.md]
|
|
21
|
+
},
|
|
22
|
+
rtree: {
|
|
23
|
+
repo: "https://github.com/tidwall/rtree.c.git",
|
|
24
|
+
ref: "v0.5.3",
|
|
25
|
+
commit: "5717a8a1eb373428ebaae8c1c623f186ec46461f",
|
|
26
|
+
sha256: "4afc86cbd3abe03730206031a5aff5b8b29d37b055fc356052f6f06e1d1f9a61",
|
|
27
|
+
target: "rtree",
|
|
28
|
+
files: %w[rtree.c rtree.h LICENSE README.md]
|
|
29
|
+
}
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
NORMALIZED_MTIME = Time.utc(2000, 1, 1).freeze
|
|
33
|
+
MANIFEST_HEADER = "# tg_geometry vendor manifest. Do not edit by hand. Regenerate with: ruby script/vendor_libs.rb --sync"
|
|
34
|
+
|
|
35
|
+
options = { mode: :sync }
|
|
36
|
+
OptionParser.new do |opts|
|
|
37
|
+
opts.banner = "Usage: vendor_libs.rb [--verify | --sync] [--force]"
|
|
38
|
+
opts.on("--verify", "Verify existing vendor tree against pinned files, VERSION metadata, and tree SHA256") { options[:mode] = :verify }
|
|
39
|
+
opts.on("--sync", "Rebuild the vendor tree only when pinned content is missing or changed") { options[:mode] = :sync }
|
|
40
|
+
opts.on("--force", "Force a fresh clone and rebuild even when the vendor tree is current") { options[:force] = true }
|
|
41
|
+
end.parse!
|
|
42
|
+
|
|
43
|
+
def sh!(cmd)
|
|
44
|
+
system(*cmd) || abort("command failed: #{cmd.join(" ")}")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def capture!(*cmd)
|
|
48
|
+
out, status = Open3.capture2(*cmd)
|
|
49
|
+
abort("command failed: #{cmd.join(" ")}") unless status.success?
|
|
50
|
+
|
|
51
|
+
out.strip
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def vendor_candidate?(path)
|
|
55
|
+
return false if File.symlink?(path)
|
|
56
|
+
return false unless File.file?(path)
|
|
57
|
+
return false if path.split(File::SEPARATOR).any? { |seg| seg.start_with?(".") && seg != "." && seg != ".." }
|
|
58
|
+
|
|
59
|
+
true
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def normalize_tree!(directory)
|
|
63
|
+
Dir.glob(File.join(directory, "**", "*"), File::FNM_DOTMATCH).each do |path|
|
|
64
|
+
base = File.basename(path)
|
|
65
|
+
next if base == "." || base == ".."
|
|
66
|
+
next if File.symlink?(path)
|
|
67
|
+
|
|
68
|
+
if File.file?(path)
|
|
69
|
+
File.chmod(0o644, path)
|
|
70
|
+
File.utime(NORMALIZED_MTIME, NORMALIZED_MTIME, path)
|
|
71
|
+
elsif File.directory?(path)
|
|
72
|
+
File.chmod(0o755, path)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
File.utime(NORMALIZED_MTIME, NORMALIZED_MTIME, directory) if File.directory?(directory)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def copy_entry!(source_root, target_root, relative)
|
|
79
|
+
src = File.join(source_root, relative)
|
|
80
|
+
abort "missing required upstream file: #{src}" unless File.file?(src)
|
|
81
|
+
abort "refusing to vendor symlink: #{src}" if File.symlink?(src)
|
|
82
|
+
|
|
83
|
+
dest = File.join(target_root, relative)
|
|
84
|
+
FileUtils.mkdir_p(File.dirname(dest))
|
|
85
|
+
FileUtils.cp(src, dest, preserve: false)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def write_version!(target, pin)
|
|
89
|
+
version = [
|
|
90
|
+
"repo=#{pin.fetch(:repo)}",
|
|
91
|
+
"ref=#{pin.fetch(:ref)}",
|
|
92
|
+
"commit=#{pin.fetch(:commit)}"
|
|
93
|
+
].join("\n") + "\n"
|
|
94
|
+
File.write(File.join(target, "VERSION"), version)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def tree_sha256_for(directory)
|
|
98
|
+
entries = Dir.glob(File.join(directory, "**", "*"), File::FNM_DOTMATCH)
|
|
99
|
+
.reject { |path| File.directory?(path) || File.symlink?(path) || %w[. ..].include?(File.basename(path)) }
|
|
100
|
+
.sort
|
|
101
|
+
|
|
102
|
+
digest = Digest::SHA256.new
|
|
103
|
+
entries.each do |path|
|
|
104
|
+
relative = path.sub(/\A#{Regexp.escape(directory)}\/?/, "")
|
|
105
|
+
digest << relative << "\0"
|
|
106
|
+
digest << File.binread(path)
|
|
107
|
+
digest << "\0"
|
|
108
|
+
end
|
|
109
|
+
digest.hexdigest
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def vendor_one!(name, pin)
|
|
113
|
+
target = File.join(VENDOR_DIR, pin.fetch(:target))
|
|
114
|
+
FileUtils.rm_rf(target)
|
|
115
|
+
FileUtils.mkdir_p(target)
|
|
116
|
+
|
|
117
|
+
Dir.mktmpdir("tg-geometry-#{name}-") do |tmpdir|
|
|
118
|
+
clone_dir = File.join(tmpdir, pin.fetch(:target))
|
|
119
|
+
sh!(["git", "clone", "--filter=blob:none", pin.fetch(:repo), clone_dir])
|
|
120
|
+
sh!(["git", "-C", clone_dir, "fetch", "--depth", "1", "origin", pin.fetch(:commit)])
|
|
121
|
+
sh!(["git", "-C", clone_dir, "checkout", "--detach", pin.fetch(:commit)])
|
|
122
|
+
|
|
123
|
+
actual_commit = capture!("git", "-C", clone_dir, "rev-parse", "HEAD")
|
|
124
|
+
abort "commit mismatch for #{name}: expected #{pin.fetch(:commit)}, got #{actual_commit}" unless actual_commit == pin.fetch(:commit)
|
|
125
|
+
|
|
126
|
+
pin.fetch(:files).each { |relative| copy_entry!(clone_dir, target, relative) }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
write_version!(target, pin)
|
|
130
|
+
normalize_tree!(target)
|
|
131
|
+
|
|
132
|
+
pin.merge(tree_sha256: tree_sha256_for(target))
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def manifest_body_lines(results)
|
|
136
|
+
lines = ["gem=tg_geometry", "libraries=#{PINS.keys.join(",")}"]
|
|
137
|
+
results.each do |name, data|
|
|
138
|
+
prefix = name.to_s
|
|
139
|
+
lines << "#{prefix}_repo=#{data.fetch(:repo)}"
|
|
140
|
+
lines << "#{prefix}_ref=#{data.fetch(:ref)}"
|
|
141
|
+
lines << "#{prefix}_commit=#{data.fetch(:commit)}"
|
|
142
|
+
lines << "#{prefix}_target=#{data.fetch(:target)}"
|
|
143
|
+
lines << "#{prefix}_files=#{(data.fetch(:files) + ["VERSION"]).join(",")}"
|
|
144
|
+
lines << "#{prefix}_tree_sha256=#{data.fetch(:tree_sha256)}"
|
|
145
|
+
end
|
|
146
|
+
lines
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def manifest_signature(body_lines)
|
|
150
|
+
Digest::SHA256.hexdigest(body_lines.join("\n") + "\n")
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def write_manifest(results)
|
|
154
|
+
body = manifest_body_lines(results)
|
|
155
|
+
sig = manifest_signature(body)
|
|
156
|
+
content = ([MANIFEST_HEADER] + body + ["manifest_sha256=#{sig}"]).join("\n") + "\n"
|
|
157
|
+
FileUtils.mkdir_p(File.dirname(MANIFEST_PATH))
|
|
158
|
+
File.write(MANIFEST_PATH, content)
|
|
159
|
+
File.chmod(0o644, MANIFEST_PATH)
|
|
160
|
+
File.utime(NORMALIZED_MTIME, NORMALIZED_MTIME, MANIFEST_PATH)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def expected_vendor_results
|
|
164
|
+
PINS.each_with_object({}) do |(name, pin), results|
|
|
165
|
+
results[name] = pin.merge(tree_sha256: pin.fetch(:sha256))
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def print_results(prefix, results)
|
|
170
|
+
puts prefix
|
|
171
|
+
results.each do |name, data|
|
|
172
|
+
puts " #{name}: commit=#{data.fetch(:commit)} tree_sha256=#{data.fetch(:tree_sha256)}"
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def parse_kv_file(path)
|
|
177
|
+
return {} unless File.file?(path)
|
|
178
|
+
|
|
179
|
+
File.readlines(path, chomp: true).each_with_object({}) do |line, hash|
|
|
180
|
+
next if line.empty? || line.start_with?("#")
|
|
181
|
+
|
|
182
|
+
key, value = line.split("=", 2)
|
|
183
|
+
hash[key] = value if key && value
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def verify_vendor_tree
|
|
188
|
+
failures = []
|
|
189
|
+
|
|
190
|
+
PINS.each do |name, pin|
|
|
191
|
+
target = File.join(VENDOR_DIR, pin.fetch(:target))
|
|
192
|
+
unless Dir.exist?(target)
|
|
193
|
+
failures << "#{name}: vendor directory missing (#{target})"
|
|
194
|
+
next
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
(pin.fetch(:files) + ["VERSION"]).each do |relative|
|
|
198
|
+
path = File.join(target, relative)
|
|
199
|
+
failures << "#{name}: missing vendored file #{relative}" unless File.file?(path)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
version = parse_kv_file(File.join(target, "VERSION"))
|
|
203
|
+
failures << "#{name}: VERSION repo mismatch" unless version["repo"] == pin.fetch(:repo)
|
|
204
|
+
failures << "#{name}: VERSION ref mismatch" unless version["ref"] == pin.fetch(:ref)
|
|
205
|
+
failures << "#{name}: VERSION commit mismatch" unless version["commit"] == pin.fetch(:commit)
|
|
206
|
+
|
|
207
|
+
next unless failures.none? { |failure| failure.start_with?("#{name}:") }
|
|
208
|
+
|
|
209
|
+
actual_sha256 = tree_sha256_for(target)
|
|
210
|
+
expected_sha256 = pin.fetch(:sha256)
|
|
211
|
+
failures << "#{name}: tree_sha256 mismatch: expected #{expected_sha256}, got #{actual_sha256}" unless actual_sha256 == expected_sha256
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
expected_results = expected_vendor_results
|
|
215
|
+
manifest = parse_kv_file(MANIFEST_PATH)
|
|
216
|
+
if manifest.empty?
|
|
217
|
+
failures << "manifest: missing #{MANIFEST_PATH}"
|
|
218
|
+
else
|
|
219
|
+
expected_body = manifest_body_lines(expected_results)
|
|
220
|
+
expected_signature = manifest_signature(expected_body)
|
|
221
|
+
|
|
222
|
+
expected_body.each do |line|
|
|
223
|
+
key, expected_value = line.split("=", 2)
|
|
224
|
+
failures << "manifest: #{key} mismatch" unless manifest[key] == expected_value
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
failures << "manifest: manifest_sha256 mismatch" unless manifest["manifest_sha256"] == expected_signature
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
failures
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
case options.fetch(:mode)
|
|
234
|
+
when :verify
|
|
235
|
+
failures = verify_vendor_tree
|
|
236
|
+
if failures.empty?
|
|
237
|
+
print_results("vendor verify: ok", expected_vendor_results)
|
|
238
|
+
exit 0
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
failures.each { |failure| warn failure }
|
|
242
|
+
exit 1
|
|
243
|
+
when :sync
|
|
244
|
+
unless options[:force]
|
|
245
|
+
failures = verify_vendor_tree
|
|
246
|
+
if failures.empty?
|
|
247
|
+
print_results("vendor sync: up to date", expected_vendor_results)
|
|
248
|
+
exit 0
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
FileUtils.rm_rf(VENDOR_DIR)
|
|
253
|
+
FileUtils.mkdir_p(VENDOR_DIR)
|
|
254
|
+
|
|
255
|
+
results = {}
|
|
256
|
+
PINS.each do |name, pin|
|
|
257
|
+
results[name] = vendor_one!(name, pin)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
write_manifest(results)
|
|
261
|
+
print_results("vendor sync: ok", results)
|
|
262
|
+
else
|
|
263
|
+
abort "unknown mode: #{options.fetch(:mode)}"
|
|
264
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe "Release Core Block 10 exact rtree accounting" do
|
|
6
|
+
let(:zone_a) { TG::Geometry.parse_wkt("POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))") }
|
|
7
|
+
let(:zone_b) { TG::Geometry.parse_wkt("POLYGON ((20 20, 30 20, 30 30, 20 30, 20 20))") }
|
|
8
|
+
|
|
9
|
+
it "builds an rtree index and reports rtree native bytes in debug builds" do
|
|
10
|
+
index = TG::Geometry::Index.build([[:a, zone_a], [:b, zone_b]], via: :geom, strategy: :rtree)
|
|
11
|
+
|
|
12
|
+
expect(index.strategy).to eq(:rtree)
|
|
13
|
+
if index.respond_to?(:_rtree_bytes_for_test)
|
|
14
|
+
expect(index._rtree_bytes_for_test).to be > 0
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it "does not build an rtree for empty indexes" do
|
|
19
|
+
index = TG::Geometry::Index.build([], via: :geojson, strategy: :rtree)
|
|
20
|
+
|
|
21
|
+
expect(index.strategy).to eq(:rtree)
|
|
22
|
+
expect(index.size).to eq(0)
|
|
23
|
+
expect(index._rtree_bytes_for_test).to eq(0) if index.respond_to?(:_rtree_bytes_for_test)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it "returns rtree bytes to zero after dispose in debug builds" do
|
|
27
|
+
index = TG::Geometry::Index.build([[:a, zone_a]], via: :geom, strategy: :rtree)
|
|
28
|
+
|
|
29
|
+
skip "TG_DEBUG_TEST hooks are not enabled" unless index.respond_to?(:_force_dispose_for_test!)
|
|
30
|
+
|
|
31
|
+
expect(index._rtree_bytes_for_test).to be > 0
|
|
32
|
+
index._force_dispose_for_test!
|
|
33
|
+
index._force_dispose_for_test!
|
|
34
|
+
expect(index._rtree_bytes_for_test).to eq(0)
|
|
35
|
+
expect(index._entries_bytes_for_test).to eq(0)
|
|
36
|
+
expect(index._initialized_entries_for_test).to eq(0)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
it "cleans up after rtree allocation failure in debug builds" do
|
|
40
|
+
skip "TG_DEBUG_TEST hooks are not enabled" unless TG::Geometry.respond_to?(:_debug_fail_next_rtree_alloc!)
|
|
41
|
+
|
|
42
|
+
TG::Geometry._debug_reset_test_hooks!
|
|
43
|
+
TG::Geometry._debug_fail_next_rtree_alloc!
|
|
44
|
+
|
|
45
|
+
expect do
|
|
46
|
+
TG::Geometry::Index.build([[:a, zone_a], [:b, zone_b]], via: :geom, strategy: :rtree)
|
|
47
|
+
end.to raise_error(NoMemoryError)
|
|
48
|
+
ensure
|
|
49
|
+
TG::Geometry._debug_reset_test_hooks! if TG::Geometry.respond_to?(:_debug_reset_test_hooks!)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it "cleans up after rtree insert allocation failure in debug builds" do
|
|
53
|
+
skip "TG_DEBUG_TEST hooks are not enabled" unless TG::Geometry.respond_to?(:_debug_fail_rtree_alloc_after!)
|
|
54
|
+
|
|
55
|
+
TG::Geometry._debug_reset_test_hooks!
|
|
56
|
+
TG::Geometry._debug_fail_rtree_alloc_after!(1)
|
|
57
|
+
|
|
58
|
+
expect do
|
|
59
|
+
TG::Geometry::Index.build([[:a, zone_a], [:b, zone_b]], via: :geom, strategy: :rtree)
|
|
60
|
+
end.to raise_error(NoMemoryError)
|
|
61
|
+
ensure
|
|
62
|
+
TG::Geometry._debug_reset_test_hooks! if TG::Geometry.respond_to?(:_debug_reset_test_hooks!)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it "allows two threads to build separate rtree indexes without cross-accounting" do
|
|
66
|
+
indexes = Queue.new
|
|
67
|
+
|
|
68
|
+
threads = 2.times.map do |i|
|
|
69
|
+
Thread.new do
|
|
70
|
+
geom = TG::Geometry.parse_wkt("POLYGON ((#{i * 20} 0, #{i * 20 + 10} 0, #{i * 20 + 10} 10, #{i * 20} 10, #{i * 20} 0))")
|
|
71
|
+
indexes << TG::Geometry::Index.build([[i, geom]], via: :geom, strategy: :rtree)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
threads.each(&:join)
|
|
75
|
+
|
|
76
|
+
built = 2.times.map { indexes.pop }
|
|
77
|
+
expect(built.map(&:size)).to eq([1, 1])
|
|
78
|
+
if built.all? { |idx| idx.respond_to?(:_rtree_bytes_for_test) }
|
|
79
|
+
expect(built.map(&:_rtree_bytes_for_test)).to all(be > 0)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe "Release Core Block 11 rtree ordered results" do
|
|
6
|
+
let(:entries) do
|
|
7
|
+
[
|
|
8
|
+
[:first, TG::Geometry.parse_wkt("POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))")],
|
|
9
|
+
[:second, TG::Geometry.parse_wkt("POLYGON ((2 2, 12 2, 12 12, 2 12, 2 2))")],
|
|
10
|
+
[:third, TG::Geometry.parse_wkt("POLYGON ((4 4, 14 4, 14 14, 4 14, 4 4))")]
|
|
11
|
+
]
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
it "matches flat result order for point queries" do
|
|
15
|
+
flat = TG::Geometry::Index.build(entries, via: :geom, strategy: :flat)
|
|
16
|
+
rtree = TG::Geometry::Index.build(entries, via: :geom, strategy: :rtree)
|
|
17
|
+
|
|
18
|
+
expect(rtree.find_covering(5, 5)).to eq(flat.find_covering(5, 5))
|
|
19
|
+
expect(rtree.covering_ids(5, 5)).to eq(flat.covering_ids(5, 5))
|
|
20
|
+
expect(rtree.covering_ids(100, 100)).to eq([])
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it "matches flat result order for rect queries" do
|
|
24
|
+
flat = TG::Geometry::Index.build(entries, via: :geom, strategy: :flat)
|
|
25
|
+
rtree = TG::Geometry::Index.build(entries, via: :geom, strategy: :rtree)
|
|
26
|
+
|
|
27
|
+
expect(rtree.intersecting_rect(3, 3, 6, 6)).to eq(flat.intersecting_rect(3, 3, 6, 6))
|
|
28
|
+
expect(rtree.intersecting_rect(100, 100, 101, 101)).to eq([])
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
it "handles many overlapping matches deterministically" do
|
|
32
|
+
many = 50.times.map do |i|
|
|
33
|
+
[i, TG::Geometry.parse_wkt("POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))")]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
index = TG::Geometry::Index.build(many, via: :geom, strategy: :rtree)
|
|
37
|
+
|
|
38
|
+
expect(index.find_covering(5, 5)).to eq(0)
|
|
39
|
+
expect(index.covering_ids(5, 5)).to eq((0...50).to_a)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
it "handles match buffer allocation failure in debug builds" do
|
|
43
|
+
skip "TG_DEBUG_TEST hooks are not enabled" unless TG::Geometry.respond_to?(:_debug_fail_next_match_buffer_alloc!)
|
|
44
|
+
|
|
45
|
+
index = TG::Geometry::Index.build(entries, via: :geom, strategy: :rtree)
|
|
46
|
+
TG::Geometry._debug_reset_test_hooks!
|
|
47
|
+
TG::Geometry._debug_fail_next_match_buffer_alloc!
|
|
48
|
+
|
|
49
|
+
expect { index.covering_ids(5, 5) }.to raise_error(NoMemoryError)
|
|
50
|
+
ensure
|
|
51
|
+
TG::Geometry._debug_reset_test_hooks! if TG::Geometry.respond_to?(:_debug_reset_test_hooks!)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe "Release Core Block 12 packed batch API" do
|
|
6
|
+
let(:zone_a) { TG::Geometry.parse_wkt("POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))") }
|
|
7
|
+
let(:zone_b) { TG::Geometry.parse_wkt("POLYGON ((20 20, 30 20, 30 30, 20 30, 20 20))") }
|
|
8
|
+
|
|
9
|
+
def pack_points(points)
|
|
10
|
+
points.flatten.pack("d*")
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
it "returns one result per input point" do
|
|
14
|
+
index = TG::Geometry::Index.build([[:a, zone_a], [:b, zone_b]], via: :geom, strategy: :flat)
|
|
15
|
+
|
|
16
|
+
packed = pack_points([[1.0, 1.0], [25.0, 25.0], [100.0, 100.0]])
|
|
17
|
+
expect(index.covering_ids_batch_packed(packed)).to eq([:a, :b, nil])
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
it "supports empty input" do
|
|
21
|
+
index = TG::Geometry::Index.build([[:a, zone_a]], via: :geom, strategy: :flat)
|
|
22
|
+
|
|
23
|
+
expect(index.covering_ids_batch_packed("".b)).to eq([])
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it "rejects invalid input type, length, and non-finite values" do
|
|
27
|
+
index = TG::Geometry::Index.build([[:a, zone_a]], via: :geom, strategy: :flat)
|
|
28
|
+
|
|
29
|
+
expect { index.covering_ids_batch_packed(Object.new) }.to raise_error(TypeError)
|
|
30
|
+
expect { index.covering_ids_batch_packed("123".b) }.to raise_error(TG::Geometry::ArgumentError)
|
|
31
|
+
expect { index.covering_ids_batch_packed([Float::NAN, 1.0].pack("d*")) }
|
|
32
|
+
.to raise_error(TG::Geometry::ArgumentError)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it "matches scalar find_covering for flat and rtree strategies" do
|
|
36
|
+
points = [[1.0, 1.0], [25.0, 25.0], [100.0, 100.0], [0.0, 5.0]]
|
|
37
|
+
|
|
38
|
+
%i[flat rtree].each do |strategy|
|
|
39
|
+
index = TG::Geometry::Index.build([[:a, zone_a], [:b, zone_b]],
|
|
40
|
+
via: :geom,
|
|
41
|
+
strategy: strategy)
|
|
42
|
+
packed = pack_points(points)
|
|
43
|
+
expected = points.map { |lon, lat| index.find_covering(lon, lat) }
|
|
44
|
+
|
|
45
|
+
expect(index.covering_ids_batch_packed(packed)).to eq(expected)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
it "returns the same VALUE ids, including mutable ids" do
|
|
50
|
+
id = +"zone-a"
|
|
51
|
+
index = TG::Geometry::Index.build([[id, zone_a]], via: :geom, strategy: :flat)
|
|
52
|
+
|
|
53
|
+
expect(index.covering_ids_batch_packed(pack_points([[1.0, 1.0]])).first).to equal(id)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe "Release Core Block 13 error hardening" do
|
|
6
|
+
let(:geojson) { '{"type":"Polygon","coordinates":[[[0,0],[1,0],[1,1],[0,1],[0,0]]]}' }
|
|
7
|
+
let(:wkb) { TG::Geometry.parse_geojson(geojson).to_wkb }
|
|
8
|
+
|
|
9
|
+
it "raises ParseError for malformed GeoJSON at first, middle, and last positions" do
|
|
10
|
+
bad = "not geojson"
|
|
11
|
+
|
|
12
|
+
[
|
|
13
|
+
[[1, bad], [2, geojson], [3, geojson]],
|
|
14
|
+
[[1, geojson], [2, bad], [3, geojson]],
|
|
15
|
+
[[1, geojson], [2, geojson], [3, bad]]
|
|
16
|
+
].each do |entries|
|
|
17
|
+
expect { TG::Geometry::Index.build(entries, via: :geojson, strategy: :flat) }
|
|
18
|
+
.to raise_error(TG::Geometry::ParseError)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it "raises ParseError for malformed WKB at first, middle, and last positions" do
|
|
23
|
+
bad = "not wkb".b
|
|
24
|
+
|
|
25
|
+
[
|
|
26
|
+
[[1, bad], [2, wkb], [3, wkb]],
|
|
27
|
+
[[1, wkb], [2, bad], [3, wkb]],
|
|
28
|
+
[[1, wkb], [2, wkb], [3, bad]]
|
|
29
|
+
].each do |entries|
|
|
30
|
+
expect { TG::Geometry::Index.build(entries, via: :wkb, strategy: :flat) }
|
|
31
|
+
.to raise_error(TG::Geometry::ParseError)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it "supports debug OOM simulation for entries allocation" do
|
|
36
|
+
skip "TG_DEBUG_TEST hooks are not enabled" unless TG::Geometry.respond_to?(:_debug_fail_next_entries_alloc!)
|
|
37
|
+
|
|
38
|
+
geom = TG::Geometry.parse_geojson(geojson)
|
|
39
|
+
TG::Geometry._debug_reset_test_hooks!
|
|
40
|
+
TG::Geometry._debug_fail_next_entries_alloc!
|
|
41
|
+
|
|
42
|
+
expect do
|
|
43
|
+
TG::Geometry::Index.build([[1, geom]], via: :geom, strategy: :flat)
|
|
44
|
+
end.to raise_error(NoMemoryError)
|
|
45
|
+
ensure
|
|
46
|
+
TG::Geometry._debug_reset_test_hooks! if TG::Geometry.respond_to?(:_debug_reset_test_hooks!)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
it "keeps dispose idempotent and clears byte counters in debug builds" do
|
|
50
|
+
skip "TG_DEBUG_TEST hooks are not enabled" unless TG::Geometry::Index.method_defined?(:_force_dispose_for_test!)
|
|
51
|
+
|
|
52
|
+
index = TG::Geometry::Index.build([[1, geojson]], via: :geojson, strategy: :rtree)
|
|
53
|
+
|
|
54
|
+
expect(index._entries_bytes_for_test).to be > 0
|
|
55
|
+
expect(index._owned_geom_bytes_for_test).to be > 0
|
|
56
|
+
expect(index._rtree_bytes_for_test).to be > 0
|
|
57
|
+
|
|
58
|
+
index._force_dispose_for_test!
|
|
59
|
+
index._force_dispose_for_test!
|
|
60
|
+
|
|
61
|
+
expect(index._entries_bytes_for_test).to eq(0)
|
|
62
|
+
expect(index._owned_geom_bytes_for_test).to eq(0)
|
|
63
|
+
expect(index._rtree_bytes_for_test).to eq(0)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "objspace"
|
|
5
|
+
|
|
6
|
+
RSpec.describe "Release Core Block 14 memory, GC, and compaction hardening" do
|
|
7
|
+
let(:small_polygon_wkt) { "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))" }
|
|
8
|
+
let(:small_polygon_geojson) { '{"type":"Polygon","coordinates":[[[0,0],[10,0],[10,10],[0,10],[0,0]]]}' }
|
|
9
|
+
let(:malformed_geojson) { '{"type":"Polygon","coordinates":[' }
|
|
10
|
+
|
|
11
|
+
def compact_if_supported
|
|
12
|
+
GC.start
|
|
13
|
+
GC.compact if GC.respond_to?(:compact)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
it "survives a parse/free loop under GC.stress" do
|
|
17
|
+
old_stress = GC.stress
|
|
18
|
+
GC.stress = true
|
|
19
|
+
|
|
20
|
+
50.times do
|
|
21
|
+
geom = TG::Geometry.parse_wkt(small_polygon_wkt)
|
|
22
|
+
expect(geom).to be_frozen
|
|
23
|
+
expect(geom.covers_xy?(5.0, 5.0)).to be(true)
|
|
24
|
+
end
|
|
25
|
+
ensure
|
|
26
|
+
GC.stress = old_stress
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it "survives index build/free loops for owned and borrowed geometry" do
|
|
30
|
+
geom = TG::Geometry.parse_wkt(small_polygon_wkt)
|
|
31
|
+
|
|
32
|
+
25.times do
|
|
33
|
+
owned = TG::Geometry::Index.build([["owned", small_polygon_geojson]], via: :geojson, strategy: :rtree)
|
|
34
|
+
borrowed = TG::Geometry::Index.build([["borrowed", geom]], via: :geom, strategy: :rtree)
|
|
35
|
+
|
|
36
|
+
expect(owned.find_covering(5, 5)).to eq("owned")
|
|
37
|
+
expect(borrowed.find_covering(5, 5)).to eq("borrowed")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
compact_if_supported
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it "survives failed build loops without leaving initialized native state observable" do
|
|
44
|
+
20.times do
|
|
45
|
+
expect do
|
|
46
|
+
TG::Geometry::Index.build([[1, small_polygon_geojson], [2, malformed_geojson]], via: :geojson, strategy: :rtree)
|
|
47
|
+
end.to raise_error(TG::Geometry::ParseError)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
compact_if_supported
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
it "survives repeated query loops for flat and rtree strategies" do
|
|
54
|
+
entries = [
|
|
55
|
+
[:a, TG::Geometry.parse_wkt(small_polygon_wkt)],
|
|
56
|
+
[:b, TG::Geometry.parse_wkt("POLYGON ((20 20, 30 20, 30 30, 20 30, 20 20))")]
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
%i[flat rtree].each do |strategy|
|
|
60
|
+
index = TG::Geometry::Index.build(entries, via: :geom, strategy: strategy)
|
|
61
|
+
|
|
62
|
+
250.times do
|
|
63
|
+
expect(index.find_covering(5, 5)).to eq(:a)
|
|
64
|
+
expect(index.covering_ids(25, 25)).to eq([:b])
|
|
65
|
+
expect(index.intersecting_rect(0, 0, 1, 1)).to eq([:a])
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
it "keeps via: :geom borrowed owners alive through GC.compact" do
|
|
71
|
+
geom = TG::Geometry.parse_wkt(small_polygon_wkt)
|
|
72
|
+
index = TG::Geometry::Index.build([["zone", geom]], via: :geom, strategy: :rtree)
|
|
73
|
+
|
|
74
|
+
geom = nil
|
|
75
|
+
compact_if_supported
|
|
76
|
+
|
|
77
|
+
expect(index.find_covering(5, 5)).to eq("zone")
|
|
78
|
+
expect(index.covering_ids(0, 5)).to eq(["zone"])
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it "keeps via: :geojson owned geometries usable through GC.compact" do
|
|
82
|
+
index = TG::Geometry::Index.build([["zone", small_polygon_geojson]], via: :geojson, strategy: :rtree)
|
|
83
|
+
|
|
84
|
+
compact_if_supported
|
|
85
|
+
|
|
86
|
+
expect(index.find_covering(5, 5)).to eq("zone")
|
|
87
|
+
expect(index.covering_ids(10, 10)).to eq(["zone"])
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
it "reports native memory through ObjectSpace.memsize_of for geom and index" do
|
|
91
|
+
geom = TG::Geometry.parse_wkt(small_polygon_wkt)
|
|
92
|
+
borrowed = TG::Geometry::Index.build([["zone", geom]], via: :geom, strategy: :flat)
|
|
93
|
+
owned = TG::Geometry::Index.build([["zone", small_polygon_geojson]], via: :geojson, strategy: :rtree)
|
|
94
|
+
|
|
95
|
+
expect(ObjectSpace.memsize_of(geom)).to be > ObjectSpace.memsize_of(Object.new)
|
|
96
|
+
expect(ObjectSpace.memsize_of(borrowed)).to be > ObjectSpace.memsize_of(Object.new)
|
|
97
|
+
expect(ObjectSpace.memsize_of(owned)).to be > ObjectSpace.memsize_of(borrowed)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
it "returns rtree, owned geometry, and entries bytes to zero after dispose in debug builds" do
|
|
101
|
+
skip "TG_DEBUG_TEST hooks are not enabled" unless TG::Geometry::Index.method_defined?(:_force_dispose_for_test!)
|
|
102
|
+
|
|
103
|
+
index = TG::Geometry::Index.build([["zone", small_polygon_geojson]], via: :geojson, strategy: :rtree)
|
|
104
|
+
|
|
105
|
+
expect(index._entries_bytes_for_test).to be > 0
|
|
106
|
+
expect(index._owned_geom_bytes_for_test).to be > 0
|
|
107
|
+
expect(index._rtree_bytes_for_test).to be > 0
|
|
108
|
+
|
|
109
|
+
index._force_dispose_for_test!
|
|
110
|
+
|
|
111
|
+
expect(index._entries_bytes_for_test).to eq(0)
|
|
112
|
+
expect(index._owned_geom_bytes_for_test).to eq(0)
|
|
113
|
+
expect(index._rtree_bytes_for_test).to eq(0)
|
|
114
|
+
expect(index._initialized_entries_for_test).to eq(0)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe "Release Core Block 1 skeleton" do
|
|
6
|
+
it "compiles the native extension" do
|
|
7
|
+
expect(File.file?(EXT_SO)).to be(true)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
it "loads through the canonical require path" do
|
|
11
|
+
expect(defined?(TG::Geometry)).to eq("constant")
|
|
12
|
+
expect(TG::Geometry::VERSION).to be_a(String)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
it "defines the required error hierarchy" do
|
|
16
|
+
expect(TG::Geometry::Error).to be < StandardError
|
|
17
|
+
expect(TG::Geometry::ParseError).to be < TG::Geometry::Error
|
|
18
|
+
expect(TG::Geometry::ArgumentError).to be < ::ArgumentError
|
|
19
|
+
expect(TG::Geometry::FrozenIndexError).to be < TG::Geometry::Error
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it "defines required public classes under TG::Geometry" do
|
|
23
|
+
expect(TG::Geometry::Geom).to be_a(Class)
|
|
24
|
+
expect(TG::Geometry::Rect).to be_a(Class)
|
|
25
|
+
expect(TG::Geometry::Index).to be_a(Class)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it "does not expose forbidden top-level TG API classes or methods" do
|
|
29
|
+
expect(TG).not_to respond_to(:parse)
|
|
30
|
+
expect(TG.const_defined?(:Geom, false)).to be(false)
|
|
31
|
+
expect(TG.const_defined?(:Rect, false)).to be(false)
|
|
32
|
+
expect(TG.const_defined?(:Index, false)).to be(false)
|
|
33
|
+
expect(TG.const_defined?(:Error, false)).to be(false)
|
|
34
|
+
expect(TG.const_defined?(:ParseError, false)).to be(false)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it "disables manual allocation for Geom and Index" do
|
|
38
|
+
expect { TG::Geometry::Geom.allocate }.to raise_error(TypeError)
|
|
39
|
+
expect { TG::Geometry::Index.allocate }.to raise_error(TypeError)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
it "keeps Rect constructible with explicit coordinates" do
|
|
43
|
+
expect(TG::Geometry::Rect.new(0, 0, 0, 0)).to be_a(TG::Geometry::Rect)
|
|
44
|
+
end
|
|
45
|
+
end
|