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.
Files changed (76) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +103 -0
  3. data/Gemfile +3 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +385 -0
  6. data/Rakefile +129 -0
  7. data/benchmark/_support.rb +115 -0
  8. data/benchmark/batch_packed_vs_loop.rb +27 -0
  9. data/benchmark/falcon_concurrency.rb +25 -0
  10. data/benchmark/flat_vs_rtree.rb +27 -0
  11. data/benchmark/gvl_threshold.rb +41 -0
  12. data/benchmark/objectspace_memsize.rb +17 -0
  13. data/benchmark/parse_throughput.rb +38 -0
  14. data/benchmark/rss_stability.rb +70 -0
  15. data/docs/ACTIVE_RECORD.md +26 -0
  16. data/docs/ARCHITECTURE.md +130 -0
  17. data/docs/AUTO_STRATEGY.md +15 -0
  18. data/docs/BENCHMARKING.md +75 -0
  19. data/docs/CASUAL_EXAMPLE.md +618 -0
  20. data/docs/CONCURRENCY.md +65 -0
  21. data/docs/ERROR_HANDLING.md +55 -0
  22. data/docs/EXPANSION_E_TO_H_STATUS.md +51 -0
  23. data/docs/FORMAT_COVERAGE.md +23 -0
  24. data/docs/FULL_TG_API_COVERAGE.md +109 -0
  25. data/docs/LIMITATIONS.md +61 -0
  26. data/docs/LOW_LEVEL_GEOMETRY.md +121 -0
  27. data/docs/MEMORY_OWNERSHIP.md +94 -0
  28. data/docs/RACTOR.md +40 -0
  29. data/docs/REGISTRY.md +37 -0
  30. data/docs/RELEASE_CHECKLIST.md +39 -0
  31. data/ext/tg_geometry/extconf.rb +91 -0
  32. data/ext/tg_geometry/tg_geometry_ext.c +3054 -0
  33. data/ext/tg_geometry/tg_geometry_vendor_rtree.c +1 -0
  34. data/ext/tg_geometry/tg_geometry_vendor_tg.c +24 -0
  35. data/ext/tg_geometry/vendor/.vendored +16 -0
  36. data/ext/tg_geometry/vendor/rtree/LICENSE +20 -0
  37. data/ext/tg_geometry/vendor/rtree/README.md +202 -0
  38. data/ext/tg_geometry/vendor/rtree/VERSION +3 -0
  39. data/ext/tg_geometry/vendor/rtree/rtree.c +840 -0
  40. data/ext/tg_geometry/vendor/rtree/rtree.h +105 -0
  41. data/ext/tg_geometry/vendor/tg/LICENSE +19 -0
  42. data/ext/tg_geometry/vendor/tg/README.md +197 -0
  43. data/ext/tg_geometry/vendor/tg/VERSION +3 -0
  44. data/ext/tg_geometry/vendor/tg/tg.c +16010 -0
  45. data/ext/tg_geometry/vendor/tg/tg.h +359 -0
  46. data/lib/tg/geometry/active_record_source.rb +57 -0
  47. data/lib/tg/geometry/registry.rb +119 -0
  48. data/lib/tg/geometry/version.rb +7 -0
  49. data/lib/tg/geometry.rb +6 -0
  50. data/lib/tg_geometry.rb +3 -0
  51. data/script/vendor_libs.rb +264 -0
  52. data/spec/block_10_rtree_strategy_spec.rb +82 -0
  53. data/spec/block_11_rtree_order_spec.rb +53 -0
  54. data/spec/block_12_batch_packed_spec.rb +55 -0
  55. data/spec/block_13_error_hardening_spec.rb +65 -0
  56. data/spec/block_14_memory_gc_hardening_spec.rb +116 -0
  57. data/spec/block_1_skeleton_spec.rb +45 -0
  58. data/spec/block_20_concurrency_spec.rb +157 -0
  59. data/spec/block_20_fuzz_spec.rb +145 -0
  60. data/spec/block_2_vendor_spec.rb +79 -0
  61. data/spec/block_3_geom_parse_spec.rb +89 -0
  62. data/spec/block_4_geom_api_spec.rb +90 -0
  63. data/spec/block_5_rect_api_spec.rb +96 -0
  64. data/spec/block_6_index_build_spec.rb +111 -0
  65. data/spec/block_7_index_owned_geometry_spec.rb +143 -0
  66. data/spec/block_8_index_borrowed_geometry_spec.rb +106 -0
  67. data/spec/block_9_flat_query_spec.rb +65 -0
  68. data/spec/expansion_a_auto_strategy_spec.rb +14 -0
  69. data/spec/expansion_b_registry_spec.rb +47 -0
  70. data/spec/expansion_c_active_record_source_spec.rb +42 -0
  71. data/spec/expansion_d_format_coverage_spec.rb +30 -0
  72. data/spec/expansion_e_low_level_geometry_spec.rb +82 -0
  73. data/spec/expansion_i_ractor_spec.rb +25 -0
  74. data/spec/expansion_j_full_tg_api_coverage_spec.rb +114 -0
  75. data/spec/spec_helper.rb +15 -0
  76. 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
+ ![Overpass Turbo query step](./images/casual_example_overpass_query.png)
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
+ ![Overpass Turbo export step](./images/casual_example_overpass_export.png)
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.
@@ -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.