rubydb 0.1.4 → 0.1.6

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 (469) hide show
  1. checksums.yaml +4 -4
  2. data/.github/PULL_REQUEST_TEMPLATE.md +15 -15
  3. data/.github/workflows/benchmark.yml +26 -26
  4. data/.github/workflows/compatibility.yml +63 -63
  5. data/.github/workflows/fuzz.yml +33 -33
  6. data/.github/workflows/lint.yml +21 -21
  7. data/.github/workflows/operations.yml +24 -24
  8. data/.github/workflows/production-validation.yml +111 -111
  9. data/.github/workflows/release.yml +77 -77
  10. data/.github/workflows/security.yml +39 -37
  11. data/.github/workflows/test.yml +26 -26
  12. data/.github/workflows/workload.yml +58 -58
  13. data/.gitignore +16 -5
  14. data/.rubocop.yml +50 -44
  15. data/.standard.yml +9 -14
  16. data/ARCHITECTURE.md +21 -21
  17. data/CHANGELOG.md +57 -27
  18. data/CODE_OF_CONDUCT.md +13 -13
  19. data/CONTRIBUTING.md +29 -29
  20. data/GOVERNANCE.md +16 -16
  21. data/Gemfile +18 -17
  22. data/Gemfile.lock +125 -71
  23. data/README.md +168 -12
  24. data/ROADMAP.md +27 -27
  25. data/Rakefile +76 -71
  26. data/SECURITY.md +54 -54
  27. data/SUPPORT.md +14 -14
  28. data/accelerator/bin/SHA256SUMS +6 -0
  29. data/accelerator/bin/rubydb-accelerator-darwin-amd64 +0 -0
  30. data/accelerator/bin/rubydb-accelerator-darwin-arm64 +0 -0
  31. data/accelerator/bin/rubydb-accelerator-linux-amd64 +0 -0
  32. data/accelerator/bin/rubydb-accelerator-linux-arm64 +0 -0
  33. data/accelerator/bin/rubydb-accelerator-windows-amd64.exe +0 -0
  34. data/accelerator/bin/rubydb-accelerator-windows-arm64.exe +0 -0
  35. data/accelerator/cmd/rubydb-accelerator/main.go +11 -0
  36. data/accelerator/go.mod +3 -0
  37. data/accelerator/internal/execution/aggregate.go +94 -0
  38. data/accelerator/internal/execution/distinct.go +22 -0
  39. data/accelerator/internal/execution/filter.go +73 -0
  40. data/accelerator/internal/execution/join.go +79 -0
  41. data/accelerator/internal/execution/operators.go +167 -0
  42. data/accelerator/internal/execution/scan.go +20 -0
  43. data/accelerator/internal/execution/sort.go +62 -0
  44. data/accelerator/internal/execution/types.go +136 -0
  45. data/accelerator/internal/execution/value.go +67 -0
  46. data/accelerator/internal/memory/arena.go +47 -0
  47. data/accelerator/internal/memory/reuse.go +22 -0
  48. data/accelerator/internal/metrics/registry.go +67 -0
  49. data/accelerator/internal/parallel/bounded_queue.go +56 -0
  50. data/accelerator/internal/parallel/scheduler.go +47 -0
  51. data/accelerator/internal/parallel/worker_pool.go +53 -0
  52. data/accelerator/internal/protocol/cancellation.go +48 -0
  53. data/accelerator/internal/protocol/columnar.go +263 -0
  54. data/accelerator/internal/protocol/frame.go +187 -0
  55. data/accelerator/internal/runtime/worker.go +521 -0
  56. data/accelerator/internal/storage/page_reader.go +81 -0
  57. data/accelerator/internal/storage/snapshot_scan.go +539 -0
  58. data/accelerator/internal/wal/checksum.go +13 -0
  59. data/accelerator/internal/wal/compression.go +41 -0
  60. data/accelerator/internal/wal/group_commit.go +24 -0
  61. data/accelerator/internal/wal/record_encoder.go +40 -0
  62. data/adapters/activerecord/Gemfile +11 -11
  63. data/adapters/activerecord/README.md +8 -3
  64. data/adapters/activerecord/lib/active_record/connection_adapters/rubydb_adapter.rb +881 -879
  65. data/adapters/activerecord/rubydb-activerecord.gemspec +21 -21
  66. data/adapters/activerecord/spec/rubydb_adapter_integration_spec.rb +143 -143
  67. data/adapters/ruby/README.md +18 -18
  68. data/adapters/sequel/README.md +11 -11
  69. data/config/monitoring/prometheus-alerts.yml +39 -39
  70. data/config/production.yml +36 -36
  71. data/docs/README.md +77 -71
  72. data/docs/architecture/concurrency.md +14 -14
  73. data/docs/architecture/current-state.md +125 -125
  74. data/docs/architecture/execution-engine.md +25 -25
  75. data/docs/architecture/go-accelerator.md +179 -0
  76. data/docs/architecture/indexes.md +19 -19
  77. data/docs/architecture/mvcc.md +19 -19
  78. data/docs/architecture/overview.md +13 -13
  79. data/docs/architecture/pages.md +11 -11
  80. data/docs/architecture/production-roadmap.md +82 -82
  81. data/docs/architecture/query-planner.md +20 -20
  82. data/docs/architecture/recovery.md +18 -18
  83. data/docs/architecture/sql-engine.md +12 -12
  84. data/docs/architecture/storage-engine.md +14 -14
  85. data/docs/architecture/transactions.md +10 -10
  86. data/docs/architecture/wal.md +28 -28
  87. data/docs/cli-cheatsheet.md +98 -98
  88. data/docs/cli.md +299 -275
  89. data/docs/contributing/architecture.md +9 -9
  90. data/docs/contributing/benchmarking.md +30 -14
  91. data/docs/contributing/development.md +16 -16
  92. data/docs/contributing/release-process.md +49 -49
  93. data/docs/contributing/testing.md +16 -16
  94. data/docs/debugging.md +229 -229
  95. data/docs/developer/branching.md +10 -10
  96. data/docs/developer/database-diff.md +10 -10
  97. data/docs/developer/local-development.md +49 -17
  98. data/docs/developer/snapshots.md +9 -9
  99. data/docs/developer/temporal-data.md +10 -10
  100. data/docs/developer-guide.md +297 -297
  101. data/docs/getting-started/first-database.md +16 -16
  102. data/docs/getting-started/first-query.md +13 -13
  103. data/docs/getting-started/installation.md +19 -19
  104. data/docs/getting-started/local-to-production.md +300 -300
  105. data/docs/getting-started/quickstart.md +17 -17
  106. data/docs/getting-started/rails.md +16 -16
  107. data/docs/hardening_backlog.md +93 -93
  108. data/docs/lessons-learned.md +112 -112
  109. data/docs/operations/backups.md +33 -33
  110. data/docs/operations/disaster-recovery.md +31 -31
  111. data/docs/operations/failover.md +30 -30
  112. data/docs/operations/monitoring.md +25 -25
  113. data/docs/operations/production-guide.md +295 -295
  114. data/docs/operations/production-runbook.md +45 -45
  115. data/docs/operations/replication.md +33 -33
  116. data/docs/operations/restore.md +6 -6
  117. data/docs/operations/runbook.md +34 -34
  118. data/docs/operations/upgrades.md +14 -14
  119. data/docs/operations/workload-testing.md +17 -17
  120. data/docs/production-readiness.md +118 -118
  121. data/docs/production_validation.md +150 -150
  122. data/docs/rails/active-record.md +11 -11
  123. data/docs/rails/compatibility-guide.md +90 -90
  124. data/docs/rails/database-yml.md +92 -92
  125. data/docs/rails/installation.md +17 -17
  126. data/docs/rails/migrations.md +17 -17
  127. data/docs/rails/production.md +82 -82
  128. data/docs/rails/troubleshooting.md +18 -18
  129. data/docs/release.md +25 -25
  130. data/docs/server/architecture.md +10 -10
  131. data/docs/server/authentication.md +10 -10
  132. data/docs/server/configuration.md +16 -16
  133. data/docs/server/connection-pooling.md +10 -10
  134. data/docs/server/deployment.md +10 -10
  135. data/docs/server/protocol.md +12 -12
  136. data/docs/sql/compatibility-guide.md +82 -82
  137. data/docs/sql/compatibility.md +39 -39
  138. data/docs/sql/data-types.md +10 -10
  139. data/docs/sql/functions.md +9 -9
  140. data/docs/sql/joins.md +9 -9
  141. data/docs/sql/operators.md +9 -9
  142. data/docs/sql/sqlite-compatibility.md +21 -21
  143. data/docs/sql/syntax.md +10 -10
  144. data/docs/sql/transactions.md +10 -10
  145. data/docs/troubleshooting.md +244 -244
  146. data/lessons/01-foundations.md +73 -0
  147. data/lessons/02-local-development.md +121 -0
  148. data/lessons/03-embedded-rubydb.md +99 -0
  149. data/lessons/04-rails-complex-apps.md +138 -0
  150. data/lessons/05-rubydb-production-server.md +237 -0
  151. data/lessons/06-postgresql-massive-apps.md +96 -0
  152. data/lessons/07-hybrid-microservices.md +179 -0
  153. data/lessons/08-migrations-backups-recovery.md +86 -0
  154. data/lessons/09-observability-security-scale.md +87 -0
  155. data/lessons/10-release-readiness.md +192 -0
  156. data/lessons/11-community-adapter.md +323 -0
  157. data/lessons/12-rails-ecommerce-pressure.md +263 -0
  158. data/lib/rubydb/accelerator/client.rb +451 -0
  159. data/lib/rubydb/accelerator/error.rb +22 -0
  160. data/lib/rubydb/accelerator/manager.rb +606 -0
  161. data/lib/rubydb/accelerator.rb +13 -0
  162. data/lib/rubydb/backup/archive.rb +332 -334
  163. data/lib/rubydb/backup/backup.rb +400 -401
  164. data/lib/rubydb/backup/incremental.rb +349 -353
  165. data/lib/rubydb/backup/restore.rb +289 -290
  166. data/lib/rubydb/backup/snapshot.rb +265 -267
  167. data/lib/rubydb/backup/verification.rb +276 -279
  168. data/lib/rubydb/branching/branch.rb +181 -181
  169. data/lib/rubydb/branching/branch_manager.rb +307 -311
  170. data/lib/rubydb/branching/branch_metadata.rb +140 -140
  171. data/lib/rubydb/branching/checkout.rb +165 -166
  172. data/lib/rubydb/branching/copy_on_write.rb +272 -272
  173. data/lib/rubydb/branching/diff.rb +137 -138
  174. data/lib/rubydb/branching/merge.rb +282 -285
  175. data/lib/rubydb/build_info.rb +15 -15
  176. data/lib/rubydb/catalog/catalog.rb +391 -391
  177. data/lib/rubydb/catalog/column.rb +112 -112
  178. data/lib/rubydb/catalog/constraint.rb +180 -180
  179. data/lib/rubydb/catalog/database.rb +184 -184
  180. data/lib/rubydb/catalog/index.rb +97 -97
  181. data/lib/rubydb/catalog/schema.rb +103 -103
  182. data/lib/rubydb/catalog/sequence.rb +90 -90
  183. data/lib/rubydb/catalog/system_catalog.rb +698 -698
  184. data/lib/rubydb/catalog/table.rb +178 -178
  185. data/lib/rubydb/catalog/trigger.rb +102 -102
  186. data/lib/rubydb/catalog/view.rb +66 -66
  187. data/lib/rubydb/cli/application.rb +168 -163
  188. data/lib/rubydb/cli/commands/accelerator.rb +72 -0
  189. data/lib/rubydb/cli/commands/backup.rb +80 -81
  190. data/lib/rubydb/cli/commands/branch.rb +72 -72
  191. data/lib/rubydb/cli/commands/checkout.rb +54 -54
  192. data/lib/rubydb/cli/commands/create.rb +58 -58
  193. data/lib/rubydb/cli/commands/diff.rb +76 -77
  194. data/lib/rubydb/cli/commands/doctor.rb +77 -74
  195. data/lib/rubydb/cli/commands/drop.rb +57 -57
  196. data/lib/rubydb/cli/commands/init.rb +101 -102
  197. data/lib/rubydb/cli/commands/inspect.rb +95 -95
  198. data/lib/rubydb/cli/commands/merge.rb +63 -63
  199. data/lib/rubydb/cli/commands/migrate.rb +62 -62
  200. data/lib/rubydb/cli/commands/restart.rb +42 -39
  201. data/lib/rubydb/cli/commands/restore.rb +121 -121
  202. data/lib/rubydb/cli/commands/shell.rb +365 -365
  203. data/lib/rubydb/cli/commands/snapshot.rb +79 -79
  204. data/lib/rubydb/cli/commands/start.rb +88 -82
  205. data/lib/rubydb/cli/commands/status.rb +96 -92
  206. data/lib/rubydb/cli/commands/stop.rb +47 -47
  207. data/lib/rubydb/cli/commands/vacuum.rb +58 -58
  208. data/lib/rubydb/cli/formatter.rb +221 -221
  209. data/lib/rubydb/cli/output.rb +168 -168
  210. data/lib/rubydb/client/client.rb +309 -304
  211. data/lib/rubydb/client/connection.rb +429 -415
  212. data/lib/rubydb/client/connection_pool.rb +168 -168
  213. data/lib/rubydb/client/connection_url.rb +96 -96
  214. data/lib/rubydb/client/prepared_statement.rb +60 -60
  215. data/lib/rubydb/client/result.rb +127 -123
  216. data/lib/rubydb/client/statement.rb +52 -52
  217. data/lib/rubydb/client/transaction.rb +130 -130
  218. data/lib/rubydb/concurrency/concurrency.rb +19 -19
  219. data/lib/rubydb/concurrency/deadlock_detector.rb +148 -150
  220. data/lib/rubydb/concurrency/latch.rb +101 -101
  221. data/lib/rubydb/concurrency/lock_graph.rb +163 -165
  222. data/lib/rubydb/concurrency/mutex.rb +181 -183
  223. data/lib/rubydb/concurrency/rw_lock.rb +180 -180
  224. data/lib/rubydb/concurrency/scheduler.rb +248 -250
  225. data/lib/rubydb/concurrency/worker_pool.rb +145 -143
  226. data/lib/rubydb/configuration/config.rb +170 -170
  227. data/lib/rubydb/configuration/defaults.rb +191 -179
  228. data/lib/rubydb/configuration/environment.rb +152 -152
  229. data/lib/rubydb/configuration/parser.rb +185 -185
  230. data/lib/rubydb/configuration/validation.rb +228 -221
  231. data/lib/rubydb/constants.rb +74 -74
  232. data/lib/rubydb/constraints/check.rb +181 -181
  233. data/lib/rubydb/constraints/constraint.rb +101 -101
  234. data/lib/rubydb/constraints/foreign_key.rb +130 -130
  235. data/lib/rubydb/constraints/not_null.rb +64 -64
  236. data/lib/rubydb/constraints/primary_key.rb +99 -99
  237. data/lib/rubydb/constraints/unique.rb +106 -108
  238. data/lib/rubydb/constraints/validator.rb +349 -350
  239. data/lib/rubydb/errors/authentication_error.rb +10 -10
  240. data/lib/rubydb/errors/authorization_error.rb +23 -23
  241. data/lib/rubydb/errors/client_error.rb +10 -10
  242. data/lib/rubydb/errors/configuration_error.rb +10 -10
  243. data/lib/rubydb/errors/connection_error.rb +10 -10
  244. data/lib/rubydb/errors/constraint_error.rb +23 -23
  245. data/lib/rubydb/errors/corruption_error.rb +10 -10
  246. data/lib/rubydb/errors/database_error.rb +10 -10
  247. data/lib/rubydb/errors/error.rb +20 -20
  248. data/lib/rubydb/errors/execution_error.rb +10 -10
  249. data/lib/rubydb/errors/parser_error.rb +10 -10
  250. data/lib/rubydb/errors/recovery_error.rb +10 -10
  251. data/lib/rubydb/errors/replication_error.rb +10 -10
  252. data/lib/rubydb/errors/server_error.rb +6 -6
  253. data/lib/rubydb/errors/storage_error.rb +10 -10
  254. data/lib/rubydb/errors/transaction_error.rb +10 -10
  255. data/lib/rubydb/execution/accelerator_dispatch.rb +30 -0
  256. data/lib/rubydb/execution/aggregate_executor.rb +134 -138
  257. data/lib/rubydb/execution/cost_model.rb +72 -0
  258. data/lib/rubydb/execution/delete_executor.rb +110 -112
  259. data/lib/rubydb/execution/distinct_executor.rb +131 -135
  260. data/lib/rubydb/execution/executor.rb +1544 -1188
  261. data/lib/rubydb/execution/expression.rb +191 -193
  262. data/lib/rubydb/execution/index_scan.rb +142 -142
  263. data/lib/rubydb/execution/insert_executor.rb +215 -217
  264. data/lib/rubydb/execution/join_executor.rb +243 -249
  265. data/lib/rubydb/execution/limit_executor.rb +83 -85
  266. data/lib/rubydb/execution/operator_selection.rb +57 -0
  267. data/lib/rubydb/execution/optimizer.rb +227 -215
  268. data/lib/rubydb/execution/physical_plan.rb +47 -0
  269. data/lib/rubydb/execution/plan.rb +355 -353
  270. data/lib/rubydb/execution/planner.rb +508 -536
  271. data/lib/rubydb/execution/predicate.rb +235 -235
  272. data/lib/rubydb/execution/scan.rb +49 -49
  273. data/lib/rubydb/execution/sequential_scan.rb +63 -63
  274. data/lib/rubydb/execution/sort_executor.rb +194 -185
  275. data/lib/rubydb/execution/update_executor.rb +160 -162
  276. data/lib/rubydb/functions/aggregate.rb +70 -70
  277. data/lib/rubydb/functions/date_functions.rb +274 -278
  278. data/lib/rubydb/functions/function.rb +85 -85
  279. data/lib/rubydb/functions/json_functions.rb +231 -215
  280. data/lib/rubydb/functions/numeric_functions.rb +346 -346
  281. data/lib/rubydb/functions/scalar.rb +52 -52
  282. data/lib/rubydb/functions/string_functions.rb +383 -383
  283. data/lib/rubydb/functions/system_functions.rb +258 -246
  284. data/lib/rubydb/history/as_of.rb +238 -238
  285. data/lib/rubydb/history/change.rb +105 -105
  286. data/lib/rubydb/history/history.rb +131 -131
  287. data/lib/rubydb/history/history_manager.rb +228 -229
  288. data/lib/rubydb/history/temporal_query.rb +202 -202
  289. data/lib/rubydb/history/timeline.rb +144 -144
  290. data/lib/rubydb/indexes/btree.rb +215 -186
  291. data/lib/rubydb/indexes/btree_cursor.rb +258 -258
  292. data/lib/rubydb/indexes/btree_node.rb +384 -385
  293. data/lib/rubydb/indexes/hash_index.rb +150 -150
  294. data/lib/rubydb/indexes/index.rb +71 -71
  295. data/lib/rubydb/indexes/index_manager.rb +408 -406
  296. data/lib/rubydb/indexes/index_scan.rb +466 -470
  297. data/lib/rubydb/migrations/migration.rb +253 -254
  298. data/lib/rubydb/migrations/migration_lock.rb +146 -146
  299. data/lib/rubydb/migrations/migration_manager.rb +187 -176
  300. data/lib/rubydb/migrations/migration_version.rb +71 -71
  301. data/lib/rubydb/migrations/schema_diff.rb +211 -211
  302. data/lib/rubydb/migrations/schema_version.rb +64 -64
  303. data/lib/rubydb/monitoring/events.rb +155 -160
  304. data/lib/rubydb/monitoring/health.rb +216 -222
  305. data/lib/rubydb/monitoring/logger.rb +188 -193
  306. data/lib/rubydb/monitoring/metrics.rb +363 -359
  307. data/lib/rubydb/monitoring/performance.rb +176 -176
  308. data/lib/rubydb/monitoring/statistics.rb +168 -170
  309. data/lib/rubydb/mvcc/garbage_collector.rb +199 -199
  310. data/lib/rubydb/mvcc/mvcc.rb +16 -16
  311. data/lib/rubydb/mvcc/snapshot.rb +146 -147
  312. data/lib/rubydb/mvcc/vacuum.rb +180 -180
  313. data/lib/rubydb/mvcc/version.rb +106 -106
  314. data/lib/rubydb/mvcc/version_store.rb +396 -398
  315. data/lib/rubydb/mvcc/visibility.rb +107 -109
  316. data/lib/rubydb/protocol/capabilities.rb +125 -125
  317. data/lib/rubydb/protocol/decoder.rb +142 -145
  318. data/lib/rubydb/protocol/encoder.rb +131 -136
  319. data/lib/rubydb/protocol/handshake.rb +306 -305
  320. data/lib/rubydb/protocol/message.rb +121 -121
  321. data/lib/rubydb/protocol/parameter_binder.rb +101 -0
  322. data/lib/rubydb/protocol/protocol.rb +276 -277
  323. data/lib/rubydb/protocol/version.rb +54 -54
  324. data/lib/rubydb/rails/adapter.rb +245 -239
  325. data/lib/rubydb/rails/connection.rb +312 -314
  326. data/lib/rubydb/rails/database_statements.rb +122 -122
  327. data/lib/rubydb/rails/migration.rb +131 -131
  328. data/lib/rubydb/rails/quoting.rb +109 -109
  329. data/lib/rubydb/rails/result.rb +117 -117
  330. data/lib/rubydb/rails/schema_statements.rb +339 -339
  331. data/lib/rubydb/rails/transaction.rb +105 -105
  332. data/lib/rubydb/rails/type.rb +126 -126
  333. data/lib/rubydb/recovery/checkpoint.rb +261 -257
  334. data/lib/rubydb/recovery/consistency.rb +457 -467
  335. data/lib/rubydb/recovery/corruption_detector.rb +5 -5
  336. data/lib/rubydb/recovery/crash_recovery.rb +381 -387
  337. data/lib/rubydb/recovery/recovery_manager.rb +204 -206
  338. data/lib/rubydb/recovery/redo.rb +235 -237
  339. data/lib/rubydb/recovery/undo.rb +204 -206
  340. data/lib/rubydb/replication/failover.rb +5 -5
  341. data/lib/rubydb/replication/fencing.rb +63 -63
  342. data/lib/rubydb/replication/primary.rb +461 -450
  343. data/lib/rubydb/replication/replica.rb +382 -384
  344. data/lib/rubydb/replication/replication_log.rb +194 -200
  345. data/lib/rubydb/replication/replication_manager.rb +307 -308
  346. data/lib/rubydb/replication/replication_slot.rb +293 -295
  347. data/lib/rubydb/replication/replication_stream.rb +198 -201
  348. data/lib/rubydb/rubydb.rb +570 -560
  349. data/lib/rubydb/security/access_control.rb +252 -254
  350. data/lib/rubydb/security/audit_log.rb +209 -213
  351. data/lib/rubydb/security/authentication.rb +302 -302
  352. data/lib/rubydb/security/authorization.rb +282 -282
  353. data/lib/rubydb/security/credentials.rb +192 -196
  354. data/lib/rubydb/security/password.rb +205 -215
  355. data/lib/rubydb/security/permissions.rb +74 -74
  356. data/lib/rubydb/security/role.rb +99 -101
  357. data/lib/rubydb/security/user.rb +86 -86
  358. data/lib/rubydb/server/connection.rb +383 -366
  359. data/lib/rubydb/server/connection_pool.rb +193 -193
  360. data/lib/rubydb/server/lifecycle.rb +227 -228
  361. data/lib/rubydb/server/listener.rb +139 -136
  362. data/lib/rubydb/server/request_handler.rb +277 -276
  363. data/lib/rubydb/server/server.rb +363 -364
  364. data/lib/rubydb/server/session.rb +416 -369
  365. data/lib/rubydb/server/worker.rb +206 -210
  366. data/lib/rubydb/server/worker_pool.rb +168 -168
  367. data/lib/rubydb/sql/ast/alter_table.rb +169 -169
  368. data/lib/rubydb/sql/ast/begin_transaction.rb +47 -47
  369. data/lib/rubydb/sql/ast/commit.rb +37 -37
  370. data/lib/rubydb/sql/ast/constraint.rb +92 -83
  371. data/lib/rubydb/sql/ast/create_database.rb +41 -41
  372. data/lib/rubydb/sql/ast/create_index.rb +61 -61
  373. data/lib/rubydb/sql/ast/create_schema.rb +52 -52
  374. data/lib/rubydb/sql/ast/create_table.rb +187 -187
  375. data/lib/rubydb/sql/ast/delete.rb +54 -54
  376. data/lib/rubydb/sql/ast/drop_database.rb +41 -41
  377. data/lib/rubydb/sql/ast/drop_index.rb +41 -41
  378. data/lib/rubydb/sql/ast/drop_schema.rb +49 -49
  379. data/lib/rubydb/sql/ast/drop_table.rb +49 -49
  380. data/lib/rubydb/sql/ast/explain.rb +64 -64
  381. data/lib/rubydb/sql/ast/expression.rb +617 -604
  382. data/lib/rubydb/sql/ast/insert.rb +66 -66
  383. data/lib/rubydb/sql/ast/node.rb +42 -42
  384. data/lib/rubydb/sql/ast/rollback.rb +63 -63
  385. data/lib/rubydb/sql/ast/savepoint.rb +59 -59
  386. data/lib/rubydb/sql/ast/select.rb +88 -88
  387. data/lib/rubydb/sql/ast/set_operation.rb +22 -20
  388. data/lib/rubydb/sql/ast/trigger.rb +35 -29
  389. data/lib/rubydb/sql/ast/update.rb +88 -88
  390. data/lib/rubydb/sql/ast/vacuum.rb +19 -19
  391. data/lib/rubydb/sql/ast/view.rb +38 -32
  392. data/lib/rubydb/sql/ast/with.rb +32 -32
  393. data/lib/rubydb/sql/grammar.rb +86 -86
  394. data/lib/rubydb/sql/keywords.rb +156 -156
  395. data/lib/rubydb/sql/lexer.rb +209 -214
  396. data/lib/rubydb/sql/operators.rb +100 -100
  397. data/lib/rubydb/sql/parser.rb +1167 -1170
  398. data/lib/rubydb/sql/planner/analyzer.rb +283 -302
  399. data/lib/rubydb/sql/planner/binder.rb +537 -550
  400. data/lib/rubydb/sql/planner/type_checker.rb +427 -431
  401. data/lib/rubydb/sql/token.rb +210 -210
  402. data/lib/rubydb/storage/buffer_frame.rb +44 -44
  403. data/lib/rubydb/storage/buffer_pool.rb +155 -155
  404. data/lib/rubydb/storage/database_lock.rb +74 -74
  405. data/lib/rubydb/storage/deserializer.rb +332 -342
  406. data/lib/rubydb/storage/engine.rb +2409 -2330
  407. data/lib/rubydb/storage/file_manager.rb +191 -187
  408. data/lib/rubydb/storage/free_space_map.rb +79 -81
  409. data/lib/rubydb/storage/page.rb +92 -94
  410. data/lib/rubydb/storage/page_allocator.rb +852 -855
  411. data/lib/rubydb/storage/page_header.rb +63 -67
  412. data/lib/rubydb/storage/page_manager.rb +127 -131
  413. data/lib/rubydb/storage/record.rb +58 -58
  414. data/lib/rubydb/storage/row.rb +78 -78
  415. data/lib/rubydb/storage/serializer.rb +51 -51
  416. data/lib/rubydb/storage/snapshot_reader.rb +167 -0
  417. data/lib/rubydb/storage/storage_layout.rb +151 -151
  418. data/lib/rubydb/storage/storage_manager.rb +114 -114
  419. data/lib/rubydb/storage/tuple.rb +458 -461
  420. data/lib/rubydb/storage/visibility_map.rb +964 -973
  421. data/lib/rubydb/transactions/commit_manager.rb +219 -220
  422. data/lib/rubydb/transactions/isolation.rb +98 -98
  423. data/lib/rubydb/transactions/lock.rb +76 -76
  424. data/lib/rubydb/transactions/lock_manager.rb +359 -362
  425. data/lib/rubydb/transactions/savepoint.rb +142 -143
  426. data/lib/rubydb/transactions/transaction.rb +214 -215
  427. data/lib/rubydb/transactions/transaction_id.rb +84 -84
  428. data/lib/rubydb/transactions/transaction_log.rb +256 -257
  429. data/lib/rubydb/transactions/transaction_manager.rb +434 -435
  430. data/lib/rubydb/types/bigint.rb +36 -36
  431. data/lib/rubydb/types/blob.rb +37 -37
  432. data/lib/rubydb/types/boolean.rb +34 -34
  433. data/lib/rubydb/types/date.rb +39 -39
  434. data/lib/rubydb/types/decimal.rb +48 -48
  435. data/lib/rubydb/types/float.rb +34 -34
  436. data/lib/rubydb/types/integer.rb +36 -36
  437. data/lib/rubydb/types/json.rb +41 -41
  438. data/lib/rubydb/types/null.rb +34 -34
  439. data/lib/rubydb/types/smallint.rb +36 -36
  440. data/lib/rubydb/types/text.rb +37 -37
  441. data/lib/rubydb/types/time.rb +46 -46
  442. data/lib/rubydb/types/timestamp.rb +39 -39
  443. data/lib/rubydb/types/type.rb +119 -119
  444. data/lib/rubydb/types/uuid.rb +47 -47
  445. data/lib/rubydb/types/varchar.rb +37 -37
  446. data/lib/rubydb/version.rb +32 -32
  447. data/lib/rubydb/wal/archive.rb +207 -193
  448. data/lib/rubydb/wal/checkpoint.rb +181 -183
  449. data/lib/rubydb/wal/lsn.rb +94 -94
  450. data/lib/rubydb/wal/reader.rb +259 -260
  451. data/lib/rubydb/wal/record.rb +105 -105
  452. data/lib/rubydb/wal/segment.rb +193 -193
  453. data/lib/rubydb/wal/wal.rb +481 -452
  454. data/lib/rubydb/wal/writer.rb +236 -236
  455. data/lib/rubydb.rb +7 -7
  456. data/packaging/docker/docker-compose.failover.yml +43 -43
  457. data/packaging/homebrew/rubydb.rb +19 -19
  458. data/rubydb.gemspec +70 -57
  459. data/scripts/benchmark +7 -7
  460. data/scripts/build_accelerator +49 -0
  461. data/scripts/durability_drill +37 -37
  462. data/scripts/fuzz +63 -63
  463. data/scripts/release +77 -42
  464. data/scripts/release_check +43 -43
  465. data/scripts/replication_failover_drill +268 -250
  466. data/scripts/replication_network_failover_drill +287 -255
  467. data/scripts/restore_drill +45 -45
  468. data/scripts/security +45 -0
  469. metadata +102 -1
