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,686 +1,730 @@
1
- # frozen_string_literal: true
2
-
3
-
4
- # Standard library
5
- require "json"
6
- require "set"
7
- require "securerandom"
8
- require "fileutils"
9
- require "monitor"
10
-
11
- # Storage components
12
- require_relative "database_lock"
13
- require_relative "storage_manager"
14
- require_relative "page"
15
- require_relative "page_header"
16
- require_relative "record"
17
- require_relative "row"
18
- require_relative "tuple"
19
- require_relative "serializer"
20
- require_relative "deserializer"
21
- require_relative "visibility_map"
22
- require_relative "../mvcc/version_store"
23
- require_relative "storage_layout"
24
- require_relative "../indexes/index"
25
- require_relative "../indexes/btree"
26
- require_relative "../indexes/btree_node"
27
- require_relative "../indexes/btree_cursor"
28
- require_relative "../indexes/hash_index"
29
- require_relative "../indexes/index_manager"
30
-
31
- # Catalog components
32
- require_relative "../catalog/catalog"
33
- require_relative "../catalog/table"
34
- require_relative "../catalog/column"
35
-
36
- # WAL and Recovery
37
- require_relative "../wal/wal"
38
- require_relative "../recovery/crash_recovery"
39
-
40
- # Types
41
- require_relative "../types/type"
42
- require_relative "../constraints/constraint"
43
- require_relative "../constraints/check"
44
-
45
- # Errors
46
- require_relative "../errors/storage_error"
47
- require_relative "../errors/database_error"
48
- require_relative "../errors/corruption_error"
49
- require_relative "../errors/recovery_error"
50
-
51
- require "json"
52
- require "set"
53
-
54
- module RubyDB
55
- module Storage
56
- # Engine - Main storage engine interface with full CRUD operations
57
- class Engine
58
- NULL_BITMAP_FLAG = 0x02
59
- VARIABLE_LENGTH_PREFIXES_FLAG = 0x04
60
-
61
- attr_reader :storage_manager, :buffer_pool, :page_manager, :catalog, :path, :last_commit_ack
62
- attr_reader :table_metadata, :transaction_manager, :wal, :crash_recovery, :version_store, :index_manager
63
-
64
- def initialize(path, config = {})
65
- @path = path
66
- @config = config
67
- @database_lock = DatabaseLock.new(path)
68
- @database_lock.acquire!
69
- @catalog = config[:catalog] || Catalog::Catalog.new
70
- @storage_manager = StorageManager.new(path, config)
71
- @storage_manager.open
72
-
73
- @buffer_pool = @storage_manager.buffer_pool
74
- @page_manager = @storage_manager.page_manager
75
- @page_allocator = @storage_manager.page_allocator
76
- visibility_path = config[:visibility_path] || "#{path}.visibility"
77
- visibility_config = config.merge(visibility_path: visibility_path)
78
- @visibility_map = VisibilityMap.new(@page_manager, visibility_config)
79
- @version_store = MVCC::VersionStore.new(persistence_path: config[:mvcc_path] || "#{path}.mvcc")
80
- @transaction_versions = Hash.new { |hash, key| hash[key] = [] }
81
- @transaction_snapshots = {}
82
- @transaction_reads = Hash.new { |hash, key| hash[key] = Set.new }
83
- @transaction_writes = Hash.new { |hash, key| hash[key] = Set.new }
84
- @transaction_predicates = Hash.new { |hash, key| hash[key] = Set.new }
85
- @table_metadata = {}
86
- @table_pages = {}
87
- @row_cache = {}
88
- @cache_size = config[:cache_size] || 1000
89
- @cache_ttl = config[:cache_ttl] || 300 # 5 minutes
90
- @is_open = true
91
- @transaction_manager = nil
92
- @transaction_context_key = "rubydb_engine_transaction_#{object_id}".to_sym
93
- @commit_listeners = []
94
- @last_commit_ack = nil
95
- @metadata_write_lock = Mutex.new
96
- @metadata_write_sequence = 0
97
- @current_transaction_id = 0
98
- @recovery_in_progress = false
99
- # A replica must never accept an accidental local mutation. Replay uses
100
- # the narrowly-scoped with_replication_apply escape hatch below.
101
- @replication_read_only = false
102
- # Optional cluster fencing lease installed by the active primary.
103
- # Checking it before every local mutation prevents a stale primary
104
- # from accepting writes after another node has taken the lease.
105
- @write_fence = nil
106
- @stats = {
107
- table_creates: 0,
108
- row_inserts: 0,
109
- row_selects: 0,
110
- row_updates: 0,
111
- row_deletes: 0,
112
- index_scans: 0,
113
- cache_hits: 0,
114
- cache_misses: 0,
115
- transaction_begin: 0,
116
- transaction_commit: 0,
117
- transaction_rollback: 0,
118
- wal_writes: 0,
119
- crash_recoveries: 0
120
- }
121
- @lock = Monitor.new
122
-
123
- # Initialize WAL
124
- wal_dir = config[:wal_dir] || "#{path}.wal"
125
- @wal = WAL::WAL.new(wal_dir, recovery: false) # Defer recovery until after metadata load
126
- @wal.attach_engine(self)
127
-
128
- # Initialize crash recovery
129
- @crash_recovery = Recovery::CrashRecovery.new(self, @wal, config)
130
-
131
- # Load table metadata from disk
132
- load_table_metadata
133
-
134
- # Recovery needs table metadata before replaying WAL records.
135
- run_crash_recovery
136
-
137
- # Build/load indexes only after metadata and crash recovery are ready.
138
- @index_manager = Indexes::IndexManager.new(self)
139
-
140
- # Start cleanup thread if configured
141
- start_cleanup_thread if config[:auto_cleanup] != false
142
- rescue Exception
143
- # Startup must never flush partially loaded catalog/page state.
144
- begin
145
- @wal&.shutdown
146
- ensure
147
- begin
148
- @storage_manager&.file_manager&.close
149
- ensure
150
- @database_lock&.release
151
- end
152
- end
153
- raise
154
- end
155
-
156
- # Page operations
157
- def read_page(page_number)
158
- @storage_manager.read_page(page_number)
159
- end
160
-
161
- def write_page(page)
162
- @storage_manager.write_page(page)
163
- end
164
-
165
- def allocate_page(page_type = 0)
166
- @storage_manager.allocate_page(page_type)
167
- end
168
-
169
- def free_page(page_number)
170
- @storage_manager.free_page(page_number)
171
- end
172
-
173
- # Record operations
174
- def read_record(page_number, offset, length)
175
- @storage_manager.read_record(page_number, offset, length)
176
- end
177
-
178
- def write_record(page_number, offset, data)
179
- @storage_manager.write_record(page_number, offset, data)
180
- end
181
-
182
- # Table operations
183
- def create_table(table_name, columns = nil, options = {}, &block)
184
- ensure_writable!
185
- if columns.is_a?(Hash) && options.empty?
186
- options = columns
187
- columns = nil
188
- end
189
- columns ||= []
190
- if block
191
- builder = Object.new
192
- builder.define_singleton_method(:column) do |name, type, **column_options|
193
- columns << Catalog::Column.new(name, type, **column_options)
194
- end
195
- block.call(builder)
196
- end
197
-
198
- # Treat symbol and string table names as the same logical identifier.
199
- # SQL always supplies strings, while the Ruby API commonly supplies
200
- # symbols; without resolving here, CREATE TABLE IF NOT EXISTS could
201
- # create a second physical table for an existing logical table.
202
- table_name = resolve_table_name(table_name)
203
-
204
- with_schema_publication do
205
- @lock.synchronize do
206
- @stats[:table_creates] += 1
207
-
208
- # Check if table already exists
209
- if @table_metadata.key?(table_name)
210
- raise DatabaseError, "Table '#{table_name}' already exists" unless options[:if_not_exists]
211
- return false
212
- end
213
-
214
- # Allocate pages for the table
215
- data_page = allocate_page(StorageLayout::PAGE_TYPE_TABLE)
216
- metadata_page = allocate_page(StorageLayout::PAGE_TYPE_TABLE)
217
-
218
- # Create table metadata
219
- metadata = StorageLayout::TableMetadata.new
220
- metadata.table_id = metadata_page
221
- metadata.table_name = table_name
222
- metadata.column_count = columns.size
223
- metadata.row_count = 0
224
- metadata.first_page = data_page
225
- metadata.last_page = data_page
226
- metadata.created_at = Time.now.to_i
227
- metadata.updated_at = Time.now.to_i
228
-
229
- # Write metadata
230
- page = Page.new(metadata_page, @storage_manager.page_size)
231
- page.write(0, metadata.serialize)
232
- write_page(page)
233
-
234
- # Write column metadata
235
- columns.each_with_index do |col, idx|
236
- col_meta = StorageLayout::ColumnMetadata.new
237
- col_meta.column_id = idx + 1
238
- col_meta.column_name = col.name
239
- col_meta.data_type = col.type_class
240
- col_meta.is_nullable = col.nullable?
241
- col_meta.is_primary_key = col.primary_key?
242
- col_meta.position = idx
243
- col_meta.default = col.default if col.has_default?
244
- col_meta.created_at = Time.now.to_i
245
-
246
- page.write(PageHeader::SIZE + idx * 128, col_meta.serialize)
247
- end
248
- write_page(page)
249
-
250
- # Initialize data page
251
- data_page_obj = Page.new(data_page, @storage_manager.page_size)
252
- data_page_obj.header.page_type = StorageLayout::PAGE_TYPE_TABLE
253
- data_page_obj.header.data_end = PageHeader::SIZE
254
- data_page_obj.write_header
255
- write_page(data_page_obj)
256
-
257
- # Store metadata
258
- @table_metadata[table_name] = {
259
- metadata_page: metadata_page,
260
- data_page: data_page,
261
- columns: columns,
262
- column_count: columns.size,
263
- row_count: 0,
264
- constraints: serialize_constraint_definitions(options[:constraints] || []),
265
- created_at: Time.now,
266
- updated_at: Time.now
267
- }
268
-
269
- @table_pages[table_name] = [data_page]
270
-
271
- # Add to catalog
272
- @catalog.create_table(table_name) do |t|
273
- columns.each do |col|
274
- t.column(col.name, col.type_class, **col.options)
275
- end
276
- end if @catalog && @catalog.current_database
277
-
278
- # Save metadata to disk
279
- save_table_metadata
280
-
281
- true
282
- end
283
- end
284
- end
285
-
286
- def drop_table(table_name, options = {})
287
- ensure_writable!
288
- table_name = resolve_table_name(table_name)
289
- @lock.synchronize do
290
- metadata = @table_metadata[table_name]
291
- raise DatabaseError, "Table '#{table_name}' does not exist" unless metadata
292
-
293
- # Free all pages for this table
294
- pages = @table_pages[table_name] || []
295
- pages.each do |page_number|
296
- free_page(page_number)
297
- end
298
-
299
- # Free metadata page
300
- free_page(metadata[:metadata_page])
301
-
302
- # Remove from metadata
303
- @table_metadata.delete(table_name)
304
- @table_pages.delete(table_name)
305
-
306
- # Remove from catalog
307
- @catalog.drop_table(table_name) if @catalog && @catalog.current_database
308
-
309
- # Save metadata to disk
310
- save_table_metadata
311
-
312
- true
313
- end
314
- end
315
-
316
- def add_column(table_name, column_name, type, options = {})
317
- ensure_writable!
318
- table_name = resolve_table_name(table_name)
319
- with_schema_publication do
320
- @lock.synchronize do
321
- table_key = @table_metadata.key?(table_name) ? table_name : table_name.to_sym
322
- metadata = @table_metadata[table_key]
323
- raise DatabaseError, "Table '#{table_name}' does not exist" unless metadata
324
- raise DatabaseError, "Column '#{column_name}' already exists" if metadata[:columns].any? { |column| column.name.to_s == column_name.to_s }
325
-
326
- column = Catalog::Column.new(column_name, type, **options)
327
- rows = select_rows(table_key, metadata[:columns])
328
- if !column.nullable? && !column.has_default? && !rows.empty?
329
- raise DatabaseError, "Column '#{column_name}' requires a default for existing rows"
330
- end
331
- metadata[:columns] << column
332
- metadata[:column_count] = metadata[:columns].size
333
- metadata[:updated_at] = Time.now
334
- rewrite_table_metadata_page(table_key, metadata)
335
- @catalog.find_table(table_key)&.add_column(column) if @catalog&.current_database
336
- save_table_metadata
337
- true
338
- end
339
- end
340
- end
341
-
342
- def drop_column(table_name, column_name)
343
- ensure_writable!
344
- table_name = resolve_table_name(table_name)
345
- with_schema_publication do
346
- @lock.synchronize do
347
- table_key = @table_metadata.key?(table_name) ? table_name : table_name.to_sym
348
- metadata = @table_metadata[table_key]
349
- raise DatabaseError, "Table '#{table_name}' does not exist" unless metadata
350
- column = metadata[:columns].find { |candidate| candidate.name.to_s == column_name.to_s }
351
- raise DatabaseError, "Column '#{column_name}' does not exist" unless column
352
- raise DatabaseError, "Cannot drop the only column" if metadata[:columns].size == 1
353
-
354
- metadata[:columns].delete(column)
355
- metadata[:column_count] = metadata[:columns].size
356
- metadata[:updated_at] = Time.now
357
- rewrite_table_metadata_page(table_key, metadata)
358
- @catalog.find_table(table_key)&.drop_column(column_name) if @catalog&.current_database
359
- save_table_metadata
360
- true
361
- end
362
- end
363
- end
364
-
365
- def add_constraint(table_name, constraint)
366
- ensure_writable!
367
- table_name = resolve_table_name(table_name)
368
- with_schema_publication do
369
- @lock.synchronize do
370
- table_key = @table_metadata.key?(table_name) ? table_name : table_name.to_sym
371
- metadata = @table_metadata[table_key]
372
- raise DatabaseError, "Table '#{table_name}' does not exist" unless metadata
373
-
374
- definition = constraint_definition(constraint)
375
- name = definition[:name].to_s
376
- raise DatabaseError, "Constraint name cannot be empty" if name.empty?
377
- if (metadata[:constraints] || []).any? { |existing| existing[:name].to_s == name }
378
- raise DatabaseError, "Constraint '#{name}' already exists"
379
- end
380
-
381
- columns = definition[:columns].to_a.map(&:to_sym)
382
- known_columns = metadata[:columns].map { |column| column.name.to_sym }
383
- unknown = columns - known_columns
384
- raise DatabaseError, "Column '#{unknown.first}' does not exist" unless unknown.empty?
385
- if definition[:type].to_s.casecmp("foreign_key").zero? && !@table_metadata.key?(definition[:reference_table]) && !@table_metadata.key?(definition[:reference_table].to_sym)
386
- raise DatabaseError, "Referenced table '#{definition[:reference_table]}' does not exist"
387
- end
388
-
389
- rows = select_rows(table_key, metadata[:columns], visibility_check: false)
390
- if definition[:type].to_s.casecmp("unique").zero?
391
- seen = {}
392
- rows.each do |row|
393
- key = columns.map { |column| row[column] || row[column.to_s] }
394
- next if key.all?(&:nil?)
395
- raise DatabaseError, "Duplicate value for #{columns.join(', ')} on '#{table_name}'" if seen[key]
396
- seen[key] = true
397
- end
398
- end
399
-
400
- metadata[:constraints] ||= []
401
- metadata[:constraints] << definition
402
- metadata[:updated_at] = Time.now
403
- save_table_metadata
404
- true
405
- end
406
- end
407
- end
408
-
409
- def drop_constraint(table_name, constraint_name)
410
- ensure_writable!
411
- table_name = resolve_table_name(table_name)
412
- with_schema_publication do
413
- @lock.synchronize do
414
- table_key = @table_metadata.key?(table_name) ? table_name : table_name.to_sym
415
- metadata = @table_metadata[table_key]
416
- raise DatabaseError, "Table '#{table_name}' does not exist" unless metadata
417
- constraints = metadata[:constraints] || []
418
- index = constraints.index { |constraint| constraint[:name].to_s == constraint_name.to_s }
419
- raise DatabaseError, "Constraint '#{constraint_name}' does not exist" unless index
420
- constraints.delete_at(index)
421
- metadata[:updated_at] = Time.now
422
- save_table_metadata
423
- true
424
- end
425
- end
426
- end
427
-
428
- def table_exists?(table_name)
429
- @table_metadata.key?(resolve_table_name(table_name))
430
- end
431
-
432
- def list_tables
433
- @table_metadata.keys
434
- end
435
-
436
- # Return a replayable SQL description of the persisted catalog. Backup
437
- # providers use this as the human-readable schema component alongside
438
- # the physical data files.
439
- def schema_dump
440
- list_tables.map do |table_name|
441
- columns = table_columns(table_name)
442
- definitions = columns.map do |column|
443
- definition = [quote_identifier(column.name), sql_type_name(column.type)]
444
- definition << "PRIMARY KEY" if column.primary_key?
445
- definition << "UNIQUE" if column.unique? && !column.primary_key?
446
- definition << "NOT NULL" unless column.nullable?
447
- definition << "DEFAULT #{sql_literal(column.default)}" if column.has_default?
448
- definition.join(" ")
449
- end
450
- "CREATE TABLE #{quote_identifier(table_name)} (#{definitions.join(', ')});"
451
- end.join("\n") + (list_tables.empty? ? "" : "\n")
452
- end
453
-
454
- def current_database_name
455
- @catalog.current_database_name || @config[:database] || File.basename(@path, File.extname(@path))
456
- end
457
-
458
- def quote_identifier(identifier)
459
- "\"#{identifier.to_s.gsub('"', '""')}\""
460
- end
461
-
462
- def sql_type_name(type)
463
- if type.is_a?(Hash)
464
- type[:type] || type["type"] || type.to_s
465
- else
466
- type.to_s
467
- end.to_s.upcase
468
- end
469
-
470
- def sql_literal(value)
471
- case value
472
- when nil then "NULL"
473
- when Numeric then value.to_s
474
- when TrueClass then "TRUE"
475
- when FalseClass then "FALSE"
476
- else "'#{value.to_s.gsub("'", "''")}'"
477
- end
478
- end
479
-
480
- # Apply a logical replication envelope. Only explicit row mutations are
481
- # accepted; unknown envelopes fail closed instead of being ignored.
482
- def apply_transaction(transaction_data)
483
- with_replication_apply do
484
- data = transaction_data.transform_keys { |key| key.to_sym rescue key }
485
- if data[:operations]
486
- Array(data[:operations]).each { |operation| apply_transaction(operation) }
487
- return true
488
- end
489
- operation = (data[:operation] || data[:type]).to_s.downcase
490
- table = data[:table_name] || data[:table]
491
- table = table.to_sym if table && !@table_metadata.key?(table) && @table_metadata.key?(table.to_sym)
492
-
493
- case operation
494
- when "insert"
495
- columns = table_columns(table)
496
- values = data[:values] || data[:row]
497
- insert_row(table, columns, values)
498
- when "update"
499
- update_row(table, data[:row_id], data[:values] || data[:row], data[:conditions] || {})
500
- when "delete"
501
- delete_row(table, data[:row_id], data[:conditions] || {})
502
- else
503
- raise ReplicationError, "Unsupported logical replication operation: #{operation}"
504
- end
505
- end
506
- end
507
-
508
- def replication_read_only?
509
- @lock.synchronize { @replication_read_only }
510
- end
511
-
512
- def set_replication_read_only(value)
513
- @lock.synchronize { @replication_read_only = !!value }
514
- end
515
-
516
- def set_write_fence(fence)
517
- @lock.synchronize { @write_fence = fence }
518
- end
519
-
520
- def write_fence
521
- @lock.synchronize { @write_fence }
522
- end
523
-
524
- def with_replication_apply
525
- key = :rubydb_replication_apply_depth
526
- Thread.current[key] = Thread.current[key].to_i + 1
527
- yield
528
- ensure
529
- Thread.current[key] = Thread.current[key].to_i - 1
530
- end
531
-
532
- # Files belonging to this database, including persisted metadata used by
533
- # MVCC and catalog reconstruction. Backup providers use these explicit
534
- # paths instead of guessing from the primary data file.
535
- def data_files
536
- [@path, "#{@path}.metadata", "#{@path}.visibility", "#{@path}.mvcc"].select { |file| File.file?(file) }
537
- end
538
-
539
- # Export the logical database state used by physical branch checkout.
540
- # The export is detached from live metadata and contains no internal
541
- # page identifiers, so it can be safely persisted in a branch catalog.
542
- def export_state
543
- list_tables.each_with_object({}) do |table_name, state|
544
- columns = table_columns(table_name)
545
- state[table_name] = {
546
- columns: columns.map(&:serialize),
547
- rows: select_rows(table_name, columns, visibility_check: false).map do |row|
548
- row.reject { |key, _| key.to_sym == :_row_id }
549
- end
550
- }
551
- end
552
- end
553
-
554
- # Replace the logical database state with a branch base snapshot and
555
- # replay its committed logical changes. Unknown change operations fail
556
- # before mutating the database.
557
- def apply_branch_state(state)
558
- ensure_writable!
559
- state = state.transform_keys(&:to_sym) if state.respond_to?(:transform_keys)
560
- base = state[:base]
561
- changes = Array(state[:changes])
562
- raise DatabaseError, "Branch has no persisted base snapshot" unless base.is_a?(Hash)
563
-
564
- unsupported = changes.reject do |change|
565
- operation = (change[:operation] || change["operation"] || change[:type] || change["type"]).to_s
566
- %w[insert update delete].include?(operation.downcase)
567
- end
568
- raise DatabaseError, "Unsupported branch change operation" unless unsupported.empty?
569
-
570
- restore_exported_state(base)
571
- changes.each { |change| apply_branch_change(change) }
572
- true
573
- end
574
-
575
- private
576
-
577
- # The metadata file is the durable commit record for schema changes.
578
- # Keep failed publications invisible to the current process as well as
579
- # to future opens. Newly allocated pages are returned to the free list;
580
- # the original exception is preserved if cleanup itself encounters an
581
- # I/O error.
582
- def with_schema_publication
583
- table_metadata_snapshot = Marshal.dump(@table_metadata)
584
- table_pages_snapshot = Marshal.dump(@table_pages)
585
- catalog_snapshot = Marshal.dump(@catalog)
586
-
587
- yield
588
- rescue Exception
589
- current_metadata = @table_metadata
590
- current_pages = @table_pages.values.flatten + current_metadata.values.map { |metadata| metadata[:metadata_page] }
591
- previous_metadata = Marshal.load(table_metadata_snapshot)
592
- previous_pages = Marshal.load(table_pages_snapshot).values.flatten + previous_metadata.values.map { |metadata| metadata[:metadata_page] }
593
- (current_pages - previous_pages).uniq.each { |page| free_page(page) rescue nil }
594
- @table_metadata = previous_metadata
595
- @table_pages = Marshal.load(table_pages_snapshot)
596
- @catalog = Marshal.load(catalog_snapshot)
597
- raise
598
- end
599
-
600
- # Metadata loaded from JSON may contain symbol keys while callers use
601
- # strings (or vice versa). Keep the original key for catalog/index
602
- # compatibility, but accept either public representation.
603
- def resolve_table_name(table_name)
604
- return table_name if @table_metadata.key?(table_name)
605
-
606
- alternate = table_name.is_a?(Symbol) ? table_name.to_s : table_name.to_s.to_sym
607
- @table_metadata.key?(alternate) ? alternate : table_name
608
- end
609
-
610
- def restore_exported_state(state)
611
- current_tables = list_tables.dup
612
- current_tables.each { |table| drop_table(table) }
613
- state.each do |table_name, table_state|
614
- data = table_state.transform_keys(&:to_sym)
615
- columns = Array(data[:columns]).map do |column|
616
- Catalog::Column.deserialize(column.transform_keys(&:to_sym))
617
- end
618
- create_table(table_name.to_s, columns)
619
- Array(data[:rows]).each do |row|
620
- # Exported state may come from either the Ruby API (symbol keys)
621
- # or JSON-backed catalogs (string keys). Column definitions retain
622
- # their original name type, so normalize row keys to symbols just
623
- # as insert_row's validation path does.
624
- values = row.each_with_object({}) do |(key, value), result|
625
- normalized_key = key.respond_to?(:to_sym) ? key.to_sym : key
626
- result[normalized_key] = value
627
- result[key] = value if key != normalized_key
628
- end
629
- insert_row(table_name.to_s, columns, values)
630
- end
631
- end
632
- end
633
-
634
- def apply_branch_change(change)
635
- ensure_writable!
636
- data = change.transform_keys(&:to_sym)
637
- table = data[:table_name] || data[:table]
638
- case (data[:operation] || data[:type]).to_s.downcase
639
- when "insert"
640
- values = data[:values] || data[:row] || {}
641
- columns = table_columns(table)
642
- normalized = columns.to_h { |column| [column.name, values[column.name] || values[column.name.to_sym]] }
643
- insert_row(table, columns, normalized)
644
- when "update"
645
- update_row(table, data[:row_id], (data[:values] || {}).transform_keys(&:to_sym), visibility_check: false)
646
- when "delete"
647
- delete_row(table, data[:row_id], visibility_check: false)
648
- end
649
- end
650
-
651
- public
652
-
653
- def wal_dir
654
- @config[:wal_dir] || "#{@path}.wal"
655
- end
656
-
657
- def wal_files
658
- Dir.glob(File.join(wal_dir, "wal_*.log"))
659
- end
660
-
661
- def data_dir
662
- File.dirname(@path)
663
- end
664
-
665
- def table_columns(table_name)
666
- metadata = @table_metadata[resolve_table_name(table_name)]
667
- return [] unless metadata
668
- metadata[:columns]
669
- end
670
-
671
- def table_row_count(table_name)
672
- metadata = @table_metadata[resolve_table_name(table_name)]
673
- return 0 unless metadata
674
- metadata[:row_count] || 0
675
- end
676
-
677
- # Row operations
1
+ # frozen_string_literal: true
2
+
3
+ # Standard library
4
+ require "json"
5
+ require "securerandom"
6
+ require "fileutils"
7
+ require "monitor"
8
+ require_relative "../accelerator"
9
+
10
+ # Storage components
11
+ require_relative "database_lock"
12
+ require_relative "storage_manager"
13
+ require_relative "page"
14
+ require_relative "page_header"
15
+ require_relative "record"
16
+ require_relative "row"
17
+ require_relative "tuple"
18
+ require_relative "serializer"
19
+ require_relative "deserializer"
20
+ require_relative "visibility_map"
21
+ require_relative "snapshot_reader"
22
+ require_relative "../mvcc/version_store"
23
+ require_relative "storage_layout"
24
+ require_relative "../indexes/index"
25
+ require_relative "../indexes/btree"
26
+ require_relative "../indexes/btree_node"
27
+ require_relative "../indexes/btree_cursor"
28
+ require_relative "../indexes/hash_index"
29
+ require_relative "../indexes/index_manager"
30
+
31
+ # Catalog components
32
+ require_relative "../catalog/catalog"
33
+ require_relative "../catalog/table"
34
+ require_relative "../catalog/column"
35
+
36
+ # WAL and Recovery
37
+ require_relative "../wal/wal"
38
+ require_relative "../recovery/crash_recovery"
39
+
40
+ # Types
41
+ require_relative "../types/type"
42
+ require_relative "../constraints/constraint"
43
+ require_relative "../constraints/check"
44
+
45
+ # Errors
46
+ require_relative "../errors/storage_error"
47
+ require_relative "../errors/database_error"
48
+ require_relative "../errors/corruption_error"
49
+ require_relative "../errors/recovery_error"
50
+
51
+ module RubyDB
52
+ module Storage
53
+ # Engine - Main storage engine interface with full CRUD operations
54
+ class Engine
55
+ NULL_BITMAP_FLAG = 0x02
56
+ VARIABLE_LENGTH_PREFIXES_FLAG = 0x04
57
+
58
+ attr_reader :storage_manager, :buffer_pool, :page_manager, :catalog, :path, :last_commit_ack, :config, :accelerator
59
+ attr_reader :table_metadata, :transaction_manager, :wal, :crash_recovery, :version_store, :index_manager, :snapshot_reader
60
+
61
+ def initialize(path, config = {})
62
+ @path = path
63
+ @config = config
64
+ @accelerator = RubyDB::Accelerator::Client.new(config[:accelerator] || {})
65
+ @database_lock = DatabaseLock.new(path)
66
+ @database_lock.acquire!
67
+ @catalog = config[:catalog] || Catalog::Catalog.new
68
+ @storage_manager = StorageManager.new(path, config)
69
+ @storage_manager.open
70
+
71
+ @buffer_pool = @storage_manager.buffer_pool
72
+ @page_manager = @storage_manager.page_manager
73
+ @page_allocator = @storage_manager.page_allocator
74
+ visibility_path = config[:visibility_path] || "#{path}.visibility"
75
+ visibility_config = config.merge(visibility_path: visibility_path)
76
+ @visibility_map = VisibilityMap.new(@page_manager, visibility_config)
77
+ @version_store = MVCC::VersionStore.new(persistence_path: config[:mvcc_path] || "#{path}.mvcc")
78
+ @transaction_versions = Hash.new { |hash, key| hash[key] = [] }
79
+ @transaction_snapshots = {}
80
+ @transaction_reads = Hash.new { |hash, key| hash[key] = Set.new }
81
+ @transaction_writes = Hash.new { |hash, key| hash[key] = Set.new }
82
+ @transaction_predicates = Hash.new { |hash, key| hash[key] = Set.new }
83
+ @table_metadata = {}
84
+ @table_pages = {}
85
+ @row_cache = {}
86
+ @cache_size = config[:cache_size] || 1000
87
+ @cache_ttl = config[:cache_ttl] || 300 # 5 minutes
88
+ @is_open = true
89
+ @transaction_manager = nil
90
+ @transaction_context_key = :"rubydb_engine_transaction_#{object_id}"
91
+ @commit_listeners = []
92
+ @last_commit_ack = nil
93
+ @metadata_write_lock = Mutex.new
94
+ @metadata_write_sequence = 0
95
+ @current_transaction_id = 0
96
+ @recovery_in_progress = false
97
+ # A replica must never accept an accidental local mutation. Replay uses
98
+ # the narrowly-scoped with_replication_apply escape hatch below.
99
+ @replication_read_only = false
100
+ # Optional cluster fencing lease installed by the active primary.
101
+ # Checking it before every local mutation prevents a stale primary
102
+ # from accepting writes after another node has taken the lease.
103
+ @write_fence = nil
104
+ @stats = {
105
+ table_creates: 0,
106
+ row_inserts: 0,
107
+ row_selects: 0,
108
+ row_updates: 0,
109
+ row_deletes: 0,
110
+ index_scans: 0,
111
+ cache_hits: 0,
112
+ cache_misses: 0,
113
+ transaction_begin: 0,
114
+ transaction_commit: 0,
115
+ transaction_rollback: 0,
116
+ wal_writes: 0,
117
+ crash_recoveries: 0
118
+ }
119
+ @lock = Monitor.new
120
+
121
+ # Initialize WAL
122
+ wal_dir = config[:wal_dir] || "#{path}.wal"
123
+ @wal = WAL::WAL.new(wal_dir, config.merge(recovery: false)) # Defer recovery until after metadata load
124
+ @wal.attach_engine(self)
125
+
126
+ # Initialize crash recovery
127
+ @crash_recovery = Recovery::CrashRecovery.new(self, @wal, config)
128
+
129
+ # Load table metadata from disk
130
+ load_table_metadata
131
+
132
+ # Recovery needs table metadata before replaying WAL records.
133
+ run_crash_recovery
134
+
135
+ # Build/load indexes only after metadata and crash recovery are ready.
136
+ @index_manager = Indexes::IndexManager.new(self)
137
+ @snapshot_reader = SnapshotReader.new(self)
138
+
139
+ # Start cleanup thread if configured
140
+ start_cleanup_thread if config[:auto_cleanup] != false
141
+ # Startup cleanup must also run for interrupts and fatal load failures.
142
+ # rubocop:disable Lint/RescueException
143
+ rescue Exception
144
+ # Startup must never flush partially loaded catalog/page state.
145
+ begin
146
+ @wal&.shutdown
147
+ ensure
148
+ begin
149
+ @storage_manager&.file_manager&.close
150
+ ensure
151
+ @database_lock&.release
152
+ end
153
+ end
154
+ raise
155
+ # rubocop:enable Lint/RescueException
156
+ end
157
+
158
+ # Page operations
159
+ def read_page(page_number)
160
+ @storage_manager.read_page(page_number)
161
+ end
162
+
163
+ def write_page(page)
164
+ @storage_manager.write_page(page)
165
+ end
166
+
167
+ def allocate_page(page_type = 0)
168
+ @storage_manager.allocate_page(page_type)
169
+ end
170
+
171
+ def free_page(page_number)
172
+ @storage_manager.free_page(page_number)
173
+ end
174
+
175
+ # Yield a stable, immutable database snapshot to the accelerator. A
176
+ # transaction or an active MVCC writer makes the snapshot ineligible;
177
+ # callers use the normal Ruby path in that case.
178
+ def with_accelerator_snapshot
179
+ @lock.synchronize do
180
+ return nil unless accelerator_snapshot_eligible?
181
+
182
+ @storage_manager.flush
183
+ @visibility_map.flush
184
+ save_table_metadata
185
+ @snapshot_reader.with_snapshot { |snapshot| yield snapshot }
186
+ end
187
+ end
188
+
189
+ def accelerator_snapshot_hidden_row_ids
190
+ @visibility_map.visibility_info.each_with_object([]) do |(row_id, info), hidden|
191
+ state = (info[:state] || info["state"]).to_s.to_sym
192
+ hidden << row_id.to_i unless %i[visible committed].include?(state)
193
+ end
194
+ end
195
+
196
+ def table_pages_for_snapshot(table_name)
197
+ @table_pages[resolve_table_name(table_name)] || []
198
+ end
199
+
200
+ # Record operations
201
+ def read_record(page_number, offset, length)
202
+ @storage_manager.read_record(page_number, offset, length)
203
+ end
204
+
205
+ def write_record(page_number, offset, data)
206
+ @storage_manager.write_record(page_number, offset, data)
207
+ end
208
+
209
+ # Table operations
210
+ def create_table(table_name, columns = nil, options = {}, &block)
211
+ ensure_writable!
212
+ if columns.is_a?(Hash) && options.empty?
213
+ options = columns
214
+ columns = nil
215
+ end
216
+ columns ||= []
217
+ if block
218
+ builder = Object.new
219
+ builder.define_singleton_method(:column) do |name, type, **column_options|
220
+ columns << Catalog::Column.new(name, type, **column_options)
221
+ end
222
+ block.call(builder)
223
+ end
224
+
225
+ # Treat symbol and string table names as the same logical identifier.
226
+ # SQL always supplies strings, while the Ruby API commonly supplies
227
+ # symbols; without resolving here, CREATE TABLE IF NOT EXISTS could
228
+ # create a second physical table for an existing logical table.
229
+ table_name = resolve_table_name(table_name)
230
+
231
+ with_schema_publication do
232
+ @lock.synchronize do
233
+ @stats[:table_creates] += 1
234
+
235
+ # Check if table already exists
236
+ if @table_metadata.key?(table_name)
237
+ raise DatabaseError, "Table '#{table_name}' already exists" unless options[:if_not_exists]
238
+ return false
239
+ end
240
+
241
+ # Allocate pages for the table
242
+ data_page = allocate_page(StorageLayout::PAGE_TYPE_TABLE)
243
+ metadata_page = allocate_page(StorageLayout::PAGE_TYPE_TABLE)
244
+
245
+ # Create table metadata
246
+ metadata = StorageLayout::TableMetadata.new
247
+ metadata.table_id = metadata_page
248
+ metadata.table_name = table_name
249
+ metadata.column_count = columns.size
250
+ metadata.row_count = 0
251
+ metadata.first_page = data_page
252
+ metadata.last_page = data_page
253
+ metadata.created_at = Time.now.to_i
254
+ metadata.updated_at = Time.now.to_i
255
+
256
+ # Write metadata
257
+ page = Page.new(metadata_page, @storage_manager.page_size)
258
+ page.write(0, metadata.serialize)
259
+ write_page(page)
260
+
261
+ # Write column metadata
262
+ columns.each_with_index do |col, idx|
263
+ col_meta = StorageLayout::ColumnMetadata.new
264
+ col_meta.column_id = idx + 1
265
+ col_meta.column_name = col.name
266
+ col_meta.data_type = col.type_class
267
+ col_meta.is_nullable = col.nullable?
268
+ col_meta.is_primary_key = col.primary_key?
269
+ col_meta.position = idx
270
+ col_meta.default = col.default if col.has_default?
271
+ col_meta.created_at = Time.now.to_i
272
+
273
+ page.write(PageHeader::SIZE + idx * 128, col_meta.serialize)
274
+ end
275
+ write_page(page)
276
+
277
+ # Initialize data page
278
+ data_page_obj = Page.new(data_page, @storage_manager.page_size)
279
+ data_page_obj.header.page_type = StorageLayout::PAGE_TYPE_TABLE
280
+ data_page_obj.header.data_end = PageHeader::SIZE
281
+ data_page_obj.write_header
282
+ write_page(data_page_obj)
283
+
284
+ # Store metadata
285
+ @table_metadata[table_name] = {
286
+ metadata_page: metadata_page,
287
+ data_page: data_page,
288
+ columns: columns,
289
+ column_count: columns.size,
290
+ row_count: 0,
291
+ constraints: serialize_constraint_definitions(options[:constraints] || []),
292
+ created_at: Time.now,
293
+ updated_at: Time.now
294
+ }
295
+
296
+ @table_pages[table_name] = [data_page]
297
+
298
+ # Add to catalog
299
+ if @catalog&.current_database
300
+ @catalog.create_table(table_name) do |t|
301
+ columns.each do |col|
302
+ t.column(col.name, col.type_class, **col.options)
303
+ end
304
+ end
305
+ end
306
+
307
+ # Save metadata to disk
308
+ save_table_metadata
309
+
310
+ true
311
+ end
312
+ end
313
+ end
314
+
315
+ def drop_table(table_name, options = {})
316
+ ensure_writable!
317
+ table_name = resolve_table_name(table_name)
318
+ @lock.synchronize do
319
+ metadata = @table_metadata[table_name]
320
+ raise DatabaseError, "Table '#{table_name}' does not exist" unless metadata
321
+
322
+ # Free all pages for this table
323
+ pages = @table_pages[table_name] || []
324
+ pages.each do |page_number|
325
+ free_page(page_number)
326
+ end
327
+
328
+ # Free metadata page
329
+ free_page(metadata[:metadata_page])
330
+
331
+ # Remove from metadata
332
+ @table_metadata.delete(table_name)
333
+ @table_pages.delete(table_name)
334
+
335
+ # Remove from catalog
336
+ @catalog.drop_table(table_name) if @catalog&.current_database
337
+
338
+ # Save metadata to disk
339
+ save_table_metadata
340
+
341
+ true
342
+ end
343
+ end
344
+
345
+ def add_column(table_name, column_name, type, options = {})
346
+ ensure_writable!
347
+ table_name = resolve_table_name(table_name)
348
+ with_schema_publication do
349
+ @lock.synchronize do
350
+ table_key = @table_metadata.key?(table_name) ? table_name : table_name.to_sym
351
+ metadata = @table_metadata[table_key]
352
+ raise DatabaseError, "Table '#{table_name}' does not exist" unless metadata
353
+ raise DatabaseError, "Column '#{column_name}' already exists" if metadata[:columns].any? { |column| column.name.to_s == column_name.to_s }
354
+
355
+ column = Catalog::Column.new(column_name, type, **options)
356
+ rows = select_rows(table_key, metadata[:columns])
357
+ if !column.nullable? && !column.has_default? && !rows.empty?
358
+ raise DatabaseError, "Column '#{column_name}' requires a default for existing rows"
359
+ end
360
+ metadata[:columns] << column
361
+ metadata[:column_count] = metadata[:columns].size
362
+ metadata[:updated_at] = Time.now
363
+ rewrite_table_metadata_page(table_key, metadata)
364
+ @catalog.find_table(table_key)&.add_column(column) if @catalog&.current_database
365
+ save_table_metadata
366
+ true
367
+ end
368
+ end
369
+ end
370
+
371
+ def drop_column(table_name, column_name)
372
+ ensure_writable!
373
+ table_name = resolve_table_name(table_name)
374
+ with_schema_publication do
375
+ @lock.synchronize do
376
+ table_key = @table_metadata.key?(table_name) ? table_name : table_name.to_sym
377
+ metadata = @table_metadata[table_key]
378
+ raise DatabaseError, "Table '#{table_name}' does not exist" unless metadata
379
+ column = metadata[:columns].find { |candidate| candidate.name.to_s == column_name.to_s }
380
+ raise DatabaseError, "Column '#{column_name}' does not exist" unless column
381
+ raise DatabaseError, "Cannot drop the only column" if metadata[:columns].size == 1
382
+
383
+ metadata[:columns].delete(column)
384
+ metadata[:column_count] = metadata[:columns].size
385
+ metadata[:updated_at] = Time.now
386
+ rewrite_table_metadata_page(table_key, metadata)
387
+ @catalog.find_table(table_key)&.drop_column(column_name) if @catalog&.current_database
388
+ save_table_metadata
389
+ true
390
+ end
391
+ end
392
+ end
393
+
394
+ def add_constraint(table_name, constraint)
395
+ ensure_writable!
396
+ table_name = resolve_table_name(table_name)
397
+ with_schema_publication do
398
+ @lock.synchronize do
399
+ table_key = @table_metadata.key?(table_name) ? table_name : table_name.to_sym
400
+ metadata = @table_metadata[table_key]
401
+ raise DatabaseError, "Table '#{table_name}' does not exist" unless metadata
402
+
403
+ definition = constraint_definition(constraint)
404
+ name = definition[:name].to_s
405
+ raise DatabaseError, "Constraint name cannot be empty" if name.empty?
406
+ if (metadata[:constraints] || []).any? { |existing| existing[:name].to_s == name }
407
+ raise DatabaseError, "Constraint '#{name}' already exists"
408
+ end
409
+
410
+ columns = definition[:columns].to_a.map(&:to_sym)
411
+ known_columns = metadata[:columns].map { |column| column.name.to_sym }
412
+ unknown = columns - known_columns
413
+ raise DatabaseError, "Column '#{unknown.first}' does not exist" unless unknown.empty?
414
+ if definition[:type].to_s.casecmp("foreign_key").zero? && !@table_metadata.key?(definition[:reference_table]) && !@table_metadata.key?(definition[:reference_table].to_sym)
415
+ raise DatabaseError, "Referenced table '#{definition[:reference_table]}' does not exist"
416
+ end
417
+
418
+ rows = select_rows(table_key, metadata[:columns], visibility_check: false)
419
+ if definition[:type].to_s.casecmp("unique").zero?
420
+ seen = {}
421
+ rows.each do |row|
422
+ key = columns.map { |column| row[column] || row[column.to_s] }
423
+ next if key.all?(&:nil?)
424
+ raise DatabaseError, "Duplicate value for #{columns.join(", ")} on '#{table_name}'" if seen[key]
425
+ seen[key] = true
426
+ end
427
+ end
428
+
429
+ metadata[:constraints] ||= []
430
+ metadata[:constraints] << definition
431
+ metadata[:updated_at] = Time.now
432
+ save_table_metadata
433
+ true
434
+ end
435
+ end
436
+ end
437
+
438
+ def drop_constraint(table_name, constraint_name)
439
+ ensure_writable!
440
+ table_name = resolve_table_name(table_name)
441
+ with_schema_publication do
442
+ @lock.synchronize do
443
+ table_key = @table_metadata.key?(table_name) ? table_name : table_name.to_sym
444
+ metadata = @table_metadata[table_key]
445
+ raise DatabaseError, "Table '#{table_name}' does not exist" unless metadata
446
+ constraints = metadata[:constraints] || []
447
+ index = constraints.index { |constraint| constraint[:name].to_s == constraint_name.to_s }
448
+ raise DatabaseError, "Constraint '#{constraint_name}' does not exist" unless index
449
+ constraints.delete_at(index)
450
+ metadata[:updated_at] = Time.now
451
+ save_table_metadata
452
+ true
453
+ end
454
+ end
455
+ end
456
+
457
+ def table_exists?(table_name)
458
+ @table_metadata.key?(resolve_table_name(table_name))
459
+ end
460
+
461
+ def list_tables
462
+ @table_metadata.keys
463
+ end
464
+
465
+ # Return a replayable SQL description of the persisted catalog. Backup
466
+ # providers use this as the human-readable schema component alongside
467
+ # the physical data files.
468
+ def schema_dump
469
+ list_tables.map do |table_name|
470
+ columns = table_columns(table_name)
471
+ definitions = columns.map do |column|
472
+ definition = [quote_identifier(column.name), sql_type_name(column.type)]
473
+ definition << "PRIMARY KEY" if column.primary_key?
474
+ definition << "UNIQUE" if column.unique? && !column.primary_key?
475
+ definition << "NOT NULL" unless column.nullable?
476
+ definition << "DEFAULT #{sql_literal(column.default)}" if column.has_default?
477
+ definition.join(" ")
478
+ end
479
+ "CREATE TABLE #{quote_identifier(table_name)} (#{definitions.join(", ")});"
480
+ end.join("\n") + (list_tables.empty? ? "" : "\n")
481
+ end
482
+
483
+ def current_database_name
484
+ @catalog.current_database_name || @config[:database] || File.basename(@path, File.extname(@path))
485
+ end
486
+
487
+ def quote_identifier(identifier)
488
+ "\"#{identifier.to_s.gsub('"', '""')}\""
489
+ end
490
+
491
+ def sql_type_name(type)
492
+ if type.is_a?(Hash)
493
+ type[:type] || type["type"] || type.to_s
494
+ else
495
+ type.to_s
496
+ end.to_s.upcase
497
+ end
498
+
499
+ def sql_literal(value)
500
+ case value
501
+ when nil then "NULL"
502
+ when Numeric then value.to_s
503
+ when TrueClass then "TRUE"
504
+ when FalseClass then "FALSE"
505
+ else "'#{value.to_s.gsub("'", "''")}'"
506
+ end
507
+ end
508
+
509
+ # Apply a logical replication envelope. Only explicit row mutations are
510
+ # accepted; unknown envelopes fail closed instead of being ignored.
511
+ def apply_transaction(transaction_data)
512
+ with_replication_apply do
513
+ data = transaction_data.transform_keys { |key|
514
+ begin
515
+ key.to_sym
516
+ rescue
517
+ key
518
+ end
519
+ }
520
+ if data[:operations]
521
+ Array(data[:operations]).each { |operation| apply_transaction(operation) }
522
+ return true
523
+ end
524
+ operation = (data[:operation] || data[:type]).to_s.downcase
525
+ table = data[:table_name] || data[:table]
526
+ table = table.to_sym if table && !@table_metadata.key?(table) && @table_metadata.key?(table.to_sym)
527
+
528
+ case operation
529
+ when "insert"
530
+ columns = table_columns(table)
531
+ values = data[:values] || data[:row]
532
+ insert_row(table, columns, values)
533
+ when "update"
534
+ update_row(table, data[:row_id], data[:values] || data[:row], data[:conditions] || {})
535
+ when "delete"
536
+ delete_row(table, data[:row_id], data[:conditions] || {})
537
+ else
538
+ raise ReplicationError, "Unsupported logical replication operation: #{operation}"
539
+ end
540
+ end
541
+ end
542
+
543
+ def replication_read_only?
544
+ @lock.synchronize { @replication_read_only }
545
+ end
546
+
547
+ def set_replication_read_only(value)
548
+ @lock.synchronize { @replication_read_only = !!value }
549
+ end
550
+
551
+ def set_write_fence(fence)
552
+ @lock.synchronize { @write_fence = fence }
553
+ end
554
+
555
+ def write_fence
556
+ @lock.synchronize { @write_fence }
557
+ end
558
+
559
+ def with_replication_apply
560
+ key = :rubydb_replication_apply_depth
561
+ Thread.current[key] = Thread.current[key].to_i + 1
562
+ yield
563
+ ensure
564
+ Thread.current[key] = Thread.current[key].to_i - 1
565
+ end
566
+
567
+ # Files belonging to this database, including persisted metadata used by
568
+ # MVCC and catalog reconstruction. Backup providers use these explicit
569
+ # paths instead of guessing from the primary data file.
570
+ def data_files
571
+ [@path, "#{@path}.metadata", "#{@path}.visibility", "#{@path}.mvcc"].select { |file| File.file?(file) }
572
+ end
573
+
574
+ # Export the logical database state used by physical branch checkout.
575
+ # The export is detached from live metadata and contains no internal
576
+ # page identifiers, so it can be safely persisted in a branch catalog.
577
+ def export_state
578
+ list_tables.each_with_object({}) do |table_name, state|
579
+ columns = table_columns(table_name)
580
+ state[table_name] = {
581
+ columns: columns.map(&:serialize),
582
+ rows: select_rows(table_name, columns, visibility_check: false).map do |row|
583
+ row.reject { |key, _| key.to_sym == :_row_id }
584
+ end
585
+ }
586
+ end
587
+ end
588
+
589
+ # Replace the logical database state with a branch base snapshot and
590
+ # replay its committed logical changes. Unknown change operations fail
591
+ # before mutating the database.
592
+ def apply_branch_state(state)
593
+ ensure_writable!
594
+ state = state.transform_keys(&:to_sym) if state.respond_to?(:transform_keys)
595
+ base = state[:base]
596
+ changes = Array(state[:changes])
597
+ raise DatabaseError, "Branch has no persisted base snapshot" unless base.is_a?(Hash)
598
+
599
+ unsupported = changes.reject do |change|
600
+ operation = (change[:operation] || change["operation"] || change[:type] || change["type"]).to_s
601
+ %w[insert update delete].include?(operation.downcase)
602
+ end
603
+ raise DatabaseError, "Unsupported branch change operation" unless unsupported.empty?
604
+
605
+ restore_exported_state(base)
606
+ changes.each { |change| apply_branch_change(change) }
607
+ true
608
+ end
609
+
610
+ private
611
+
612
+ # The metadata file is the durable commit record for schema changes.
613
+ # Keep failed publications invisible to the current process as well as
614
+ # to future opens. Newly allocated pages are returned to the free list;
615
+ # the original exception is preserved if cleanup itself encounters an
616
+ # I/O error.
617
+ def with_schema_publication
618
+ table_metadata_snapshot = Marshal.dump(@table_metadata)
619
+ table_pages_snapshot = Marshal.dump(@table_pages)
620
+ catalog_snapshot = Marshal.dump(@catalog)
621
+
622
+ yield
623
+ # Schema publication must restore memory and pages for every failure,
624
+ # including an interrupted write, before the exception is re-raised.
625
+ # rubocop:disable Lint/RescueException
626
+ rescue Exception
627
+ current_metadata = @table_metadata
628
+ current_pages = @table_pages.values.flatten + current_metadata.values.map { |metadata| metadata[:metadata_page] }
629
+ previous_metadata = Marshal.load(table_metadata_snapshot)
630
+ previous_pages = Marshal.load(table_pages_snapshot).values.flatten + previous_metadata.values.map { |metadata| metadata[:metadata_page] }
631
+ (current_pages - previous_pages).uniq.each { |page|
632
+ begin
633
+ free_page(page)
634
+ rescue
635
+ nil
636
+ end
637
+ }
638
+ @table_metadata = previous_metadata
639
+ @table_pages = Marshal.load(table_pages_snapshot)
640
+ @catalog = Marshal.load(catalog_snapshot)
641
+ raise
642
+ end
643
+
644
+ # Metadata loaded from JSON may contain symbol keys while callers use
645
+ # strings (or vice versa). Keep the original key for catalog/index
646
+ # compatibility, but accept either public representation.
647
+ def resolve_table_name(table_name)
648
+ return table_name if @table_metadata.key?(table_name)
649
+
650
+ alternate = table_name.is_a?(Symbol) ? table_name.to_s : table_name.to_s.to_sym
651
+ @table_metadata.key?(alternate) ? alternate : table_name
652
+ end
653
+
654
+ def restore_exported_state(state)
655
+ current_tables = list_tables.dup
656
+ current_tables.each { |table| drop_table(table) }
657
+ state.each do |table_name, table_state|
658
+ data = table_state.transform_keys(&:to_sym)
659
+ columns = Array(data[:columns]).map do |column|
660
+ Catalog::Column.deserialize(column.transform_keys(&:to_sym))
661
+ end
662
+ create_table(table_name.to_s, columns)
663
+ Array(data[:rows]).each do |row|
664
+ # Exported state may come from either the Ruby API (symbol keys)
665
+ # or JSON-backed catalogs (string keys). Column definitions retain
666
+ # their original name type, so normalize row keys to symbols just
667
+ # as insert_row's validation path does.
668
+ values = row.each_with_object({}) do |(key, value), result|
669
+ normalized_key = key.respond_to?(:to_sym) ? key.to_sym : key
670
+ result[normalized_key] = value
671
+ result[key] = value if key != normalized_key
672
+ end
673
+ insert_row(table_name.to_s, columns, values)
674
+ end
675
+ end
676
+ end
677
+
678
+ def apply_branch_change(change)
679
+ ensure_writable!
680
+ data = change.transform_keys(&:to_sym)
681
+ table = data[:table_name] || data[:table]
682
+ case (data[:operation] || data[:type]).to_s.downcase
683
+ when "insert"
684
+ values = data[:values] || data[:row] || {}
685
+ columns = table_columns(table)
686
+ normalized = columns.to_h { |column| [column.name, values[column.name] || values[column.name.to_sym]] }
687
+ insert_row(table, columns, normalized)
688
+ when "update"
689
+ update_row(table, data[:row_id], (data[:values] || {}).transform_keys(&:to_sym), visibility_check: false)
690
+ when "delete"
691
+ delete_row(table, data[:row_id], visibility_check: false)
692
+ end
693
+ end
694
+
695
+ public
696
+
697
+ def wal_dir
698
+ @config[:wal_dir] || "#{@path}.wal"
699
+ end
700
+
701
+ def wal_files
702
+ Dir.glob(File.join(wal_dir, "wal_*.log"))
703
+ end
704
+
705
+ def data_dir
706
+ File.dirname(@path)
707
+ end
708
+
709
+ def table_columns(table_name)
710
+ metadata = @table_metadata[resolve_table_name(table_name)]
711
+ return [] unless metadata
712
+ metadata[:columns]
713
+ end
714
+
715
+ def table_row_count(table_name)
716
+ metadata = @table_metadata[resolve_table_name(table_name)]
717
+ return 0 unless metadata
718
+ metadata[:row_count] || 0
719
+ end
720
+
721
+ # Row operations
678
722
  def insert_row(table_name, columns, values)
