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,618 @@
|
|
|
1
|
+
# Casual Example: Rails + ActiveRecord + real OSM polygons
|
|
2
|
+
|
|
3
|
+
This example shows a simple practical integration of `tg_geometry` in a Rails app.
|
|
4
|
+
|
|
5
|
+
Goal:
|
|
6
|
+
|
|
7
|
+
- store real polygon zones in a `zones` table;
|
|
8
|
+
- import them from OpenStreetMap / Overpass Turbo as GeoJSON;
|
|
9
|
+
- build a `TG::Geometry::Index` through a small registry;
|
|
10
|
+
- check which zone covers a given `(lon, lat)` point;
|
|
11
|
+
- expose that check through a Rails controller.
|
|
12
|
+
|
|
13
|
+
This is intentionally casual and application-oriented. It is not a full GIS setup and does not require PostGIS.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## 1. Create the `zones` table
|
|
18
|
+
|
|
19
|
+
For the first practical integration, store polygon geometry as raw GeoJSON text.
|
|
20
|
+
|
|
21
|
+
Generate a migration and use this schema:
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
class CreateZones < ActiveRecord::Migration[8.1]
|
|
25
|
+
def change
|
|
26
|
+
create_table :zones do |t|
|
|
27
|
+
t.string :code, null: false
|
|
28
|
+
t.string :name
|
|
29
|
+
|
|
30
|
+
# Data source: "osm", "manual", "city_open_data", etc.
|
|
31
|
+
t.string :source, null: false, default: "manual"
|
|
32
|
+
|
|
33
|
+
# For OSM imports: "way/123", "relation/456", etc.
|
|
34
|
+
t.string :source_uid
|
|
35
|
+
|
|
36
|
+
# park, school, hospital, residential, commercial, etc.
|
|
37
|
+
t.string :kind, null: false, default: "unknown"
|
|
38
|
+
|
|
39
|
+
# Raw GeoJSON geometry only: Polygon / MultiPolygon.
|
|
40
|
+
# Not Feature, not FeatureCollection.
|
|
41
|
+
t.text :geojson, null: false
|
|
42
|
+
|
|
43
|
+
# Original imported feature properties/tags.
|
|
44
|
+
t.jsonb :properties, null: false, default: {}
|
|
45
|
+
|
|
46
|
+
t.boolean :active, null: false, default: true
|
|
47
|
+
t.integer :priority, null: false, default: 100
|
|
48
|
+
|
|
49
|
+
t.datetime :imported_at
|
|
50
|
+
|
|
51
|
+
t.timestamps
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
add_index :zones, :code, unique: true
|
|
55
|
+
add_index :zones, [:source, :source_uid], unique: true, where: "source_uid IS NOT NULL"
|
|
56
|
+
add_index :zones, [:active, :priority, :id]
|
|
57
|
+
add_index :zones, :kind
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Run:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
bin/rails db:migrate
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Why `text`, not `jsonb`, for `geojson`?
|
|
69
|
+
|
|
70
|
+
`TG::Geometry::Index.build(..., via: :geojson)` expects a GeoJSON string. Keeping the geometry as text avoids converting `jsonb` back to JSON on every index reload.
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## 2. Add the `Zone` model
|
|
75
|
+
|
|
76
|
+
The model validates GeoJSON through `TG::Geometry.parse_geojson` and provides a small importer for a GeoJSON FeatureCollection.
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
# app/models/zone.rb
|
|
80
|
+
class Zone < ApplicationRecord
|
|
81
|
+
OSM_KIND_KEYS = %w[
|
|
82
|
+
amenity
|
|
83
|
+
leisure
|
|
84
|
+
landuse
|
|
85
|
+
tourism
|
|
86
|
+
boundary
|
|
87
|
+
natural
|
|
88
|
+
building
|
|
89
|
+
shop
|
|
90
|
+
].freeze
|
|
91
|
+
|
|
92
|
+
KIND_PRIORITY = {
|
|
93
|
+
"hospital" => 10,
|
|
94
|
+
"school" => 20,
|
|
95
|
+
"university" => 20,
|
|
96
|
+
"college" => 20,
|
|
97
|
+
"kindergarten" => 20,
|
|
98
|
+
"park" => 30,
|
|
99
|
+
"marketplace" => 35,
|
|
100
|
+
"commercial" => 40,
|
|
101
|
+
"retail" => 40,
|
|
102
|
+
"industrial" => 45,
|
|
103
|
+
"residential" => 50,
|
|
104
|
+
"forest" => 60,
|
|
105
|
+
"grass" => 70,
|
|
106
|
+
"unknown" => 100
|
|
107
|
+
}.freeze
|
|
108
|
+
|
|
109
|
+
validates :code, presence: true, uniqueness: true
|
|
110
|
+
validates :source, presence: true
|
|
111
|
+
validates :kind, presence: true
|
|
112
|
+
validates :geojson, presence: true
|
|
113
|
+
validates :source_uid, uniqueness: { scope: :source }, allow_nil: true
|
|
114
|
+
validates :priority, numericality: { only_integer: true }
|
|
115
|
+
|
|
116
|
+
validate :geojson_must_be_parseable_polygon
|
|
117
|
+
|
|
118
|
+
class << self
|
|
119
|
+
def import_geojson_file!(path, source: "osm", replace: false)
|
|
120
|
+
data = JSON.parse(File.read(path))
|
|
121
|
+
|
|
122
|
+
unless data["type"] == "FeatureCollection"
|
|
123
|
+
raise ArgumentError, "expected GeoJSON FeatureCollection"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
imported = 0
|
|
127
|
+
skipped = 0
|
|
128
|
+
|
|
129
|
+
transaction do
|
|
130
|
+
where(source: source).delete_all if replace
|
|
131
|
+
|
|
132
|
+
data.fetch("features").each_with_index do |feature, index|
|
|
133
|
+
geometry = feature["geometry"]
|
|
134
|
+
properties = feature["properties"] || {}
|
|
135
|
+
|
|
136
|
+
unless geometry && %w[Polygon MultiPolygon].include?(geometry["type"])
|
|
137
|
+
skipped += 1
|
|
138
|
+
next
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
source_uid = osm_source_uid(feature, properties, index)
|
|
142
|
+
kind = kind_from_properties(properties)
|
|
143
|
+
geojson = geometry.to_json
|
|
144
|
+
|
|
145
|
+
# Fail fast using the same parser that the index uses later.
|
|
146
|
+
TG::Geometry.parse_geojson(geojson)
|
|
147
|
+
|
|
148
|
+
zone = find_or_initialize_by(source: source, source_uid: source_uid)
|
|
149
|
+
|
|
150
|
+
zone.assign_attributes(
|
|
151
|
+
code: code_for(source, source_uid),
|
|
152
|
+
name: name_from_properties(properties, kind, source_uid),
|
|
153
|
+
kind: kind,
|
|
154
|
+
geojson: geojson,
|
|
155
|
+
properties: properties,
|
|
156
|
+
active: true,
|
|
157
|
+
priority: priority_for(kind),
|
|
158
|
+
imported_at: Time.current
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
zone.save!
|
|
162
|
+
imported += 1
|
|
163
|
+
rescue TG::Geometry::ParseError, ActiveRecord::RecordInvalid, JSON::ParserError => e
|
|
164
|
+
skipped += 1
|
|
165
|
+
warn "skip feature #{index}: #{e.class}: #{e.message}"
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
{ imported: imported, skipped: skipped }
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def kind_from_properties(properties)
|
|
173
|
+
OSM_KIND_KEYS.each do |key|
|
|
174
|
+
value = properties[key]
|
|
175
|
+
return value.to_s if value.present?
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
"unknown"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def priority_for(kind)
|
|
182
|
+
KIND_PRIORITY.fetch(kind.to_s, 100)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
private
|
|
186
|
+
|
|
187
|
+
def osm_source_uid(feature, properties, index)
|
|
188
|
+
uid =
|
|
189
|
+
properties["@id"] ||
|
|
190
|
+
feature["id"] ||
|
|
191
|
+
properties["id"]
|
|
192
|
+
|
|
193
|
+
uid.present? ? uid.to_s : "feature/#{index}"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def code_for(source, source_uid)
|
|
197
|
+
"#{source}_#{source_uid}".parameterize(separator: "_")
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def name_from_properties(properties, kind, source_uid)
|
|
201
|
+
properties["name:ru"].presence ||
|
|
202
|
+
properties["name"].presence ||
|
|
203
|
+
properties["name:en"].presence ||
|
|
204
|
+
"#{kind} #{source_uid}"
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
private
|
|
209
|
+
|
|
210
|
+
def geojson_must_be_parseable_polygon
|
|
211
|
+
geom = TG::Geometry.parse_geojson(geojson)
|
|
212
|
+
|
|
213
|
+
unless %i[polygon multipolygon].include?(geom.type)
|
|
214
|
+
errors.add(:geojson, "must be Polygon or MultiPolygon")
|
|
215
|
+
end
|
|
216
|
+
rescue TG::Geometry::ParseError => e
|
|
217
|
+
errors.add(:geojson, "is invalid: #{e.message}")
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
---
|
|
223
|
+
|
|
224
|
+
## 3. Add a rake task for import
|
|
225
|
+
|
|
226
|
+
```ruby
|
|
227
|
+
# lib/tasks/zones.rake
|
|
228
|
+
namespace :zones do
|
|
229
|
+
desc "Import zones from GeoJSON FeatureCollection"
|
|
230
|
+
task import_geojson: :environment do
|
|
231
|
+
path = ENV.fetch("PATH", Rails.root.join("db/geo/almaty_zones.geojson").to_s)
|
|
232
|
+
replace = ActiveModel::Type::Boolean.new.cast(ENV.fetch("REPLACE", "false"))
|
|
233
|
+
|
|
234
|
+
result = Zone.import_geojson_file!(
|
|
235
|
+
path,
|
|
236
|
+
source: ENV.fetch("SOURCE", "osm"),
|
|
237
|
+
replace: replace
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
puts "Imported: #{result[:imported]}, skipped: #{result[:skipped]}"
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
---
|
|
246
|
+
|
|
247
|
+
## 4. Download real polygons from Overpass Turbo
|
|
248
|
+
|
|
249
|
+
For a practical first dataset, use real OpenStreetMap polygons from [Overpass Turbo](https://overpass-turbo.eu/).
|
|
250
|
+
|
|
251
|
+
### 4.1. Paste the query
|
|
252
|
+
|
|
253
|
+
Open Overpass Turbo and paste this query:
|
|
254
|
+
|
|
255
|
+
```overpass
|
|
256
|
+
[out:json][timeout:90];
|
|
257
|
+
|
|
258
|
+
(
|
|
259
|
+
way["leisure"="park"](43.12,76.75,43.40,77.20);
|
|
260
|
+
relation["leisure"="park"](43.12,76.75,43.40,77.20);
|
|
261
|
+
|
|
262
|
+
way["landuse"~"residential|commercial|industrial|retail|grass|forest|cemetery"](43.12,76.75,43.40,77.20);
|
|
263
|
+
relation["landuse"~"residential|commercial|industrial|retail|grass|forest|cemetery"](43.12,76.75,43.40,77.20);
|
|
264
|
+
|
|
265
|
+
way["amenity"~"school|university|college|kindergarten|hospital|marketplace"](43.12,76.75,43.40,77.20);
|
|
266
|
+
relation["amenity"~"school|university|college|kindergarten|hospital|marketplace"](43.12,76.75,43.40,77.20);
|
|
267
|
+
|
|
268
|
+
way["tourism"~"zoo|attraction|theme_park"](43.12,76.75,43.40,77.20);
|
|
269
|
+
relation["tourism"~"zoo|attraction|theme_park"](43.12,76.75,43.40,77.20);
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
out tags geom;
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
Then click **Run**.
|
|
276
|
+
|
|
277
|
+

|
|
278
|
+
|
|
279
|
+
### 4.2. Export as GeoJSON
|
|
280
|
+
|
|
281
|
+
After the query finishes, click:
|
|
282
|
+
|
|
283
|
+
1. **Export**
|
|
284
|
+
2. **GeoJSON**
|
|
285
|
+
3. **download**
|
|
286
|
+
|
|
287
|
+
Save the file as:
|
|
288
|
+
|
|
289
|
+
```text
|
|
290
|
+
db/geo/almaty_zones.geojson
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+

|
|
294
|
+
|
|
295
|
+
---
|
|
296
|
+
|
|
297
|
+
## 5. Import zones into the database
|
|
298
|
+
|
|
299
|
+
Create the directory if needed:
|
|
300
|
+
|
|
301
|
+
```bash
|
|
302
|
+
mkdir -p db/geo
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
Then run:
|
|
306
|
+
|
|
307
|
+
```bash
|
|
308
|
+
bundle exec rake zones:import_geojson PATH=db/geo/almaty_zones.geojson REPLACE=true
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
Example result:
|
|
312
|
+
|
|
313
|
+
```text
|
|
314
|
+
Imported: 7469, skipped: 1
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
---
|
|
318
|
+
|
|
319
|
+
## 6. Create `ZonesRegistry`
|
|
320
|
+
|
|
321
|
+
For real overlapping data, ordering matters. `find_covering` returns the first matching id in index insertion order, so we build entries ordered by `priority ASC, id ASC`.
|
|
322
|
+
|
|
323
|
+
```ruby
|
|
324
|
+
# app/services/zones_registry.rb
|
|
325
|
+
class ZonesRegistry < TG::Geometry::Registry
|
|
326
|
+
source do
|
|
327
|
+
Zone
|
|
328
|
+
.where(active: true)
|
|
329
|
+
.order(:priority, :id)
|
|
330
|
+
.pluck(:id, :geojson)
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
index_options(
|
|
334
|
+
via: :geojson,
|
|
335
|
+
strategy: :rtree,
|
|
336
|
+
predicate: :covers,
|
|
337
|
+
geometry_index: :ystripes
|
|
338
|
+
)
|
|
339
|
+
end
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
Notes:
|
|
343
|
+
|
|
344
|
+
- `via: :geojson` means the index parses GeoJSON strings.
|
|
345
|
+
- `strategy: :rtree` is a good practical default for thousands of zones.
|
|
346
|
+
- `predicate: :covers` is usually the right default for geofencing because boundary points count as covered.
|
|
347
|
+
- Coordinates are passed as `(lon, lat)`, not `(lat, lon)`.
|
|
348
|
+
|
|
349
|
+
---
|
|
350
|
+
|
|
351
|
+
## 7. Check everything in `rails console`
|
|
352
|
+
|
|
353
|
+
Open console:
|
|
354
|
+
|
|
355
|
+
```bash
|
|
356
|
+
bin/rails console
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
### 7.1. Basic data checks
|
|
360
|
+
|
|
361
|
+
```ruby
|
|
362
|
+
Zone.count
|
|
363
|
+
Zone.group(:kind).count
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
### 7.2. Parse one geometry
|
|
367
|
+
|
|
368
|
+
```ruby
|
|
369
|
+
zone = Zone.first
|
|
370
|
+
geom = TG::Geometry.parse_geojson(zone.geojson)
|
|
371
|
+
|
|
372
|
+
geom.type
|
|
373
|
+
geom.bbox
|
|
374
|
+
geom.frozen?
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
Expected:
|
|
378
|
+
|
|
379
|
+
- `geom.type` returns `:polygon` or `:multipolygon`;
|
|
380
|
+
- `geom.bbox` returns `TG::Geometry::Rect`;
|
|
381
|
+
- `geom.frozen?` returns `true`.
|
|
382
|
+
|
|
383
|
+
### 7.3. Build the index
|
|
384
|
+
|
|
385
|
+
```ruby
|
|
386
|
+
registry = ZonesRegistry.new
|
|
387
|
+
index = registry.reload!
|
|
388
|
+
|
|
389
|
+
[index.size, index.strategy, index.predicate, index.frozen?]
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
Example:
|
|
393
|
+
|
|
394
|
+
```ruby
|
|
395
|
+
[7469, :rtree, :covers, true]
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
### 7.4. Check a single point
|
|
399
|
+
|
|
400
|
+
Important: pass coordinates as `(lon, lat)`.
|
|
401
|
+
|
|
402
|
+
```ruby
|
|
403
|
+
lon = 76.945
|
|
404
|
+
lat = 43.238
|
|
405
|
+
|
|
406
|
+
id = registry.find_covering(lon, lat)
|
|
407
|
+
ids = registry.covering_ids(lon, lat)
|
|
408
|
+
|
|
409
|
+
[id, ids]
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
Inspect matched zones:
|
|
413
|
+
|
|
414
|
+
```ruby
|
|
415
|
+
Zone
|
|
416
|
+
.where(id: ids)
|
|
417
|
+
.order(:priority, :id)
|
|
418
|
+
.pluck(:id, :kind, :name, :priority)
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
### 7.5. Check a rectangle
|
|
422
|
+
|
|
423
|
+
```ruby
|
|
424
|
+
ids = registry.intersecting_rect(76.90, 43.20, 77.00, 43.30)
|
|
425
|
+
|
|
426
|
+
ids.size
|
|
427
|
+
ids.first(20)
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
If you want to inspect a small sample without a giant SQL `IN (...)` query:
|
|
431
|
+
|
|
432
|
+
```ruby
|
|
433
|
+
sample_ids = ids.first(20)
|
|
434
|
+
zones_by_id = Zone.where(id: sample_ids).index_by(&:id)
|
|
435
|
+
|
|
436
|
+
sample_ids.map do |id|
|
|
437
|
+
zone = zones_by_id[id]
|
|
438
|
+
[zone.id, zone.kind, zone.name, zone.priority]
|
|
439
|
+
end
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
### 7.6. Check the packed batch API
|
|
443
|
+
|
|
444
|
+
The packed API expects a flat binary buffer of native-endian doubles:
|
|
445
|
+
|
|
446
|
+
```text
|
|
447
|
+
lon1, lat1, lon2, lat2, ...
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
Do not call `points.pack("d*")` on nested arrays. Flatten first:
|
|
451
|
+
|
|
452
|
+
```ruby
|
|
453
|
+
points = [
|
|
454
|
+
[76.945, 43.238],
|
|
455
|
+
[76.900, 43.250],
|
|
456
|
+
[80.000, 50.000]
|
|
457
|
+
]
|
|
458
|
+
|
|
459
|
+
packed = points.flatten.pack("d*")
|
|
460
|
+
|
|
461
|
+
registry.covering_ids_batch_packed(packed)
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
You can compare scalar vs batch:
|
|
465
|
+
|
|
466
|
+
```ruby
|
|
467
|
+
scalar = points.map { |lon, lat| registry.find_covering(lon, lat) }
|
|
468
|
+
batch = registry.covering_ids_batch_packed(points.flatten.pack("d*"))
|
|
469
|
+
|
|
470
|
+
[scalar, batch, scalar == batch]
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
Expected final element:
|
|
474
|
+
|
|
475
|
+
```ruby
|
|
476
|
+
true
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
---
|
|
480
|
+
|
|
481
|
+
## 8. Add a simple lookup service
|
|
482
|
+
|
|
483
|
+
```ruby
|
|
484
|
+
# app/services/zone_lookup.rb
|
|
485
|
+
class ZoneLookup
|
|
486
|
+
class << self
|
|
487
|
+
def registry
|
|
488
|
+
@registry ||= ZonesRegistry.new.tap(&:reload!)
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
def reload!
|
|
492
|
+
@registry = ZonesRegistry.new.tap(&:reload!)
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
def covering_zones(lon, lat)
|
|
496
|
+
ids = registry.covering_ids(lon, lat)
|
|
497
|
+
zones_by_id = Zone.where(id: ids).index_by(&:id)
|
|
498
|
+
|
|
499
|
+
ids.filter_map { |id| zones_by_id[id] }
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
def first_zone(lon, lat)
|
|
503
|
+
id = registry.find_covering(lon, lat)
|
|
504
|
+
id && Zone.find_by(id: id)
|
|
505
|
+
end
|
|
506
|
+
end
|
|
507
|
+
end
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
---
|
|
511
|
+
|
|
512
|
+
## 9. Check coordinates in a controller
|
|
513
|
+
|
|
514
|
+
A minimal controller example:
|
|
515
|
+
|
|
516
|
+
```ruby
|
|
517
|
+
# app/controllers/zones_controller.rb
|
|
518
|
+
class ZonesController < ApplicationController
|
|
519
|
+
def check
|
|
520
|
+
lon = Float(params.require(:lon))
|
|
521
|
+
lat = Float(params.require(:lat))
|
|
522
|
+
|
|
523
|
+
zones = ZoneLookup.covering_zones(lon, lat)
|
|
524
|
+
|
|
525
|
+
render json: {
|
|
526
|
+
lon: lon,
|
|
527
|
+
lat: lat,
|
|
528
|
+
matched: zones.any?,
|
|
529
|
+
first_zone: serialize_zone(zones.first),
|
|
530
|
+
zones: zones.map { |zone| serialize_zone(zone) }
|
|
531
|
+
}
|
|
532
|
+
rescue KeyError, ActionController::ParameterMissing, ArgumentError
|
|
533
|
+
render json: { error: "invalid lon/lat params" }, status: :unprocessable_entity
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
private
|
|
537
|
+
|
|
538
|
+
def serialize_zone(zone)
|
|
539
|
+
return nil unless zone
|
|
540
|
+
|
|
541
|
+
{
|
|
542
|
+
id: zone.id,
|
|
543
|
+
code: zone.code,
|
|
544
|
+
name: zone.name,
|
|
545
|
+
kind: zone.kind,
|
|
546
|
+
priority: zone.priority,
|
|
547
|
+
source: zone.source,
|
|
548
|
+
source_uid: zone.source_uid
|
|
549
|
+
}
|
|
550
|
+
end
|
|
551
|
+
end
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
Add a route:
|
|
555
|
+
|
|
556
|
+
```ruby
|
|
557
|
+
# config/routes.rb
|
|
558
|
+
Rails.application.routes.draw do
|
|
559
|
+
get "/zones/check", to: "zones#check"
|
|
560
|
+
end
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
Now test it:
|
|
564
|
+
|
|
565
|
+
```bash
|
|
566
|
+
curl "http://localhost:3000/zones/check?lon=76.945&lat=43.238"
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
Example response:
|
|
570
|
+
|
|
571
|
+
```json
|
|
572
|
+
{
|
|
573
|
+
"lon": 76.945,
|
|
574
|
+
"lat": 43.238,
|
|
575
|
+
"matched": true,
|
|
576
|
+
"first_zone": {
|
|
577
|
+
"id": 7401,
|
|
578
|
+
"code": "osm_way_...",
|
|
579
|
+
"name": "Айнабулак",
|
|
580
|
+
"kind": "cemetery",
|
|
581
|
+
"priority": 100,
|
|
582
|
+
"source": "osm",
|
|
583
|
+
"source_uid": "way/..."
|
|
584
|
+
},
|
|
585
|
+
"zones": [
|
|
586
|
+
{
|
|
587
|
+
"id": 7401,
|
|
588
|
+
"code": "osm_way_...",
|
|
589
|
+
"name": "Айнабулак",
|
|
590
|
+
"kind": "cemetery",
|
|
591
|
+
"priority": 100,
|
|
592
|
+
"source": "osm",
|
|
593
|
+
"source_uid": "way/..."
|
|
594
|
+
}
|
|
595
|
+
]
|
|
596
|
+
}
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
---
|
|
600
|
+
|
|
601
|
+
## 10. Summary
|
|
602
|
+
|
|
603
|
+
This example shows a practical end-to-end setup:
|
|
604
|
+
|
|
605
|
+
1. store polygon zones in PostgreSQL;
|
|
606
|
+
2. import real map polygons from OpenStreetMap / Overpass Turbo;
|
|
607
|
+
3. build a `TG::Geometry::Index`;
|
|
608
|
+
4. query by `(lon, lat)`;
|
|
609
|
+
5. expose the result in a Rails controller.
|
|
610
|
+
|
|
611
|
+
This simple setup is enough for many point-in-polygon lookup use cases:
|
|
612
|
+
|
|
613
|
+
- no PostGIS required;
|
|
614
|
+
- no external geometry service;
|
|
615
|
+
- no Redis;
|
|
616
|
+
- no background processing required for the first version.
|
|
617
|
+
|
|
618
|
+
For production, add your own reload strategy, monitoring, and attribution handling for OSM data.
|
data/docs/CONCURRENCY.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Concurrency
|
|
2
|
+
|
|
3
|
+
## Immutable read model
|
|
4
|
+
|
|
5
|
+
`TG::Geometry::Geom` and `TG::Geometry::Index` are immutable after construction. The Index stores stable entry pointers before inserting payloads into rtree, and entries are never reallocated after build.
|
|
6
|
+
|
|
7
|
+
This supports concurrent read-only use from normal Ruby threads. Public query methods do not mutate Index state and do not store persistent match marks inside the Index.
|
|
8
|
+
|
|
9
|
+
## Reload pattern
|
|
10
|
+
|
|
11
|
+
Use reference replacement, not mutation:
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
old_index = @index
|
|
15
|
+
new_index = TG::Geometry::Index.build(entries, via: :geojson, strategy: :rtree)
|
|
16
|
+
@index = new_index
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
A reader that already captured `old_index` can finish safely. Later readers can use `new_index`.
|
|
20
|
+
|
|
21
|
+
## GVL policy
|
|
22
|
+
|
|
23
|
+
The first release does not release the GVL for:
|
|
24
|
+
|
|
25
|
+
- parse;
|
|
26
|
+
- writers;
|
|
27
|
+
- `Geom` predicates;
|
|
28
|
+
- Index point queries;
|
|
29
|
+
- Index rect queries;
|
|
30
|
+
- packed batch queries;
|
|
31
|
+
- index build/free.
|
|
32
|
+
|
|
33
|
+
This is intentional. Incorrect no-GVL code is worse than keeping GVL. Future no-GVL work requires benchmark evidence, input copying rules, and no Ruby C API calls outside the GVL.
|
|
34
|
+
|
|
35
|
+
## Rtree owner thread-local
|
|
36
|
+
|
|
37
|
+
Rtree build uses a `_Thread_local` owner so exact allocation accounting can attribute rtree internals to the correct Index. The owner is saved and restored with an ensure path.
|
|
38
|
+
|
|
39
|
+
Concurrent builds on different OS threads are supported by thread-local owner separation. Re-entrant same-thread builds from callbacks are not part of the first-release API because no public callbacks are exposed.
|
|
40
|
+
|
|
41
|
+
## Rtree callback safety
|
|
42
|
+
|
|
43
|
+
Rtree search callbacks do not touch Ruby objects. They only mark candidate ordinals in C memory. Ruby arrays are built after `rtree_search` returns.
|
|
44
|
+
|
|
45
|
+
Callback rules:
|
|
46
|
+
|
|
47
|
+
- no `rb_yield`;
|
|
48
|
+
- no Ruby Array push;
|
|
49
|
+
- no Ruby exception/longjmp;
|
|
50
|
+
- no Ruby allocation;
|
|
51
|
+
- no Index mutation.
|
|
52
|
+
|
|
53
|
+
## Ractor
|
|
54
|
+
|
|
55
|
+
No Ractor support is claimed. Expansion Block I records the current boundary: frozen native wrappers are not treated as Ractor-shareable objects, and tests assert that `Ractor.shareable?` remains false for `TG::Geometry::Geom`, `TG::Geometry::Rect`, and `TG::Geometry::Index`.
|
|
56
|
+
|
|
57
|
+
See `docs/RACTOR.md` for the unsupported-boundary notes and the requirements before this status can change.
|
|
58
|
+
|
|
59
|
+
## Falcon / Async
|
|
60
|
+
|
|
61
|
+
No Falcon or Async performance claim is made. `benchmark/falcon_concurrency.rb` is only a baseline placeholder that records normal Ruby thread read behavior and states the Falcon/Async benchmark as an open setup question.
|
|
62
|
+
|
|
63
|
+
## Low-level borrowed wrappers
|
|
64
|
+
|
|
65
|
+
`TG::Geometry::Line`, `TG::Geometry::Ring`, `TG::Geometry::Polygon`, and borrowed GeometryCollection child `TG::Geometry::Geom` wrappers are immutable borrowed wrappers. They do not mutate or free child TG pointers. Each wrapper marks and compacts the parent `TG::Geometry::Geom` through `geom_owner`, so the parent native geometry remains alive while a child wrapper is in use.
|