@@ -1,450 +1,461 @@
1
- # frozen_string_literal: true
2
-
3
- require "socket"
4
- require "time"
5
- require "thread"
6
- require "json"
7
- require "monitor"
8
- require "fileutils"
9
- require "digest"
10
- require_relative "replication_slot"
11
- require_relative "fencing"
12
-
13
- module RubyDB
14
- module Replication
15
- # Primary - Primary database server in replication setup
16
- class Primary
17
- attr_reader :config, :engine, :replication_log, :replication_slots
18
- attr_reader :replicas, :stats
19
-
20
- def initialize(engine, config = {})
21
- @engine = engine
22
- @engine.set_replication_read_only(false) if @engine.respond_to?(:set_replication_read_only)
23
- @config = {
24
- host: config[:host] || "localhost",
25
- port: config[:port] || 7433,
26
- replication_port: config[:replication_port] || 7434,
27
- max_replicas: config[:max_replicas] || 10,
28
- sync_mode: config[:sync_mode] || :async, # :async, :sync, :quorum
29
- sync_replicas: config[:sync_replicas] || 1,
30
- wal_keep_segments: config[:wal_keep_segments] || 100,
31
- replication_timeout: config[:replication_timeout] || 60,
32
- heartbeat_interval: config[:heartbeat_interval] || 10,
33
- replication_auth_token: config[:replication_auth_token] || config[:auth_token],
34
- enable_slots: config[:enable_slots] != false,
35
- log_dir: config[:log_dir] || "#{@engine.path}.replication_log",
36
- fence_path: config[:fence_path] || "#{@engine.path}.fence",
37
- node_id: config[:node_id] || "primary_#{Process.pid}"
38
- }
39
-
40
- @replication_log = ReplicationLog.new(@engine, @config)
41
- @replication_slots = {}
42
- @replicas = {}
43
- @replica_counter = 0
44
- @stats = {
45
- total_writes: 0,
46
- total_bytes_replicated: 0,
47
- replication_lag_ms: 0,
48
- sync_status: :async,
49
- last_commit_time: nil,
50
- committed_lsn: nil,
51
- replicated_lsn: nil,
52
- active_replicas: 0,
53
- total_replicas_connected: 0,
54
- authentication_failures: 0
55
- }
56
- @lock = Monitor.new
57
- @slot_path = config[:slot_path] || "#{@engine.path}.replication_slots.json"
58
- @running = false
59
- @replication_server = nil
60
- @heartbeat_thread = nil
61
- @recovery = nil
62
- @fencing_lease = FencingLease.new(@config[:fence_path], @config[:node_id]).acquire!
63
- @engine.set_write_fence(@fencing_lease) if @engine.respond_to?(:set_write_fence)
64
- @engine_commit_listener = proc do |transaction_id, changes|
65
- write(
66
- id: "engine_tx_#{transaction_id}",
67
- operation: "transaction",
68
- transaction_id: transaction_id,
69
- operations: changes
70
- )
71
- end
72
- @engine.add_commit_listener(@engine_commit_listener) if @engine.respond_to?(:add_commit_listener)
73
-
74
- # Initialize replication slots if enabled
75
- initialize_slots if @config[:enable_slots]
76
- end
77
-
78
- def start
79
- @lock.synchronize do
80
- return if @running
81
-
82
- @fencing_lease.assert_valid!
83
- @replication_log = ReplicationLog.new(@engine, @config) if @replication_log.closed?
84
- @engine.add_commit_listener(@engine_commit_listener) if @engine.respond_to?(:add_commit_listener)
85
-
86
- @running = true
87
-
88
- # Start replication server
89
- start_replication_server
90
-
91
- # Start heartbeat thread
92
- start_heartbeat
93
-
94
- puts "Primary replication started on port #{@config[:replication_port]}"
95
- true
96
- end
97
- end
98
-
99
- def stop
100
- connections = []
101
- @lock.synchronize do
102
- if @running
103
- @running = false
104
- @engine.remove_commit_listener(@engine_commit_listener) if @engine.respond_to?(:remove_commit_listener)
105
-
106
- @replication_server&.close
107
- @replication_server = nil
108
-
109
- @heartbeat_thread&.kill
110
- @heartbeat_thread = nil
111
-
112
- puts "Primary replication stopped"
113
- else
114
- @engine.remove_commit_listener(@engine_commit_listener) if @engine.respond_to?(:remove_commit_listener)
115
- end
116
-
117
- # Closing the listener alone does not wake established replica
118
- # sessions. Close those sockets as part of shutdown so replicas
119
- # observe the partition and can reconnect/catch up from the durable
120
- # replication log.
121
- connections = @replicas.values.filter_map { |replica| replica[:connection] }
122
- @replicas.each_value { |replica| replica[:connection] = nil }
123
- @replicas.clear
124
- @stats[:active_replicas] = 0
125
- @replication_log.close unless @replication_log.closed?
126
- end
127
- connections.each do |connection|
128
- connection.shutdown(Socket::SHUT_RDWR) rescue nil
129
- connection.close rescue nil
130
- end
131
- true
132
- end
133
-
134
- def write(transaction_data)
135
- @lock.synchronize do
136
- @fencing_lease.assert_valid!
137
- # Logical replication uses a monotonic LSN over durable envelopes.
138
- lsn = (@replication_log.get_last_lsn || 0) + 1
139
-
140
- # Log the transaction
141
- @replication_log.log_transaction(transaction_data, lsn)
142
-
143
- # Update stats
144
- @stats[:total_writes] += 1
145
- @stats[:committed_lsn] = lsn
146
-
147
- # Notify replicas
148
- notify_replicas(transaction_data, lsn)
149
-
150
- # Wait for sync replicas if configured
151
- wait_for_sync_replicas if @config[:sync_mode] == :sync
152
-
153
- lsn
154
- end
155
- end
156
-
157
- def fencing_status
158
- @lock.synchronize do
159
- { node_id: @fencing_lease.node_id, epoch: @fencing_lease.epoch, valid: @fencing_lease.valid? }
160
- end
161
- end
162
-
163
- def register_replica(replica_info)
164
- @lock.synchronize do
165
- replica_id = @replica_counter + 1
166
- @replica_counter = replica_id
167
-
168
- # Create replication slot if enabled
169
- slot = nil
170
- if @config[:enable_slots]
171
- slot_name = "replica_#{replica_id}"
172
- slot = create_replication_slot(slot_name)
173
- end
174
-
175
- replica = {
176
- id: replica_id,
177
- info: replica_info,
178
- connected_at: Time.now,
179
- last_heartbeat: Time.now,
180
- last_lsn: nil,
181
- last_ack_lsn: nil,
182
- sync_standby: false,
183
- slot: slot,
184
- connection: nil,
185
- lag_bytes: 0
186
- }
187
-
188
- @replicas[replica_id] = replica
189
- @stats[:active_replicas] = @replicas.size
190
- @stats[:total_replicas_connected] += 1
191
-
192
- replica_id
193
- end
194
- end
195
-
196
- def unregister_replica(replica_id)
197
- @lock.synchronize do
198
- replica = @replicas.delete(replica_id)
199
- return false unless replica
200
-
201
- @stats[:active_replicas] = @replicas.size
202
- true
203
- end
204
- end
205
-
206
- def send_heartbeat(replica_id)
207
- @lock.synchronize do
208
- replica = @replicas[replica_id]
209
- return false unless replica
210
-
211
- replica[:last_heartbeat] = Time.now
212
-
213
- # Calculate replication lag
214
- if replica[:last_lsn] && @stats[:committed_lsn]
215
- replica[:lag_bytes] = [@stats[:committed_lsn].to_i - replica[:last_lsn].to_i, 0].max
216
- end
217
-
218
- true
219
- end
220
- end
221
-
222
- def get_replication_status
223
- @lock.synchronize do
224
- {
225
- running: @running,
226
- role: "primary",
227
- sync_mode: @config[:sync_mode],
228
- active_replicas: @replicas.size,
229
- total_writes: @stats[:total_writes],
230
- committed_lsn: @stats[:committed_lsn],
231
- replicated_lsn: @stats[:replicated_lsn],
232
- wal_keep_segments: @config[:wal_keep_segments],
233
- slots: @replication_slots.size,
234
- replicas: @replicas.map do |id, r|
235
- {
236
- id: id,
237
- connected_at: r[:connected_at].iso8601,
238
- last_heartbeat: r[:last_heartbeat].iso8601,
239
- sync_standby: r[:sync_standby],
240
- lag_bytes: r[:lag_bytes],
241
- acknowledged_lsn: r[:last_ack_lsn]
242
- }
243
- end
244
- }
245
- end
246
- end
247
-
248
- private
249
-
250
- def start_replication_server
251
- @replication_server = TCPServer.new(@config[:host], @config[:replication_port])
252
-
253
- Thread.new do
254
- while @running
255
- begin
256
- client = @replication_server.accept
257
- Thread.new { handle_replica_connection(client) }
258
- rescue => e
259
- # Log error but continue
260
- end
261
- end
262
- end
263
- end
264
-
265
- def handle_replica_connection(client)
266
- line = client.gets
267
- handshake = JSON.parse(line, symbolize_names: true)
268
- unless handshake[:type].to_s == "replica_handshake"
269
- client.write(JSON.generate(success: false, error: "Invalid replication handshake") + "\n")
270
- return
271
- end
272
-
273
- expected_token = @config[:replication_auth_token]
274
- if expected_token && !secure_token_match?(expected_token, handshake[:auth_token])
275
- @lock.synchronize { @stats[:authentication_failures] += 1 }
276
- client.write(JSON.generate(success: false, error: "Replication authentication failed") + "\n")
277
- client.flush
278
- return
279
- end
280
-
281
- replica_id = register_replica(handshake)
282
- @lock.synchronize do
283
- replica = @replicas[replica_id]
284
- replica[:connection] = client
285
-
286
- # A reconnecting replica may already have replayed every durable
287
- # entry and therefore receive no catch-up frames. Record the
288
- # handshake position as an acknowledgement, bounded by the primary
289
- # log, so operators can distinguish a caught-up reconnect from a
290
- # merely connected socket.
291
- reported_lsn = handshake[:wal_position].to_i
292
- durable_lsn = @replication_log.get_last_lsn.to_i
293
- acknowledged_lsn = [reported_lsn, durable_lsn].min
294
- if acknowledged_lsn.positive?
295
- replica[:last_ack_lsn] = acknowledged_lsn
296
- replica[:slot]&.advance(acknowledged_lsn)
297
- persist_slots if replica[:slot]
298
- @stats[:replicated_lsn] = [@stats[:replicated_lsn].to_i, acknowledged_lsn].max
299
- end
300
- end
301
- client.write(JSON.generate(success: true, replica_id: replica_id, mode: "logical") + "\n")
302
- client.flush
303
-
304
- # Send the catalog before row replay so a new replica does not require
305
- # an out-of-band schema copy. IF NOT EXISTS keeps reconnects safe.
306
- schema = @engine.respond_to?(:schema_dump) ? @engine.schema_dump : ""
307
- unless schema.empty?
308
- schema = schema.gsub(/^CREATE TABLE /, "CREATE TABLE IF NOT EXISTS ")
309
- client.write(JSON.generate(type: "replication_bootstrap", schema: schema) + "\n")
310
- client.flush
311
- end
312
-
313
- from_lsn = handshake[:wal_position].to_i + 1
314
- @replication_log.read_transactions(from_lsn).each do |entry|
315
- send_replication_entry(client, entry)
316
- @lock.synchronize { @replicas[replica_id][:last_lsn] = entry[:lsn] if @replicas[replica_id] }
317
- end
318
-
319
- while @running && !client.closed?
320
- ready = IO.select([client], nil, nil, 0.1)
321
- next unless ready
322
- line = client.gets
323
- break unless line
324
- message = JSON.parse(line, symbolize_names: true)
325
- if message[:type].to_s == "heartbeat"
326
- send_heartbeat(replica_id)
327
- next
328
- end
329
- next unless message[:type].to_s == "ack"
330
-
331
- ack_lsn = message[:lsn].to_i
332
- @lock.synchronize do
333
- replica = @replicas[replica_id]
334
- if replica
335
- replica[:last_ack_lsn] = [replica[:last_ack_lsn].to_i, ack_lsn].max
336
- replica[:slot]&.advance(ack_lsn)
337
- persist_slots if replica[:slot]
338
- @stats[:replicated_lsn] = [@stats[:replicated_lsn].to_i, ack_lsn].max
339
- end
340
- end
341
- end
342
- rescue StandardError
343
- client.close rescue nil
344
- ensure
345
- if replica_id
346
- unregister_replica(replica_id)
347
- end
348
- end
349
-
350
- def start_heartbeat
351
- @heartbeat_thread = Thread.new do
352
- while @running
353
- sleep(@config[:heartbeat_interval])
354
-
355
- @lock.synchronize do
356
- @replicas.each do |id, replica|
357
- # Check if replica is still alive
358
- if Time.now - replica[:last_heartbeat] > @config[:replication_timeout]
359
- unregister_replica(id)
360
- end
361
- end
362
- end
363
- end
364
- end
365
- end
366
-
367
- def initialize_slots
368
- return unless File.file?(@slot_path)
369
-
370
- data = JSON.parse(File.read(@slot_path), symbolize_names: true)
371
- data.fetch(:slots, {}).each do |name, slot_data|
372
- slot = ReplicationSlot.new(name, self)
373
- slot.confirmed_lsn = slot_data[:confirmed_lsn].to_i
374
- @replication_slots[slot.name] = slot
375
- end
376
- rescue JSON::ParserError => error
377
- raise RubyDB::ReplicationError, "Invalid replication slot catalog #{@slot_path}: #{error.message}"
378
- end
379
-
380
- def create_replication_slot(name)
381
- existing = @replication_slots[name.to_s]
382
- return existing if existing
383
-
384
- slot = ReplicationSlot.new(name, self)
385
- @replication_slots[name] = slot
386
- persist_slots
387
- slot
388
- end
389
-
390
- def drop_replication_slot(name)
391
- removed = @replication_slots.delete(name.to_s)
392
- persist_slots if removed
393
- removed
394
- end
395
-
396
- def persist_slots
397
- FileUtils.mkdir_p(File.dirname(@slot_path))
398
- payload = { slots: @replication_slots.transform_values { |slot| { confirmed_lsn: slot.confirmed_lsn } } }
399
- temporary = "#{@slot_path}.tmp-#{Process.pid}"
400
- File.write(temporary, JSON.generate(payload))
401
- File.rename(temporary, @slot_path)
402
- ensure
403
- File.delete(temporary) if defined?(temporary) && File.file?(temporary)
404
- end
405
-
406
- def notify_replicas(transaction_data, lsn)
407
- @stats[:replicated_lsn] = lsn
408
- entry = { lsn: lsn, timestamp: Time.now.iso8601, transaction_id: transaction_data[:id], data: transaction_data }
409
- @replicas.each_value do |replica|
410
- connection = replica[:connection]
411
- next unless connection
412
- next if replica[:last_lsn] && replica[:last_lsn] >= lsn
413
- send_replication_entry(connection, entry)
414
- replica[:last_lsn] = lsn
415
- rescue StandardError
416
- unregister_replica(replica[:id])
417
- end
418
- end
419
-
420
- def send_replication_entry(connection, entry)
421
- payload = JSON.generate(type: "replication_data", data: [entry]) + "\n"
422
- connection.write(payload)
423
- connection.flush
424
- @stats[:total_bytes_replicated] += payload.bytesize
425
- end
426
-
427
- def wait_for_sync_replicas
428
- deadline = Time.now + @config[:replication_timeout]
429
- loop do
430
- acknowledged = @replicas.values.count { |replica| replica[:last_ack_lsn] == @stats[:committed_lsn] }
431
- return true if acknowledged >= @config[:sync_replicas]
432
- raise ReplicationError, "Synchronous replica acknowledgment timed out" if Time.now >= deadline
433
- sleep(0.01)
434
- end
435
- end
436
-
437
- def secure_token_match?(expected, presented)
438
- return false unless presented
439
-
440
- expected_bytes = expected.to_s.b
441
- presented_bytes = presented.to_s.b
442
- return false unless expected_bytes.bytesize == presented_bytes.bytesize
443
-
444
- mismatch = 0
445
- expected_bytes.bytes.each_with_index { |byte, index| mismatch |= byte ^ presented_bytes.getbyte(index) }
446
- mismatch.zero?
447
- end
448
- end
449
- end
450
- end
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "time"
5
+ require "json"
6
+ require "monitor"
7
+ require "fileutils"
8
+ require "digest"
9
+ require_relative "replication_slot"
10
+ require_relative "fencing"
11
+
12
+ module RubyDB
13
+ module Replication
14
+ # Primary - Primary database server in replication setup
15
+ class Primary
16
+ attr_reader :config, :engine, :replication_log, :replication_slots
17
+ attr_reader :replicas, :stats
18
+
19
+ def initialize(engine, config = {})
20
+ @engine = engine
21
+ @engine.set_replication_read_only(false) if @engine.respond_to?(:set_replication_read_only)
22
+ @config = {
23
+ host: config[:host] || "localhost",
24
+ port: config[:port] || 7433,
25
+ replication_port: config[:replication_port] || 7434,
26
+ max_replicas: config[:max_replicas] || 10,
27
+ sync_mode: config[:sync_mode] || :async, # :async, :sync, :quorum
28
+ sync_replicas: config[:sync_replicas] || 1,
29
+ wal_keep_segments: config[:wal_keep_segments] || 100,
30
+ replication_timeout: config[:replication_timeout] || 60,
31
+ heartbeat_interval: config[:heartbeat_interval] || 10,
32
+ replication_auth_token: config[:replication_auth_token] || config[:auth_token],
33
+ enable_slots: config[:enable_slots] != false,
34
+ log_dir: config[:log_dir] || "#{@engine.path}.replication_log",
35
+ fence_path: config[:fence_path] || "#{@engine.path}.fence",
36
+ node_id: config[:node_id] || "primary_#{Process.pid}"
37
+ }
38
+
39
+ @replication_log = ReplicationLog.new(@engine, @config)
40
+ @replication_slots = {}
41
+ @replicas = {}
42
+ @replica_counter = 0
43
+ @stats = {
44
+ total_writes: 0,
45
+ total_bytes_replicated: 0,
46
+ replication_lag_ms: 0,
47
+ sync_status: :async,
48
+ last_commit_time: nil,
49
+ committed_lsn: nil,
50
+ replicated_lsn: nil,
51
+ active_replicas: 0,
52
+ total_replicas_connected: 0,
53
+ authentication_failures: 0
54
+ }
55
+ @lock = Monitor.new
56
+ @slot_path = config[:slot_path] || "#{@engine.path}.replication_slots.json"
57
+ @running = false
58
+ @replication_server = nil
59
+ @heartbeat_thread = nil
60
+ @recovery = nil
61
+ @fencing_lease = FencingLease.new(@config[:fence_path], @config[:node_id]).acquire!
62
+ @engine.set_write_fence(@fencing_lease) if @engine.respond_to?(:set_write_fence)
63
+ @engine_commit_listener = proc do |transaction_id, changes|
64
+ write(
65
+ id: "engine_tx_#{transaction_id}",
66
+ operation: "transaction",
67
+ transaction_id: transaction_id,
68
+ operations: changes
69
+ )
70
+ end
71
+ @engine.add_commit_listener(@engine_commit_listener) if @engine.respond_to?(:add_commit_listener)
72
+
73
+ # Initialize replication slots if enabled
74
+ initialize_slots if @config[:enable_slots]
75
+ end
76
+
77
+ def start
78
+ @lock.synchronize do
79
+ return if @running
80
+
81
+ @fencing_lease.assert_valid!
82
+ @replication_log = ReplicationLog.new(@engine, @config) if @replication_log.closed?
83
+ @engine.add_commit_listener(@engine_commit_listener) if @engine.respond_to?(:add_commit_listener)
84
+
85
+ @running = true
86
+
87
+ # Start replication server
88
+ start_replication_server
89
+
90
+ # Start heartbeat thread
91
+ start_heartbeat
92
+
93
+ puts "Primary replication started on port #{@config[:replication_port]}"
94
+ true
95
+ end
96
+ end
97
+
98
+ def stop
99
+ connections = []
100
+ @lock.synchronize do
101
+ if @running
102
+ @running = false
103
+ @engine.remove_commit_listener(@engine_commit_listener) if @engine.respond_to?(:remove_commit_listener)
104
+
105
+ @replication_server&.close
106
+ @replication_server = nil
107
+
108
+ @heartbeat_thread&.kill
109
+ @heartbeat_thread = nil
110
+
111
+ puts "Primary replication stopped"
112
+ elsif @engine.respond_to?(:remove_commit_listener)
113
+ @engine.remove_commit_listener(@engine_commit_listener)
114
+ end
115
+
116
+ # Closing the listener alone does not wake established replica
117
+ # sessions. Close those sockets as part of shutdown so replicas
118
+ # observe the partition and can reconnect/catch up from the durable
119
+ # replication log.
120
+ connections = @replicas.values.filter_map { |replica| replica[:connection] }
121
+ @replicas.each_value { |replica| replica[:connection] = nil }
122
+ @replicas.clear
123
+ @stats[:active_replicas] = 0
124
+ @replication_log.close unless @replication_log.closed?
125
+ end
126
+ connections.each do |connection|
127
+ begin
128
+ connection.shutdown(Socket::SHUT_RDWR)
129
+ rescue
130
+ nil
131
+ end
132
+ begin
133
+ connection.close
134
+ rescue
135
+ nil
136
+ end
137
+ end
138
+ true
139
+ end
140
+
141
+ def write(transaction_data)
142
+ @lock.synchronize do
143
+ @fencing_lease.assert_valid!
144
+ # Logical replication uses a monotonic LSN over durable envelopes.
145
+ lsn = (@replication_log.get_last_lsn || 0) + 1
146
+
147
+ # Log the transaction
148
+ @replication_log.log_transaction(transaction_data, lsn)
149
+
150
+ # Update stats
151
+ @stats[:total_writes] += 1
152
+ @stats[:committed_lsn] = lsn
153
+
154
+ # Notify replicas
155
+ notify_replicas(transaction_data, lsn)
156
+
157
+ # Wait for sync replicas if configured
158
+ wait_for_sync_replicas if @config[:sync_mode] == :sync
159
+
160
+ lsn
161
+ end
162
+ end
163
+
164
+ def fencing_status
165
+ @lock.synchronize do
166
+ {node_id: @fencing_lease.node_id, epoch: @fencing_lease.epoch, valid: @fencing_lease.valid?}
167
+ end
168
+ end
169
+
170
+ def register_replica(replica_info)
171
+ @lock.synchronize do
172
+ replica_id = @replica_counter + 1
173
+ @replica_counter = replica_id
174
+
175
+ # Create replication slot if enabled
176
+ slot = nil
177
+ if @config[:enable_slots]
178
+ slot_name = "replica_#{replica_id}"
179
+ slot = create_replication_slot(slot_name)
180
+ end
181
+
182
+ replica = {
183
+ id: replica_id,
184
+ info: replica_info,
185
+ connected_at: Time.now,
186
+ last_heartbeat: Time.now,
187
+ last_lsn: nil,
188
+ last_ack_lsn: nil,
189
+ sync_standby: false,
190
+ slot: slot,
191
+ connection: nil,
192
+ lag_bytes: 0
193
+ }
194
+
195
+ @replicas[replica_id] = replica
196
+ @stats[:active_replicas] = @replicas.size
197
+ @stats[:total_replicas_connected] += 1
198
+
199
+ replica_id
200
+ end
201
+ end
202
+
203
+ def unregister_replica(replica_id)
204
+ @lock.synchronize do
205
+ replica = @replicas.delete(replica_id)
206
+ return false unless replica
207
+
208
+ @stats[:active_replicas] = @replicas.size
209
+ true
210
+ end
211
+ end
212
+
213
+ def send_heartbeat(replica_id)
214
+ @lock.synchronize do
215
+ replica = @replicas[replica_id]
216
+ return false unless replica
217
+
218
+ replica[:last_heartbeat] = Time.now
219
+
220
+ # Calculate replication lag
221
+ if replica[:last_lsn] && @stats[:committed_lsn]
222
+ replica[:lag_bytes] = [@stats[:committed_lsn].to_i - replica[:last_lsn].to_i, 0].max
223
+ end
224
+
225
+ true
226
+ end
227
+ end
228
+
229
+ def get_replication_status
230
+ @lock.synchronize do
231
+ {
232
+ running: @running,
233
+ role: "primary",
234
+ sync_mode: @config[:sync_mode],
235
+ active_replicas: @replicas.size,
236
+ total_writes: @stats[:total_writes],
237
+ committed_lsn: @stats[:committed_lsn],
238
+ replicated_lsn: @stats[:replicated_lsn],
239
+ wal_keep_segments: @config[:wal_keep_segments],
240
+ slots: @replication_slots.size,
241
+ replicas: @replicas.map do |id, r|
242
+ {
243
+ id: id,
244
+ connected_at: r[:connected_at].iso8601,
245
+ last_heartbeat: r[:last_heartbeat].iso8601,
246
+ sync_standby: r[:sync_standby],
247
+ lag_bytes: r[:lag_bytes],
248
+ acknowledged_lsn: r[:last_ack_lsn]
249
+ }
250
+ end
251
+ }
252
+ end
253
+ end
254
+
255
+ private
256
+
257
+ def start_replication_server
258
+ @replication_server = TCPServer.new(@config[:host], @config[:replication_port])
259
+
260
+ Thread.new do
261
+ while @running
262
+ begin
263
+ client = @replication_server.accept
264
+ Thread.new { handle_replica_connection(client) }
265
+ rescue
266
+ # Log error but continue
267
+ end
268
+ end
269
+ end
270
+ end
271
+
272
+ def handle_replica_connection(client)
273
+ line = client.gets
274
+ handshake = JSON.parse(line, symbolize_names: true)
275
+ unless handshake[:type].to_s == "replica_handshake"
276
+ client.write(JSON.generate(success: false, error: "Invalid replication handshake") + "\n")
277
+ return
278
+ end
279
+
280
+ expected_token = @config[:replication_auth_token]
281
+ if expected_token && !secure_token_match?(expected_token, handshake[:auth_token])
282
+ @lock.synchronize { @stats[:authentication_failures] += 1 }
283
+ client.write(JSON.generate(success: false, error: "Replication authentication failed") + "\n")
284
+ client.flush
285
+ return
286
+ end
287
+
288
+ replica_id = register_replica(handshake)
289
+ @lock.synchronize do
290
+ replica = @replicas[replica_id]
291
+ replica[:connection] = client
292
+
293
+ # A reconnecting replica may already have replayed every durable
294
+ # entry and therefore receive no catch-up frames. Record the
295
+ # handshake position as an acknowledgement, bounded by the primary
296
+ # log, so operators can distinguish a caught-up reconnect from a
297
+ # merely connected socket.
298
+ reported_lsn = handshake[:wal_position].to_i
299
+ durable_lsn = @replication_log.get_last_lsn.to_i
300
+ acknowledged_lsn = [reported_lsn, durable_lsn].min
301
+ if acknowledged_lsn.positive?
302
+ replica[:last_ack_lsn] = acknowledged_lsn
303
+ replica[:slot]&.advance(acknowledged_lsn)
304
+ persist_slots if replica[:slot]
305
+ @stats[:replicated_lsn] = [@stats[:replicated_lsn].to_i, acknowledged_lsn].max
306
+ end
307
+ end
308
+ client.write(JSON.generate(success: true, replica_id: replica_id, mode: "logical") + "\n")
309
+ client.flush
310
+
311
+ # Send the catalog before row replay so a new replica does not require
312
+ # an out-of-band schema copy. IF NOT EXISTS keeps reconnects safe.
313
+ schema = @engine.respond_to?(:schema_dump) ? @engine.schema_dump : ""
314
+ unless schema.empty?
315
+ schema = schema.gsub(/^CREATE TABLE /, "CREATE TABLE IF NOT EXISTS ")
316
+ client.write(JSON.generate(type: "replication_bootstrap", schema: schema) + "\n")
317
+ client.flush
318
+ end
319
+
320
+ from_lsn = handshake[:wal_position].to_i + 1
321
+ @replication_log.read_transactions(from_lsn).each do |entry|
322
+ send_replication_entry(client, entry)
323
+ @lock.synchronize { @replicas[replica_id][:last_lsn] = entry[:lsn] if @replicas[replica_id] }
324
+ end
325
+
326
+ while @running && !client.closed?
327
+ ready = IO.select([client], nil, nil, 0.1)
328
+ next unless ready
329
+ line = client.gets
330
+ break unless line
331
+ message = JSON.parse(line, symbolize_names: true)
332
+ if message[:type].to_s == "heartbeat"
333
+ send_heartbeat(replica_id)
334
+ next
335
+ end
336
+ next unless message[:type].to_s == "ack"
337
+
338
+ ack_lsn = message[:lsn].to_i
339
+ @lock.synchronize do
340
+ replica = @replicas[replica_id]
341
+ if replica
342
+ replica[:last_ack_lsn] = [replica[:last_ack_lsn].to_i, ack_lsn].max
343
+ replica[:slot]&.advance(ack_lsn)
344
+ persist_slots if replica[:slot]
345
+ @stats[:replicated_lsn] = [@stats[:replicated_lsn].to_i, ack_lsn].max
346
+ end
347
+ end
348
+ end
349
+ rescue
350
+ begin
351
+ client.close
352
+ rescue
353
+ nil
354
+ end
355
+ ensure
356
+ if replica_id
357
+ unregister_replica(replica_id)
358
+ end
359
+ end
360
+
361
+ def start_heartbeat
362
+ @heartbeat_thread = Thread.new do
363
+ while @running
364
+ sleep(@config[:heartbeat_interval])
365
+
366
+ @lock.synchronize do
367
+ @replicas.each do |id, replica|
368
+ # Check if replica is still alive
369
+ if Time.now - replica[:last_heartbeat] > @config[:replication_timeout]
370
+ unregister_replica(id)
371
+ end
372
+ end
373
+ end
374
+ end
375
+ end
376
+ end
377
+
378
+ def initialize_slots
379
+ return unless File.file?(@slot_path)
380
+
381
+ data = JSON.parse(File.read(@slot_path), symbolize_names: true)
382
+ data.fetch(:slots, {}).each do |name, slot_data|
383
+ slot = ReplicationSlot.new(name, self)
384
+ slot.confirmed_lsn = slot_data[:confirmed_lsn].to_i
385
+ @replication_slots[slot.name] = slot
386
+ end
387
+ rescue JSON::ParserError => error
388
+ raise RubyDB::ReplicationError, "Invalid replication slot catalog #{@slot_path}: #{error.message}"
389
+ end
390
+
391
+ def create_replication_slot(name)
392
+ existing = @replication_slots[name.to_s]
393
+ return existing if existing
394
+
395
+ slot = ReplicationSlot.new(name, self)
396
+ @replication_slots[name] = slot
397
+ persist_slots
398
+ slot
399
+ end
400
+
401
+ def drop_replication_slot(name)
402
+ removed = @replication_slots.delete(name.to_s)
403
+ persist_slots if removed
404
+ removed
405
+ end
406
+
407
+ def persist_slots
408
+ FileUtils.mkdir_p(File.dirname(@slot_path))
409
+ payload = {slots: @replication_slots.transform_values { |slot| {confirmed_lsn: slot.confirmed_lsn} }}
410
+ temporary = "#{@slot_path}.tmp-#{Process.pid}"
411
+ File.write(temporary, JSON.generate(payload))
412
+ File.rename(temporary, @slot_path)
413
+ ensure
414
+ File.delete(temporary) if defined?(temporary) && File.file?(temporary)
415
+ end
416
+
417
+ def notify_replicas(transaction_data, lsn)
418
+ @stats[:replicated_lsn] = lsn
419
+ entry = {lsn: lsn, timestamp: Time.now.iso8601, transaction_id: transaction_data[:id], data: transaction_data}
420
+ @replicas.each_value do |replica|
421
+ connection = replica[:connection]
422
+ next unless connection
423
+ next if replica[:last_lsn] && replica[:last_lsn] >= lsn
424
+ send_replication_entry(connection, entry)
425
+ replica[:last_lsn] = lsn
426
+ rescue
427
+ unregister_replica(replica[:id])
428
+ end
429
+ end
430
+
431
+ def send_replication_entry(connection, entry)
432
+ payload = JSON.generate(type: "replication_data", data: [entry]) + "\n"
433
+ connection.write(payload)
434
+ connection.flush
435
+ @stats[:total_bytes_replicated] += payload.bytesize
436
+ end
437
+
438
+ def wait_for_sync_replicas
439
+ deadline = Time.now + @config[:replication_timeout]
440
+ loop do
441
+ acknowledged = @replicas.values.count { |replica| replica[:last_ack_lsn] == @stats[:committed_lsn] }
442
+ return true if acknowledged >= @config[:sync_replicas]
443
+ raise ReplicationError, "Synchronous replica acknowledgment timed out" if Time.now >= deadline
444
+ sleep(0.01)
445
+ end
446
+ end
447
+
448
+ def secure_token_match?(expected, presented)
449
+ return false unless presented
450
+
451
+ expected_bytes = expected.to_s.b
452
+ presented_bytes = presented.to_s.b
453
+ return false unless expected_bytes.bytesize == presented_bytes.bytesize
454
+
455
+ mismatch = 0
456
+ expected_bytes.bytes.each_with_index { |byte, index| mismatch |= byte ^ presented_bytes.getbyte(index) }
457
+ mismatch.zero?
458
+ end
459
+ end
460
+ end
461
+ end