679
723
  ensure_writable!
680
724
  table_name = resolve_table_name(table_name)
681
725
  @lock.synchronize do
682
- @stats[:row_inserts] += 1
683
-
726
+ @stats[:row_inserts] += 1
727
+
684
728
  metadata = @table_metadata[table_name]
685
729
  raise DatabaseError, "Table '#{table_name}' does not exist" unless metadata
686
730
 
@@ -701,1654 +745,1689 @@ module RubyDB
701
745
  end
702
746
 
703
747
  # Rails and other SQL clients omit an INTEGER primary key on insert
704
- # and expect the database to allocate it. Keep allocation in the
705
- # engine so embedded and server connections have identical behavior.
706
- if values.is_a?(Hash)
707
- primary_key = columns.find { |column| column.primary_key? }
708
- primary_key_name = primary_key&.name&.to_sym
709
- primary_key_present = primary_key && (values.key?(primary_key_name) || values.key?(primary_key.name.to_s))
710
- primary_key_value = if primary_key
711
- values.key?(primary_key_name) ? values[primary_key_name] : values[primary_key.name.to_s]
712
- end
713
- if primary_key && primary_key_name && %i[integer bigint smallint].include?(primary_key.type.to_sym) &&
714
- (!primary_key_present || primary_key_value.nil?)
715
- existing_rows = select_rows(table_name, metadata[:columns])
716
- current_max = existing_rows.filter_map do |row|
717
- value = row[primary_key_name] || row[primary_key.name.to_s]
718
- value.to_i if value
719
- end.max || 0
720
- values = values.dup
721
- values[primary_key_name] = current_max + 1
722
- end
723
- end
724
-
725
- validate_constraints!(table_name, metadata, columns, values)
726
- validate_relational_constraints!(table_name, metadata, columns, values)
727
- row_for_index = if values.is_a?(Hash)
728
- values.merge(_row_id: metadata[:row_count] + 1)
729
- else
730
- columns.each_with_index.to_h { |column, index| [column.name, values[index]] }.merge(_row_id: metadata[:row_count] + 1)
731
- end
732
- @index_manager&.validate_insert!(table_name, row_for_index)
733
-
734
- # Get the last data page or allocate new one
735
- data_page = get_or_allocate_data_page(table_name)
736
- page = read_page(data_page)
737
-
738
- # Create row
739
- row_id = metadata[:row_count] + 1
740
- row = Row.new(row_id, columns, values)
741
-
742
- # Serialize row data
743
- row_data = Serializer.serialize_row(row, columns, null_bitmap: true)
744
-
745
- # Log to WAL before writing to page
746
- log_to_wal(WAL::Record::TYPE_INSERT, {
747
- table_name: table_name,
748
- row_id: row_id,
749
- page: data_page,
750
- values: values
751
- }, current_transaction_id)
752
-
753
- # Calculate required space (row header + data)
754
- required_space = 16 + row_data.bytesize # 16 bytes for row header
755
-
756
- # Check if we have enough space on the page
757
- if page.free_space < required_space
758
- # Allocate new page
759
- new_page_num = allocate_page(StorageLayout::PAGE_TYPE_TABLE)
760
- new_page = Page.new(new_page_num, @storage_manager.page_size)
761
- new_page.header.page_type = StorageLayout::PAGE_TYPE_TABLE
762
- new_page.header.data_end = PageHeader::SIZE
763
- new_page.write_header
764
- write_page(new_page)
765
-
766
- @table_pages[table_name] << new_page_num
767
- metadata[:last_page] = new_page_num
768
-
769
- page = new_page
770
- data_page = new_page_num
771
- end
772
-
773
- # Write record
774
- offset = page.header.data_end
775
- record_id = row_id
776
- record_size = row_data.bytesize
777
- flags = NULL_BITMAP_FLAG | VARIABLE_LENGTH_PREFIXES_FLAG
778
- column_count = columns.size
779
-
780
- # Write record header: record_id(8) + record_size(4) + flags(2) + column_count(2)
781
- page.write(offset, [record_id, record_size, flags, column_count].pack("Q>L>S>S"))
782
- offset += 16
783
-
784
- # Write record data
785
- page.write(offset, row_data)
786
- offset += record_size
787
-
788
- # Update page header
789
- page.header.data_end = offset
790
- page.write_header
791
- write_page(page)
792
-
793
- # Update metadata
794
- metadata[:row_count] += 1
795
- metadata[:updated_at] = Time.now
796
- record_transaction_change(:insert, table_name, row_id, values: values, columns: columns)
797
- record_mvcc_version(table_name, row_id, values, current_transaction_id)
798
-
799
- @index_manager&.insert_row(table_name, values.merge(_row_id: row_id)) if values.is_a?(Hash)
800
- if values.is_a?(Array)
801
- @index_manager&.insert_row(table_name, columns.each_with_index.to_h { |column, index| [column.name, values[index]] }.merge(_row_id: row_id))
802
- end
803
-
804
- # Update catalog
805
- if @catalog && @catalog.current_database
806
- table = @catalog.find_table(table_name)
807
- table.row_count = metadata[:row_count] if table
808
- end
809
-
810
- # Save metadata
811
- save_table_metadata
812
-
813
- # Invalidate cache
814
- invalidate_cache(table_name, row_id)
815
- fire_triggers(:insert, table_name, values, row_id)
816
-
817
- row_id
818
- end
819
- end
820
-
821
- def select_rows(table_name, columns, conditions = {})
822
- table_name = resolve_table_name(table_name)
823
- @lock.synchronize do
824
- @stats[:row_selects] += 1
825
-
826
- metadata = @table_metadata[table_name]
827
- raise DatabaseError, "Table '#{table_name}' does not exist" unless metadata
828
-
829
- # Get all data pages for the table
830
- pages = @table_pages[table_name] || []
831
- return [] if pages.empty?
832
-
833
- rows = []
834
- seen_row_ids = {}
835
- indexed_row_ids = indexed_row_ids_for(table_name, conditions)
836
- @stats[:index_scans] = (@stats[:index_scans] || 0) + 1 if indexed_row_ids
837
- page_numbers = pages.dup
838
-
839
- page_numbers.each do |page_number|
840
- page = read_page(page_number)
841
-
842
- # Scan records on the page
843
- offset = PageHeader::SIZE
844
-
845
- while offset < page.header.data_end
846
- # Read record header
847
- record_header = page.read(offset, 16)
848
- break if record_header.nil? || record_header.bytesize < 16
849
-
850
- record_id, record_size, flags, col_count = record_header.unpack("Q>L>S>S")
851
- offset += 16
852
-
853
- # Deleted row versions remain on the page until vacuum/compact;
854
- # they must not be visible to ordinary scans.
855
- if (flags & 0x01) != 0
856
- offset += record_size
857
- next
858
- end
859
- if indexed_row_ids && !indexed_row_ids.include?(record_id)
860
- offset += record_size
861
- next
862
- end
863
-
864
- # Read record data
865
- record_data = page.read(offset, record_size)
866
- offset += record_size
867
-
868
- # Deserialize the physical row, then select the historical
869
- # version for REPEATABLE READ transactions when available.
870
- row = Deserializer.deserialize_row(
871
- record_data,
872
- columns,
873
- null_bitmap: (flags & NULL_BITMAP_FLAG) != 0,
874
- variable_length_prefixes: (flags & VARIABLE_LENGTH_PREFIXES_FLAG) != 0
875
- )
876
- # Older physical records may have fewer fields after an ADD
877
- # COLUMN. Materialize the declared column default at read time
878
- # without rewriting the original row or changing its identity.
879
- if col_count < columns.size
880
- columns.each_with_index do |column, index|
881
- row[column.name] = column.default if index >= col_count && column.has_default?
882
- end
883
- end
884
- snapshot = @transaction_snapshots[current_transaction_id]
885
- if snapshot
886
- @transaction_reads[current_transaction_id].add(version_key(table_name, record_id))
887
- version = @version_store.get_latest_version(
888
- record_id, current_transaction_id, snapshot,
889
- key: version_key(table_name, record_id)
890
- )
891
- next if version.nil? && @version_store.keys(version_key(table_name, record_id)).any?
892
- if version
893
- row = version.data.is_a?(Array) ?
894
- columns.each_with_index.to_h { |column, index| [column.name, version.data[index]] } : version.data.dup
895
- end
896
- end
897
- row[:_row_id] = record_id
898
- seen_row_ids[record_id] = true
899
-
900
- # Active transactions must not read uncommitted or deleted
901
- # versions. Callers can explicitly disable this only for
902
- # internal recovery/maintenance operations.
903
- transaction_id = conditions[:transaction_id] || current_transaction_id
904
- visibility_check = conditions.key?(:visibility_check) ? conditions[:visibility_check] : in_transaction?
905
- if visibility_check
906
- unless @visibility_map.is_visible?(record_id, transaction_id)
907
- next
908
- end
909
- end
910
-
911
- # Apply conditions
912
- if matches_conditions?(row, conditions)
913
- rows << row
914
- end
915
- end
916
- end
917
-
918
- # A physical DELETE removes the row from the normal scan, but an
919
- # older snapshot may still need the last committed version. Walk the
920
- # table's version keys to restore those historical rows.
921
- snapshot = @transaction_snapshots[current_transaction_id]
922
- if snapshot
923
- prefix = "#{table_name}\0"
924
- @version_store.keys(prefix).each do |key|
925
- row_id = key.to_s.split("\0", 2).last.to_i
926
- next if seen_row_ids[row_id]
927
-
928
- version = @version_store.get_latest_version(
929
- row_id, current_transaction_id, snapshot, key: key
930
- )
931
- next unless version
932
-
933
- row = version.data.is_a?(Array) ?
934
- columns.each_with_index.to_h { |column, index| [column.name, version.data[index]] } : version.data.dup
935
- row[:_row_id] = row_id
936
- rows << row if matches_conditions?(row, conditions)
937
- end
938
- end
939
-
940
- if current_transaction_manager && current_transaction_manager[:isolation_level].to_sym == :serializable
941
- @transaction_predicates[current_transaction_id].add("#{table_name}\0")
942
- end
943
-
944
- # Apply limit and offset
945
- if conditions[:limit]
946
- offset_val = conditions[:offset] || 0
947
- rows = rows[offset_val, conditions[:limit]]
948
- end
949
-
950
- rows
951
- end
952
- end
953
-
954
- def select_row(table_name, row_id, columns)
955
- table_name = resolve_table_name(table_name)
956
- @lock.synchronize do
957
- # Check cache first
958
- cache_key = "#{table_name}:#{row_id}"
959
- if @row_cache.key?(cache_key)
960
- cached_row, cached_time = @row_cache[cache_key]
961
- if Time.now - cached_time < @cache_ttl
962
- @stats[:cache_hits] += 1
963
- return cached_row
964
- end
965
- end
966
- @stats[:cache_misses] += 1
967
-
968
- rows = select_rows(table_name, columns, { row_id: row_id, limit: 1 })
969
- row = rows.first
970
-
971
- # Cache the result
972
- if row
973
- @row_cache[cache_key] = [row, Time.now]
974
- cleanup_cache if @row_cache.size > @cache_size
975
- end
976
-
977
- row
978
- end
979
- end
980
-
981
- def update_row(table_name, row_id, values, conditions = {})
982
- ensure_writable!
983
- table_name = resolve_table_name(table_name)
984
- @lock.synchronize do
985
- @stats[:row_updates] += 1
986
-
987
- metadata = @table_metadata[table_name]
988
- raise DatabaseError, "Table '#{table_name}' does not exist" unless metadata
989
-
990
- transaction_id = conditions[:transaction_id] || current_transaction_id
991
-
992
- updated = false
993
- old_values = nil
994
- updated_values = nil
995
- pages = @table_pages[table_name] || []
996
-
997
- pages.each do |page_number|
998
- page = read_page(page_number)
999
- offset = PageHeader::SIZE
1000
-
1001
- while offset < page.header.data_end
1002
- record_header = page.read(offset, 16)
1003
- break if record_header.nil? || record_header.bytesize < 16
1004
-
1005
- record_id, record_size, flags, col_count = record_header.unpack("Q>L>S>S")
1006
-
1007
- if record_id == row_id
1008
- # Check visibility
1009
- if conditions[:visibility_check] != false
1010
- unless @visibility_map.is_visible?(row_id, transaction_id)
1011
- break
1012
- end
1013
- end
1014
-
1015
- # Read current row data
1016
- columns = metadata[:columns]
1017
- current_row = Deserializer.deserialize_row(
1018
- page.read(offset + 16, record_size),
1019
- columns,
1020
- null_bitmap: (flags & NULL_BITMAP_FLAG) != 0,
1021
- variable_length_prefixes: (flags & VARIABLE_LENGTH_PREFIXES_FLAG) != 0
1022
- )
1023
- old_values = current_row.reject { |key, _| key == :_row_id }
1024
-
1025
- # Update values
1026
- values.each do |key, value|
1027
- current_row[key] = value
1028
- end
1029
- updated_values = current_row.reject { |key, _| key == :_row_id }
1030
-
1031
- validate_constraints!(table_name, metadata, columns, updated_values, exclude_row_id: row_id)
1032
- validate_relational_constraints!(table_name, metadata, columns, updated_values)
1033
- validate_referential_update!(table_name, old_values, updated_values)
1034
- @index_manager&.validate_update!(table_name, old_values.merge(_row_id: row_id), updated_values.merge(_row_id: row_id))
1035
-
1036
- # Create updated row
1037
- row = Row.new(row_id, columns, current_row)
1038
- new_row_data = Serializer.serialize_row(row, columns, null_bitmap: true)
1039
-
1040
- # The before-image is captured before the page write, so an
1041
- # interrupted transaction can be undone safely.
1042
- log_to_wal(WAL::Record::TYPE_UPDATE, {
1043
- table_name: table_name,
1044
- row_id: row_id,
1045
- values: values,
1046
- old_values: old_values
1047
- }, transaction_id)
1048
-
1049
- # Check if we have enough space for the updated row
1050
- new_size = new_row_data.bytesize
1051
- old_size = record_size
1052
-
1053
- if new_size != old_size
1054
- # Need to rewrite the row
1055
- # Mark the old row as deleted/hidden
1056
- flags |= 0x01 # Deleted flag
1057
-
1058
- # Write updated header with new size
1059
- # The old slot remains part of the page layout. Its
1060
- # physical size must stay unchanged so subsequent records
1061
- # remain aligned when the replacement is appended.
1062
- page.write(offset, [record_id, old_size, flags, col_count].pack("Q>L>S>S"))
1063
-
1064
- # Write new data (may overlap with old data)
1065
- if new_size <= old_size
1066
- # Keep the original physical allocation and mark this
1067
- # record visible again. Length-prefixed fields allow the
1068
- # reader to ignore any unused trailing bytes.
1069
- page.write(offset + 16, new_row_data)
1070
- page.write(offset, [record_id, old_size, (flags & ~0x01) | NULL_BITMAP_FLAG | VARIABLE_LENGTH_PREFIXES_FLAG, col_count].pack("Q>L>S>S"))
1071
- else
1072
- # Need to move to end of page
1073
- new_offset = page.header.data_end
1074
- page.write(new_offset, [record_id, new_size, (flags & ~0x01) | NULL_BITMAP_FLAG | VARIABLE_LENGTH_PREFIXES_FLAG, col_count].pack("Q>L>S>S"))
1075
- page.write(new_offset + 16, new_row_data)
1076
- page.header.data_end = new_offset + 16 + new_size
1077
- end
1078
- else
1079
- # Same size - update in place
1080
- page.write(offset + 16, new_row_data)
1081
- page.write(offset, [record_id, record_size, flags | VARIABLE_LENGTH_PREFIXES_FLAG, col_count].pack("Q>L>S>S"))
1082
- end
1083
-
1084
- # Mark old version as hidden
1085
- @visibility_map.mark_hidden(row_id, transaction_id)
1086
-
1087
- # Mark new version as visible
1088
- @visibility_map.mark_visible(row_id, transaction_id)
1089
-
1090
- page.write_header
1091
- write_page(page)
1092
- updated = true
1093
- break
1094
- end
1095
-
1096
- offset += 16 + record_size
1097
- end
1098
-
1099
- break if updated
1100
- end
1101
-
1102
- if updated
1103
- # Invalidate cache
1104
- invalidate_cache(table_name, row_id)
1105
-
1106
- # Update metadata
1107
- metadata[:updated_at] = Time.now
1108
- save_table_metadata
1109
- record_transaction_change(:update, table_name, row_id, old_values: old_values, values: values)
1110
- record_mvcc_version(table_name, row_id, updated_values, transaction_id)
1111
- @index_manager&.update_row(table_name, old_values.merge(_row_id: row_id), updated_values.merge(_row_id: row_id))
1112
- apply_referential_update!(table_name, old_values, updated_values)
1113
- fire_triggers(:update, table_name, updated_values, row_id, old_row: old_values)
1114
- end
1115
-
1116
- updated
1117
- end
1118
- end
1119
-
1120
- def delete_row(table_name, row_id, conditions = {})
1121
- ensure_writable!
1122
- table_name = resolve_table_name(table_name)
1123
- @lock.synchronize do
1124
- @stats[:row_deletes] += 1
1125
-
1126
- metadata = @table_metadata[table_name]
1127
- raise DatabaseError, "Table '#{table_name}' does not exist" unless metadata
1128
-
1129
- transaction_id = conditions[:transaction_id] || current_transaction_id
1130
-
1131
- deleted = false
1132
- old_values = nil
1133
- pages = @table_pages[table_name] || []
1134
-
1135
- pages.each do |page_number|
1136
- page = read_page(page_number)
1137
- offset = PageHeader::SIZE
1138
-
1139
- while offset < page.header.data_end
1140
- record_header = page.read(offset, 16)
1141
- break if record_header.nil? || record_header.bytesize < 16
1142
-
1143
- record_id, record_size, flags, col_count = record_header.unpack("Q>L>S>S")
1144
-
1145
- if record_id == row_id
1146
- # Check visibility
1147
- if conditions[:visibility_check] != false
1148
- unless @visibility_map.is_visible?(row_id, transaction_id)
1149
- break
1150
- end
1151
- end
1152
-
1153
- record_data = page.read(offset + 16, record_size)
1154
- columns = metadata[:columns]
1155
- current_row = Deserializer.deserialize_row(
1156
- record_data,
1157
- columns,
1158
- null_bitmap: (flags & NULL_BITMAP_FLAG) != 0,
1159
- variable_length_prefixes: (flags & VARIABLE_LENGTH_PREFIXES_FLAG) != 0
1160
- )
1161
- old_values = current_row.reject { |key, _| key == :_row_id }
1162
- old_values_array = columns.map { |column| old_values[column.name] }
1163
-
1164
- validate_referential_delete!(table_name, metadata, old_values)
1165
-
1166
- log_to_wal(WAL::Record::TYPE_DELETE, {
1167
- table_name: table_name,
1168
- row_id: row_id,
1169
- columns: columns,
1170
- row_data: old_values_array,
1171
- old_values: old_values
1172
- }, transaction_id)
1173
-
1174
- # Mark as deleted in visibility map
1175
- @visibility_map.mark_deleted(row_id, transaction_id)
1176
-
1177
- # Mark record as deleted on page
1178
- flags |= 0x01 # Deleted flag
1179
- page.write(offset, [record_id, record_size, flags, col_count].pack("Q>L>S>S"))
1180
- page.write_header
1181
- write_page(page)
1182
-
1183
- deleted = true
1184
- break
1185
- end
1186
-
1187
- offset += 16 + record_size
1188
- end
1189
-
1190
- break if deleted
1191
- end
1192
-
1193
- if deleted
1194
- # Invalidate cache
1195
- invalidate_cache(table_name, row_id)
1196
-
1197
- # Update metadata
1198
- metadata[:updated_at] = Time.now
1199
- save_table_metadata
1200
- record_transaction_change(:delete, table_name, row_id, old_values: old_values, columns: metadata[:columns])
1201
- record_mvcc_version(table_name, row_id, old_values.merge(_deleted: true), transaction_id, deleted: true)
1202
- @index_manager&.delete_row(table_name, old_values.merge(_row_id: row_id))
1203
- fire_triggers(:delete, table_name, old_values, row_id)
1204
- end
1205
-
1206
- deleted
1207
- end
1208
- end
1209
-
1210
- def restore_deleted_row(table_name, row_id)
1211
- table_name = resolve_table_name(table_name)
1212
- @lock.synchronize do
1213
- metadata = @table_metadata[table_name]
1214
- return false unless metadata
1215
-
1216
- (@table_pages[table_name] || []).each do |page_number|
1217
- page = read_page(page_number)
1218
- offset = PageHeader::SIZE
1219
- while offset < page.header.data_end
1220
- header = page.read(offset, 16)
1221
- break if header.nil? || header.bytesize < 16
1222
-
1223
- record_id, record_size, flags, col_count = header.unpack("Q>L>S>S")
1224
- if record_id == row_id && (flags & 0x01) != 0
1225
- page.write(offset, [record_id, record_size, flags & ~0x01, col_count].pack("Q>L>S>S"))
1226
- page.write_header
1227
- write_page(page)
1228
- @visibility_map.mark_visible(row_id, 0)
1229
- invalidate_cache(table_name, row_id)
1230
- return true
1231
- end
1232
- offset += 16 + record_size
1233
- end
1234
- end
1235
- false
1236
- end
1237
- end
1238
-
1239
- def restore_row_values(table_name, row_id, values)
1240
- table_name = resolve_table_name(table_name)
1241
- @lock.synchronize do
1242
- metadata = @table_metadata[table_name]
1243
- return false unless metadata
1244
- columns = metadata[:columns]
1245
- replacement = Serializer.serialize_row(Row.new(row_id, columns, values), columns, null_bitmap: true)
1246
-
1247
- (@table_pages[table_name] || []).each do |page_number|
1248
- page = read_page(page_number)
1249
- offset = PageHeader::SIZE
1250
- while offset < page.header.data_end
1251
- header = page.read(offset, 16)
1252
- break if header.nil? || header.bytesize < 16
1253
- record_id, record_size, flags, col_count = header.unpack("Q>L>S>S")
1254
- if record_id == row_id && (flags & 0x01) == 0
1255
- # Append a fresh physical record. Variable-width values have
1256
- # no per-column length prefix, so shrinking in place would
1257
- # leave stale bytes that become part of the next value.
1258
- page.write(offset, [row_id, record_size, 1, col_count].pack("Q>L>S>S"))
1259
- new_offset = page.header.data_end
1260
- page.write(new_offset, [row_id, replacement.bytesize, NULL_BITMAP_FLAG | VARIABLE_LENGTH_PREFIXES_FLAG, col_count].pack("Q>L>S>S"))
1261
- page.write(new_offset + 16, replacement)
1262
- page.header.data_end = new_offset + 16 + replacement.bytesize
1263
- page.write_header
1264
- write_page(page)
1265
- invalidate_cache(table_name, row_id)
1266
- return true
1267
- end
1268
- offset += 16 + record_size
1269
- end
1270
- end
1271
- false
1272
- end
1273
- end
1274
-
1275
- # Transaction support
1276
- def begin_transaction(isolation_level = :read_committed)
1277
- @lock.synchronize do
1278
- unless %i[read_committed repeatable_read serializable].include?(isolation_level.to_sym)
1279
- raise DatabaseError, "Isolation level #{isolation_level} is not supported yet"
1280
- end
1281
-
1282
- # Sessions have independent in-memory maps. Refresh only at the
1283
- # transaction boundary so a new transaction sees commits made by
1284
- # another session without discarding this session's active state.
1285
- @visibility_map.load_visibility
1286
- @version_store.load
1287
- @stats[:transaction_begin] += 1
1288
-
1289
- transaction_id = next_transaction_id
1290
- transaction = {
1291
- id: transaction_id,
1292
- started_at: Time.now,
1293
- isolation_level: isolation_level,
1294
- active: true,
1295
- changes: {},
1296
- savepoints: {}
1297
- }
1298
- Thread.current[@transaction_context_key] = transaction
1299
-
1300
- log_to_wal(WAL::Record::TYPE_BEGIN, {}, transaction_id)
1301
-
1302
- # Register transaction with visibility map
1303
- @visibility_map.register_transaction(transaction_id)
1304
- if %i[repeatable_read serializable].include?(isolation_level.to_sym)
1305
- @transaction_snapshots[transaction_id] = MVCC::Snapshot.new(
1306
- transaction_id,
1307
- @visibility_map.active_transactions.keys,
1308
- @visibility_map.committed_transactions.to_a
1309
- )
1310
- end
1311
-
1312
- transaction_id
1313
- end
1314
- end
1315
-
1316
- def current_transaction
1317
- current_transaction_manager
1318
- end
1319
-
1320
- def add_commit_listener(listener = nil, &block)
1321
- listener ||= block
1322
- raise ArgumentError, "Commit listener must respond to call" unless listener.respond_to?(:call)
1323
-
1324
- @lock.synchronize do
1325
- @commit_listeners << listener unless @commit_listeners.include?(listener)
1326
- end
1327
- listener
1328
- end
1329
-
1330
- def remove_commit_listener(listener)
1331
- @lock.synchronize { @commit_listeners.delete(listener) }
1332
- end
1333
-
1334
- def commit_transaction(transaction = nil)
1335
- @lock.synchronize do
1336
- @stats[:transaction_commit] += 1
1337
-
1338
- tx = transaction || current_transaction_manager
1339
- return false unless tx && tx[:active]
1340
-
1341
- begin
1342
- # The commit record is the transaction's durable commit point. It
1343
- # must be flushed and fsynced before the transaction is reported.
1344
- if (tx[:isolation_level] || :read_committed).to_sym == :serializable
1345
- @version_store.validate_serializable!(
1346
- @transaction_snapshots.fetch(tx[:id]),
1347
- @transaction_reads[tx[:id]],
1348
- @transaction_writes[tx[:id]],
1349
- read_predicates: @transaction_predicates[tx[:id]]
1350
- )
1351
- end
1352
- commit_lsn = log_to_wal(WAL::Record::TYPE_COMMIT, {}, tx[:id])
1353
- if @wal
1354
- @wal.flush
1355
- @wal.sync
1356
- end
1357
- @last_commit_ack = {
1358
- status: :durable,
1359
- transaction_id: tx[:id],
1360
- commit_lsn: commit_lsn&.to_i,
1361
- recovery_required: false
1362
- }
1363
-
1364
- # Commit transaction in visibility map
1365
- @visibility_map.commit_transaction(tx[:id])
1366
- commit_mvcc_versions(tx[:id])
1367
- @transaction_snapshots.delete(tx[:id])
1368
- @transaction_reads.delete(tx[:id])
1369
- @transaction_writes.delete(tx[:id])
1370
- @transaction_predicates.delete(tx[:id])
1371
-
1372
- # A page or metadata flush can fail after the WAL commit is
1373
- # durable. Recovery can replay that commit, so retain a truthful
1374
- # durable acknowledgement instead of reporting an ambiguous abort.
1375
- begin
1376
- flush
1377
- rescue StandardError => error
1378
- @last_commit_ack[:recovery_required] = true
1379
- @last_commit_ack[:flush_error] = "#{error.class}: #{error.message}"
1380
- end
1381
-
1382
- tx[:active] = false
1383
- tx[:committed_at] = Time.now
1384
- clear_transaction_context(tx)
1385
-
1386
- committed_changes = (tx[:changes] || {}).flat_map do |table_name, rows|
1387
- rows.values.map { |change| change.merge(table: table_name) }
1388
- end
1389
- listener_errors = []
1390
- @commit_listeners.dup.each do |listener|
1391
- next if committed_changes.empty?
1392
-
1393
- begin
1394
- listener.call(tx[:id], committed_changes)
1395
- rescue StandardError => error
1396
- listener_errors << "#{error.class}: #{error.message}"
1397
- end
1398
- end
1399
- @last_commit_ack[:replication_errors] = listener_errors unless listener_errors.empty?
1400
-
1401
- true
1402
- rescue Exception => error
1403
- @last_commit_ack = {
1404
- status: :uncertain,
1405
- transaction_id: tx[:id],
1406
- error: "#{error.class}: #{error.message}",
1407
- recovery_required: true
1408
- }
1409
- raise
1410
- end
1411
- end
1412
- end
1413
-
1414
- def rollback_transaction(transaction = nil)
1415
- @lock.synchronize do
1416
- @stats[:transaction_rollback] += 1
1417
-
1418
- tx = transaction || current_transaction_manager
1419
- return false unless tx && tx[:active]
1420
-
1421
- # Rollback transaction in visibility map
1422
- @visibility_map.abort_transaction(tx[:id])
1423
-
1424
- # Rollback changes
1425
- abort_mvcc_versions(tx[:id])
1426
- @transaction_snapshots.delete(tx[:id])
1427
- @transaction_reads.delete(tx[:id])
1428
- @transaction_writes.delete(tx[:id])
1429
- @transaction_predicates.delete(tx[:id])
1430
- if tx[:changes]
1431
- tx[:changes].reverse_each do |table_name, rows|
1432
- rows.to_a.reverse_each do |row_id, change|
1433
- if change[:type] == :insert
1434
- mark_row_deleted(change[:table_name] || table_name, row_id)
1435
- if @index_manager
1436
- indexed_row = if change[:values].is_a?(Hash)
1437
- change[:values].merge(_row_id: row_id)
1438
- elsif change[:columns].is_a?(Array)
1439
- change[:columns].each_with_index.to_h { |column, index| [column.respond_to?(:name) ? column.name : column, change[:values][index]] }.merge(_row_id: row_id)
1440
- end
1441
- @index_manager.delete_row(change[:table_name] || table_name, indexed_row) if indexed_row
1442
- end
1443
- elsif change[:type] == :update
1444
- restore_row_values(change[:table_name] || table_name, row_id, change[:old_values] || {})
1445
- elsif change[:type] == :delete
1446
- table = change[:table_name] || table_name
1447
- restore_deleted_row(table, row_id)
1448
- @index_manager&.insert_row(table, change[:old_values].merge(_row_id: row_id)) if change[:old_values].is_a?(Hash)
1449
- end
1450
- end
1451
- end
1452
- end
1453
-
1454
- tx[:active] = false
1455
- tx[:aborted_at] = Time.now
1456
-
1457
- log_to_wal(WAL::Record::TYPE_ROLLBACK, {}, tx[:id])
1458
- @wal&.sync
1459
- clear_transaction_context(tx)
1460
-
1461
- true
1462
- end
1463
- end
1464
-
1465
- def in_transaction?
1466
- current_transaction_manager && current_transaction_manager[:active]
1467
- end
1468
-
1469
- def create_savepoint(name)
1470
- @lock.synchronize do
1471
- tx = current_transaction_manager
1472
- raise DatabaseError, "SAVEPOINT requires an active transaction" unless tx && tx[:active]
1473
- name = name.to_s
1474
- tx[:savepoints] ||= {}
1475
- tx[:savepoints][name] = {
1476
- changes: tx[:changes].transform_values { |rows| rows.transform_values(&:dup) },
1477
- version_count: @transaction_versions[tx[:id]].length
1478
- }
1479
- true
1480
- end
1481
- end
1482
-
1483
- def rollback_to_savepoint(name)
1484
- @lock.synchronize do
1485
- tx = current_transaction_manager
1486
- raise DatabaseError, "ROLLBACK TO SAVEPOINT requires an active transaction" unless tx && tx[:active]
1487
- savepoint = tx[:savepoints]&.[](name.to_s)
1488
- raise DatabaseError, "Savepoint '#{name}' does not exist" unless savepoint
1489
-
1490
- tx[:changes].each do |table_name, rows|
1491
- rows.to_a.reverse_each do |row_id, change|
1492
- next if savepoint[:changes].dig(table_name, row_id)
1493
- case change[:type]
1494
- when :insert
1495
- mark_row_deleted(change[:table_name] || table_name, row_id)
1496
- when :update
1497
- restore_row_values(change[:table_name] || table_name, row_id, change[:old_values] || {})
1498
- when :delete
1499
- table = change[:table_name] || table_name
1500
- restore_deleted_row(table, row_id)
1501
- @index_manager&.insert_row(table, change[:old_values].merge(_row_id: row_id)) if change[:old_values].is_a?(Hash)
1502
- end
1503
- end
1504
- end
1505
-
1506
- versions = @transaction_versions[tx[:id]]
1507
- versions.slice!(savepoint[:version_count], versions.length) if versions.length > savepoint[:version_count]
1508
- tx[:changes] = savepoint[:changes].transform_values { |rows| rows.transform_values(&:dup) }
1509
- true
1510
- end
1511
- end
1512
-
1513
- def release_savepoint(name)
1514
- @lock.synchronize do
1515
- tx = current_transaction_manager
1516
- raise DatabaseError, "RELEASE SAVEPOINT requires an active transaction" unless tx && tx[:active]
1517
- savepoints = tx[:savepoints] || {}
1518
- raise DatabaseError, "Savepoint '#{name}' does not exist" unless savepoints.delete(name.to_s)
1519
- true
1520
- end
1521
- end
1522
-
1523
- # Recovery uses the normal storage operations so page/catalog/index
1524
- # maintenance remains centralized, but must not emit new WAL records.
1525
- def with_recovery
1526
- previous = @recovery_in_progress
1527
- @recovery_in_progress = true
1528
- yield
1529
- ensure
1530
- @recovery_in_progress = previous
1531
- end
1532
-
1533
- def record_transaction_change(type, table_name, row_id, details = {})
1534
- transaction = current_transaction_manager
1535
- return unless transaction && transaction[:active]
1536
-
1537
- (transaction[:changes][table_name] ||= {})[row_id] =
1538
- details.merge(type: type, table: table_name, row_id: row_id)
1539
- end
1540
-
1541
- def record_mvcc_version(table_name, row_id, data, transaction_id, deleted: false)
1542
- return if @recovery_in_progress || !@version_store
1543
-
1544
- version = @version_store.create_version(row_id, data, transaction_id, key: version_key(table_name, row_id))
1545
- @transaction_writes[transaction_id].add(version_key(table_name, row_id)) if transaction_id.to_i != 0
1546
- version.mark_deleted if deleted
1547
- if transaction_id.to_i == 0
1548
- @version_store.commit_version(version, 0)
1549
- else
1550
- @transaction_versions[transaction_id] << version
1551
- end
1552
- end
1553
-
1554
- def version_key(table_name, row_id)
1555
- "#{table_name}\0#{row_id}"
1556
- end
1557
-
1558
- def commit_mvcc_versions(transaction_id)
1559
- @transaction_versions.delete(transaction_id)&.each do |version|
1560
- @version_store.commit_version(version, transaction_id)
1561
- end
1562
- @version_store.persist
1563
- end
1564
-
1565
- def abort_mvcc_versions(transaction_id)
1566
- @transaction_versions.delete(transaction_id)&.each do |version|
1567
- @version_store.abort_version(version)
1568
- end
1569
- @version_store.persist
1570
- end
1571
-
1572
- # Cache operations
1573
- def invalidate_cache(table_name, row_id = nil)
1574
- if row_id
1575
- @row_cache.delete("#{table_name}:#{row_id}")
1576
- else
1577
- # Invalidate all rows for table
1578
- @row_cache.delete_if { |key, _| key.start_with?("#{table_name}:") }
1579
- end
1580
- end
1581
-
1582
- def clear_cache
1583
- @row_cache.clear
1584
- @stats[:cache_hits] = 0
1585
- @stats[:cache_misses] = 0
1586
- end
1587
-
1588
- # Utility operations
1589
- def flush
1590
- @storage_manager.flush
1591
- @visibility_map.flush
1592
- save_table_metadata
1593
- end
1594
-
1595
- def close
1596
- return unless @is_open
1597
-
1598
- stop_cleanup_thread
1599
-
1600
- # Flush WAL first (ensures all mutations are recorded)
1601
- if @wal
1602
- @wal.flush
1603
- @wal.checkpoint.create_checkpoint(@wal.current_lsn)
1604
- end
1605
-
1606
- flush
1607
- @wal&.shutdown
1608
- @version_store&.persist
1609
- @storage_manager.close
1610
- @is_open = false
1611
- @database_lock.release
1612
- true
1613
- end
1614
-
1615
- def stats
1616
- @lock.synchronize do
1617
- {
1618
- table_count: @table_metadata.size,
1619
- row_count: @table_metadata.values.sum { |m| m[:row_count] || 0 },
1620
- page_count: @page_manager.total_pages,
1621
- free_pages: @page_manager.free_pages,
1622
- used_pages: @page_manager.used_pages,
1623
- buffer_size: @buffer_pool.size,
1624
- cache_size: @row_cache.size,
1625
- cache_hits: @stats[:cache_hits],
1626
- cache_misses: @stats[:cache_misses],
1627
- cache_hit_rate: cache_hit_rate,
1628
- table_creates: @stats[:table_creates],
1629
- row_inserts: @stats[:row_inserts],
1630
- row_selects: @stats[:row_selects],
1631
- row_updates: @stats[:row_updates],
1632
- row_deletes: @stats[:row_deletes],
1633
- index_scans: @stats[:index_scans] || 0,
1634
- transaction_begin: @stats[:transaction_begin],
1635
- transaction_commit: @stats[:transaction_commit],
1636
- transaction_rollback: @stats[:transaction_rollback],
1637
- wal_writes: @stats[:wal_writes],
1638
- crash_recoveries: @stats[:crash_recoveries],
1639
- last_maintenance_error: @last_maintenance_error,
1640
- is_open: @is_open,
1641
- visibility_rows: @visibility_map.visibility_info.size,
1642
- active_transactions: @visibility_map.active_transaction_count
1643
- }
1644
- end
1645
- end
1646
-
1647
- def open?
1648
- @is_open
1649
- end
1650
-
1651
- def vacuum
1652
- ensure_writable!
1653
- result = @visibility_map.vacuum
1654
- save_table_metadata
1655
- result
1656
- end
1657
-
1658
- def compact_table(table_name)
1659
- ensure_writable!
1660
- table_name = resolve_table_name(table_name)
1661
- @lock.synchronize do
1662
- metadata = @table_metadata[table_name]
1663
- return false unless metadata
1664
-
1665
- pages = @table_pages[table_name] || []
1666
- compacted_count = 0
1667
-
1668
- pages.each do |page_number|
1669
- result = @page_allocator.compact_page(page_number)
1670
- compacted_count += 1 if result[:compacted]
1671
- end
1672
-
1673
- compacted_count > 0
1674
- end
1675
- end
1676
-
1677
- # Crash recovery
1678
- def run_crash_recovery
1679
- return false unless @wal
1680
-
1681
- # Check if recovery is needed (WAL files exist)
1682
- wal_dir = @config[:wal_dir] || "#{@path}.wal"
1683
- return false unless Dir.exist?(wal_dir) && Dir.glob(File.join(wal_dir, '*.log')).any?
1684
-
1685
- @stats[:crash_recoveries] += 1
1686
- result = @crash_recovery.recover
1687
- return true if result.is_a?(Hash) && result[:success]
1688
-
1689
- detail = result.is_a?(Hash) ? result[:error] : "recovery returned an invalid result"
1690
- raise RecoveryError, "Crash recovery failed for '#{@path}': #{detail || 'unknown error'}"
1691
- rescue RecoveryError
1692
- raise
1693
- rescue StandardError => error
1694
- raise RecoveryError, "Crash recovery failed for '#{@path}': #{error.message}"
1695
- end
1696
-
1697
- # WAL logging helpers
1698
- def log_to_wal(type, data, transaction_id = nil)
1699
- return nil unless @wal && !@recovery_in_progress
1700
-
1701
- transaction_id ||= current_transaction_id
1702
- record = WAL::Record.new(type, data, transaction_id: transaction_id)
1703
- @stats[:wal_writes] += 1
1704
-
1705
- @wal.write(record)
1706
- end
1707
-
1708
- private
1709
-
1710
- def current_transaction_manager
1711
- Thread.current[@transaction_context_key]
1712
- end
1713
-
1714
- def current_transaction_id
1715
- current_transaction_manager&.[](:id) || 0
1716
- end
1717
-
1718
- def clear_transaction_context(transaction)
1719
- Thread.current[@transaction_context_key] = nil if current_transaction_manager.equal?(transaction)
1720
- end
1721
-
1722
- def ensure_writable!
1723
- return if Thread.current[:rubydb_replication_apply_depth].to_i.positive?
1724
-
1725
- @write_fence&.assert_valid!
1726
- return unless @replication_read_only
1727
-
1728
- raise ReplicationError, "Replica is read-only; promote it before accepting local writes"
1729
- end
1730
-
1731
- def fire_triggers(event, table_name, row, row_id, old_row: nil)
1732
- database = @catalog&.current_database
1733
- return unless database && database.respond_to?(:triggers)
1734
-
1735
- database.triggers.values.each do |trigger|
1736
- next unless trigger.enabled? && trigger.table_name.to_s == table_name.to_s
1737
- next unless trigger.event_types.map(&:to_sym).include?(event.to_sym)
1738
- next unless trigger.definition.respond_to?(:call)
1739
-
1740
- trigger.definition.call(
1741
- event: event,
1742
- table_name: table_name,
1743
- row: row,
1744
- old_row: old_row,
1745
- row_id: row_id
1746
- )
1747
- end
1748
- end
1749
-
1750
- def get_or_allocate_data_page(table_name)
1751
- metadata = @table_metadata[table_name]
1752
- pages = @table_pages[table_name] || []
1753
-
1754
- if pages.empty?
1755
- # Allocate first page
1756
- page_number = allocate_page(StorageLayout::PAGE_TYPE_TABLE)
1757
- pages << page_number
1758
- @table_pages[table_name] = pages
1759
- metadata[:first_page] = page_number
1760
- metadata[:last_page] = page_number
1761
- return page_number
1762
- end
1763
-
1764
- # Check if last page has space
1765
- last_page = pages.last
1766
- page = read_page(last_page)
1767
-
1768
- if page.free_space < 1000 # Less than 1KB free
1769
- # Allocate new page
1770
- new_page = allocate_page(StorageLayout::PAGE_TYPE_TABLE)
1771
- pages << new_page
1772
- @table_pages[table_name] = pages
1773
- metadata[:last_page] = new_page
1774
- return new_page
1775
- end
1776
-
1777
- last_page
1778
- end
1779
-
1780
- def mark_row_deleted(table_name, row_id)
1781
- pages = @table_pages[table_name] || []
1782
- pages.each do |page_number|
1783
- page = read_page(page_number)
1784
- offset = PageHeader::SIZE
1785
- while offset < page.header.data_end
1786
- header = page.read(offset, 16)
1787
- break if header.nil? || header.bytesize < 16
1788
- record_id, record_size, flags, col_count = header.unpack("Q>L>S>S")
1789
- if record_id == row_id && (flags & 0x01).zero?
1790
- page.write(offset, [record_id, record_size, flags | 0x01, col_count].pack("Q>L>S>S"))
1791
- page.write_header
1792
- write_page(page)
1793
- return true
1794
- end
1795
- offset += 16 + record_size
1796
- end
1797
- end
1798
- false
1799
- end
1800
-
1801
- # Enforce the column-level constraints that are persisted in table
1802
- # metadata before any WAL record or page mutation is produced.
1803
- def validate_constraints!(table_name, metadata, columns, values, exclude_row_id: nil)
1804
- candidate = if values.is_a?(Hash)
1805
- values.transform_keys { |key| key.respond_to?(:to_sym) ? key.to_sym : key }
1806
- else
1807
- columns.each_with_index.to_h do |column, index|
1808
- [column.name.to_sym, values[index]]
1809
- end
1810
- end
1811
- schema = metadata[:columns] || columns
1812
-
1813
- schema.each do |column|
1814
- name = column.name.to_sym
1815
- if (column.primary_key? || !column.nullable?) && candidate[name].nil?
1816
- raise DatabaseError, "Column '#{name}' on '#{table_name}' cannot be NULL"
1817
- end
1818
- end
1819
-
1820
- constrained_sets = schema.filter_map do |column|
1821
- [column.name.to_sym] if column.primary_key? || column.unique?
1822
- end
1823
- return true if constrained_sets.empty?
1824
-
1825
- existing_rows = select_rows(table_name, schema, visibility_check: false)
1826
- constrained_sets.each do |column_names|
1827
- key = column_names.map { |name| candidate[name] }
1828
- next if key.all?(&:nil?) && !schema.any? { |column| column.name.to_sym == column_names.first && column.primary_key? }
1829
-
1830
- duplicate = existing_rows.any? do |existing|
1831
- next false if exclude_row_id && existing[:_row_id] == exclude_row_id
1832
- column_names.all? do |name|
1833
- existing_value = existing.key?(name) ? existing[name] : existing[name.to_s]
1834
- existing_value == candidate[name]
1835
- end
1836
- end
1837
- if duplicate
1838
- raise DatabaseError, "Duplicate value for #{column_names.join(', ')} on '#{table_name}'"
1839
- end
1840
- end
1841
- true
1842
- end
1843
-
1844
- def validate_relational_constraints!(table_name, metadata, columns, values)
1845
- candidate = if values.is_a?(Hash)
1846
- values.transform_keys { |key| key.respond_to?(:to_sym) ? key.to_sym : key }
1847
- else
1848
- columns.each_with_index.to_h { |column, index| [column.name.to_sym, values[index]] }
1849
- end
1850
-
1851
- (metadata[:constraints] || []).each do |definition|
1852
- definition = definition.transform_keys(&:to_sym)
1853
- type = definition[:type].to_s.downcase
1854
- case type
1855
- when "unique"
1856
- unique_columns = Array(definition[:columns]).map(&:to_sym)
1857
- next if unique_columns.empty?
1858
- key = unique_columns.map { |column| candidate[column] }
1859
- next if key.all?(&:nil?)
1860
- duplicate = select_rows(table_name, columns, visibility_check: false).any? do |row|
1861
- unique_columns.all? { |column| (row[column] || row[column.to_s]) == candidate[column] }
1862
- end
1863
- raise DatabaseError, "Duplicate value for #{unique_columns.join(', ')} on '#{table_name}'" if duplicate
1864
- when "check"
1865
- expression = definition[:expression]
1866
- constraint = Constraints::CheckConstraint.new(table_name, expression, expression_type: :sql)
1867
- unless constraint.validate(candidate)
1868
- raise DatabaseError, constraint.validation_errors.last
1869
- end
1870
- when "foreign_key", "foreign key"
1871
- foreign_columns = Array(definition[:columns]).map(&:to_sym)
1872
- reference_table = definition[:reference_table]
1873
- reference_columns = Array(definition[:reference_columns] || :id).map(&:to_sym)
1874
- next if foreign_columns.all? { |column| candidate[column].nil? }
1875
-
1876
- reference_metadata = @table_metadata[reference_table] || @table_metadata[reference_table.to_sym]
1877
- raise DatabaseError, "Referenced table '#{reference_table}' does not exist" unless reference_metadata
1878
- resolved_reference_table = @table_metadata.key?(reference_table) ? reference_table : reference_table.to_sym
1879
- reference_rows = select_rows(resolved_reference_table, reference_metadata[:columns], visibility_check: false)
1880
- found = reference_rows.any? do |reference_row|
1881
- foreign_columns.each_with_index.all? do |column, index|
1882
- reference_value = reference_row[reference_columns[index]] || reference_row[reference_columns[index].to_s]
1883
- reference_value == candidate[column]
1884
- end
1885
- end
1886
- unless found
1887
- raise DatabaseError, "Referenced row does not exist for '#{table_name}'"
1888
- end
1889
- end
1890
- end
1891
- true
1892
- end
1893
-
1894
- def serialize_constraint_definitions(constraints)
1895
- constraints.map do |constraint|
1896
- definition = if constraint.respond_to?(:to_hash)
1897
- constraint.to_hash
1898
- elsif constraint.respond_to?(:columns) || constraint.respond_to?(:condition)
1899
- constraint_definition(constraint)
1900
- else
1901
- constraint
1902
- end
1903
- definition = definition.transform_keys(&:to_sym)
1904
- {
1905
- type: definition[:type],
1906
- name: definition[:name],
1907
- columns: definition[:columns],
1908
- reference_table: definition[:reference_table],
1909
- reference_columns: definition[:reference_columns],
1910
- on_delete: definition[:on_delete],
1911
- on_update: definition[:on_update],
1912
- expression: definition[:expression]
1913
- }.reject { |_, value| value.nil? }
1914
- end
1915
- end
1916
-
1917
- def constraint_definition(constraint)
1918
- type = constraint.class.name.split("::").last.sub(/Constraint$/, "").gsub(/([a-z])([A-Z])/, '\\1_\\2').downcase
1919
- definition = { type: type, name: constraint.name }
1920
- definition[:columns] = constraint.columns if constraint.respond_to?(:columns)
1921
- if constraint.respond_to?(:reference_table)
1922
- definition[:reference_table] = constraint.reference_table
1923
- definition[:reference_columns] = constraint.reference_columns
1924
- end
1925
- definition[:on_delete] = constraint.on_delete if constraint.respond_to?(:on_delete)
1926
- definition[:on_update] = constraint.on_update if constraint.respond_to?(:on_update)
1927
- definition[:expression] = constraint.condition.to_sql if constraint.respond_to?(:condition)
1928
- definition
1929
- end
1930
-
1931
- def matches_conditions?(row, conditions)
1932
- conditions.each do |key, value|
1933
- next if key == :transaction_id || key == :visibility_check ||
1934
- key == :limit || key == :offset || key == :row_id
1935
-
1936
- if key == :_row_id
1937
- return false unless row[:_row_id] == value
1938
- elsif (row.key?(key) ? row[key] : row[key.to_s]) != value
1939
- if value.is_a?(Hash)
1940
- row_value = row.key?(key) ? row[key] : row[key.to_s]
1941
- return false unless matches_operator?(row_value, value)
1942
- else
1943
- return false
1944
- end
1945
- end
1946
- end
1947
-
1948
- # Check row_id condition
1949
- if conditions[:row_id]
1950
- return false unless row[:_row_id] == conditions[:row_id]
1951
- end
1952
-
1953
- true
1954
- end
1955
-
1956
- def indexed_row_ids_for(table_name, conditions)
1957
- return nil unless @index_manager
1958
- return nil if conditions.empty?
1959
-
1960
- index = @index_manager.get_indexes_for_table(table_name).find do |candidate|
1961
- candidate.columns.all? do |column|
1962
- conditions.key?(column) || conditions.key?(column.to_s)
1963
- end
1964
- end
1965
- return nil unless index
1966
-
1967
- condition = index.columns.map do |column|
1968
- conditions.key?(column) ? conditions[column] : conditions[column.to_s]
1969
- end.first
1970
-
1971
- if condition.is_a?(Hash)
1972
- operator = (condition[:operator] || condition["operator"]).to_sym
1973
- value = condition[:value] || condition["value"]
1974
- return nil unless %i[between lt lte gt gte].include?(operator)
1975
- if operator == :between
1976
- start_key, end_key = value
1977
- elsif %i[lt lte].include?(operator)
1978
- start_key = range_minimum(value)
1979
- end_key = value
1980
- else
1981
- start_key = value
1982
- end_key = range_maximum(value)
1983
- end
1984
- result = index.range_search(start_key, end_key)
1985
- result = result.map { |entry| entry[:value] || entry[:row_id] }
1986
- result.reject! { |row_id| row_id.nil? }
1987
- else
1988
- key = index.columns.size == 1 ? condition : index.columns.map { |column| conditions[column] }
1989
- result = index.search(key)
1990
- end
1991
- result.is_a?(Array) ? result.to_set : Set.new([result].compact)
1992
- end
1993
-
1994
- def matches_operator?(row_value, condition)
1995
- operator = (condition[:operator] || condition["operator"]).to_sym
1996
- value = condition[:value] || condition["value"]
1997
- return false if row_value.nil?
1998
- case operator
1999
- when :eq then row_value == value
2000
- when :lt then row_value < value
2001
- when :lte then row_value <= value
2002
- when :gt then row_value > value
2003
- when :gte then row_value >= value
2004
- when :between then row_value >= value[0] && row_value <= value[1]
2005
- else false
2006
- end
2007
- rescue TypeError
2008
- false
2009
- end
2010
-
2011
- def range_minimum(value)
2012
- value.is_a?(Numeric) ? -Float::INFINITY : ""
2013
- end
2014
-
2015
- def range_maximum(value)
2016
- value.is_a?(Numeric) ? Float::INFINITY : "\u{10ffff}"
2017
- end
2018
-
2019
- def validate_referential_delete!(table_name, metadata, deleted_row)
2020
- @table_metadata.each do |child_table, child_metadata|
2021
- (child_metadata[:constraints] || []).each do |definition|
2022
- definition = definition.transform_keys(&:to_sym)
2023
- next unless %w[foreign_key foreign key].include?(definition[:type].to_s.downcase)
2024
- next unless definition[:reference_table].to_s == table_name.to_s
2025
-
2026
- child_columns = Array(definition[:columns]).map(&:to_sym)
2027
- parent_columns = Array(definition[:reference_columns] || :id).map(&:to_sym)
2028
- child_rows = select_rows(child_table, child_metadata[:columns], visibility_check: false)
2029
- referenced = child_rows.any? do |child_row|
2030
- child_columns.each_with_index.all? do |child_column, index|
2031
- child_value = child_row[child_column] || child_row[child_column.to_s]
2032
- parent_value = deleted_row[parent_columns[index]] || deleted_row[parent_columns[index].to_s]
2033
- !child_value.nil? && child_value == parent_value
2034
- end
2035
- end
2036
- next unless referenced
2037
-
2038
- action = (definition[:on_delete] || :restrict).to_s.downcase.tr("- ", "__")
2039
- matching_rows = child_rows.select do |child_row|
2040
- child_columns.each_with_index.all? do |child_column, index|
2041
- child_value = child_row[child_column] || child_row[child_column.to_s]
2042
- parent_value = deleted_row[parent_columns[index]] || deleted_row[parent_columns[index].to_s]
2043
- !child_value.nil? && child_value == parent_value
2044
- end
2045
- end
2046
-
2047
- case action
2048
- when "cascade"
2049
- matching_rows.each do |child_row|
2050
- child_row_id = child_row[:_row_id] || child_row["_row_id"]
2051
- delete_row(child_table, child_row_id, visibility_check: false)
2052
- end
2053
- when "set_null"
2054
- matching_rows.each do |child_row|
2055
- child_row_id = child_row[:_row_id] || child_row["_row_id"]
2056
- updates = child_columns.to_h { |column| [column, nil] }
2057
- update_row(child_table, child_row_id, updates, visibility_check: false)
2058
- end
2059
- when "set_default"
2060
- matching_rows.each do |child_row|
2061
- child_row_id = child_row[:_row_id] || child_row["_row_id"]
2062
- updates = child_columns.each_with_object({}) do |column, result|
2063
- result[column] = column.default
2064
- end
2065
- update_row(child_table, child_row_id, updates, visibility_check: false)
2066
- end
2067
- when "restrict", "no_action", "noaction"
2068
- raise DatabaseError, "Cannot delete referenced row from '#{table_name}' (ON DELETE #{action.upcase})"
2069
- else
2070
- raise DatabaseError, "Unsupported ON DELETE action: #{action}"
2071
- end
2072
- end
2073
- end
2074
- true
2075
- end
2076
-
2077
- def validate_referential_update!(table_name, old_row, new_row)
2078
- return true if old_row == new_row
2079
-
2080
- @table_metadata.each do |child_table, child_metadata|
2081
- (child_metadata[:constraints] || []).each do |raw_definition|
2082
- definition = raw_definition.transform_keys(&:to_sym)
2083
- next unless %w[foreign_key foreign key].include?(definition[:type].to_s.downcase)
2084
- next unless definition[:reference_table].to_s == table_name.to_s
2085
-
2086
- child_columns = Array(definition[:columns]).map(&:to_sym)
2087
- parent_columns = Array(definition[:reference_columns] || :id).map(&:to_sym)
2088
- next unless parent_columns.any? { |column| old_row[column] != new_row[column] }
2089
-
2090
- child_rows = select_rows(child_table, child_metadata[:columns], visibility_check: false)
2091
- referenced = child_rows.any? do |child_row|
2092
- child_columns.each_with_index.all? do |child_column, index|
2093
- child_value = child_row[child_column] || child_row[child_column.to_s]
2094
- parent_value = old_row[parent_columns[index]] || old_row[parent_columns[index].to_s]
2095
- !child_value.nil? && child_value == parent_value
2096
- end
2097
- end
2098
- next unless referenced
2099
-
2100
- action = (definition[:on_update] || :restrict).to_s.downcase.tr("- ", "__")
2101
- case action
2102
- when "cascade", "set_null", "set_default"
2103
- if action == "set_null"
2104
- child_columns.each do |column_name|
2105
- column = child_metadata[:columns].find { |candidate| candidate.name.to_sym == column_name }
2106
- raise DatabaseError, "ON UPDATE SET NULL requires nullable column '#{column_name}'" unless column&.nullable?
2107
- end
2108
- end
2109
- when "restrict", "no_action", "noaction"
2110
- raise DatabaseError, "Cannot update referenced row in '#{table_name}' (ON UPDATE #{action.upcase})"
2111
- else
2112
- raise DatabaseError, "Unsupported ON UPDATE action: #{action}"
2113
- end
2114
- end
2115
- end
2116
- true
2117
- end
2118
-
2119
- def apply_referential_update!(table_name, old_row, new_row)
2120
- return true if old_row == new_row
2121
-
2122
- @table_metadata.each do |child_table, child_metadata|
2123
- (child_metadata[:constraints] || []).each do |raw_definition|
2124
- definition = raw_definition.transform_keys(&:to_sym)
2125
- next unless %w[foreign_key foreign key].include?(definition[:type].to_s.downcase)
2126
- next unless definition[:reference_table].to_s == table_name.to_s
2127
-
2128
- child_columns = Array(definition[:columns]).map(&:to_sym)
2129
- parent_columns = Array(definition[:reference_columns] || :id).map(&:to_sym)
2130
- next unless parent_columns.any? { |column| old_row[column] != new_row[column] }
2131
- child_rows = select_rows(child_table, child_metadata[:columns], visibility_check: false)
2132
- matching_rows = child_rows.select do |child_row|
2133
- child_columns.each_with_index.all? do |child_column, index|
2134
- child_value = child_row[child_column] || child_row[child_column.to_s]
2135
- parent_value = old_row[parent_columns[index]] || old_row[parent_columns[index].to_s]
2136
- !child_value.nil? && child_value == parent_value
2137
- end
2138
- end
2139
- next if matching_rows.empty?
2140
-
2141
- action = (definition[:on_update] || :restrict).to_s.downcase.tr("- ", "__")
2142
- matching_rows.each do |child_row|
2143
- child_row_id = child_row[:_row_id] || child_row["_row_id"]
2144
- updates = case action
2145
- when "cascade"
2146
- child_columns.each_with_index.to_h { |column, index| [column, new_row[parent_columns[index]] || new_row[parent_columns[index].to_s]] }
2147
- when "set_null"
2148
- child_columns.to_h { |column| [column, nil] }
2149
- when "set_default"
2150
- child_columns.each_with_object({}) do |column, result|
2151
- metadata_column = child_metadata[:columns].find { |candidate| candidate.name.to_sym == column }
2152
- result[column] = metadata_column&.default
2153
- end
2154
- else
2155
- next
2156
- end
2157
- update_row(child_table, child_row_id, updates, visibility_check: false)
2158
- end
2159
- end
2160
- end
2161
- true
2162
- end
2163
-
2164
- def cache_hit_rate
2165
- total = @stats[:cache_hits] + @stats[:cache_misses]
2166
- return 0.0 if total == 0
2167
- (@stats[:cache_hits].to_f / total * 100).round(2)
2168
- end
2169
-
2170
- def cleanup_cache
2171
- if @row_cache.size > @cache_size
2172
- # Remove oldest entries
2173
- sorted = @row_cache.sort_by { |_, (_, time)| time }
2174
- remove_count = (@row_cache.size - @cache_size) * 0.2
2175
- sorted.first(remove_count.to_i).each do |key, _|
2176
- @row_cache.delete(key)
2177
- end
2178
- end
2179
- end
2180
-
2181
- def next_transaction_id
2182
- # Transaction IDs cross session/process boundaries in the persisted
2183
- # visibility and MVCC stores. A per-engine counter can collide when
2184
- # two sessions open the same database, so include a time/process
2185
- # component and retain a local monotonic suffix for same-tick calls.
2186
- @transaction_id_counter ||= 0
2187
- @transaction_id_counter += 1
2188
- @transaction_id_namespace ||= SecureRandom.random_number(1_000_000)
2189
- ((Time.now.to_f * 1_000_000).to_i * 1_000_000_000) +
2190
- (Process.pid * 1_000_000) + (@transaction_id_namespace * 1_000) +
2191
- @transaction_id_counter
2192
- end
2193
-
2194
- def load_table_metadata
2195
- metadata_path = "#{@path}.metadata"
2196
- return unless File.exist?(metadata_path)
2197
-
2198
- data = File.read(metadata_path)
2199
- parsed = JSON.parse(data, symbolize_names: true)
2200
- unless parsed.is_a?(Hash) && parsed[:tables].is_a?(Hash)
2201
- raise CorruptionError, "Invalid table metadata format in #{metadata_path}"
2202
- end
2203
-
2204
- parsed[:tables].each do |table_name, table_data|
2205
- unless table_data.is_a?(Hash) && table_data[:columns].is_a?(Array) && table_data[:pages].is_a?(Array)
2206
- raise CorruptionError, "Invalid table metadata for '#{table_name}' in #{metadata_path}"
2207
- end
2208
-
2209
- @table_metadata[table_name] = {
2210
- metadata_page: table_data[:metadata_page],
2211
- data_page: table_data[:data_page],
2212
- columns: table_data[:columns].map do |c|
2213
- options = {
2214
- null: c[:nullable],
2215
- primary_key: c[:primary_key] || false,
2216
- unique: c[:unique] || false,
2217
- default: c[:default]
2218
- }
2219
- Catalog::Column.new(c[:name], c[:type].respond_to?(:to_sym) ? c[:type].to_sym : c[:type], **options)
2220
- end,
2221
- column_count: table_data[:column_count] || 0,
2222
- row_count: table_data[:row_count] || 0,
2223
- constraints: table_data[:constraints] || [],
2224
- created_at: Time.at(table_data[:created_at]),
2225
- updated_at: Time.at(table_data[:updated_at])
2226
- }
2227
- @table_pages[table_name] = table_data[:pages]
2228
- end
2229
- rescue JSON::ParserError, TypeError, KeyError, ArgumentError => error
2230
- raise CorruptionError, "Unable to load table metadata #{metadata_path}: #{error.message}"
2231
- end
2232
-
2233
- def rewrite_table_metadata_page(table_name, metadata)
2234
- page = Page.new(metadata[:metadata_page], @storage_manager.page_size)
2235
- page.write(0, begin
2236
- serialized = StorageLayout::TableMetadata.new
2237
- serialized.table_id = metadata[:metadata_page]
2238
- serialized.table_name = table_name.to_s
2239
- serialized.column_count = metadata[:columns].size
2240
- serialized.row_count = metadata[:row_count].to_i
2241
- serialized.first_page = metadata[:data_page]
2242
- serialized.last_page = (@table_pages[table_name] || [metadata[:data_page]]).last
2243
- serialized.created_at = metadata[:created_at].to_i
2244
- serialized.updated_at = metadata[:updated_at].to_i
2245
- serialized.serialize
2246
- end)
2247
- metadata[:columns].each_with_index do |column, index|
2248
- column_meta = StorageLayout::ColumnMetadata.new
2249
- column_meta.column_id = index + 1
2250
- column_meta.column_name = column.name
2251
- column_meta.data_type = column.type_class
2252
- column_meta.is_nullable = column.nullable?
2253
- column_meta.is_primary_key = column.primary_key?
2254
- column_meta.position = index
2255
- column_meta.default = column.default if column.has_default?
2256
- page.write(PageHeader::SIZE + index * 128, column_meta.serialize)
2257
- end
2258
- write_page(page)
2259
- end
2260
-
2261
- def save_table_metadata
2262
- data = {
2263
- tables: {}
2264
- }
2265
-
2266
- @table_metadata.each do |table_name, metadata|
2267
- data[:tables][table_name] = {
2268
- metadata_page: metadata[:metadata_page],
2269
- data_page: metadata[:data_page],
2270
- columns: metadata[:columns].map do |c|
2271
- {
2272
- name: c.name,
2273
- type: c.type_class,
2274
- nullable: c.nullable?,
2275
- primary_key: c.primary_key?,
2276
- unique: c.unique?,
2277
- default: c.default
2278
- }
2279
- end,
2280
- constraints: metadata[:constraints] || [],
2281
- column_count: metadata[:column_count],
2282
- row_count: metadata[:row_count],
2283
- created_at: metadata[:created_at].to_i,
2284
- updated_at: metadata[:updated_at].to_i,
2285
- pages: @table_pages[table_name] || []
2286
- }
2287
- end
2288
-
2289
- metadata_path = "#{@path}.metadata"
2290
- @metadata_write_lock.synchronize do
2291
- @metadata_write_sequence += 1
2292
- temp_path = "#{metadata_path}.tmp-#{Process.pid}-#{Thread.current.object_id}-#{@metadata_write_sequence}"
2293
- File.open(temp_path, "wb") do |file|
2294
- file.write(JSON.generate(data))
2295
- file.flush
2296
- file.fsync
2297
- end
2298
- begin
2299
- File.rename(temp_path, metadata_path)
2300
- ensure
2301
- File.delete(temp_path) if File.file?(temp_path)
2302
- end
2303
- end
2304
- true
2305
- rescue SystemCallError, JSON::GeneratorError => error
2306
- raise StorageError, "Unable to persist table metadata for '#{@path}': #{error.message}"
2307
- end
2308
-
2309
- def start_cleanup_thread
2310
- return if @cleanup_thread&.alive?
2311
-
2312
- @cleanup_lock = Mutex.new
2313
- @cleanup_condition = ConditionVariable.new
2314
- @cleanup_stop = false
2315
- interval = @config[:cleanup_interval] || 3600
2316
- @cleanup_thread = Thread.new do
2317
- loop do
2318
- should_stop = @cleanup_lock.synchronize do
2319
- # Shutdown may be requested before this thread reaches its first
2320
- # wait. Check the predicate first so that signal is never lost.
2321
- @cleanup_condition.wait(@cleanup_lock, interval) unless @cleanup_stop
2322
- @cleanup_stop
2323
- end
2324
- break if should_stop
2325
-
2326
- begin
2327
- # Clean up old cache entries
2328
- cleanup_cache
2329
-
2330
- # Run vacuum
2331
- vacuum
2332
-
2333
- # Save metadata
2334
- save_table_metadata
2335
- rescue StandardError => error
2336
- @last_maintenance_error = "#{error.class}: #{error.message}"
2337
- end
2338
- end
2339
- end
2340
- end
2341
-
2342
- def stop_cleanup_thread
2343
- return unless @cleanup_thread
2344
-
2345
- @cleanup_lock.synchronize do
2346
- @cleanup_stop = true
2347
- @cleanup_condition.signal
2348
- end
2349
- @cleanup_thread.join
2350
- @cleanup_thread = nil
2351
- end
2352
- end
2353
- end
2354
- end
748
+ # and expect the database to allocate it. Keep allocation in the
749
+ # engine so embedded and server connections have identical behavior.
750
+ if values.is_a?(Hash)
751
+ primary_key = columns.find { |column| column.primary_key? }
752
+ primary_key_name = primary_key&.name&.to_sym
753
+ primary_key_present = primary_key && (values.key?(primary_key_name) || values.key?(primary_key.name.to_s))
754
+ primary_key_value = if primary_key
755
+ values.key?(primary_key_name) ? values[primary_key_name] : values[primary_key.name.to_s]
756
+ end
757
+ if primary_key && primary_key_name && %i[integer bigint smallint].include?(primary_key.type.to_sym) &&
758
+ (!primary_key_present || primary_key_value.nil?)
759
+ existing_rows = select_rows(table_name, metadata[:columns])
760
+ current_max = existing_rows.filter_map do |row|
761
+ value = row[primary_key_name] || row[primary_key.name.to_s]
762
+ value&.to_i
763
+ end.max || 0
764
+ values = values.dup
765
+ values[primary_key_name] = current_max + 1
766
+ end
767
+ end
768
+
769
+ validate_constraints!(table_name, metadata, columns, values)
770
+ validate_relational_constraints!(table_name, metadata, columns, values)
771
+ row_for_index = if values.is_a?(Hash)
772
+ values.merge(_row_id: metadata[:row_count] + 1)
773
+ else
774
+ columns.each_with_index.to_h { |column, index| [column.name, values[index]] }.merge(_row_id: metadata[:row_count] + 1)
775
+ end
776
+ @index_manager&.validate_insert!(table_name, row_for_index)
777
+
778
+ # Get the last data page or allocate new one
779
+ data_page = get_or_allocate_data_page(table_name)
780
+ page = read_page(data_page)
781
+
782
+ # Create row
783
+ row_id = metadata[:row_count] + 1
784
+ row = Row.new(row_id, columns, values)
785
+
786
+ # Serialize row data
787
+ row_data = Serializer.serialize_row(row, columns, null_bitmap: true)
788
+
789
+ # Log to WAL before writing to page
790
+ log_to_wal(WAL::Record::TYPE_INSERT, {
791
+ table_name: table_name,
792
+ row_id: row_id,
793
+ page: data_page,
794
+ values: values
795
+ }, current_transaction_id)
796
+
797
+ # Calculate required space (row header + data)
798
+ required_space = 16 + row_data.bytesize # 16 bytes for row header
799
+
800
+ # Check if we have enough space on the page
801
+ if page.free_space < required_space
802
+ # Allocate new page
803
+ new_page_num = allocate_page(StorageLayout::PAGE_TYPE_TABLE)
804
+ new_page = Page.new(new_page_num, @storage_manager.page_size)
805
+ new_page.header.page_type = StorageLayout::PAGE_TYPE_TABLE
806
+ new_page.header.data_end = PageHeader::SIZE
807
+ new_page.write_header
808
+ write_page(new_page)
809
+
810
+ @table_pages[table_name] << new_page_num
811
+ metadata[:last_page] = new_page_num
812
+
813
+ page = new_page
814
+ new_page_num
815
+ end
816
+
817
+ # Write record
818
+ offset = page.header.data_end
819
+ record_id = row_id
820
+ record_size = row_data.bytesize
821
+ flags = NULL_BITMAP_FLAG | VARIABLE_LENGTH_PREFIXES_FLAG
822
+ column_count = columns.size
823
+
824
+ # Write record header: record_id(8) + record_size(4) + flags(2) + column_count(2)
825
+ page.write(offset, [record_id, record_size, flags, column_count].pack("Q>L>S>S"))
826
+ offset += 16
827
+
828
+ # Write record data
829
+ page.write(offset, row_data)
830
+ offset += record_size
831
+
832
+ # Update page header
833
+ page.header.data_end = offset
834
+ page.write_header
835
+ write_page(page)
836
+
837
+ # Update metadata
838
+ metadata[:row_count] += 1
839
+ metadata[:updated_at] = Time.now
840
+ record_transaction_change(:insert, table_name, row_id, values: values, columns: columns)
841
+ record_mvcc_version(table_name, row_id, values, current_transaction_id)
842
+
843
+ @index_manager&.insert_row(table_name, values.merge(_row_id: row_id)) if values.is_a?(Hash)
844
+ if values.is_a?(Array)
845
+ @index_manager&.insert_row(table_name, columns.each_with_index.to_h { |column, index| [column.name, values[index]] }.merge(_row_id: row_id))
846
+ end
847
+
848
+ # Update catalog
849
+ if @catalog&.current_database
850
+ table = @catalog.find_table(table_name)
851
+ table.row_count = metadata[:row_count] if table
852
+ end
853
+
854
+ # Save metadata
855
+ save_table_metadata
856
+
857
+ # Invalidate cache
858
+ invalidate_cache(table_name, row_id)
859
+ fire_triggers(:insert, table_name, values, row_id)
860
+
861
+ row_id
862
+ end
863
+ end
864
+
865
+ def select_rows(table_name, columns, conditions = {})
866
+ table_name = resolve_table_name(table_name)
867
+ @lock.synchronize do
868
+ @stats[:row_selects] += 1
869
+
870
+ metadata = @table_metadata[table_name]
871
+ raise DatabaseError, "Table '#{table_name}' does not exist" unless metadata
872
+
873
+ # Get all data pages for the table
874
+ pages = @table_pages[table_name] || []
875
+ return [] if pages.empty?
876
+
877
+ rows = []
878
+ seen_row_ids = {}
879
+ indexed_row_ids = indexed_row_ids_for(table_name, conditions)
880
+ @stats[:index_scans] = (@stats[:index_scans] || 0) + 1 if indexed_row_ids
881
+ page_numbers = pages.dup
882
+
883
+ page_numbers.each do |page_number|
884
+ page = read_page(page_number)
885
+
886
+ # Scan records on the page
887
+ offset = PageHeader::SIZE
888
+
889
+ while offset < page.header.data_end
890
+ # Read record header
891
+ record_header = page.read(offset, 16)
892
+ break if record_header.nil? || record_header.bytesize < 16
893
+
894
+ record_id, record_size, flags, col_count = record_header.unpack("Q>L>S>S")
895
+ offset += 16
896
+
897
+ # Deleted row versions remain on the page until vacuum/compact;
898
+ # they must not be visible to ordinary scans.
899
+ if (flags & 0x01) != 0
900
+ offset += record_size
901
+ next
902
+ end
903
+ if indexed_row_ids && !indexed_row_ids.include?(record_id)
904
+ offset += record_size
905
+ next
906
+ end
907
+
908
+ # Read record data
909
+ record_data = page.read(offset, record_size)
910
+ offset += record_size
911
+
912
+ # Deserialize the physical row, then select the historical
913
+ # version for REPEATABLE READ transactions when available.
914
+ row = Deserializer.deserialize_row(
915
+ record_data,
916
+ columns,
917
+ null_bitmap: (flags & NULL_BITMAP_FLAG) != 0,
918
+ variable_length_prefixes: (flags & VARIABLE_LENGTH_PREFIXES_FLAG) != 0
919
+ )
920
+ # Older physical records may have fewer fields after an ADD
921
+ # COLUMN. Materialize the declared column default at read time
922
+ # without rewriting the original row or changing its identity.
923
+ if col_count < columns.size
924
+ columns.each_with_index do |column, index|
925
+ row[column.name] = column.default if index >= col_count && column.has_default?
926
+ end
927
+ end
928
+ snapshot = @transaction_snapshots[current_transaction_id]
929
+ if snapshot
930
+ @transaction_reads[current_transaction_id].add(version_key(table_name, record_id))
931
+ version = @version_store.get_latest_version(
932
+ record_id, current_transaction_id, snapshot,
933
+ key: version_key(table_name, record_id)
934
+ )
935
+ next if version.nil? && @version_store.keys(version_key(table_name, record_id)).any?
936
+ if version
937
+ row = version.data.is_a?(Array) ?
938
+ columns.each_with_index.to_h { |column, index| [column.name, version.data[index]] } : version.data.dup
939
+ end
940
+ end
941
+ row[:_row_id] = record_id
942
+ seen_row_ids[record_id] = true
943
+
944
+ # Active transactions must not read uncommitted or deleted
945
+ # versions. Callers can explicitly disable this only for
946
+ # internal recovery/maintenance operations.
947
+ transaction_id = conditions[:transaction_id] || current_transaction_id
948
+ visibility_check = conditions.key?(:visibility_check) ? conditions[:visibility_check] : in_transaction?
949
+ if visibility_check
950
+ unless @visibility_map.is_visible?(record_id, transaction_id)
951
+ next
952
+ end
953
+ end
954
+
955
+ # Apply conditions
956
+ if matches_conditions?(row, conditions)
957
+ rows << row
958
+ end
959
+ end
960
+ end
961
+
962
+ # A physical DELETE removes the row from the normal scan, but an
963
+ # older snapshot may still need the last committed version. Walk the
964
+ # table's version keys to restore those historical rows.
965
+ snapshot = @transaction_snapshots[current_transaction_id]
966
+ if snapshot
967
+ prefix = "#{table_name}\0"
968
+ @version_store.keys(prefix).each do |key|
969
+ row_id = key.to_s.split("\0", 2).last.to_i
970
+ next if seen_row_ids[row_id]
971
+
972
+ version = @version_store.get_latest_version(
973
+ row_id, current_transaction_id, snapshot, key: key
974
+ )
975
+ next unless version
976
+
977
+ row = version.data.is_a?(Array) ?
978
+ columns.each_with_index.to_h { |column, index| [column.name, version.data[index]] } : version.data.dup
979
+ row[:_row_id] = row_id
980
+ rows << row if matches_conditions?(row, conditions)
981
+ end
982
+ end
983
+
984
+ if current_transaction_manager && current_transaction_manager[:isolation_level].to_sym == :serializable
985
+ @transaction_predicates[current_transaction_id].add("#{table_name}\0")
986
+ end
987
+
988
+ # Apply limit and offset
989
+ if conditions[:limit]
990
+ offset_val = conditions[:offset] || 0
991
+ rows = rows[offset_val, conditions[:limit]]
992
+ end
993
+
994
+ rows
995
+ end
996
+ end
997
+
998
+ def select_row(table_name, row_id, columns)
999
+ table_name = resolve_table_name(table_name)
1000
+ @lock.synchronize do
1001
+ # Check cache first
1002
+ cache_key = "#{table_name}:#{row_id}"
1003
+ if @row_cache.key?(cache_key)
1004
+ cached_row, cached_time = @row_cache[cache_key]
1005
+ if Time.now - cached_time < @cache_ttl
1006
+ @stats[:cache_hits] += 1
1007
+ return cached_row
1008
+ end
1009
+ end
1010
+ @stats[:cache_misses] += 1
1011
+
1012
+ rows = select_rows(table_name, columns, {row_id: row_id, limit: 1})
1013
+ row = rows.first
1014
+
1015
+ # Cache the result
1016
+ if row
1017
+ @row_cache[cache_key] = [row, Time.now]
1018
+ cleanup_cache if @row_cache.size > @cache_size
1019
+ end
1020
+
1021
+ row
1022
+ end
1023
+ end
1024
+
1025
+ def update_row(table_name, row_id, values, conditions = {})
1026
+ ensure_writable!
1027
+ table_name = resolve_table_name(table_name)
1028
+ @lock.synchronize do
1029
+ @stats[:row_updates] += 1
1030
+
1031
+ metadata = @table_metadata[table_name]
1032
+ raise DatabaseError, "Table '#{table_name}' does not exist" unless metadata
1033
+
1034
+ transaction_id = conditions[:transaction_id] || current_transaction_id
1035
+
1036
+ updated = false
1037
+ old_values = nil
1038
+ updated_values = nil
1039
+ pages = @table_pages[table_name] || []
1040
+
1041
+ pages.each do |page_number|
1042
+ page = read_page(page_number)
1043
+ offset = PageHeader::SIZE
1044
+
1045
+ while offset < page.header.data_end
1046
+ record_header = page.read(offset, 16)
1047
+ break if record_header.nil? || record_header.bytesize < 16
1048
+
1049
+ record_id, record_size, flags, col_count = record_header.unpack("Q>L>S>S")
1050
+
1051
+ if record_id == row_id
1052
+ # Check visibility
1053
+ if conditions[:visibility_check] != false
1054
+ unless @visibility_map.is_visible?(row_id, transaction_id)
1055
+ break
1056
+ end
1057
+ end
1058
+
1059
+ # Read current row data
1060
+ columns = metadata[:columns]
1061
+ current_row = Deserializer.deserialize_row(
1062
+ page.read(offset + 16, record_size),
1063
+ columns,
1064
+ null_bitmap: (flags & NULL_BITMAP_FLAG) != 0,
1065
+ variable_length_prefixes: (flags & VARIABLE_LENGTH_PREFIXES_FLAG) != 0
1066
+ )
1067
+ old_values = current_row.except(:_row_id)
1068
+
1069
+ # Update values
1070
+ values.each do |key, value|
1071
+ current_row[key] = value
1072
+ end
1073
+ updated_values = current_row.except(:_row_id)
1074
+
1075
+ validate_constraints!(table_name, metadata, columns, updated_values, exclude_row_id: row_id)
1076
+ validate_relational_constraints!(table_name, metadata, columns, updated_values)
1077
+ validate_referential_update!(table_name, old_values, updated_values)
1078
+ @index_manager&.validate_update!(table_name, old_values.merge(_row_id: row_id), updated_values.merge(_row_id: row_id))
1079
+
1080
+ # Create updated row
1081
+ row = Row.new(row_id, columns, current_row)
1082
+ new_row_data = Serializer.serialize_row(row, columns, null_bitmap: true)
1083
+
1084
+ # The before-image is captured before the page write, so an
1085
+ # interrupted transaction can be undone safely.
1086
+ log_to_wal(WAL::Record::TYPE_UPDATE, {
1087
+ table_name: table_name,
1088
+ row_id: row_id,
1089
+ values: values,
1090
+ old_values: old_values
1091
+ }, transaction_id)
1092
+
1093
+ # Check if we have enough space for the updated row
1094
+ new_size = new_row_data.bytesize
1095
+ old_size = record_size
1096
+
1097
+ if new_size != old_size
1098
+ # Need to rewrite the row
1099
+ # Mark the old row as deleted/hidden
1100
+ flags |= 0x01 # Deleted flag
1101
+
1102
+ # Write updated header with new size
1103
+ # The old slot remains part of the page layout. Its
1104
+ # physical size must stay unchanged so subsequent records
1105
+ # remain aligned when the replacement is appended.
1106
+ page.write(offset, [record_id, old_size, flags, col_count].pack("Q>L>S>S"))
1107
+
1108
+ # Write new data (may overlap with old data)
1109
+ if new_size <= old_size
1110
+ # Keep the original physical allocation and mark this
1111
+ # record visible again. Length-prefixed fields allow the
1112
+ # reader to ignore any unused trailing bytes.
1113
+ page.write(offset + 16, new_row_data)
1114
+ page.write(offset, [record_id, old_size, (flags & ~0x01) | NULL_BITMAP_FLAG | VARIABLE_LENGTH_PREFIXES_FLAG, col_count].pack("Q>L>S>S"))
1115
+ else
1116
+ # Need to move to end of page
1117
+ new_offset = page.header.data_end
1118
+ page.write(new_offset, [record_id, new_size, (flags & ~0x01) | NULL_BITMAP_FLAG | VARIABLE_LENGTH_PREFIXES_FLAG, col_count].pack("Q>L>S>S"))
1119
+ page.write(new_offset + 16, new_row_data)
1120
+ page.header.data_end = new_offset + 16 + new_size
1121
+ end
1122
+ else
1123
+ # Same size - update in place
1124
+ page.write(offset + 16, new_row_data)
1125
+ page.write(offset, [record_id, record_size, flags | VARIABLE_LENGTH_PREFIXES_FLAG, col_count].pack("Q>L>S>S"))
1126
+ end
1127
+
1128
+ # Mark old version as hidden
1129
+ @visibility_map.mark_hidden(row_id, transaction_id)
1130
+
1131
+ # Mark new version as visible
1132
+ @visibility_map.mark_visible(row_id, transaction_id)
1133
+
1134
+ page.write_header
1135
+ write_page(page)
1136
+ updated = true
1137
+ break
1138
+ end
1139
+
1140
+ offset += 16 + record_size
1141
+ end
1142
+
1143
+ break if updated
1144
+ end
1145
+
1146
+ if updated
1147
+ # Invalidate cache
1148
+ invalidate_cache(table_name, row_id)
1149
+
1150
+ # Update metadata
1151
+ metadata[:updated_at] = Time.now
1152
+ save_table_metadata
1153
+ record_transaction_change(:update, table_name, row_id, old_values: old_values, values: values)
1154
+ record_mvcc_version(table_name, row_id, updated_values, transaction_id)
1155
+ @index_manager&.update_row(table_name, old_values.merge(_row_id: row_id), updated_values.merge(_row_id: row_id))
1156
+ apply_referential_update!(table_name, old_values, updated_values)
1157
+ fire_triggers(:update, table_name, updated_values, row_id, old_row: old_values)
1158
+ end
1159
+
1160
+ updated
1161
+ end
1162
+ end
1163
+
1164
+ def delete_row(table_name, row_id, conditions = {})
1165
+ ensure_writable!
1166
+ table_name = resolve_table_name(table_name)
1167
+ @lock.synchronize do
1168
+ @stats[:row_deletes] += 1
1169
+
1170
+ metadata = @table_metadata[table_name]
1171
+ raise DatabaseError, "Table '#{table_name}' does not exist" unless metadata
1172
+
1173
+ transaction_id = conditions[:transaction_id] || current_transaction_id
1174
+
1175
+ deleted = false
1176
+ old_values = nil
1177
+ pages = @table_pages[table_name] || []
1178
+
1179
+ pages.each do |page_number|
1180
+ page = read_page(page_number)
1181
+ offset = PageHeader::SIZE
1182
+
1183
+ while offset < page.header.data_end
1184
+ record_header = page.read(offset, 16)
1185
+ break if record_header.nil? || record_header.bytesize < 16
1186
+
1187
+ record_id, record_size, flags, col_count = record_header.unpack("Q>L>S>S")
1188
+
1189
+ if record_id == row_id
1190
+ # Check visibility
1191
+ if conditions[:visibility_check] != false
1192
+ unless @visibility_map.is_visible?(row_id, transaction_id)
1193
+ break
1194
+ end
1195
+ end
1196
+
1197
+ record_data = page.read(offset + 16, record_size)
1198
+ columns = metadata[:columns]
1199
+ current_row = Deserializer.deserialize_row(
1200
+ record_data,
1201
+ columns,
1202
+ null_bitmap: (flags & NULL_BITMAP_FLAG) != 0,
1203
+ variable_length_prefixes: (flags & VARIABLE_LENGTH_PREFIXES_FLAG) != 0
1204
+ )
1205
+ old_values = current_row.except(:_row_id)
1206
+ old_values_array = columns.map { |column| old_values[column.name] }
1207
+
1208
+ validate_referential_delete!(table_name, metadata, old_values)
1209
+
1210
+ log_to_wal(WAL::Record::TYPE_DELETE, {
1211
+ table_name: table_name,
1212
+ row_id: row_id,
1213
+ columns: columns,
1214
+ row_data: old_values_array,
1215
+ old_values: old_values
1216
+ }, transaction_id)
1217
+
1218
+ # Mark as deleted in visibility map
1219
+ @visibility_map.mark_deleted(row_id, transaction_id)
1220
+
1221
+ # Mark record as deleted on page
1222
+ flags |= 0x01 # Deleted flag
1223
+ page.write(offset, [record_id, record_size, flags, col_count].pack("Q>L>S>S"))
1224
+ page.write_header
1225
+ write_page(page)
1226
+
1227
+ deleted = true
1228
+ break
1229
+ end
1230
+
1231
+ offset += 16 + record_size
1232
+ end
1233
+
1234
+ break if deleted
1235
+ end
1236
+
1237
+ if deleted
1238
+ # Invalidate cache
1239
+ invalidate_cache(table_name, row_id)
1240
+
1241
+ # Update metadata
1242
+ metadata[:updated_at] = Time.now
1243
+ save_table_metadata
1244
+ record_transaction_change(:delete, table_name, row_id, old_values: old_values, columns: metadata[:columns])
1245
+ record_mvcc_version(table_name, row_id, old_values.merge(_deleted: true), transaction_id, deleted: true)
1246
+ @index_manager&.delete_row(table_name, old_values.merge(_row_id: row_id))
1247
+ fire_triggers(:delete, table_name, old_values, row_id)
1248
+ end
1249
+
1250
+ deleted
1251
+ end
1252
+ end
1253
+
1254
+ def restore_deleted_row(table_name, row_id)
1255
+ table_name = resolve_table_name(table_name)
1256
+ @lock.synchronize do
1257
+ metadata = @table_metadata[table_name]
1258
+ return false unless metadata
1259
+
1260
+ (@table_pages[table_name] || []).each do |page_number|
1261
+ page = read_page(page_number)
1262
+ offset = PageHeader::SIZE
1263
+ while offset < page.header.data_end
1264
+ header = page.read(offset, 16)
1265
+ break if header.nil? || header.bytesize < 16
1266
+
1267
+ record_id, record_size, flags, col_count = header.unpack("Q>L>S>S")
1268
+ if record_id == row_id && (flags & 0x01) != 0
1269
+ page.write(offset, [record_id, record_size, flags & ~0x01, col_count].pack("Q>L>S>S"))
1270
+ page.write_header
1271
+ write_page(page)
1272
+ @visibility_map.mark_visible(row_id, 0)
1273
+ invalidate_cache(table_name, row_id)
1274
+ return true
1275
+ end
1276
+ offset += 16 + record_size
1277
+ end
1278
+ end
1279
+ false
1280
+ end
1281
+ end
1282
+
1283
+ def restore_row_values(table_name, row_id, values)
1284
+ table_name = resolve_table_name(table_name)
1285
+ @lock.synchronize do
1286
+ metadata = @table_metadata[table_name]
1287
+ return false unless metadata
1288
+ columns = metadata[:columns]
1289
+ replacement = Serializer.serialize_row(Row.new(row_id, columns, values), columns, null_bitmap: true)
1290
+
1291
+ (@table_pages[table_name] || []).each do |page_number|
1292
+ page = read_page(page_number)
1293
+ offset = PageHeader::SIZE
1294
+ while offset < page.header.data_end
1295
+ header = page.read(offset, 16)
1296
+ break if header.nil? || header.bytesize < 16
1297
+ record_id, record_size, flags, col_count = header.unpack("Q>L>S>S")
1298
+ if record_id == row_id && (flags & 0x01) == 0
1299
+ # Append a fresh physical record. Variable-width values have
1300
+ # no per-column length prefix, so shrinking in place would
1301
+ # leave stale bytes that become part of the next value.
1302
+ page.write(offset, [row_id, record_size, 1, col_count].pack("Q>L>S>S"))
1303
+ new_offset = page.header.data_end
1304
+ page.write(new_offset, [row_id, replacement.bytesize, NULL_BITMAP_FLAG | VARIABLE_LENGTH_PREFIXES_FLAG, col_count].pack("Q>L>S>S"))
1305
+ page.write(new_offset + 16, replacement)
1306
+ page.header.data_end = new_offset + 16 + replacement.bytesize
1307
+ page.write_header
1308
+ write_page(page)
1309
+ invalidate_cache(table_name, row_id)
1310
+ return true
1311
+ end
1312
+ offset += 16 + record_size
1313
+ end
1314
+ end
1315
+ false
1316
+ end
1317
+ end
1318
+
1319
+ # Transaction support
1320
+ def begin_transaction(isolation_level = :read_committed)
1321
+ @lock.synchronize do
1322
+ unless %i[read_committed repeatable_read serializable].include?(isolation_level.to_sym)
1323
+ raise DatabaseError, "Isolation level #{isolation_level} is not supported yet"
1324
+ end
1325
+
1326
+ # Sessions have independent in-memory maps. Refresh only at the
1327
+ # transaction boundary so a new transaction sees commits made by
1328
+ # another session without discarding this session's active state.
1329
+ @visibility_map.load_visibility
1330
+ @version_store.load
1331
+ @stats[:transaction_begin] += 1
1332
+
1333
+ transaction_id = next_transaction_id
1334
+ transaction = {
1335
+ id: transaction_id,
1336
+ started_at: Time.now,
1337
+ isolation_level: isolation_level,
1338
+ active: true,
1339
+ changes: {},
1340
+ savepoints: {}
1341
+ }
1342
+ Thread.current[@transaction_context_key] = transaction
1343
+
1344
+ log_to_wal(WAL::Record::TYPE_BEGIN, {}, transaction_id)
1345
+
1346
+ # Register transaction with visibility map
1347
+ @visibility_map.register_transaction(transaction_id)
1348
+ if %i[repeatable_read serializable].include?(isolation_level.to_sym)
1349
+ @transaction_snapshots[transaction_id] = MVCC::Snapshot.new(
1350
+ transaction_id,
1351
+ @visibility_map.active_transactions.keys,
1352
+ @visibility_map.committed_transactions.to_a
1353
+ )
1354
+ end
1355
+
1356
+ transaction_id
1357
+ end
1358
+ end
1359
+
1360
+ def current_transaction
1361
+ current_transaction_manager
1362
+ end
1363
+
1364
+ def add_commit_listener(listener = nil, &block)
1365
+ listener ||= block
1366
+ raise ArgumentError, "Commit listener must respond to call" unless listener.respond_to?(:call)
1367
+
1368
+ @lock.synchronize do
1369
+ @commit_listeners << listener unless @commit_listeners.include?(listener)
1370
+ end
1371
+ listener
1372
+ end
1373
+
1374
+ def remove_commit_listener(listener)
1375
+ @lock.synchronize { @commit_listeners.delete(listener) }
1376
+ end
1377
+
1378
+ def commit_transaction(transaction = nil)
1379
+ @lock.synchronize do
1380
+ @stats[:transaction_commit] += 1
1381
+
1382
+ tx = transaction || current_transaction_manager
1383
+ return false unless tx && tx[:active]
1384
+
1385
+ begin
1386
+ # The commit record is the transaction's durable commit point. It
1387
+ # must be flushed and fsynced before the transaction is reported.
1388
+ if (tx[:isolation_level] || :read_committed).to_sym == :serializable
1389
+ @version_store.validate_serializable!(
1390
+ @transaction_snapshots.fetch(tx[:id]),
1391
+ @transaction_reads[tx[:id]],
1392
+ @transaction_writes[tx[:id]],
1393
+ read_predicates: @transaction_predicates[tx[:id]]
1394
+ )
1395
+ end
1396
+ commit_lsn = log_to_wal(WAL::Record::TYPE_COMMIT, {}, tx[:id])
1397
+ if @wal
1398
+ @wal.flush
1399
+ @wal.sync
1400
+ end
1401
+ @last_commit_ack = {
1402
+ status: :durable,
1403
+ transaction_id: tx[:id],
1404
+ commit_lsn: commit_lsn&.to_i,
1405
+ recovery_required: false
1406
+ }
1407
+
1408
+ # Commit transaction in visibility map
1409
+ @visibility_map.commit_transaction(tx[:id])
1410
+ commit_mvcc_versions(tx[:id])
1411
+ @transaction_snapshots.delete(tx[:id])
1412
+ @transaction_reads.delete(tx[:id])
1413
+ @transaction_writes.delete(tx[:id])
1414
+ @transaction_predicates.delete(tx[:id])
1415
+
1416
+ # A page or metadata flush can fail after the WAL commit is
1417
+ # durable. Recovery can replay that commit, so retain a truthful
1418
+ # durable acknowledgement instead of reporting an ambiguous abort.
1419
+ begin
1420
+ flush
1421
+ rescue => error
1422
+ @last_commit_ack[:recovery_required] = true
1423
+ @last_commit_ack[:flush_error] = "#{error.class}: #{error.message}"
1424
+ end
1425
+
1426
+ tx[:active] = false
1427
+ tx[:committed_at] = Time.now
1428
+ clear_transaction_context(tx)
1429
+
1430
+ committed_changes = (tx[:changes] || {}).flat_map do |table_name, rows|
1431
+ rows.values.map { |change| change.merge(table: table_name) }
1432
+ end
1433
+ listener_errors = []
1434
+ @commit_listeners.dup.each do |listener|
1435
+ next if committed_changes.empty?
1436
+
1437
+ begin
1438
+ listener.call(tx[:id], committed_changes)
1439
+ rescue => error
1440
+ listener_errors << "#{error.class}: #{error.message}"
1441
+ end
1442
+ end
1443
+ @last_commit_ack[:replication_errors] = listener_errors unless listener_errors.empty?
1444
+
1445
+ true
1446
+ # A commit acknowledgement is uncertain for every interruption class;
1447
+ # callers must recover rather than treating it as committed.
1448
+ # rubocop:disable Lint/RescueException
1449
+ rescue Exception => error
1450
+ @last_commit_ack = {
1451
+ status: :uncertain,
1452
+ transaction_id: tx[:id],
1453
+ error: "#{error.class}: #{error.message}",
1454
+ recovery_required: true
1455
+ }
1456
+ raise
1457
+ end
1458
+ # rubocop:enable Lint/RescueException
1459
+ end
1460
+ end
1461
+
1462
+ def rollback_transaction(transaction = nil)
1463
+ @lock.synchronize do
1464
+ @stats[:transaction_rollback] += 1
1465
+
1466
+ tx = transaction || current_transaction_manager
1467
+ return false unless tx && tx[:active]
1468
+
1469
+ # Rollback transaction in visibility map
1470
+ @visibility_map.abort_transaction(tx[:id])
1471
+
1472
+ # Rollback changes
1473
+ abort_mvcc_versions(tx[:id])
1474
+ @transaction_snapshots.delete(tx[:id])
1475
+ @transaction_reads.delete(tx[:id])
1476
+ @transaction_writes.delete(tx[:id])
1477
+ @transaction_predicates.delete(tx[:id])
1478
+ tx[:changes]&.reverse_each do |table_name, rows|
1479
+ rows.to_a.reverse_each do |row_id, change|
1480
+ if change[:type] == :insert
1481
+ mark_row_deleted(change[:table_name] || table_name, row_id)
1482
+ if @index_manager
1483
+ indexed_row = if change[:values].is_a?(Hash)
1484
+ change[:values].merge(_row_id: row_id)
1485
+ elsif change[:columns].is_a?(Array)
1486
+ change[:columns].each_with_index.to_h { |column, index| [column.respond_to?(:name) ? column.name : column, change[:values][index]] }.merge(_row_id: row_id)
1487
+ end
1488
+ @index_manager.delete_row(change[:table_name] || table_name, indexed_row) if indexed_row
1489
+ end
1490
+ elsif change[:type] == :update
1491
+ restore_row_values(change[:table_name] || table_name, row_id, change[:old_values] || {})
1492
+ elsif change[:type] == :delete
1493
+ table = change[:table_name] || table_name
1494
+ restore_deleted_row(table, row_id)
1495
+ @index_manager&.insert_row(table, change[:old_values].merge(_row_id: row_id)) if change[:old_values].is_a?(Hash)
1496
+ end
1497
+ end
1498
+ end
1499
+
1500
+ tx[:active] = false
1501
+ tx[:aborted_at] = Time.now
1502
+
1503
+ log_to_wal(WAL::Record::TYPE_ROLLBACK, {}, tx[:id])
1504
+ @wal&.sync
1505
+ clear_transaction_context(tx)
1506
+
1507
+ true
1508
+ end
1509
+ end
1510
+
1511
+ def in_transaction?
1512
+ current_transaction_manager && current_transaction_manager[:active]
1513
+ end
1514
+
1515
+ def accelerator_snapshot_eligible?
1516
+ return false unless @is_open
1517
+ return false if in_transaction?
1518
+ return false unless @visibility_map.active_transaction_count.zero?
1519
+
1520
+ current_transaction_id.to_i.zero?
1521
+ end
1522
+
1523
+ def create_savepoint(name)
1524
+ @lock.synchronize do
1525
+ tx = current_transaction_manager
1526
+ raise DatabaseError, "SAVEPOINT requires an active transaction" unless tx && tx[:active]
1527
+ name = name.to_s
1528
+ tx[:savepoints] ||= {}
1529
+ tx[:savepoints][name] = {
1530
+ changes: tx[:changes].transform_values { |rows| rows.transform_values(&:dup) },
1531
+ version_count: @transaction_versions[tx[:id]].length
1532
+ }
1533
+ true
1534
+ end
1535
+ end
1536
+
1537
+ def rollback_to_savepoint(name)
1538
+ @lock.synchronize do
1539
+ tx = current_transaction_manager
1540
+ raise DatabaseError, "ROLLBACK TO SAVEPOINT requires an active transaction" unless tx && tx[:active]
1541
+ savepoint = tx[:savepoints]&.[](name.to_s)
1542
+ raise DatabaseError, "Savepoint '#{name}' does not exist" unless savepoint
1543
+
1544
+ tx[:changes].each do |table_name, rows|
1545
+ rows.to_a.reverse_each do |row_id, change|
1546
+ next if savepoint[:changes].dig(table_name, row_id)
1547
+ case change[:type]
1548
+ when :insert
1549
+ mark_row_deleted(change[:table_name] || table_name, row_id)
1550
+ when :update
1551
+ restore_row_values(change[:table_name] || table_name, row_id, change[:old_values] || {})
1552
+ when :delete
1553
+ table = change[:table_name] || table_name
1554
+ restore_deleted_row(table, row_id)
1555
+ @index_manager&.insert_row(table, change[:old_values].merge(_row_id: row_id)) if change[:old_values].is_a?(Hash)
1556
+ end
1557
+ end
1558
+ end
1559
+
1560
+ versions = @transaction_versions[tx[:id]]
1561
+ versions.slice!(savepoint[:version_count], versions.length) if versions.length > savepoint[:version_count]
1562
+ tx[:changes] = savepoint[:changes].transform_values { |rows| rows.transform_values(&:dup) }
1563
+ true
1564
+ end
1565
+ end
1566
+
1567
+ def release_savepoint(name)
1568
+ @lock.synchronize do
1569
+ tx = current_transaction_manager
1570
+ raise DatabaseError, "RELEASE SAVEPOINT requires an active transaction" unless tx && tx[:active]
1571
+ savepoints = tx[:savepoints] || {}
1572
+ raise DatabaseError, "Savepoint '#{name}' does not exist" unless savepoints.delete(name.to_s)
1573
+ true
1574
+ end
1575
+ end
1576
+
1577
+ # Recovery uses the normal storage operations so page/catalog/index
1578
+ # maintenance remains centralized, but must not emit new WAL records.
1579
+ def with_recovery
1580
+ previous = @recovery_in_progress
1581
+ @recovery_in_progress = true
1582
+ yield
1583
+ ensure
1584
+ @recovery_in_progress = previous
1585
+ end
1586
+
1587
+ def record_transaction_change(type, table_name, row_id, details = {})
1588
+ transaction = current_transaction_manager
1589
+ return unless transaction && transaction[:active]
1590
+
1591
+ (transaction[:changes][table_name] ||= {})[row_id] =
1592
+ details.merge(type: type, table: table_name, row_id: row_id)
1593
+ end
1594
+
1595
+ def record_mvcc_version(table_name, row_id, data, transaction_id, deleted: false)
1596
+ return if @recovery_in_progress || !@version_store
1597
+
1598
+ version = @version_store.create_version(row_id, data, transaction_id, key: version_key(table_name, row_id))
1599
+ @transaction_writes[transaction_id].add(version_key(table_name, row_id)) if transaction_id.to_i != 0
1600
+ version.mark_deleted if deleted
1601
+ if transaction_id.to_i == 0
1602
+ @version_store.commit_version(version, 0)
1603
+ else
1604
+ @transaction_versions[transaction_id] << version
1605
+ end
1606
+ end
1607
+
1608
+ def version_key(table_name, row_id)
1609
+ "#{table_name}\0#{row_id}"
1610
+ end
1611
+
1612
+ def commit_mvcc_versions(transaction_id)
1613
+ @transaction_versions.delete(transaction_id)&.each do |version|
1614
+ @version_store.commit_version(version, transaction_id)
1615
+ end
1616
+ @version_store.persist
1617
+ end
1618
+
1619
+ def abort_mvcc_versions(transaction_id)
1620
+ @transaction_versions.delete(transaction_id)&.each do |version|
1621
+ @version_store.abort_version(version)
1622
+ end
1623
+ @version_store.persist
1624
+ end
1625
+
1626
+ # Cache operations
1627
+ def invalidate_cache(table_name, row_id = nil)
1628
+ if row_id
1629
+ @row_cache.delete("#{table_name}:#{row_id}")
1630
+ else
1631
+ # Invalidate all rows for table
1632
+ @row_cache.delete_if { |key, _| key.start_with?("#{table_name}:") }
1633
+ end
1634
+ end
1635
+
1636
+ def clear_cache
1637
+ @row_cache.clear
1638
+ @stats[:cache_hits] = 0
1639
+ @stats[:cache_misses] = 0
1640
+ end
1641
+
1642
+ # Utility operations
1643
+ def flush
1644
+ @storage_manager.flush
1645
+ @visibility_map.flush
1646
+ save_table_metadata
1647
+ end
1648
+
1649
+ def close
1650
+ return unless @is_open
1651
+
1652
+ stop_cleanup_thread
1653
+ @accelerator&.close
1654
+ @snapshot_reader&.close
1655
+
1656
+ # Flush WAL first (ensures all mutations are recorded)
1657
+ if @wal
1658
+ @wal.flush
1659
+ @wal.checkpoint.create_checkpoint(@wal.current_lsn)
1660
+ end
1661
+
1662
+ flush
1663
+ @wal&.shutdown
1664
+ @version_store&.persist
1665
+ @storage_manager.close
1666
+ @is_open = false
1667
+ @database_lock.release
1668
+ true
1669
+ end
1670
+
1671
+ def stats
1672
+ @lock.synchronize do
1673
+ {
1674
+ table_count: @table_metadata.size,
1675
+ row_count: @table_metadata.values.sum { |m| m[:row_count] || 0 },
1676
+ page_count: @page_manager.total_pages,
1677
+ free_pages: @page_manager.free_pages,
1678
+ used_pages: @page_manager.used_pages,
1679
+ buffer_size: @buffer_pool.size,
1680
+ cache_size: @row_cache.size,
1681
+ cache_hits: @stats[:cache_hits],
1682
+ cache_misses: @stats[:cache_misses],
1683
+ cache_hit_rate: cache_hit_rate,
1684
+ table_creates: @stats[:table_creates],
1685
+ row_inserts: @stats[:row_inserts],
1686
+ row_selects: @stats[:row_selects],
1687
+ row_updates: @stats[:row_updates],
1688
+ row_deletes: @stats[:row_deletes],
1689
+ index_scans: @stats[:index_scans] || 0,
1690
+ transaction_begin: @stats[:transaction_begin],
1691
+ transaction_commit: @stats[:transaction_commit],
1692
+ transaction_rollback: @stats[:transaction_rollback],
1693
+ wal_writes: @stats[:wal_writes],
1694
+ crash_recoveries: @stats[:crash_recoveries],
1695
+ accelerator: @accelerator&.stats,
1696
+ last_maintenance_error: @last_maintenance_error,
1697
+ is_open: @is_open,
1698
+ visibility_rows: @visibility_map.visibility_info.size,
1699
+ active_transactions: @visibility_map.active_transaction_count
1700
+ }
1701
+ end
1702
+ end
1703
+
1704
+ def open?
1705
+ @is_open
1706
+ end
1707
+
1708
+ def vacuum
1709
+ ensure_writable!
1710
+ result = @visibility_map.vacuum
1711
+ save_table_metadata
1712
+ result
1713
+ end
1714
+
1715
+ def compact_table(table_name)
1716
+ ensure_writable!
1717
+ table_name = resolve_table_name(table_name)
1718
+ @lock.synchronize do
1719
+ metadata = @table_metadata[table_name]
1720
+ return false unless metadata
1721
+
1722
+ pages = @table_pages[table_name] || []
1723
+ compacted_count = 0
1724
+
1725
+ pages.each do |page_number|
1726
+ result = @page_allocator.compact_page(page_number)
1727
+ compacted_count += 1 if result[:compacted]
1728
+ end
1729
+
1730
+ compacted_count > 0
1731
+ end
1732
+ end
1733
+
1734
+ # Crash recovery
1735
+ def run_crash_recovery
1736
+ return false unless @wal
1737
+
1738
+ # Check if recovery is needed (WAL files exist)
1739
+ wal_dir = @config[:wal_dir] || "#{@path}.wal"
1740
+ return false unless Dir.exist?(wal_dir) && Dir.glob(File.join(wal_dir, "*.log")).any?
1741
+
1742
+ @stats[:crash_recoveries] += 1
1743
+ result = @crash_recovery.recover
1744
+ return true if result.is_a?(Hash) && result[:success]
1745
+
1746
+ detail = result.is_a?(Hash) ? result[:error] : "recovery returned an invalid result"
1747
+ raise RecoveryError, "Crash recovery failed for '#{@path}': #{detail || "unknown error"}"
1748
+ rescue RecoveryError
1749
+ raise
1750
+ rescue => error
1751
+ raise RecoveryError, "Crash recovery failed for '#{@path}': #{error.message}"
1752
+ end
1753
+
1754
+ # WAL logging helpers
1755
+ def log_to_wal(type, data, transaction_id = nil)
1756
+ return nil unless @wal && !@recovery_in_progress
1757
+
1758
+ transaction_id ||= current_transaction_id
1759
+ record = WAL::Record.new(type, data, transaction_id: transaction_id)
1760
+ @stats[:wal_writes] += 1
1761
+
1762
+ @wal.write(record)
1763
+ end
1764
+
1765
+ private
1766
+
1767
+ def current_transaction_manager
1768
+ Thread.current[@transaction_context_key]
1769
+ end
1770
+
1771
+ def current_transaction_id
1772
+ current_transaction_manager&.[](:id) || 0
1773
+ end
1774
+
1775
+ def clear_transaction_context(transaction)
1776
+ Thread.current[@transaction_context_key] = nil if current_transaction_manager.equal?(transaction)
1777
+ end
1778
+
1779
+ def ensure_writable!
1780
+ return if Thread.current[:rubydb_replication_apply_depth].to_i.positive?
1781
+
1782
+ @write_fence&.assert_valid!
1783
+ return unless @replication_read_only
1784
+
1785
+ raise ReplicationError, "Replica is read-only; promote it before accepting local writes"
1786
+ end
1787
+
1788
+ def fire_triggers(event, table_name, row, row_id, old_row: nil)
1789
+ database = @catalog&.current_database
1790
+ return unless database&.respond_to?(:triggers)
1791
+
1792
+ database.triggers.values.each do |trigger|
1793
+ next unless trigger.enabled? && trigger.table_name.to_s == table_name.to_s
1794
+ next unless trigger.event_types.map(&:to_sym).include?(event.to_sym)
1795
+ next unless trigger.definition.respond_to?(:call)
1796
+
1797
+ trigger.definition.call(
1798
+ event: event,
1799
+ table_name: table_name,
1800
+ row: row,
1801
+ old_row: old_row,
1802
+ row_id: row_id
1803
+ )
1804
+ end
1805
+ end
1806
+
1807
+ def get_or_allocate_data_page(table_name)
1808
+ metadata = @table_metadata[table_name]
1809
+ pages = @table_pages[table_name] || []
1810
+
1811
+ if pages.empty?
1812
+ # Allocate first page
1813
+ page_number = allocate_page(StorageLayout::PAGE_TYPE_TABLE)
1814
+ pages << page_number
1815
+ @table_pages[table_name] = pages
1816
+ metadata[:first_page] = page_number
1817
+ metadata[:last_page] = page_number
1818
+ return page_number
1819
+ end
1820
+
1821
+ # Check if last page has space
1822
+ last_page = pages.last
1823
+ page = read_page(last_page)
1824
+
1825
+ if page.free_space < 1000 # Less than 1KB free
1826
+ # Allocate new page
1827
+ new_page = allocate_page(StorageLayout::PAGE_TYPE_TABLE)
1828
+ pages << new_page
1829
+ @table_pages[table_name] = pages
1830
+ metadata[:last_page] = new_page
1831
+ return new_page
1832
+ end
1833
+
1834
+ last_page
1835
+ end
1836
+
1837
+ def mark_row_deleted(table_name, row_id)
1838
+ pages = @table_pages[table_name] || []
1839
+ pages.each do |page_number|
1840
+ page = read_page(page_number)
1841
+ offset = PageHeader::SIZE
1842
+ while offset < page.header.data_end
1843
+ header = page.read(offset, 16)
1844
+ break if header.nil? || header.bytesize < 16
1845
+ record_id, record_size, flags, col_count = header.unpack("Q>L>S>S")
1846
+ if record_id == row_id && (flags & 0x01).zero?
1847
+ page.write(offset, [record_id, record_size, flags | 0x01, col_count].pack("Q>L>S>S"))
1848
+ page.write_header
1849
+ write_page(page)
1850
+ return true
1851
+ end
1852
+ offset += 16 + record_size
1853
+ end
1854
+ end
1855
+ false
1856
+ end
1857
+
1858
+ # Enforce the column-level constraints that are persisted in table
1859
+ # metadata before any WAL record or page mutation is produced.
1860
+ def validate_constraints!(table_name, metadata, columns, values, exclude_row_id: nil)
1861
+ candidate = if values.is_a?(Hash)
1862
+ values.transform_keys { |key| key.respond_to?(:to_sym) ? key.to_sym : key }
1863
+ else
1864
+ columns.each_with_index.to_h do |column, index|
1865
+ [column.name.to_sym, values[index]]
1866
+ end
1867
+ end
1868
+ schema = metadata[:columns] || columns
1869
+
1870
+ schema.each do |column|
1871
+ name = column.name.to_sym
1872
+ if (column.primary_key? || !column.nullable?) && candidate[name].nil?
1873
+ raise DatabaseError, "Column '#{name}' on '#{table_name}' cannot be NULL"
1874
+ end
1875
+ end
1876
+
1877
+ constrained_sets = schema.filter_map do |column|
1878
+ [column.name.to_sym] if column.primary_key? || column.unique?
1879
+ end
1880
+ return true if constrained_sets.empty?
1881
+
1882
+ existing_rows = select_rows(table_name, schema, visibility_check: false)
1883
+ constrained_sets.each do |column_names|
1884
+ key = column_names.map { |name| candidate[name] }
1885
+ next if key.all?(&:nil?) && !schema.any? { |column| column.name.to_sym == column_names.first && column.primary_key? }
1886
+
1887
+ duplicate = existing_rows.any? do |existing|
1888
+ next false if exclude_row_id && existing[:_row_id] == exclude_row_id
1889
+ column_names.all? do |name|
1890
+ existing_value = existing.key?(name) ? existing[name] : existing[name.to_s]
1891
+ existing_value == candidate[name]
1892
+ end
1893
+ end
1894
+ if duplicate
1895
+ raise DatabaseError, "Duplicate value for #{column_names.join(", ")} on '#{table_name}'"
1896
+ end
1897
+ end
1898
+ true
1899
+ end
1900
+
1901
+ def validate_relational_constraints!(table_name, metadata, columns, values)
1902
+ candidate = if values.is_a?(Hash)
1903
+ values.transform_keys { |key| key.respond_to?(:to_sym) ? key.to_sym : key }
1904
+ else
1905
+ columns.each_with_index.to_h { |column, index| [column.name.to_sym, values[index]] }
1906
+ end
1907
+
1908
+ (metadata[:constraints] || []).each do |definition|
1909
+ definition = definition.transform_keys(&:to_sym)
1910
+ type = definition[:type].to_s.downcase
1911
+ case type
1912
+ when "unique"
1913
+ unique_columns = Array(definition[:columns]).map(&:to_sym)
1914
+ next if unique_columns.empty?
1915
+ key = unique_columns.map { |column| candidate[column] }
1916
+ next if key.all?(&:nil?)
1917
+ duplicate = select_rows(table_name, columns, visibility_check: false).any? do |row|
1918
+ unique_columns.all? { |column| (row[column] || row[column.to_s]) == candidate[column] }
1919
+ end
1920
+ raise DatabaseError, "Duplicate value for #{unique_columns.join(", ")} on '#{table_name}'" if duplicate
1921
+ when "check"
1922
+ expression = definition[:expression]
1923
+ constraint = Constraints::CheckConstraint.new(table_name, expression, expression_type: :sql)
1924
+ unless constraint.validate(candidate)
1925
+ raise DatabaseError, constraint.validation_errors.last
1926
+ end
1927
+ when "foreign_key", "foreign key"
1928
+ foreign_columns = Array(definition[:columns]).map(&:to_sym)
1929
+ reference_table = definition[:reference_table]
1930
+ reference_columns = Array(definition[:reference_columns] || :id).map(&:to_sym)
1931
+ next if foreign_columns.all? { |column| candidate[column].nil? }
1932
+
1933
+ reference_metadata = @table_metadata[reference_table] || @table_metadata[reference_table.to_sym]
1934
+ raise DatabaseError, "Referenced table '#{reference_table}' does not exist" unless reference_metadata
1935
+ resolved_reference_table = @table_metadata.key?(reference_table) ? reference_table : reference_table.to_sym
1936
+ reference_rows = select_rows(resolved_reference_table, reference_metadata[:columns], visibility_check: false)
1937
+ found = reference_rows.any? do |reference_row|
1938
+ foreign_columns.each_with_index.all? do |column, index|
1939
+ reference_value = reference_row[reference_columns[index]] || reference_row[reference_columns[index].to_s]
1940
+ reference_value == candidate[column]
1941
+ end
1942
+ end
1943
+ unless found
1944
+ raise DatabaseError, "Referenced row does not exist for '#{table_name}'"
1945
+ end
1946
+ end
1947
+ end
1948
+ true
1949
+ end
1950
+
1951
+ def serialize_constraint_definitions(constraints)
1952
+ constraints.map do |constraint|
1953
+ definition = if constraint.respond_to?(:to_hash)
1954
+ constraint.to_hash
1955
+ elsif constraint.respond_to?(:columns) || constraint.respond_to?(:condition)
1956
+ constraint_definition(constraint)
1957
+ else
1958
+ constraint
1959
+ end
1960
+ definition = definition.transform_keys(&:to_sym)
1961
+ {
1962
+ type: definition[:type],
1963
+ name: definition[:name],
1964
+ columns: definition[:columns],
1965
+ reference_table: definition[:reference_table],
1966
+ reference_columns: definition[:reference_columns],
1967
+ on_delete: definition[:on_delete],
1968
+ on_update: definition[:on_update],
1969
+ expression: definition[:expression]
1970
+ }.reject { |_, value| value.nil? }
1971
+ end
1972
+ end
1973
+
1974
+ def constraint_definition(constraint)
1975
+ type = constraint.class.name.split("::").last.sub(/Constraint$/, "").gsub(/([a-z])([A-Z])/, '\\1_\\2').downcase
1976
+ definition = {type: type, name: constraint.name}
1977
+ definition[:columns] = constraint.columns if constraint.respond_to?(:columns)
1978
+ if constraint.respond_to?(:reference_table)
1979
+ definition[:reference_table] = constraint.reference_table
1980
+ definition[:reference_columns] = constraint.reference_columns
1981
+ end
1982
+ definition[:on_delete] = constraint.on_delete if constraint.respond_to?(:on_delete)
1983
+ definition[:on_update] = constraint.on_update if constraint.respond_to?(:on_update)
1984
+ definition[:expression] = constraint.condition.to_sql if constraint.respond_to?(:condition)
1985
+ definition
1986
+ end
1987
+
1988
+ def matches_conditions?(row, conditions)
1989
+ conditions.each do |key, value|
1990
+ next if key == :transaction_id || key == :visibility_check ||
1991
+ key == :limit || key == :offset || key == :row_id
1992
+
1993
+ if key == :_row_id
1994
+ return false unless row[:_row_id] == value
1995
+ elsif (row.key?(key) ? row[key] : row[key.to_s]) != value
1996
+ if value.is_a?(Hash)
1997
+ row_value = row.key?(key) ? row[key] : row[key.to_s]
1998
+ return false unless matches_operator?(row_value, value)
1999
+ else
2000
+ return false
2001
+ end
2002
+ end
2003
+ end
2004
+
2005
+ # Check row_id condition
2006
+ if conditions[:row_id]
2007
+ return false unless row[:_row_id] == conditions[:row_id]
2008
+ end
2009
+
2010
+ true
2011
+ end
2012
+
2013
+ def indexed_row_ids_for(table_name, conditions)
2014
+ return nil unless @index_manager
2015
+ return nil if conditions.empty?
2016
+
2017
+ index = @index_manager.get_indexes_for_table(table_name).find do |candidate|
2018
+ candidate.columns.all? do |column|
2019
+ conditions.key?(column) || conditions.key?(column.to_s)
2020
+ end
2021
+ end
2022
+ return nil unless index
2023
+
2024
+ condition = index.columns.map do |column|
2025
+ conditions.key?(column) ? conditions[column] : conditions[column.to_s]
2026
+ end.first
2027
+
2028
+ if condition.is_a?(Hash)
2029
+ operator = (condition[:operator] || condition["operator"]).to_sym
2030
+ value = condition[:value] || condition["value"]
2031
+ return nil unless %i[between lt lte gt gte].include?(operator)
2032
+ if operator == :between
2033
+ start_key, end_key = value
2034
+ elsif %i[lt lte].include?(operator)
2035
+ start_key = range_minimum(value)
2036
+ end_key = value
2037
+ else
2038
+ start_key = value
2039
+ end_key = range_maximum(value)
2040
+ end
2041
+ result = index.range_search(start_key, end_key)
2042
+ result = result.map { |entry| entry[:value] || entry[:row_id] }
2043
+ result.reject! { |row_id| row_id.nil? }
2044
+ else
2045
+ key = (index.columns.size == 1) ? condition : index.columns.map { |column| conditions[column] }
2046
+ result = index.search(key)
2047
+ end
2048
+ result.is_a?(Array) ? result.to_set : Set.new([result].compact)
2049
+ end
2050
+
2051
+ def matches_operator?(row_value, condition)
2052
+ operator = (condition[:operator] || condition["operator"]).to_sym
2053
+ value = condition[:value] || condition["value"]
2054
+ return false if row_value.nil?
2055
+ case operator
2056
+ when :eq then row_value == value
2057
+ when :lt then row_value < value
2058
+ when :lte then row_value <= value
2059
+ when :gt then row_value > value
2060
+ when :gte then row_value >= value
2061
+ when :between then row_value >= value[0] && row_value <= value[1]
2062
+ else false
2063
+ end
2064
+ rescue TypeError
2065
+ false
2066
+ end
2067
+
2068
+ def range_minimum(value)
2069
+ value.is_a?(Numeric) ? -Float::INFINITY : ""
2070
+ end
2071
+
2072
+ def range_maximum(value)
2073
+ value.is_a?(Numeric) ? Float::INFINITY : "\u{10ffff}"
2074
+ end
2075
+
2076
+ def validate_referential_delete!(table_name, metadata, deleted_row)
2077
+ @table_metadata.each do |child_table, child_metadata|
2078
+ (child_metadata[:constraints] || []).each do |definition|
2079
+ definition = definition.transform_keys(&:to_sym)
2080
+ next unless %w[foreign_key foreign key].include?(definition[:type].to_s.downcase)
2081
+ next unless definition[:reference_table].to_s == table_name.to_s
2082
+
2083
+ child_columns = Array(definition[:columns]).map(&:to_sym)
2084
+ parent_columns = Array(definition[:reference_columns] || :id).map(&:to_sym)
2085
+ child_rows = select_rows(child_table, child_metadata[:columns], visibility_check: false)
2086
+ referenced = child_rows.any? do |child_row|
2087
+ child_columns.each_with_index.all? do |child_column, index|
2088
+ child_value = child_row[child_column] || child_row[child_column.to_s]
2089
+ parent_value = deleted_row[parent_columns[index]] || deleted_row[parent_columns[index].to_s]
2090
+ !child_value.nil? && child_value == parent_value
2091
+ end
2092
+ end
2093
+ next unless referenced
2094
+
2095
+ action = (definition[:on_delete] || :restrict).to_s.downcase.tr("- ", "__")
2096
+ matching_rows = child_rows.select do |child_row|
2097
+ child_columns.each_with_index.all? do |child_column, index|
2098
+ child_value = child_row[child_column] || child_row[child_column.to_s]
2099
+ parent_value = deleted_row[parent_columns[index]] || deleted_row[parent_columns[index].to_s]
2100
+ !child_value.nil? && child_value == parent_value
2101
+ end
2102
+ end
2103
+
2104
+ case action
2105
+ when "cascade"
2106
+ matching_rows.each do |child_row|
2107
+ child_row_id = child_row[:_row_id] || child_row["_row_id"]
2108
+ delete_row(child_table, child_row_id, visibility_check: false)
2109
+ end
2110
+ when "set_null"
2111
+ matching_rows.each do |child_row|
2112
+ child_row_id = child_row[:_row_id] || child_row["_row_id"]
2113
+ updates = child_columns.to_h { |column| [column, nil] }
2114
+ update_row(child_table, child_row_id, updates, visibility_check: false)
2115
+ end
2116
+ when "set_default"
2117
+ matching_rows.each do |child_row|
2118
+ child_row_id = child_row[:_row_id] || child_row["_row_id"]
2119
+ updates = child_columns.each_with_object({}) do |column, result|
2120
+ result[column] = column.default
2121
+ end
2122
+ update_row(child_table, child_row_id, updates, visibility_check: false)
2123
+ end
2124
+ when "restrict", "no_action", "noaction"
2125
+ raise DatabaseError, "Cannot delete referenced row from '#{table_name}' (ON DELETE #{action.upcase})"
2126
+ else
2127
+ raise DatabaseError, "Unsupported ON DELETE action: #{action}"
2128
+ end
2129
+ end
2130
+ end
2131
+ true
2132
+ end
2133
+
2134
+ def validate_referential_update!(table_name, old_row, new_row)
2135
+ return true if referential_row_values_equal?(table_name, old_row, new_row)
2136
+
2137
+ @table_metadata.each do |child_table, child_metadata|
2138
+ (child_metadata[:constraints] || []).each do |raw_definition|
2139
+ definition = raw_definition.transform_keys(&:to_sym)
2140
+ next unless %w[foreign_key foreign key].include?(definition[:type].to_s.downcase)
2141
+ next unless definition[:reference_table].to_s == table_name.to_s
2142
+
2143
+ child_columns = Array(definition[:columns]).map(&:to_sym)
2144
+ parent_columns = Array(definition[:reference_columns] || :id).map(&:to_sym)
2145
+ next unless parent_columns.any? { |column| row_value(old_row, column) != row_value(new_row, column) }
2146
+
2147
+ child_rows = select_rows(child_table, child_metadata[:columns], visibility_check: false)
2148
+ referenced = child_rows.any? do |child_row|
2149
+ child_columns.each_with_index.all? do |child_column, index|
2150
+ child_value = row_value(child_row, child_column)
2151
+ parent_value = row_value(old_row, parent_columns[index])
2152
+ !child_value.nil? && child_value == parent_value
2153
+ end
2154
+ end
2155
+ next unless referenced
2156
+
2157
+ action = (definition[:on_update] || :restrict).to_s.downcase.tr("- ", "__")
2158
+ case action
2159
+ when "cascade", "set_null", "set_default"
2160
+ if action == "set_null"
2161
+ child_columns.each do |column_name|
2162
+ column = child_metadata[:columns].find { |candidate| candidate.name.to_sym == column_name }
2163
+ raise DatabaseError, "ON UPDATE SET NULL requires nullable column '#{column_name}'" unless column&.nullable?
2164
+ end
2165
+ end
2166
+ when "restrict", "no_action", "noaction"
2167
+ raise DatabaseError, "Cannot update referenced row in '#{table_name}' (ON UPDATE #{action.upcase})"
2168
+ else
2169
+ raise DatabaseError, "Unsupported ON UPDATE action: #{action}"
2170
+ end
2171
+ end
2172
+ end
2173
+ true
2174
+ end
2175
+
2176
+ def apply_referential_update!(table_name, old_row, new_row)
2177
+ return true if referential_row_values_equal?(table_name, old_row, new_row)
2178
+
2179
+ @table_metadata.each do |child_table, child_metadata|
2180
+ (child_metadata[:constraints] || []).each do |raw_definition|
2181
+ definition = raw_definition.transform_keys(&:to_sym)
2182
+ next unless %w[foreign_key foreign key].include?(definition[:type].to_s.downcase)
2183
+ next unless definition[:reference_table].to_s == table_name.to_s
2184
+
2185
+ child_columns = Array(definition[:columns]).map(&:to_sym)
2186
+ parent_columns = Array(definition[:reference_columns] || :id).map(&:to_sym)
2187
+ next unless parent_columns.any? { |column| row_value(old_row, column) != row_value(new_row, column) }
2188
+ child_rows = select_rows(child_table, child_metadata[:columns], visibility_check: false)
2189
+ matching_rows = child_rows.select do |child_row|
2190
+ child_columns.each_with_index.all? do |child_column, index|
2191
+ child_value = row_value(child_row, child_column)
2192
+ parent_value = row_value(old_row, parent_columns[index])
2193
+ !child_value.nil? && child_value == parent_value
2194
+ end
2195
+ end
2196
+ next if matching_rows.empty?
2197
+
2198
+ action = (definition[:on_update] || :restrict).to_s.downcase.tr("- ", "__")
2199
+ matching_rows.each do |child_row|
2200
+ child_row_id = child_row[:_row_id] || child_row["_row_id"]
2201
+ updates = case action
2202
+ when "cascade"
2203
+ child_columns.each_with_index.to_h { |column, index| [column, row_value(new_row, parent_columns[index])] }
2204
+ when "set_null"
2205
+ child_columns.to_h { |column| [column, nil] }
2206
+ when "set_default"
2207
+ child_columns.each_with_object({}) do |column, result|
2208
+ metadata_column = child_metadata[:columns].find { |candidate| candidate.name.to_sym == column }
2209
+ result[column] = metadata_column&.default
2210
+ end
2211
+ else
2212
+ next
2213
+ end
2214
+ update_row(child_table, child_row_id, updates, visibility_check: false)
2215
+ end
2216
+ end
2217
+ end
2218
+ true
2219
+ end
2220
+
2221
+ # Rows can cross the storage, SQL, WAL, and recovery boundaries with
2222
+ # symbol or string keys. Referential actions compare column values, not
2223
+ # the Ruby key representation. Without this normalization, updating a
2224
+ # non-key parent column could look like a primary-key change during WAL
2225
+ # replay and incorrectly fail ON UPDATE RESTRICT.
2226
+ def row_value(row, column)
2227
+ return row[column] if row.key?(column)
2228
+ return row[column.to_s] if row.key?(column.to_s)
2229
+
2230
+ nil
2231
+ end
2232
+
2233
+ def referential_row_values_equal?(table_name, old_row, new_row)
2234
+ columns = @table_metadata[table_name.to_s] || @table_metadata[table_name.to_sym]
2235
+ column_definitions = columns && columns[:columns]
2236
+ return old_row == new_row if column_definitions.nil?
2237
+
2238
+ column_definitions.all? do |column|
2239
+ row_value(old_row, column.name.to_sym) == row_value(new_row, column.name.to_sym)
2240
+ end
2241
+ end
2242
+
2243
+ def cache_hit_rate
2244
+ total = @stats[:cache_hits] + @stats[:cache_misses]
2245
+ return 0.0 if total == 0
2246
+ (@stats[:cache_hits].to_f / total * 100).round(2)
2247
+ end
2248
+
2249
+ def cleanup_cache
2250
+ if @row_cache.size > @cache_size
2251
+ # Remove oldest entries
2252
+ sorted = @row_cache.sort_by { |_, (_, time)| time }
2253
+ remove_count = (@row_cache.size - @cache_size) * 0.2
2254
+ sorted.first(remove_count.to_i).each do |key, _|
2255
+ @row_cache.delete(key)
2256
+ end
2257
+ end
2258
+ end
2259
+
2260
+ def next_transaction_id
2261
+ # Transaction IDs cross session/process boundaries in the persisted
2262
+ # visibility and MVCC stores. A per-engine counter can collide when
2263
+ # two sessions open the same database, so include a time/process
2264
+ # component and retain a local monotonic suffix for same-tick calls.
2265
+ @transaction_id_counter ||= 0
2266
+ @transaction_id_counter += 1
2267
+ @transaction_id_namespace ||= SecureRandom.random_number(1_000_000)
2268
+ ((Time.now.to_f * 1_000_000).to_i * 1_000_000_000) +
2269
+ (Process.pid * 1_000_000) + (@transaction_id_namespace * 1_000) +
2270
+ @transaction_id_counter
2271
+ end
2272
+
2273
+ def load_table_metadata
2274
+ metadata_path = "#{@path}.metadata"
2275
+ return unless File.exist?(metadata_path)
2276
+
2277
+ data = File.read(metadata_path)
2278
+ parsed = JSON.parse(data, symbolize_names: true)
2279
+ unless parsed.is_a?(Hash) && parsed[:tables].is_a?(Hash)
2280
+ raise CorruptionError, "Invalid table metadata format in #{metadata_path}"
2281
+ end
2282
+
2283
+ parsed[:tables].each do |table_name, table_data|
2284
+ unless table_data.is_a?(Hash) && table_data[:columns].is_a?(Array) && table_data[:pages].is_a?(Array)
2285
+ raise CorruptionError, "Invalid table metadata for '#{table_name}' in #{metadata_path}"
2286
+ end
2287
+
2288
+ @table_metadata[table_name] = {
2289
+ metadata_page: table_data[:metadata_page],
2290
+ data_page: table_data[:data_page],
2291
+ columns: table_data[:columns].map do |c|
2292
+ options = {
2293
+ null: c[:nullable],
2294
+ primary_key: c[:primary_key] || false,
2295
+ unique: c[:unique] || false,
2296
+ default: c[:default]
2297
+ }
2298
+ Catalog::Column.new(c[:name], c[:type].respond_to?(:to_sym) ? c[:type].to_sym : c[:type], **options)
2299
+ end,
2300
+ column_count: table_data[:column_count] || 0,
2301
+ row_count: table_data[:row_count] || 0,
2302
+ constraints: table_data[:constraints] || [],
2303
+ created_at: Time.at(table_data[:created_at]),
2304
+ updated_at: Time.at(table_data[:updated_at])
2305
+ }
2306
+ @table_pages[table_name] = table_data[:pages]
2307
+ end
2308
+ rescue JSON::ParserError, TypeError, KeyError, ArgumentError => error
2309
+ raise CorruptionError, "Unable to load table metadata #{metadata_path}: #{error.message}"
2310
+ end
2311
+
2312
+ def rewrite_table_metadata_page(table_name, metadata)
2313
+ page = Page.new(metadata[:metadata_page], @storage_manager.page_size)
2314
+ page.write(0, begin
2315
+ serialized = StorageLayout::TableMetadata.new
2316
+ serialized.table_id = metadata[:metadata_page]
2317
+ serialized.table_name = table_name.to_s
2318
+ serialized.column_count = metadata[:columns].size
2319
+ serialized.row_count = metadata[:row_count].to_i
2320
+ serialized.first_page = metadata[:data_page]
2321
+ serialized.last_page = (@table_pages[table_name] || [metadata[:data_page]]).last
2322
+ serialized.created_at = metadata[:created_at].to_i
2323
+ serialized.updated_at = metadata[:updated_at].to_i
2324
+ serialized.serialize
2325
+ end)
2326
+ metadata[:columns].each_with_index do |column, index|
2327
+ column_meta = StorageLayout::ColumnMetadata.new
2328
+ column_meta.column_id = index + 1
2329
+ column_meta.column_name = column.name
2330
+ column_meta.data_type = column.type_class
2331
+ column_meta.is_nullable = column.nullable?
2332
+ column_meta.is_primary_key = column.primary_key?
2333
+ column_meta.position = index
2334
+ column_meta.default = column.default if column.has_default?
2335
+ page.write(PageHeader::SIZE + index * 128, column_meta.serialize)
2336
+ end
2337
+ write_page(page)
2338
+ end
2339
+
2340
+ def save_table_metadata
2341
+ data = {
2342
+ tables: {}
2343
+ }
2344
+
2345
+ @table_metadata.each do |table_name, metadata|
2346
+ data[:tables][table_name] = {
2347
+ metadata_page: metadata[:metadata_page],
2348
+ data_page: metadata[:data_page],
2349
+ columns: metadata[:columns].map do |c|
2350
+ {
2351
+ name: c.name,
2352
+ type: c.type_class,
2353
+ nullable: c.nullable?,
2354
+ primary_key: c.primary_key?,
2355
+ unique: c.unique?,
2356
+ default: c.default
2357
+ }
2358
+ end,
2359
+ constraints: metadata[:constraints] || [],
2360
+ column_count: metadata[:column_count],
2361
+ row_count: metadata[:row_count],
2362
+ created_at: metadata[:created_at].to_i,
2363
+ updated_at: metadata[:updated_at].to_i,
2364
+ pages: @table_pages[table_name] || []
2365
+ }
2366
+ end
2367
+
2368
+ metadata_path = "#{@path}.metadata"
2369
+ @metadata_write_lock.synchronize do
2370
+ @metadata_write_sequence += 1
2371
+ temp_path = "#{metadata_path}.tmp-#{Process.pid}-#{Thread.current.object_id}-#{@metadata_write_sequence}"
2372
+ File.open(temp_path, "wb") do |file|
2373
+ file.write(JSON.generate(data))
2374
+ file.flush
2375
+ file.fsync
2376
+ end
2377
+ begin
2378
+ File.rename(temp_path, metadata_path)
2379
+ ensure
2380
+ File.delete(temp_path) if File.file?(temp_path)
2381
+ end
2382
+ end
2383
+ true
2384
+ rescue SystemCallError, JSON::GeneratorError => error
2385
+ raise StorageError, "Unable to persist table metadata for '#{@path}': #{error.message}"
2386
+ end
2387
+
2388
+ def start_cleanup_thread
2389
+ return if @cleanup_thread&.alive?
2390
+
2391
+ @cleanup_lock = Mutex.new
2392
+ @cleanup_condition = ConditionVariable.new
2393
+ @cleanup_stop = false
2394
+ interval = @config[:cleanup_interval] || 3600
2395
+ @cleanup_thread = Thread.new do
2396
+ loop do
2397
+ should_stop = @cleanup_lock.synchronize do
2398
+ # Shutdown may be requested before this thread reaches its first
2399
+ # wait. Check the predicate first so that signal is never lost.
2400
+ @cleanup_condition.wait(@cleanup_lock, interval) unless @cleanup_stop
2401
+ @cleanup_stop
2402
+ end
2403
+ break if should_stop
2404
+
2405
+ begin
2406
+ # Clean up old cache entries
2407
+ cleanup_cache
2408
+
2409
+ # Run vacuum
2410
+ vacuum
2411
+
2412
+ # Save metadata
2413
+ save_table_metadata
2414
+ rescue => error
2415
+ @last_maintenance_error = "#{error.class}: #{error.message}"
2416
+ end
2417
+ end
2418
+ end
2419
+ end
2420
+
2421
+ def stop_cleanup_thread
2422
+ return unless @cleanup_thread
2423
+
2424
+ @cleanup_lock.synchronize do
2425
+ @cleanup_stop = true
2426
+ @cleanup_condition.signal
2427
+ end
2428
+ @cleanup_thread.join
2429
+ @cleanup_thread = nil
2430
+ end
2431
+ end
2432
+ end
2433
+ end