rubydb 0.1.3 → 0.1.5

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