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.
- checksums.yaml +7 -0
- data/.gitattributes +20 -0
- data/.github/ISSUE_TEMPLATE/bug.yml +0 -0
- data/.github/ISSUE_TEMPLATE/feature.yml +0 -0
- data/.github/ISSUE_TEMPLATE/performance.yml +0 -0
- data/.github/ISSUE_TEMPLATE/security.yml +0 -0
- data/.github/PULL_REQUEST_TEMPLATE.md +15 -0
- data/.github/dependabot.yml +0 -0
- data/.github/workflows/benchmark.yml +26 -0
- data/.github/workflows/compatibility.yml +63 -0
- data/.github/workflows/fuzz.yml +33 -0
- data/.github/workflows/lint.yml +21 -0
- data/.github/workflows/operations.yml +24 -0
- data/.github/workflows/production-validation.yml +111 -0
- data/.github/workflows/release.yml +77 -0
- data/.github/workflows/security.yml +37 -0
- data/.github/workflows/test.yml +26 -0
- data/.github/workflows/workload.yml +58 -0
- data/.gitignore +43 -0
- data/.rspec +4 -0
- data/.rubocop.yml +44 -0
- data/.ruby-version +1 -0
- data/.standard.yml +14 -0
- data/ARCHITECTURE.md +21 -0
- data/CHANGELOG.md +31 -0
- data/CODE_OF_CONDUCT.md +13 -0
- data/CONTRIBUTING.md +29 -0
- data/GOVERNANCE.md +16 -0
- data/Gemfile +17 -0
- data/Gemfile.lock +71 -0
- data/LICENSE +60 -0
- data/README.md +162 -0
- data/ROADMAP.md +27 -0
- data/Rakefile +71 -0
- data/SECURITY.md +54 -0
- data/SUPPORT.md +14 -0
- data/adapters/activerecord/Gemfile +11 -0
- data/adapters/activerecord/README.md +9 -0
- data/adapters/activerecord/lib/active_record/connection_adapters/rubydb_adapter.rb +887 -0
- data/adapters/activerecord/rubydb-activerecord.gemspec +27 -0
- data/adapters/activerecord/spec/rubydb_adapter_integration_spec.rb +151 -0
- data/adapters/ruby/README.md +18 -0
- data/adapters/sequel/README.md +11 -0
- data/bin/rubydb +19 -0
- data/bin/rubydb-server +126 -0
- data/config/development.yml +0 -0
- data/config/monitoring/prometheus-alerts.yml +39 -0
- data/config/production.yml +36 -0
- data/config/test.yml +0 -0
- data/docs/README.md +71 -0
- data/docs/architecture/concurrency.md +14 -0
- data/docs/architecture/current-state.md +125 -0
- data/docs/architecture/execution-engine.md +25 -0
- data/docs/architecture/indexes.md +19 -0
- data/docs/architecture/mvcc.md +19 -0
- data/docs/architecture/overview.md +13 -0
- data/docs/architecture/pages.md +11 -0
- data/docs/architecture/production-roadmap.md +82 -0
- data/docs/architecture/query-planner.md +20 -0
- data/docs/architecture/recovery.md +18 -0
- data/docs/architecture/sql-engine.md +12 -0
- data/docs/architecture/storage-engine.md +14 -0
- data/docs/architecture/transactions.md +10 -0
- data/docs/architecture/wal.md +28 -0
- data/docs/cli-cheatsheet.md +98 -0
- data/docs/cli.md +275 -0
- data/docs/contributing/architecture.md +9 -0
- data/docs/contributing/benchmarking.md +14 -0
- data/docs/contributing/development.md +16 -0
- data/docs/contributing/release-process.md +49 -0
- data/docs/contributing/testing.md +16 -0
- data/docs/debugging.md +229 -0
- data/docs/developer/branching.md +10 -0
- data/docs/developer/database-diff.md +10 -0
- data/docs/developer/local-development.md +17 -0
- data/docs/developer/snapshots.md +9 -0
- data/docs/developer/temporal-data.md +10 -0
- data/docs/developer-guide.md +297 -0
- data/docs/getting-started/first-database.md +16 -0
- data/docs/getting-started/first-query.md +13 -0
- data/docs/getting-started/installation.md +19 -0
- data/docs/getting-started/local-to-production.md +300 -0
- data/docs/getting-started/quickstart.md +17 -0
- data/docs/getting-started/rails.md +16 -0
- data/docs/hardening_backlog.md +93 -0
- data/docs/lessons-learned.md +112 -0
- data/docs/operations/backups.md +33 -0
- data/docs/operations/disaster-recovery.md +31 -0
- data/docs/operations/failover.md +30 -0
- data/docs/operations/monitoring.md +25 -0
- data/docs/operations/production-guide.md +295 -0
- data/docs/operations/production-runbook.md +45 -0
- data/docs/operations/replication.md +33 -0
- data/docs/operations/restore.md +6 -0
- data/docs/operations/runbook.md +34 -0
- data/docs/operations/upgrades.md +14 -0
- data/docs/operations/workload-testing.md +17 -0
- data/docs/production-readiness.md +118 -0
- data/docs/production_validation.md +150 -0
- data/docs/rails/active-record.md +11 -0
- data/docs/rails/compatibility-guide.md +90 -0
- data/docs/rails/database-yml.md +92 -0
- data/docs/rails/installation.md +17 -0
- data/docs/rails/migrations.md +17 -0
- data/docs/rails/production.md +82 -0
- data/docs/rails/troubleshooting.md +18 -0
- data/docs/release.md +59 -0
- data/docs/server/architecture.md +10 -0
- data/docs/server/authentication.md +10 -0
- data/docs/server/configuration.md +16 -0
- data/docs/server/connection-pooling.md +10 -0
- data/docs/server/deployment.md +10 -0
- data/docs/server/protocol.md +12 -0
- data/docs/sql/compatibility-guide.md +82 -0
- data/docs/sql/compatibility.md +39 -0
- data/docs/sql/data-types.md +10 -0
- data/docs/sql/functions.md +9 -0
- data/docs/sql/joins.md +9 -0
- data/docs/sql/operators.md +9 -0
- data/docs/sql/sqlite-compatibility.md +21 -0
- data/docs/sql/syntax.md +10 -0
- data/docs/sql/transactions.md +10 -0
- data/docs/troubleshooting.md +244 -0
- data/exe/rubydb +23 -0
- data/lib/rubydb/backup/archive.rb +334 -0
- data/lib/rubydb/backup/backup.rb +401 -0
- data/lib/rubydb/backup/incremental.rb +353 -0
- data/lib/rubydb/backup/restore.rb +290 -0
- data/lib/rubydb/backup/snapshot.rb +267 -0
- data/lib/rubydb/backup/verification.rb +279 -0
- data/lib/rubydb/branching/branch.rb +181 -0
- data/lib/rubydb/branching/branch_manager.rb +311 -0
- data/lib/rubydb/branching/branch_metadata.rb +140 -0
- data/lib/rubydb/branching/checkout.rb +166 -0
- data/lib/rubydb/branching/copy_on_write.rb +272 -0
- data/lib/rubydb/branching/diff.rb +138 -0
- data/lib/rubydb/branching/merge.rb +285 -0
- data/lib/rubydb/build_info.rb +15 -0
- data/lib/rubydb/catalog/catalog.rb +391 -0
- data/lib/rubydb/catalog/column.rb +112 -0
- data/lib/rubydb/catalog/constraint.rb +180 -0
- data/lib/rubydb/catalog/database.rb +184 -0
- data/lib/rubydb/catalog/index.rb +97 -0
- data/lib/rubydb/catalog/schema.rb +103 -0
- data/lib/rubydb/catalog/sequence.rb +90 -0
- data/lib/rubydb/catalog/system_catalog.rb +698 -0
- data/lib/rubydb/catalog/table.rb +178 -0
- data/lib/rubydb/catalog/trigger.rb +102 -0
- data/lib/rubydb/catalog/view.rb +66 -0
- data/lib/rubydb/cli/application.rb +163 -0
- data/lib/rubydb/cli/commands/backup.rb +81 -0
- data/lib/rubydb/cli/commands/branch.rb +72 -0
- data/lib/rubydb/cli/commands/checkout.rb +54 -0
- data/lib/rubydb/cli/commands/create.rb +58 -0
- data/lib/rubydb/cli/commands/diff.rb +77 -0
- data/lib/rubydb/cli/commands/doctor.rb +74 -0
- data/lib/rubydb/cli/commands/drop.rb +57 -0
- data/lib/rubydb/cli/commands/init.rb +102 -0
- data/lib/rubydb/cli/commands/inspect.rb +95 -0
- data/lib/rubydb/cli/commands/merge.rb +63 -0
- data/lib/rubydb/cli/commands/migrate.rb +62 -0
- data/lib/rubydb/cli/commands/restart.rb +39 -0
- data/lib/rubydb/cli/commands/restore.rb +121 -0
- data/lib/rubydb/cli/commands/shell.rb +365 -0
- data/lib/rubydb/cli/commands/snapshot.rb +79 -0
- data/lib/rubydb/cli/commands/start.rb +82 -0
- data/lib/rubydb/cli/commands/status.rb +92 -0
- data/lib/rubydb/cli/commands/stop.rb +47 -0
- data/lib/rubydb/cli/commands/vacuum.rb +58 -0
- data/lib/rubydb/cli/formatter.rb +221 -0
- data/lib/rubydb/cli/output.rb +168 -0
- data/lib/rubydb/client/client.rb +304 -0
- data/lib/rubydb/client/connection.rb +415 -0
- data/lib/rubydb/client/connection_pool.rb +168 -0
- data/lib/rubydb/client/connection_url.rb +96 -0
- data/lib/rubydb/client/prepared_statement.rb +60 -0
- data/lib/rubydb/client/result.rb +123 -0
- data/lib/rubydb/client/statement.rb +52 -0
- data/lib/rubydb/client/transaction.rb +130 -0
- data/lib/rubydb/concurrency/concurrency.rb +19 -0
- data/lib/rubydb/concurrency/deadlock_detector.rb +150 -0
- data/lib/rubydb/concurrency/latch.rb +101 -0
- data/lib/rubydb/concurrency/lock_graph.rb +165 -0
- data/lib/rubydb/concurrency/mutex.rb +183 -0
- data/lib/rubydb/concurrency/rw_lock.rb +180 -0
- data/lib/rubydb/concurrency/scheduler.rb +250 -0
- data/lib/rubydb/concurrency/worker_pool.rb +143 -0
- data/lib/rubydb/configuration/config.rb +170 -0
- data/lib/rubydb/configuration/defaults.rb +179 -0
- data/lib/rubydb/configuration/environment.rb +152 -0
- data/lib/rubydb/configuration/parser.rb +185 -0
- data/lib/rubydb/configuration/validation.rb +221 -0
- data/lib/rubydb/constants.rb +74 -0
- data/lib/rubydb/constraints/check.rb +181 -0
- data/lib/rubydb/constraints/constraint.rb +101 -0
- data/lib/rubydb/constraints/foreign_key.rb +130 -0
- data/lib/rubydb/constraints/not_null.rb +64 -0
- data/lib/rubydb/constraints/primary_key.rb +99 -0
- data/lib/rubydb/constraints/unique.rb +108 -0
- data/lib/rubydb/constraints/validator.rb +350 -0
- data/lib/rubydb/errors/authentication_error.rb +10 -0
- data/lib/rubydb/errors/authorization_error.rb +23 -0
- data/lib/rubydb/errors/client_error.rb +10 -0
- data/lib/rubydb/errors/configuration_error.rb +10 -0
- data/lib/rubydb/errors/connection_error.rb +10 -0
- data/lib/rubydb/errors/constraint_error.rb +23 -0
- data/lib/rubydb/errors/corruption_error.rb +10 -0
- data/lib/rubydb/errors/database_error.rb +10 -0
- data/lib/rubydb/errors/error.rb +20 -0
- data/lib/rubydb/errors/execution_error.rb +10 -0
- data/lib/rubydb/errors/parser_error.rb +10 -0
- data/lib/rubydb/errors/recovery_error.rb +10 -0
- data/lib/rubydb/errors/replication_error.rb +10 -0
- data/lib/rubydb/errors/server_error.rb +6 -0
- data/lib/rubydb/errors/storage_error.rb +10 -0
- data/lib/rubydb/errors/transaction_error.rb +10 -0
- data/lib/rubydb/execution/aggregate_executor.rb +138 -0
- data/lib/rubydb/execution/delete_executor.rb +112 -0
- data/lib/rubydb/execution/distinct_executor.rb +135 -0
- data/lib/rubydb/execution/executor.rb +1192 -0
- data/lib/rubydb/execution/expression.rb +193 -0
- data/lib/rubydb/execution/index_scan.rb +142 -0
- data/lib/rubydb/execution/insert_executor.rb +217 -0
- data/lib/rubydb/execution/join_executor.rb +249 -0
- data/lib/rubydb/execution/limit_executor.rb +85 -0
- data/lib/rubydb/execution/optimizer.rb +215 -0
- data/lib/rubydb/execution/plan.rb +361 -0
- data/lib/rubydb/execution/planner.rb +546 -0
- data/lib/rubydb/execution/predicate.rb +235 -0
- data/lib/rubydb/execution/scan.rb +49 -0
- data/lib/rubydb/execution/sequential_scan.rb +63 -0
- data/lib/rubydb/execution/sort_executor.rb +185 -0
- data/lib/rubydb/execution/update_executor.rb +162 -0
- data/lib/rubydb/functions/aggregate.rb +70 -0
- data/lib/rubydb/functions/date_functions.rb +278 -0
- data/lib/rubydb/functions/function.rb +85 -0
- data/lib/rubydb/functions/json_functions.rb +215 -0
- data/lib/rubydb/functions/numeric_functions.rb +346 -0
- data/lib/rubydb/functions/scalar.rb +52 -0
- data/lib/rubydb/functions/string_functions.rb +383 -0
- data/lib/rubydb/functions/system_functions.rb +246 -0
- data/lib/rubydb/history/as_of.rb +238 -0
- data/lib/rubydb/history/change.rb +105 -0
- data/lib/rubydb/history/history.rb +131 -0
- data/lib/rubydb/history/history_manager.rb +229 -0
- data/lib/rubydb/history/temporal_query.rb +202 -0
- data/lib/rubydb/history/timeline.rb +144 -0
- data/lib/rubydb/indexes/btree.rb +186 -0
- data/lib/rubydb/indexes/btree_cursor.rb +258 -0
- data/lib/rubydb/indexes/btree_node.rb +385 -0
- data/lib/rubydb/indexes/hash_index.rb +150 -0
- data/lib/rubydb/indexes/index.rb +71 -0
- data/lib/rubydb/indexes/index_manager.rb +406 -0
- data/lib/rubydb/indexes/index_scan.rb +470 -0
- data/lib/rubydb/migrations/migration.rb +254 -0
- data/lib/rubydb/migrations/migration_lock.rb +146 -0
- data/lib/rubydb/migrations/migration_manager.rb +176 -0
- data/lib/rubydb/migrations/migration_version.rb +71 -0
- data/lib/rubydb/migrations/schema_diff.rb +211 -0
- data/lib/rubydb/migrations/schema_version.rb +64 -0
- data/lib/rubydb/monitoring/events.rb +160 -0
- data/lib/rubydb/monitoring/health.rb +222 -0
- data/lib/rubydb/monitoring/logger.rb +193 -0
- data/lib/rubydb/monitoring/metrics.rb +359 -0
- data/lib/rubydb/monitoring/performance.rb +176 -0
- data/lib/rubydb/monitoring/statistics.rb +170 -0
- data/lib/rubydb/mvcc/garbage_collector.rb +199 -0
- data/lib/rubydb/mvcc/mvcc.rb +16 -0
- data/lib/rubydb/mvcc/snapshot.rb +147 -0
- data/lib/rubydb/mvcc/vacuum.rb +180 -0
- data/lib/rubydb/mvcc/version.rb +106 -0
- data/lib/rubydb/mvcc/version_store.rb +398 -0
- data/lib/rubydb/mvcc/visibility.rb +109 -0
- data/lib/rubydb/protocol/capabilities.rb +125 -0
- data/lib/rubydb/protocol/decoder.rb +145 -0
- data/lib/rubydb/protocol/encoder.rb +136 -0
- data/lib/rubydb/protocol/handshake.rb +305 -0
- data/lib/rubydb/protocol/message.rb +121 -0
- data/lib/rubydb/protocol/protocol.rb +277 -0
- data/lib/rubydb/protocol/version.rb +54 -0
- data/lib/rubydb/rails/adapter.rb +239 -0
- data/lib/rubydb/rails/connection.rb +314 -0
- data/lib/rubydb/rails/database_statements.rb +122 -0
- data/lib/rubydb/rails/migration.rb +131 -0
- data/lib/rubydb/rails/quoting.rb +109 -0
- data/lib/rubydb/rails/result.rb +120 -0
- data/lib/rubydb/rails/schema_statements.rb +339 -0
- data/lib/rubydb/rails/transaction.rb +105 -0
- data/lib/rubydb/rails/type.rb +126 -0
- data/lib/rubydb/recovery/checkpoint.rb +257 -0
- data/lib/rubydb/recovery/consistency.rb +467 -0
- data/lib/rubydb/recovery/corruption_detector.rb +5 -0
- data/lib/rubydb/recovery/crash_recovery.rb +387 -0
- data/lib/rubydb/recovery/recovery_manager.rb +206 -0
- data/lib/rubydb/recovery/redo.rb +237 -0
- data/lib/rubydb/recovery/undo.rb +206 -0
- data/lib/rubydb/replication/failover.rb +5 -0
- data/lib/rubydb/replication/fencing.rb +63 -0
- data/lib/rubydb/replication/primary.rb +450 -0
- data/lib/rubydb/replication/replica.rb +384 -0
- data/lib/rubydb/replication/replication_log.rb +200 -0
- data/lib/rubydb/replication/replication_manager.rb +308 -0
- data/lib/rubydb/replication/replication_slot.rb +295 -0
- data/lib/rubydb/replication/replication_stream.rb +201 -0
- data/lib/rubydb/rubydb.rb +560 -0
- data/lib/rubydb/security/access_control.rb +254 -0
- data/lib/rubydb/security/audit_log.rb +213 -0
- data/lib/rubydb/security/authentication.rb +302 -0
- data/lib/rubydb/security/authorization.rb +282 -0
- data/lib/rubydb/security/credentials.rb +196 -0
- data/lib/rubydb/security/password.rb +215 -0
- data/lib/rubydb/security/permissions.rb +74 -0
- data/lib/rubydb/security/role.rb +101 -0
- data/lib/rubydb/security/user.rb +86 -0
- data/lib/rubydb/server/connection.rb +366 -0
- data/lib/rubydb/server/connection_pool.rb +193 -0
- data/lib/rubydb/server/lifecycle.rb +228 -0
- data/lib/rubydb/server/listener.rb +136 -0
- data/lib/rubydb/server/request_handler.rb +276 -0
- data/lib/rubydb/server/server.rb +364 -0
- data/lib/rubydb/server/session.rb +369 -0
- data/lib/rubydb/server/worker.rb +210 -0
- data/lib/rubydb/server/worker_pool.rb +168 -0
- data/lib/rubydb/sql/ast/alter_table.rb +169 -0
- data/lib/rubydb/sql/ast/begin_transaction.rb +47 -0
- data/lib/rubydb/sql/ast/commit.rb +37 -0
- data/lib/rubydb/sql/ast/constraint.rb +83 -0
- data/lib/rubydb/sql/ast/create_database.rb +41 -0
- data/lib/rubydb/sql/ast/create_index.rb +61 -0
- data/lib/rubydb/sql/ast/create_schema.rb +52 -0
- data/lib/rubydb/sql/ast/create_table.rb +187 -0
- data/lib/rubydb/sql/ast/delete.rb +54 -0
- data/lib/rubydb/sql/ast/drop_database.rb +41 -0
- data/lib/rubydb/sql/ast/drop_index.rb +41 -0
- data/lib/rubydb/sql/ast/drop_schema.rb +49 -0
- data/lib/rubydb/sql/ast/drop_table.rb +49 -0
- data/lib/rubydb/sql/ast/explain.rb +64 -0
- data/lib/rubydb/sql/ast/expression.rb +604 -0
- data/lib/rubydb/sql/ast/insert.rb +82 -0
- data/lib/rubydb/sql/ast/node.rb +42 -0
- data/lib/rubydb/sql/ast/rollback.rb +63 -0
- data/lib/rubydb/sql/ast/savepoint.rb +59 -0
- data/lib/rubydb/sql/ast/select.rb +88 -0
- data/lib/rubydb/sql/ast/set_operation.rb +20 -0
- data/lib/rubydb/sql/ast/trigger.rb +29 -0
- data/lib/rubydb/sql/ast/update.rb +88 -0
- data/lib/rubydb/sql/ast/vacuum.rb +19 -0
- data/lib/rubydb/sql/ast/view.rb +32 -0
- data/lib/rubydb/sql/ast/with.rb +32 -0
- data/lib/rubydb/sql/grammar.rb +86 -0
- data/lib/rubydb/sql/keywords.rb +156 -0
- data/lib/rubydb/sql/lexer.rb +214 -0
- data/lib/rubydb/sql/operators.rb +100 -0
- data/lib/rubydb/sql/parser.rb +1185 -0
- data/lib/rubydb/sql/planner/analyzer.rb +302 -0
- data/lib/rubydb/sql/planner/binder.rb +550 -0
- data/lib/rubydb/sql/planner/type_checker.rb +431 -0
- data/lib/rubydb/sql/token.rb +210 -0
- data/lib/rubydb/storage/buffer_frame.rb +44 -0
- data/lib/rubydb/storage/buffer_pool.rb +155 -0
- data/lib/rubydb/storage/database_lock.rb +74 -0
- data/lib/rubydb/storage/deserializer.rb +342 -0
- data/lib/rubydb/storage/engine.rb +2338 -0
- data/lib/rubydb/storage/file_manager.rb +187 -0
- data/lib/rubydb/storage/free_space_map.rb +81 -0
- data/lib/rubydb/storage/page.rb +94 -0
- data/lib/rubydb/storage/page_allocator.rb +855 -0
- data/lib/rubydb/storage/page_header.rb +67 -0
- data/lib/rubydb/storage/page_manager.rb +131 -0
- data/lib/rubydb/storage/record.rb +58 -0
- data/lib/rubydb/storage/row.rb +78 -0
- data/lib/rubydb/storage/serializer.rb +51 -0
- data/lib/rubydb/storage/storage_layout.rb +151 -0
- data/lib/rubydb/storage/storage_manager.rb +114 -0
- data/lib/rubydb/storage/tuple.rb +461 -0
- data/lib/rubydb/storage/visibility_map.rb +984 -0
- data/lib/rubydb/transactions/commit_manager.rb +220 -0
- data/lib/rubydb/transactions/isolation.rb +98 -0
- data/lib/rubydb/transactions/lock.rb +76 -0
- data/lib/rubydb/transactions/lock_manager.rb +362 -0
- data/lib/rubydb/transactions/savepoint.rb +143 -0
- data/lib/rubydb/transactions/transaction.rb +215 -0
- data/lib/rubydb/transactions/transaction_id.rb +84 -0
- data/lib/rubydb/transactions/transaction_log.rb +257 -0
- data/lib/rubydb/transactions/transaction_manager.rb +435 -0
- data/lib/rubydb/types/bigint.rb +36 -0
- data/lib/rubydb/types/blob.rb +37 -0
- data/lib/rubydb/types/boolean.rb +34 -0
- data/lib/rubydb/types/date.rb +39 -0
- data/lib/rubydb/types/decimal.rb +48 -0
- data/lib/rubydb/types/float.rb +34 -0
- data/lib/rubydb/types/integer.rb +36 -0
- data/lib/rubydb/types/json.rb +41 -0
- data/lib/rubydb/types/null.rb +34 -0
- data/lib/rubydb/types/smallint.rb +36 -0
- data/lib/rubydb/types/text.rb +37 -0
- data/lib/rubydb/types/time.rb +46 -0
- data/lib/rubydb/types/timestamp.rb +39 -0
- data/lib/rubydb/types/type.rb +119 -0
- data/lib/rubydb/types/uuid.rb +47 -0
- data/lib/rubydb/types/varchar.rb +37 -0
- data/lib/rubydb/version.rb +33 -0
- data/lib/rubydb/wal/archive.rb +193 -0
- data/lib/rubydb/wal/checkpoint.rb +183 -0
- data/lib/rubydb/wal/lsn.rb +94 -0
- data/lib/rubydb/wal/reader.rb +260 -0
- data/lib/rubydb/wal/record.rb +105 -0
- data/lib/rubydb/wal/segment.rb +193 -0
- data/lib/rubydb/wal/wal.rb +452 -0
- data/lib/rubydb/wal/writer.rb +236 -0
- data/lib/rubydb.rb +7 -0
- data/packaging/docker/Dockerfile +36 -0
- data/packaging/docker/docker-compose.failover.yml +43 -0
- data/packaging/docker/entrypoint.sh +15 -0
- data/packaging/homebrew/rubydb.rb +19 -0
- data/packaging/systemd/rubydb.service +22 -0
- data/rubydb.gemspec +57 -0
- data/scripts/benchmark +7 -0
- data/scripts/durability_drill +37 -0
- data/scripts/format +0 -0
- data/scripts/fuzz +63 -0
- data/scripts/lint +0 -0
- data/scripts/release +50 -0
- data/scripts/release_check +43 -0
- data/scripts/replication_failover_drill +250 -0
- data/scripts/replication_network_failover_drill +255 -0
- data/scripts/restore_drill +45 -0
- data/scripts/security +0 -0
- data/scripts/setup +0 -0
- data/scripts/test +0 -0
- data/scripts/test-chaos +0 -0
- data/scripts/test-crash +0 -0
- data/scripts/test-integration +0 -0
- data/scripts/test-unit +0 -0
- data/visibility_map.json +1 -0
- 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
|