rubydb 0.1.0

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