diff --git a/doc/source/index.rst b/doc/source/index.rst index 478dc6b1f..cc87813c9 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -73,6 +73,7 @@ NeuG documentation :caption: Transaction transaction/transaction.md + transaction/checkpoint.md .. toctree:: :maxdepth: 1 diff --git a/doc/source/reference/cpp_api/neug_db.md b/doc/source/reference/cpp_api/neug_db.md index 1b6576621..a7eb3d58e 100644 --- a/doc/source/reference/cpp_api/neug_db.md +++ b/doc/source/reference/cpp_api/neug_db.md @@ -59,10 +59,20 @@ Open the database from persistent storage. Initializes and opens the NeuG database from the specified data directory. This method loads the graph schema, vertex/edge data, and initializes the query processor and planner. -**Data Directory Structure:** The data_dir should contain: -- `graph.yaml`: `Schema` definition file -- `snapshot/`: Vertex and edge data files -- `wal/`: Write-ahead log files (optional, for recovery) +**Data Directory Structure:** Persistent state is organized into numbered checkpoint generations: + +```text +data_dir/ +├── checkpoint-N/ +│ ├── meta +│ ├── snapshot/ +│ ├── runtime/ +│ ├── allocator/ +│ └── wal/ +└── checkpoint-(N+1).next/ # transient staging generation, when present +``` + +NeuG opens the highest valid published generation. A `.next` directory is not visible as the current checkpoint and is cleaned up during writable recovery if checkpoint creation did not complete. **Usage Example:** ```cpp @@ -135,7 +145,8 @@ db.Close(); // Persist data and cleanup - **Notes:** - This method is idempotent - calling it multiple times is safe. - - After closing, the database cannot be reopened. Create a new `NeugDB` instance to open the database again. + - After closing, the same `NeugDB` instance can be opened again. + - Checkpoint-on-close is best effort. If it fails, `Close()` logs an error, suppresses the exception, and continues releasing resources. - **Since:** v0.1.0 diff --git a/doc/source/reference/nodejs_api/database.md b/doc/source/reference/nodejs_api/database.md index cbadf9a42..a88ded9c0 100644 --- a/doc/source/reference/nodejs_api/database.md +++ b/doc/source/reference/nodejs_api/database.md @@ -169,5 +169,7 @@ Connect to the database asynchronously. close() ``` -Close the database connection and release all resources. +Close the database and release all resources. All open connections and async connections will be closed automatically. + +For a read-write database with `checkpointOnClose: true`, this method creates a checkpoint before releasing the database resources. Automatic checkpoint creation is best effort: failures are logged and do not propagate to the caller. diff --git a/doc/source/reference/python_api/database.md b/doc/source/reference/python_api/database.md index 692d88b7d..bbdaccbb6 100644 --- a/doc/source/reference/python_api/database.md +++ b/doc/source/reference/python_api/database.md @@ -231,7 +231,11 @@ Connect to the database asynchronously. def close() ``` -Close the database connection. +Close the database and all of its connections. + +For a read-write database with `checkpoint_on_close=True`, this method creates a checkpoint before releasing the database resources. +The method is idempotent. +Automatic checkpoint creation is best effort: failures are logged and do not propagate to the caller. diff --git a/doc/source/transaction/_meta.ts b/doc/source/transaction/_meta.ts new file mode 100644 index 000000000..f5c42407e --- /dev/null +++ b/doc/source/transaction/_meta.ts @@ -0,0 +1,4 @@ +export default { + transaction: "Transaction Management", + checkpoint: "Checkpoints", +}; diff --git a/doc/source/transaction/checkpoint.md b/doc/source/transaction/checkpoint.md new file mode 100644 index 000000000..0af5ada18 --- /dev/null +++ b/doc/source/transaction/checkpoint.md @@ -0,0 +1,176 @@ +# Checkpoints + +A checkpoint creates a durable snapshot of the current database state. The +two deployment modes persist writes differently: Embedded mode holds changes +in memory until a checkpoint writes them to disk, while Service mode appends +every committed change to a write-ahead log (WAL). A checkpoint therefore +plays a different role in each mode: + +| Question | Embedded mode | Service mode | +|---|---|---| +| Is `CHECKPOINT` required for durability? | Yes; un-checkpointed changes are lost when the database closes | No; committed changes are already durable in the WAL | +| What is it for? | Persist changes and create a recovery point | Optional maintenance: fold WAL records into a fresh snapshot | +| What is recovered after a restart? | The last successful checkpoint | The last checkpoint plus WAL replay | + +For transaction boundaries and concurrency outside checkpoint operations, see +[Transaction Management](transaction.md). + +## Run a checkpoint + +```cypher +CHECKPOINT; +``` + +`CHECKPOINT` takes no arguments and runs under the `update` access mode. +Omit `access_mode` and NeuG infers `update`, or set it explicitly: + +```python +conn.execute("CHECKPOINT") +conn.execute("CHECKPOINT", access_mode="update") # or "u" +``` + +Any other explicit mode (`read`/`r`, `insert`/`i`, or `schema`/`s`) is +rejected, and a database opened read-only cannot create a checkpoint. + +- `EXPLAIN CHECKPOINT` returns the execution plan without creating a + checkpoint. +- `PROFILE CHECKPOINT` creates the checkpoint and reports its execution time + as a single `Checkpoint` operator. + +### Embedded mode example + +```python +import neug + +# This application checkpoints explicitly and checks the result of each +# call, so it disables the automatic checkpoint on close. +db = neug.Database("/path/to/database", checkpoint_on_close=False) +conn = db.connect() + +conn.execute("COPY Person FROM 'people.csv'") +conn.execute("CHECKPOINT") # The loaded data is now durable. + +conn.close() +db.close() +``` + +### Service mode example + +```python +from neug import Session + +session = Session("http://localhost:10000/") + +# This insert is durable as soon as it commits; CHECKPOINT is not required. +session.execute( + "CREATE (p:Person {name: 'Alice'})", + access_mode="insert", +) + +# Optional maintenance: fold committed WAL records into a fresh snapshot. +session.execute("CHECKPOINT") +session.close() +``` + +Closing a client `Session` only disconnects that client; it neither closes +nor checkpoints the server database. + +## What a checkpoint does + +Each successful checkpoint performs the following steps: + +1. Take exclusive control of the database, waiting for in-flight work to + finish (see [Concurrency](#concurrency)). +2. Write a complete new snapshot generation to disk, alongside the current + one. +3. Publish the new generation atomically. From this point on it is the + recovery point; the previous generation is no longer needed for recovery. +4. Reopen the live database on the new generation and resume normal + operation. In Service mode this also starts a fresh, empty WAL. +5. Remove the retired generation (best effort). + +Because step 2 writes a full copy while the current generation still exists, +peak disk usage during a checkpoint is roughly twice the database size, and +checkpoint time grows with database size. Disk usage drops back once the +retired generation is removed in step 5. Run checkpoints as often as your +recovery-point and WAL-size requirements justify, rather than on a fixed +tight interval. + +### Concurrency + +- **Embedded mode:** A checkpoint takes the exclusive query lock. It waits + for running operations to finish and blocks new operations until it + completes. +- **Service mode:** A checkpoint waits for in-flight reads and writes to + finish without interrupting them, holds off new transactions while it + runs, and then executes with no concurrent transactions. The wait is + unbounded: a single long-running query can delay the entire checkpoint. + After a successful checkpoint, existing sessions remain valid and NeuG + starts a new empty WAL. + +For Service mode, schedule checkpoints during a quiet period when possible. + +## Automatic checkpoint on close + +In the Python API, persistent read-write databases default to +`checkpoint_on_close=True`, so closing the database attempts a final +checkpoint. This automatic checkpoint is best effort: NeuG logs a failure +but does not raise it from `close()`. + +Use an explicit `CHECKPOINT` when the application must know whether +persistence succeeded. If you set `checkpoint_on_close=False`: + +- Embedded mode discards changes made after the last successful checkpoint + when the database closes. +- Service mode can recover committed changes from the WAL, even if they were + made after the last checkpoint. + +Checkpoints do not make a temporary in-memory database survive after it is +closed. + +## Failure and recovery + +On startup, NeuG opens the newest completely published checkpoint; an +incomplete generation is never selected. In Service mode, NeuG then replays +WAL records created after that checkpoint. + +A manual `CHECKPOINT` fails in one of two ways: + +- **Before the snapshot build starts** — for example, if the database cannot + begin maintenance — the statement returns an error and the database keeps + running normally. +- **After the snapshot build has started**, a failure can leave the + in-memory state undefined, so NeuG deliberately terminates the database + process rather than continue. Restart the database to recover from the + latest published checkpoint and, in Service mode, the WAL. + +### Recovering from a failed Embedded write + +Large Embedded-mode writes are not fully atomic. To make a failed batch +discardable, disable checkpoint on close, create a recovery point before the +batch, and checkpoint again only after the batch succeeds: + +```python +import neug + +path = "/path/to/database" +db = neug.Database(path, checkpoint_on_close=False) +conn = db.connect() + +conn.execute("CHECKPOINT") # Recovery point before the batch + +try: + conn.execute("COPY Person FROM 'large_batch.csv'") + conn.execute("CHECKPOINT") # Persist the completed batch +except Exception: + # Do not checkpoint the possibly partial in-memory state. + conn.close() + db.close() + + db = neug.Database(path, checkpoint_on_close=False) + conn = db.connect() # Reopens the last successful checkpoint +``` + +For long imports, checkpoint after each batch whose completed work is worth +preserving. Always leave enough temporary disk space for a new full +generation. diff --git a/doc/source/transaction/transaction.md b/doc/source/transaction/transaction.md index 0c645b24f..0231a7f45 100644 --- a/doc/source/transaction/transaction.md +++ b/doc/source/transaction/transaction.md @@ -48,18 +48,9 @@ Embedded mode is designed for analytical workloads where simplicity and single-q For statements involving substantial data writes (such as `COPY` for bulk loading), a failure mid-execution may result in partial data being written to memory. This is a known trade-off for AP workloads where the overhead of full rollback mechanisms is typically unnecessary. -**Practical Workaround:** Since all writes are held in memory until an explicit `CHECKPOINT`, users can establish recovery points: - -```cypher --- Create a recovery point before large operations -CHECKPOINT; - --- Perform bulk data loading -COPY Person FROM 'large_dataset.csv'; - --- If an error occurs before this point, restart the database --- to reload from the last checkpoint (all uncommitted changes are discarded) -``` +In Embedded mode, checkpoints can be used as recovery points around large +write operations. See [Checkpoints](checkpoint.md) for a safe recovery example +and the interaction with `checkpoint_on_close`. #### Consistency @@ -83,23 +74,7 @@ Changes are persisted to disk **only** after: - Database closure with `checkpoint_on_close=True` (default) For in-memory databases, durability is not applicable as data exists only in volatile memory. - -```python -import neug - -db = neug.Database("/path/to/database") -conn = db.connect() - -# Changes are in memory only -conn.execute("CREATE (p:Person {name: 'Alice'})") - -# Explicit persistence to disk -conn.execute("CHECKPOINT") - -# Or rely on automatic checkpoint at close -conn.close() -db.close() # checkpoint_on_close=True by default -``` +For usage and failure behavior, see [Checkpoints](checkpoint.md). ### Service Mode @@ -123,7 +98,7 @@ NeuG uses **Multi-Version Concurrency Control (MVCC)** to provide serializable i | Insert | Concurrent with reads and other inserts. Waits if an update is in progress. | | Update | One at a time. Reads continue on a consistent snapshot. Inserts and other updates wait until the update finishes. | | Schema (DDL) | Same concurrency behavior as update. | -| Checkpoint | Same concurrency behavior as update. | +| Checkpoint | Exclusive maintenance. See [Checkpoints](checkpoint.md). | > Note: A read that arrives while an update is finishing may wait briefly, > typically at millisecond scale. @@ -131,7 +106,8 @@ NeuG uses **Multi-Version Concurrency Control (MVCC)** to provide serializable i **Design Rationale:** This hybrid approach reflects the reality of graph workloads: - **Reads and inserts** are the dominant operations in most graph applications (social networks, knowledge graphs, recommendation systems) -- **Updates, schema changes, and checkpoints** are relatively rare and are serialized, while reads continue on a consistent snapshot with only brief waits when they arrive as a write is finishing +- **Updates and schema changes** are relatively rare and are serialized, while reads continue on a consistent snapshot with only brief waits when they arrive as a write is finishing +- **Checkpoints** are database-level maintenance operations; their concurrency behavior is described in [Checkpoints](checkpoint.md) - Full MVCC for all write types would add significant complexity with minimal benefit for typical graph workloads ```python @@ -156,7 +132,7 @@ All modifications are immediately persisted through **Write-Ahead Logging (WAL)* - Changes are logged to WAL before execution completes - Crash recovery automatically replays WAL to restore consistent state -- No explicit checkpoint required for durability (but available for WAL consolidation) +- No explicit checkpoint is required for durability; checkpoints are available for WAL consolidation. See [Checkpoints](checkpoint.md) ## Access Mode @@ -171,24 +147,22 @@ When executing queries, you can specify an `access_mode` to control transaction **Default Behavior:** If not specified, NeuG infers the appropriate mode. Explicitly specifying the correct mode enables better concurrency optimization in Service mode. -**Access Mode Hierarchy:** Access modes follow a hierarchy where higher modes provide stronger guarantees but lower concurrency: - -``` -read < insert < update -``` - -`schema` uses the same serialized update path as `update`. - -- **Upward compatibility**: Using a higher access mode than required always works (e.g., `update` mode for a read-only query), but may reduce throughput due to stronger locking -- **Downward restriction**: Using a lower access mode than required causes an error (e.g., `read` mode for an insert operation) +**Access Mode Capabilities:** Access modes are operation-specific rather than a strict hierarchy. In Service mode, `read` and `insert` are narrow modes, while `update` and `schema` use the general serialized update path. Using a mode that does not support the planned operation returns an error. | Specified Mode | Actual Operation | Result | |----------------|------------------|--------| | `read` | read | ✅ OK | -| `read` | insert/update | ❌ Error | -| `insert` | read/insert | ✅ OK | -| `insert` | update/schema | ❌ Error | -| `update`/`schema` | any | ✅ OK (serialized update path) | +| `read` | insert/update/schema/checkpoint | ❌ Error | +| `insert` | insert | ✅ OK | +| `insert` | read/update/schema/checkpoint | ❌ Error | +| `update` | read/insert/update/schema/checkpoint | ✅ OK (serialized update path) | +| `schema` | read/insert/update/schema | ✅ OK (serialized update path) | + +In Embedded mode, modes other than `read` primarily select shared versus exclusive query locking rather than distinct transaction implementations. Explicit `read` still rejects write operations, and the special `CHECKPOINT` restriction below applies in both deployment modes. + +`CHECKPOINT` is a special administrative statement. See +[Checkpoints](checkpoint.md) for its accepted access modes and +`EXPLAIN`/`PROFILE` behavior. ```python # Optimal: match access mode to operation for best concurrency @@ -203,33 +177,8 @@ conn.execute("MATCH (n) RETURN n", access_mode="update") # works, but reduc ### Embedded Mode: Checkpoint-Based -All changes are held in memory until explicitly persisted: - -```python -import neug - -db = neug.Database("/path/to/database") -conn = db.connect() - -# Establish recovery point -conn.execute("CHECKPOINT") - -# Perform operations (changes in memory) -conn.execute("COPY Person FROM 'employees.csv'") -conn.execute("COPY Company FROM 'companies.csv'") - -# Persist all changes to disk -conn.execute("CHECKPOINT") - -conn.close() -db.close() -``` - -**Checkpoint Characteristics:** -- Creates an atomic snapshot of the current database state -- Blocks other operations during execution -- May require significant time for large datasets -- Replaces the previous checkpoint atomically +All changes are held in memory until a checkpoint persists them. See +[Checkpoints](checkpoint.md) for usage, examples, and recovery behavior. ### Service Mode: WAL-Based @@ -244,46 +193,18 @@ session = Session("http://localhost:10000/") session.execute("CREATE (p:Person {name: 'Alice'})") # Persisted via WAL session.execute("CREATE (p:Person {name: 'Bob'})") # Persisted via WAL -# Optional: consolidate WAL into checkpoint for storage optimization -session.execute("CHECKPOINT") - session.close() ``` -**Service Mode Checkpoint:** -- Does not block active reads -- Briefly waits for in-flight inserts before starting -- New reads and inserts wait briefly during checkpoint finalization -- Consolidates WAL entries into a unified checkpoint -- Clears processed WAL entries to reclaim storage -- Does not affect the automatic durability of individual statements +An optional checkpoint consolidates the WAL but is not required for statement +durability. See [Checkpoints](checkpoint.md). ## Error Recovery ### Embedded Mode -Recovery relies on the last successful checkpoint: - -```python -import neug - -db = neug.Database("/path/to/database") -conn = db.connect() - -conn.execute("CHECKPOINT") # Recovery point - -try: - conn.execute("COPY LargeTable FROM 'huge_file.csv'") - conn.execute("CHECKPOINT") # Commit if successful -except Exception as e: - # Partial data may be in memory - # Close and reopen to restore from last checkpoint - conn.close() - db.close() - - db = neug.Database("/path/to/database") # Restores from checkpoint - conn = db.connect() -``` +Recovery relies on the last successful checkpoint. See +[Recovering from a failed Embedded write](checkpoint.md) for an example. ### Service Mode @@ -357,9 +278,9 @@ finally: |----------|-------------------|-------------------| | **Atomicity** | Partial (checkpoint-based recovery) | Full (automatic rollback) | | **Consistency** | Schema constraints enforced | Schema constraints enforced | -| **Isolation** | Exclusive write locks | MVCC for reads/inserts; serialized updates/DDL/checkpoints | +| **Isolation** | Exclusive write locks | MVCC for reads/inserts; serialized updates/DDL; exclusive checkpoints | | **Durability** | Explicit CHECKPOINT or close | Automatic WAL persistence | -| **Concurrent Reads** | Yes | Yes | +| **Concurrent Reads** | Yes | Yes, except during exclusive checkpoint maintenance | | **Concurrent Inserts** | No | Yes, unless an update is active | | **Concurrent Updates** | No | No, updates are serialized while reads continue on consistent snapshots | | **Recovery** | Manual (checkpoint reload) | Automatic (WAL replay) | diff --git a/include/neug/compiler/gopt/g_physical_analyzer.h b/include/neug/compiler/gopt/g_physical_analyzer.h index 3db560159..33d384e72 100644 --- a/include/neug/compiler/gopt/g_physical_analyzer.h +++ b/include/neug/compiler/gopt/g_physical_analyzer.h @@ -47,7 +47,7 @@ struct ExecutionFlag { bool schema = false; bool batch = false; bool create_temp_table = false; - bool transaction = false; + bool checkpoint = false; bool procedure_call = false; }; @@ -253,7 +253,9 @@ class GPhysicalAnalyzer { break; } case planner::LogicalOperatorType::TRANSACTION: { - flag.transaction = true; + auto transaction = op.constPtrCast(); + flag.checkpoint = transaction->getTransactionAction() == + transaction::TransactionAction::CHECKPOINT; break; } // set read to true for graph operators diff --git a/include/neug/compiler/gopt/g_physical_convertor.h b/include/neug/compiler/gopt/g_physical_convertor.h index ce6c012d8..491064bbf 100644 --- a/include/neug/compiler/gopt/g_physical_convertor.h +++ b/include/neug/compiler/gopt/g_physical_convertor.h @@ -67,7 +67,7 @@ class GPhysicalConvertor { flagPB->set_schema(flag.schema); flagPB->set_batch(flag.batch); flagPB->set_create_temp_table(flag.create_temp_table); - flagPB->set_checkpoint(flag.transaction); + flagPB->set_checkpoint(flag.checkpoint); flagPB->set_procedure_call(flag.procedure_call); return flagPB; } diff --git a/include/neug/compiler/planner/graph_planner.h b/include/neug/compiler/planner/graph_planner.h index 73583e29c..fe6e87945 100644 --- a/include/neug/compiler/planner/graph_planner.h +++ b/include/neug/compiler/planner/graph_planner.h @@ -49,7 +49,7 @@ class IGraphPlanner { // Attempts to infer the execution access mode from the given query. // The current implementation relies on static analysis of the query string - // and can only distinguish between "update" and "read" modes. + // and can distinguish read, update, and schema modes. // Inferring an "insert" mode would require more complex operator // combination analysis, which is not supported at the moment. virtual AccessMode analyzeMode(const std::string& query) const = 0; diff --git a/include/neug/execution/common/params_map.h b/include/neug/execution/common/params_map.h index 7fbf82ecf..efd19732f 100644 --- a/include/neug/execution/common/params_map.h +++ b/include/neug/execution/common/params_map.h @@ -14,6 +14,8 @@ */ #pragma once +#include + #include #include @@ -25,5 +27,24 @@ struct DataType; namespace execution { using ParamsMap = std::map; using ParamsMetaMap = std::map; + +inline ParamsMap build_params_map(const rapidjson::Value& parameters, + const ParamsMetaMap& params_type) { + ParamsMap params_map; + if (!parameters.IsObject()) { + return params_map; + } + + for (const auto& member : parameters.GetObject()) { + std::string key = member.name.GetString(); + auto iter = params_type.find(key); + if (iter == params_type.end()) { + LOG(WARNING) << "Ignoring unexpected parameter: " << key; + continue; + } + params_map.emplace(key, Value::FromJson(member.value, iter->second)); + } + return params_map; +} } // namespace execution } // namespace neug diff --git a/include/neug/main/checkpoint_coordinator.h b/include/neug/main/checkpoint_coordinator.h new file mode 100644 index 000000000..4f8207d07 --- /dev/null +++ b/include/neug/main/checkpoint_coordinator.h @@ -0,0 +1,129 @@ +/** Copyright 2020 Alibaba Group Holding Limited. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +#pragma once + +#include +#include +#include +#include + +#include "neug/config.h" +#include "neug/storages/graph_snapshot_store.h" +#include "neug/transaction/update_timestamp_guard.h" +#include "neug/utils/property/types.h" +#include "neug/utils/result.h" + +namespace neug { + +class CheckpointManager; + +/** + * Owns the database-level checkpoint build and live graph reopen protocol. + * + * NeugDB owns one coordinator for the lifetime of an open database. AP callers + * provide synchronization by holding the exclusive query lock. TP callers + * transfer an UpdateTimestampGuard to ExecuteTpManual() for the entire graph + * and runtime-state rotation. + * + * Two extension points run after the graph reopens from a published + * checkpoint, before retired generations are reclaimed and before new + * transactions are allowed to start: + * + * - PostReopenHandler (mandatory): injected at construction by the database + * owner and invoked on EVERY reopen (AP manual, TP manual, and recovery + * checkpoints). It carries correctness-critical state rotation such as + * reopening the allocators onto the published generation, and must be + * infallible: it runs in the destructive phase where failure terminates + * the process. + * + * - CheckpointActivationHandler (optional): set by the service owner and + * invoked ONLY on the TP manual path, after the post-reopen handler. It + * activates service-owned state such as session WAL rotation and cache + * invalidation for the published checkpoint. + * + * Shutdown checkpoints invoke neither, because they do not reopen the graph. + */ +class CheckpointCoordinator { + public: + /// Mandatory database-level rotation onto the published checkpoint + /// generation. Invoked with the generation's allocator directory. + using PostReopenHandler = + std::function; + + /// Optional service-owned state activation for the TP manual path. + /// Invoked with the published checkpoint's WAL directory. + using CheckpointActivationHandler = + std::function; + + CheckpointCoordinator(CheckpointManager& checkpoint_manager, + GraphSnapshotStore& snapshot_store, + MemoryLevel memory_level, + timestamp_t recovered_wal_timestamp, + PostReopenHandler post_reopen_handler); + + /// Set the single activation handler invoked by ExecuteTpManual() after the + /// post-reopen handler and before retired generations are reclaimed or the + /// transaction gate is reopened. Setting a second handler without first + /// clearing the existing one is an error. + /// + /// The service sets the handler at startup and clears it before destroying + /// handler-owned state. ClearActivationHandler() waits for an in-flight + /// invocation to finish, so no invocation can outlive that call. The handler + /// must not call SetActivationHandler() or ClearActivationHandler(). + void SetActivationHandler(CheckpointActivationHandler handler); + void ClearActivationHandler(); + + /// Execute an AP checkpoint. The caller must hold the AP exclusive query + /// lock and must not hold an ordinary snapshot pin. + Status ExecuteApManual(); + + /// Execute a TP checkpoint and activate database- and service-owned runtime + /// state through the configured handlers. + Status ExecuteTpManual(UpdateTimestampGuard timestamp_guard); + + /// Publish a recovery checkpoint and reopen the live graph. + Status ExecuteRecovery(); + + /// Publish the shutdown checkpoint without reopening the live graph. + Status PrepareShutdown(); + + timestamp_t recovered_wal_timestamp() const noexcept { + return recovered_wal_timestamp_; + } + + private: + enum class Reason { + kManual, + kRecovery, + kShutdown, + }; + + Status execute(GraphSnapshotStore::CheckpointMaintenanceHandle& maintenance, + Reason reason, bool invoke_activation_handler); + Status executeWithNewMaintenance(Reason reason, + bool invoke_activation_handler = false); + void invokeActivationHandler(const std::string& checkpoint_wal_uri); + static const char* reasonName(Reason reason); + + CheckpointManager& checkpoint_manager_; + GraphSnapshotStore& snapshot_store_; + MemoryLevel memory_level_; + timestamp_t recovered_wal_timestamp_; + PostReopenHandler post_reopen_handler_; + std::mutex activation_handler_mutex_; + CheckpointActivationHandler activation_handler_; +}; + +} // namespace neug diff --git a/include/neug/main/neug_db.h b/include/neug/main/neug_db.h index 75e233c2d..9e5e90015 100644 --- a/include/neug/main/neug_db.h +++ b/include/neug/main/neug_db.h @@ -21,6 +21,7 @@ #include #include #include +#include #include "neug/config.h" #include "neug/execution/execute/query_cache.h" @@ -45,14 +46,12 @@ namespace neug { class NeugDBService; class AppManager; -class Checkpoint; -class CheckpointSession; +class CheckpointCoordinator; class Connection; class ConnectionManager; class FileLock; class IGraphPlanner; class IWalParser; -class NeugDBSession; class QueryProcessor; class Schema; @@ -124,10 +123,15 @@ class NeugDB { * the query processor and planner. * * **Data Directory Structure:** - * The data_dir should contain: - * - `graph.yaml`: Schema definition file - * - `snapshot/`: Vertex and edge data files - * - `wal/`: Write-ahead log files (optional, for recovery) + * Persistent state is organized into numbered checkpoint generations: + * ``` + * data_dir/ + * |-- checkpoint-N/ # current published generation + * `-- checkpoint-(N+1).next/ # transient staging generation, when present + * ``` + * A published generation contains all persistent database state. + * NeuG opens the highest valid published generation. + * Writable recovery removes incomplete staging generations. * * **Usage Example:** * @code{.cpp} @@ -214,8 +218,10 @@ class NeugDB { * @endcode * * @note This method is idempotent - calling it multiple times is safe. - * @note After closing, the database cannot be reopened. Create a new - * NeugDB instance to open the database again. + * @note After closing, the same NeugDB object may be opened again. + * @note Checkpoint-on-close is best effort. A checkpoint failure is logged + * as an error and does not prevent the remaining resources from being + * released. * * @since v0.1.0 */ @@ -301,6 +307,9 @@ class NeugDB { return *snapshot_store_; } + CheckpointCoordinator& checkpoint_coordinator(); + const CheckpointCoordinator& checkpoint_coordinator() const; + std::string work_dir() const { return checkpoint_mgr_.db_dir(); } inline const NeugDBConfig& config() const { return config_; } @@ -316,27 +325,33 @@ class NeugDB { private: void preprocessConfig(); void initAllocators(const std::string& allocator_dir); - void openGraphAndIngestWals(); + /** + * @brief Rotate every per-thread allocator onto the published checkpoint + * generation. + * + * Injected into CheckpointCoordinator as the mandatory post-reopen handler + * and invoked after every live-graph reopen (manual and recovery + * checkpoints), before retired generations are reclaimed. Must not throw: + * it runs in the checkpoint destructive phase where failure is fatal. + */ + void reopenAllocators(const std::string& allocator_dir); + timestamp_t openGraphAndIngestWals(); void ingestWals(IWalParser& parser, PropertyGraph& graph); void initPlanner(); void initQueryRuntime(); void initPlannerAndQueryProcessor(); - std::shared_ptr consumeLiveGraphAndCommitCheckpoint( - CheckpointSession& checkpoint_session); /** * @brief Create a checkpoint and keep the DB open on the published graph. * - * This publishes the current live graph and reopens it from the published - * checkpoint so the live store owns checkpoint files. If reopening fails, the - * published checkpoint is discarded and the checkpoint manager is restored to - * the previous checkpoint generation; callers should treat the failure as - * fatal. It is shared by recovery checkpoint and AP-to-TP service - * preparation. A durable checkpoint is a transaction timeline reset boundary: - * it always compacts storage timestamps before dumping, and a successful - * checkpoint resets last_ts_ to 0. Must not be called while a NeugDBService - * is running. + * A recovery checkpoint publishes the recovered graph and switches the live + * runtime to the new generation before query service is initialized. + * + * A durable checkpoint is a transaction timeline reset boundary: it always + * compacts storage timestamps before dumping, and a successful checkpoint + * resets the recovered WAL timeline. Must not be called while a + * NeugDBService is running. */ - void createCheckpointAndRefreshLiveGraph(); + void createCheckpointAfterRecovery(); /** * @brief Create a checkpoint while closing the DB. @@ -347,15 +362,13 @@ class NeugDB { * * A durable checkpoint is a transaction timeline reset boundary: it always * compacts storage timestamps before dumping, and a successful checkpoint - * resets last_ts_ to 0. Must not be called while a NeugDBService is running. + * resets the recovered WAL timeline. Must not be called while a + * NeugDBService is running. */ void createCheckpointOnClose(); - friend class NeugDBSession; friend class neug::NeugDBService; - timestamp_t last_compaction_ts_; - timestamp_t last_ts_; // Configuration and settings std::atomic closed_; bool is_pure_memory_; @@ -366,6 +379,9 @@ class NeugDB { // GraphSnapshotStore - manages multiple versions of PropertyGraph for MVCC std::unique_ptr snapshot_store_; + // Declared after its borrowed dependencies and before QueryProcessor, so it + // is destroyed after QueryProcessor and before the snapshot/allocators. + std::unique_ptr checkpoint_coordinator_; std::shared_ptr planner_; std::shared_ptr query_processor_; diff --git a/include/neug/main/query_processor.h b/include/neug/main/query_processor.h index c41aefa75..5a663d984 100644 --- a/include/neug/main/query_processor.h +++ b/include/neug/main/query_processor.h @@ -38,14 +38,18 @@ namespace neug { +class CheckpointCoordinator; + class QueryProcessor { public: QueryProcessor( + CheckpointCoordinator& checkpoint_coordinator, GraphSnapshotStore& snapshot_store, std::shared_ptr planner, std::shared_ptr global_query_cache, Allocator& alloc, int32_t max_thread_num, bool is_read_only = false) - : snapshot_store_(snapshot_store), + : checkpoint_coordinator_(checkpoint_coordinator), + snapshot_store_(snapshot_store), planner_(planner), global_query_cache_(global_query_cache), allocator_(alloc), @@ -69,11 +73,9 @@ class QueryProcessor { void clear_cache(); private: - result>> - check_and_retrieve_pipeline(const PropertyGraph& pg, - const std::string& query_string, - const std::string& access_mode, - int32_t num_threads); + result> check_and_retrieve_pipeline( + const PropertyGraph& pg, const std::string& query_string, + AccessMode access_mode, int32_t num_threads); result execute_internal( SnapshotGuard& guard, const std::string& query_string, @@ -94,6 +96,7 @@ class QueryProcessor { const physical::ExecutionFlag& flags, AccessMode mode); + CheckpointCoordinator& checkpoint_coordinator_; GraphSnapshotStore& snapshot_store_; std::shared_ptr planner_; std::shared_ptr global_query_cache_; diff --git a/include/neug/server/neug_db_session.h b/include/neug/server/neug_db_session.h index 52fcbdd2f..a9da0b84f 100644 --- a/include/neug/server/neug_db_session.h +++ b/include/neug/server/neug_db_session.h @@ -21,8 +21,8 @@ #include #include "neug/compiler/planner/graph_planner.h" +#include "neug/config.h" #include "neug/execution/execute/query_cache.h" -#include "neug/main/neug_db.h" #include "neug/storages/allocators.h" #include "neug/transaction/compact_transaction.h" #include "neug/transaction/insert_transaction.h" @@ -32,13 +32,14 @@ namespace neug { -class NeugDB; +class GraphSnapshotStore; class IWalWriter; class ColumnBase; class Encoder; class PropertyGraph; class RefColumnBase; class AppManager; +class CheckpointCoordinator; class IVersionManager; /** @@ -91,16 +92,20 @@ class IVersionManager; class NeugDBSession { public: static constexpr int32_t MAX_RETRY = 3; - NeugDBSession(NeugDB& db, std::shared_ptr planner, + NeugDBSession(GraphSnapshotStore& snapshot_store, + std::shared_ptr planner, std::shared_ptr global_query_cache, std::shared_ptr vm, Allocator& alloc, - IWalWriter& logger, const NeugDBConfig& config_, int thread_id) - : db_(db), + IWalWriter& wal_writer, + CheckpointCoordinator& checkpoint_coordinator, + const NeugDBConfig& config_, int thread_id) + : snapshot_store_(snapshot_store), planner_(planner), pipeline_cache_(global_query_cache), version_manager_(vm), alloc_(alloc), - logger_(logger), + wal_writer_(wal_writer), + checkpoint_coordinator_(checkpoint_coordinator), db_config_(config_), thread_id_(thread_id), eval_duration_(0), @@ -171,13 +176,28 @@ class NeugDBSession { int64_t query_num() const; + // Invalidate the global query cache through this session's local cache, + // mirroring how update transactions invalidate it on schema change. Any + // session can initiate this; every other session discards its local cache + // lazily on the next version check. + void InvalidateGlobalQueryCache(); + private: - NeugDB& db_; + UpdateTimestampGuard getUpdateTimestampGuard(); + UpdateTransaction createUpdateTransaction( + UpdateTimestampGuard timestamp_guard); + + GraphSnapshotStore& snapshot_store_; std::shared_ptr planner_; execution::LocalQueryCache pipeline_cache_; std::shared_ptr version_manager_; Allocator& alloc_; - IWalWriter& logger_; + IWalWriter& wal_writer_; + // A checkpoint is a database-wide operation initiated by one session. The + // session drives the coordinator directly; service-owned runtime state + // (WAL rotation, cache invalidation) is installed through the hooks + // registered on the coordinator at service startup. + CheckpointCoordinator& checkpoint_coordinator_; const NeugDBConfig& db_config_; int thread_id_; diff --git a/include/neug/server/session_pool.h b/include/neug/server/session_pool.h index 27a0a95c2..1d5c5ab80 100644 --- a/include/neug/server/session_pool.h +++ b/include/neug/server/session_pool.h @@ -14,9 +14,16 @@ */ #pragma once +#include +#include +#include +#include +#include +#include +#include + #include "neug/config.h" #include "neug/execution/execute/query_cache.h" -#include "neug/main/neug_db.h" #include "neug/server/neug_db_session.h" #include "neug/storages/allocators.h" #include "neug/storages/graph/property_graph.h" @@ -25,24 +32,34 @@ #include "bthread/bthread.h" namespace neug { -class NeugDBService; +class CheckpointCoordinator; +class SessionPool; + +inline constexpr size_t kSessionContextAlignment = 4096; -struct SessionLocalContext { +struct alignas(kSessionContextAlignment) SessionLocalContext { SessionLocalContext( - NeugDB& db, std::shared_ptr planner, + GraphSnapshotStore& snapshot_store, + std::shared_ptr planner, std::shared_ptr global_query_cache, std::shared_ptr alloc, std::shared_ptr version_manager, int thread_id, - std::unique_ptr in_logger, const NeugDBConfig& config_) + std::unique_ptr in_logger, const std::string& wal_uri, + CheckpointCoordinator& checkpoint_coordinator, + const NeugDBConfig& config_) : allocator(alloc), logger(std::move(in_logger)), - session(db, planner, global_query_cache, version_manager, *alloc, - *logger, config_, thread_id) { - logger->open(); + session(snapshot_store, planner, global_query_cache, version_manager, + *alloc, *logger, checkpoint_coordinator, config_, thread_id) { + logger->open(wal_uri); } ~SessionLocalContext() { if (logger) { - logger->close(); + try { + logger->close(); + } catch (const std::exception& e) { + LOG(WARNING) << "Failed to close session WAL writer: " << e.what(); + } catch (...) { LOG(WARNING) << "Failed to close session WAL writer"; } } } std::shared_ptr allocator; @@ -54,7 +71,8 @@ struct SessionLocalContext { char _padding2[(4096 - sizeof(NeugDBSession) % 4096) % 4096]; }; -class SessionPool; +static_assert(alignof(SessionLocalContext) == kSessionContextAlignment); +static_assert(sizeof(SessionLocalContext) % kSessionContextAlignment == 0); /** * @brief RAII guard for session lifecycle management. @@ -77,6 +95,8 @@ class SessionPool; * * **Thread Safety:** SessionGuard is move-only (non-copyable) to ensure * exclusive session ownership. Each guard should be used by a single thread. + * It only prevents concurrent reuse of a pool slot; it is not a VersionManager + * lease and does not exclude checkpoint or compaction maintenance. * * @see SessionPool for session pool management * @see NeugDBSession for query execution @@ -139,28 +159,51 @@ class SessionGuard { */ class SessionPool { public: + // checkpoint_coordinator is only forwarded to each NeugDBSession so that + // a session can initiate a database-wide checkpoint directly; the pool + // itself does not participate in the checkpoint protocol. explicit SessionPool( - NeugDB& db, std::shared_ptr planner, + GraphSnapshotStore& snapshot_store, + CheckpointCoordinator& checkpoint_coordinator, + std::shared_ptr planner, std::shared_ptr global_query_cache, std::shared_ptr version_manager, - std::vector>& allocators, - const NeugDBConfig& config) { - session_num_ = config.max_thread_num; + const std::vector>& allocators, + const std::string& wal_uri, const NeugDBConfig& config) + : contexts_(nullptr), session_num_(config.max_thread_num) { WalWriterFactory::Init(); - contexts_ = static_cast(aligned_alloc( - 4096, sizeof(SessionLocalContext) * config.max_thread_num)); - for (int i = 0; i < config.max_thread_num; ++i) { - new (&contexts_[i]) SessionLocalContext( - db, planner, global_query_cache, allocators[i], version_manager, i, - WalWriterFactory::CreateWalWriter(db.graph().checkpoint().wal_dir(), - i), - config); - } - bthread_cond_init(&cond_, nullptr); - bthread_mutex_init(&mutex_, nullptr); for (size_t i = 0; i < session_num_; ++i) { available_sessions_.push_back(i); } + + contexts_ = static_cast( + aligned_alloc(kSessionContextAlignment, + sizeof(SessionLocalContext) * config.max_thread_num)); + if (contexts_ == nullptr) { + throw std::bad_alloc(); + } + + size_t constructed_contexts = 0; + try { + for (; constructed_contexts < session_num_; ++constructed_contexts) { + const auto thread_id = static_cast(constructed_contexts); + new (&contexts_[constructed_contexts]) SessionLocalContext( + snapshot_store, planner, global_query_cache, + allocators.at(constructed_contexts), version_manager, thread_id, + WalWriterFactory::CreateWalWriter(wal_uri, thread_id), wal_uri, + checkpoint_coordinator, config); + } + } catch (...) { + while (constructed_contexts > 0) { + contexts_[--constructed_contexts].~SessionLocalContext(); + } + free(contexts_); + contexts_ = nullptr; + throw; + } + + bthread_cond_init(&cond_, nullptr); + bthread_mutex_init(&mutex_, nullptr); LOG(INFO) << "Initializing SessionPool with " << session_num_ << " sessions."; } @@ -199,12 +242,21 @@ class SessionPool { return ret; } + // Installs all service-owned state derived from a checkpoint: rotates every + // session-owned WAL writer to the new generation, then invalidates the + // global query cache through one session's local cache (every other session + // discards its local cache lazily on the next version check). Registered + // with CheckpointCoordinator as the activation handler at service startup; + // invoked on the TP manual checkpoint path after active transactions have + // been drained and the mandatory post-reopen handler has completed, while + // new transactions remain blocked, so cross-session WAL access is + // exclusive. + void RotateWalAndInvalidateCaches(const std::string& wal_uri); + private: void ReleaseSession(size_t session_id); friend class SessionGuard; - friend class NeugDBService; - SessionLocalContext* contexts_; size_t session_num_; std::deque available_sessions_; diff --git a/include/neug/storages/allocators.h b/include/neug/storages/allocators.h index ea00dc2b3..a88b483bb 100644 --- a/include/neug/storages/allocators.h +++ b/include/neug/storages/allocators.h @@ -21,6 +21,7 @@ #include #include #include +#include #include #include "neug/config.h" @@ -46,6 +47,24 @@ class ArenaAllocator { allocated_batches_(0) {} ~ArenaAllocator() {} + ArenaAllocator(const ArenaAllocator&) = delete; + ArenaAllocator& operator=(const ArenaAllocator&) = delete; + + /// Release all backing state and reopen this allocator for a new checkpoint + /// generation without invalidating references to the allocator object. + /// @p prefix is passed by value so all allocation needed to construct it + /// happens before the old backing state is released. + void Reopen(MemoryLevel strategy, std::string prefix) noexcept { + mmap_buffers_.clear(); + strategy_ = strategy; + prefix_ = std::move(prefix); + cur_buffer_ = nullptr; + cur_loc_ = 0; + cur_size_ = 0; + allocated_memory_ = 0; + allocated_batches_ = 0; + } + void reserve(size_t cap) { if (cur_size_ - cur_loc_ >= cap) { return; diff --git a/include/neug/storages/checkpoint.h b/include/neug/storages/checkpoint.h index a1f96f45a..84ead1a1f 100644 --- a/include/neug/storages/checkpoint.h +++ b/include/neug/storages/checkpoint.h @@ -16,6 +16,7 @@ #include #include +#include #include #include #include @@ -167,4 +168,13 @@ class Checkpoint { std::unique_ptr file_mgr_; }; +/// File name prefix for the allocator with @p allocator_id under +/// @p allocator_dir, e.g. `/allocator_3_`. +inline std::string allocator_prefix(const std::string& allocator_dir, + size_t allocator_id) { + return (std::filesystem::path(allocator_dir) / + ("allocator_" + std::to_string(allocator_id) + "_")) + .string(); +} + } // namespace neug diff --git a/include/neug/storages/checkpoint_manager.h b/include/neug/storages/checkpoint_manager.h index 5db6ff6e0..a203c9ecd 100644 --- a/include/neug/storages/checkpoint_manager.h +++ b/include/neug/storages/checkpoint_manager.h @@ -58,6 +58,8 @@ class CheckpointManager { StagingCheckpoint& operator=(const StagingCheckpoint&) = delete; std::shared_ptr checkpoint() const; + /// Path that this staging checkpoint will have after Commit(). + std::string TargetPublishedPath() const; /// Publish this staging checkpoint as the current checkpoint. If /// previous_checkpoint_path is non-null, it receives the retired checkpoint /// directory path. The caller decides when that directory is safe to @@ -106,19 +108,18 @@ class CheckpointManager { StagingCheckpoint CreateStagingCheckpoint(); /** - * @brief Restore the in-memory current checkpoint to an older published - * checkpoint. + * @brief Point the manager at another already-published checkpoint. * - * Used by DB-level checkpoint creation after it removes a newer published - * checkpoint whose graph reopen failed. + * This is an explicit repair primitive. It does not undo a durable + * publication and is not used by the live checkpoint execution protocol. */ void RestoreCurrentCheckpoint(std::shared_ptr checkpoint); /** * @brief Best-effort cleanup of one published checkpoint that is not current. * - * Used by DB-level rollback after it restores the current checkpoint to a - * previous generation. Refuses to remove the current checkpoint. + * This is an explicit repair/maintenance primitive and refuses to remove the + * current checkpoint. */ void CleanupPublishedCheckpoint(std::shared_ptr checkpoint); diff --git a/include/neug/storages/checkpoint_session.h b/include/neug/storages/checkpoint_session.h deleted file mode 100644 index 602b475ee..000000000 --- a/include/neug/storages/checkpoint_session.h +++ /dev/null @@ -1,54 +0,0 @@ -/** Copyright 2020 Alibaba Group Holding Limited. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -#pragma once - -#include - -#include "neug/storages/checkpoint_manager.h" - -namespace neug { - -class Checkpoint; - -/** - * RAII wrapper for one checkpoint creation attempt. - * - * This class owns staging cleanup. It does not dump a graph, reopen a graph, - * publish a GraphSnapshotStore slot, delete published checkpoints, or decide - * when a retired checkpoint directory is safe to delete. - */ -class CheckpointSession { - public: - static CheckpointSession Begin(CheckpointManager& checkpoint_mgr); - - ~CheckpointSession(); - - CheckpointSession(const CheckpointSession&) = delete; - CheckpointSession& operator=(const CheckpointSession&) = delete; - CheckpointSession(CheckpointSession&& other) = delete; - CheckpointSession& operator=(CheckpointSession&& other) = delete; - - std::shared_ptr staging_checkpoint() const; - std::shared_ptr Commit(); - - private: - explicit CheckpointSession( - CheckpointManager::StagingCheckpoint staging_checkpoint); - - CheckpointManager::StagingCheckpoint staging_checkpoint_; - std::shared_ptr published_checkpoint_; -}; - -} // namespace neug diff --git a/include/neug/storages/graph/graph_interface.h b/include/neug/storages/graph/graph_interface.h index 3d0daf7b7..ee2c5d5fe 100644 --- a/include/neug/storages/graph/graph_interface.h +++ b/include/neug/storages/graph/graph_interface.h @@ -836,11 +836,6 @@ class StorageUpdateInterface : public StorageReadInterface, return st; } - /** - * @brief Create a checkpoint of the current graph state. - */ - virtual void CreateCheckpoint() = 0; - private: virtual void MarkSchemaDirty() = 0; @@ -909,8 +904,6 @@ class StorageAPUpdateInterface : public StorageUpdateInterface { timestamp_(timestamp) {} ~StorageAPUpdateInterface() {} - void CreateCheckpoint() override; - private: void MarkVertexTableDirty(label_t label) override { graph_.MarkVertexTableDirty(label); diff --git a/include/neug/storages/graph_snapshot_store.h b/include/neug/storages/graph_snapshot_store.h index 8a1c0b8fa..9c7c220d0 100644 --- a/include/neug/storages/graph_snapshot_store.h +++ b/include/neug/storages/graph_snapshot_store.h @@ -27,6 +27,8 @@ namespace neug { +class Checkpoint; + /** * @brief Fixed-size slot pool for MVCC PropertyGraph snapshots. * @@ -43,7 +45,7 @@ namespace neug { * Concurrency: * - Lock-free PinCurrentSnapshot via optimistic pin + verify loop. * - Concurrent installs are NOT safe — VersionManager serializes - * updates via begin_update_commit (CAS 0→1), ensuring only one + * updates via its update-execution state, ensuring only one * update/compact can be in progress at a time. * - PublishSnapshot publishes the new slot BEFORE VersionManager advances * read_ts_, so readers never see "new ts + old slot". @@ -80,6 +82,36 @@ class GraphSnapshotStore { std::atomic reader_count_{0}; }; + /** + * Scoped capability for destructive in-place maintenance. + * + * GraphSnapshotStore owns the precondition checks and grants this handle only + * after verifying that the current slot is safe for maintenance. The handle + * keeps the low-level mutable/reopen operations out of the public + * GraphSnapshotStore API while avoiding dependencies on higher-level + * coordinators. + */ + class CheckpointMaintenanceHandle { + public: + CheckpointMaintenanceHandle(const CheckpointMaintenanceHandle&) = delete; + CheckpointMaintenanceHandle& operator=(const CheckpointMaintenanceHandle&) = + delete; + CheckpointMaintenanceHandle(CheckpointMaintenanceHandle&& other) noexcept; + CheckpointMaintenanceHandle& operator=( + CheckpointMaintenanceHandle&& other) noexcept; + + PropertyGraph& MutableCurrentSnapshot(); + void ReopenCurrentGraphFromCheckpoint( + std::shared_ptr checkpoint, MemoryLevel memory_level); + + private: + friend class GraphSnapshotStore; + + explicit CheckpointMaintenanceHandle(GraphSnapshotStore& store) noexcept; + + GraphSnapshotStore* store_; + }; + /// @param slot_num Pool capacity (default 128). /// @param initial_pg Published into slot 0. explicit GraphSnapshotStore(int slot_num, @@ -98,7 +130,7 @@ class GraphSnapshotStore { /// Current PropertyGraph (for UpdateTransaction to Clone). /// No lock — VersionManager guarantees exclusive update access /// (update_state_==1, all inserters drained). - const PropertyGraph& CurrentSnapshot() const; + const PropertyGraph& CurrentSnapshot() const noexcept; /// Publish a COW PropertyGraph into a free slot and switch cur_slot_index_. /// Steps: reserve free slot -> prep-pin new slot -> write PG + build view @@ -118,7 +150,23 @@ class GraphSnapshotStore { return !free_list_.empty(); } + /** + * Begin checkpoint maintenance after external quiescence has been acquired. + * + * The caller must already hold the AP exclusive query lock or TP + * VersionManager compact lease or a drained update commit guard. This method + * verifies that the current slot has no ordinary pins and that every + * non-current slot has + * already been reclaimed, then returns a capability for the maintenance-only + * operations. + */ + CheckpointMaintenanceHandle AcquireCheckpointMaintenance(); + private: + PropertyGraph& mutableCurrentSnapshot(); + void reopenCurrentGraphFromCheckpoint(std::shared_ptr checkpoint, + MemoryLevel memory_level); + int slot_num_; std::vector slots_; std::atomic cur_slot_index_{0}; @@ -130,6 +178,7 @@ class GraphSnapshotStore { void returnFreeSlot(int slot_index); void UnpinSnapshotByIndex(int slot_index) noexcept; void cleanupSlot(int slot_index); + SnapshotSlot& currentSlotForMaintenance(); }; /** diff --git a/include/neug/transaction/README.md b/include/neug/transaction/README.md index a81c40578..11d3e26ad 100644 --- a/include/neug/transaction/README.md +++ b/include/neug/transaction/README.md @@ -4,58 +4,144 @@ > application-facing behavior, see > [Transaction Management](../../../doc/source/transaction/transaction.md). +All transaction objects own their `VersionManager` timestamp and storage +resources through RAII. A successful `Commit()` or an explicit `Abort()` +releases those resources; destroying an active transaction aborts or releases +it. Transaction factories acquire the timestamp before pinning or cloning +graph state so that checkpoint maintenance cannot rotate storage, allocator, +or WAL state between those operations. + ## Read Transaction -With an `ReadTransaction`, a specific version of the graph can be read. The version is determined by the timestamp of the transaction. +A `ReadTransaction` is assigned the current committed read timestamp and pins +the current `GraphSnapshotStore` slot with a `SnapshotGuard`. It provides +read-only access to the schema, topology, and properties through that pinned +snapshot. Record-level visibility is limited to timestamps less than or equal +to the transaction timestamp. -`ReadTransaction` provides a set of APIs to read the graph, including schema, topology, and properties. +`Commit()` and `Abort()` have the same effect: they release the snapshot pin and +the reader lease. `Commit()` always returns `true`. The destructor performs the +same release if the transaction is still active; there is no public +`ReadTransaction::Release()` method. -After query with the `ReadTransaction` object, the transaction should be released by calling `ReadTransaction::Release()`. +References backed by the pinned graph, including graph-backed query results, +must not be used after the transaction is committed, aborted, or destroyed. ## Insert Transaction -With an `InsertTransaction`, a set of vertices and edges can be inserted into the graph with the timestamp of transaction. - -After insertion, the transaction can be committed by calling `InsertTransaction::Commit()` or be aborted by calling `InsertTransaction::Abort()`. +An `InsertTransaction` receives a unique write timestamp and pins the current +snapshot slot. It buffers vertex and edge insert operations in a local WAL +archive; graph storage is not changed while the operations are being collected. -`InsertTransaction` does not provide interfaces to read the graph. +On a non-empty `Commit()`, the transaction: -## Update Transaction +1. appends the complete transaction record to the WAL; +2. replays the buffered operations into the live `PropertyGraph` behind the + pinned slot, tagging every new record with the transaction timestamp; and +3. releases the snapshot pin and marks the write timestamp complete. -With an `UpdateTransaction`, a specific version of the graph can be read. The version is determined by the timestamp of the transaction. +The live graph is shared with readers, so insert isolation does not come from a +copy-on-write snapshot. Every read path must filter records by timestamp. A +reader with an older timestamp can therefore share the slot while the insert is +being applied without observing the new records. The committed read watermark +advances only after the transaction has finished and all earlier write +timestamps are also complete. -Also, `UpdateTransaction` provides interfaces to insert and update vertices and edges. +An empty commit only releases the transaction. `Abort()` discards buffered +operations and completes the timestamp without applying graph changes. The +destructor calls `Abort()` if necessary. `InsertTransaction` does not expose a +general-purpose read interface; its schema and vertex lookup helpers exist to +validate inserts and resolve edge endpoints, including vertices added by the +same transaction. -After insertion and update, the transaction can be committed by calling `UpdateTransaction::Commit()` or be aborted by calling `UpdateTransaction::Abort()`. +## Update Transaction -`UpdateTransaction` mutates a copy-on-write `PropertyGraph` clone. Its changes are invisible until commit publishes the clone through `GraphSnapshotStore`. +Acquiring an `UpdateTransaction` enters the update-execution phase, blocks new +inserts and updates, and waits for active inserts to finish. Reads remain +allowed. Once the update lease has been acquired, the current `PropertyGraph` +is cloned using copy-on-write and all DML or DDL changes are made to that clone. +The changes are invisible to readers until commit publishes the clone through +`GraphSnapshotStore`. + +For a non-empty update, `Commit()` verifies that a snapshot slot is available, +appends the finalized transaction to the WAL, and then enters the update-commit +phase. That phase blocks new reads and writers while the COW snapshot is +published. Existing readers are not drained by an ordinary update commit; they +continue on their pinned older snapshots. Publishing happens before the write +timestamp is marked complete, so a new reader cannot observe the advanced +timestamp with the old snapshot. + +An empty commit releases the update lease without publishing a new snapshot. +`Abort()` and the destructor discard the COW clone and complete the timestamp +without making its changes visible. + +## Compact Transaction + +A `CompactTransaction` enters the exclusive compact phase. It blocks new +transactions and waits for both active inserts and active readers to finish +because compaction rewrites the live graph in place. On commit it writes a +compact WAL record, compacts and rebuilds the pinned graph view, and then +restores `update_state_` to `0`. `Abort()` or destruction also restores the +state without compacting. + +## Checkpoint Maintenance + +In service mode, a manual checkpoint uses an `UpdateTimestampGuard`, not a +`CompactTransaction`. It first enters update execution and waits for active +inserts, then enters update commit and explicitly drains active readers before +acquiring the checkpoint-maintenance handle. New transactions remain blocked +while the graph, allocator, and WAL state are replaced. A successful checkpoint +resets the timestamp timeline and restores `update_state_` to `0`. # Version Management ## Visibility -Graph records that participate in MVCC visibility are associated with a timestamp, which is the timestamp of the transaction that creates or publishes the record version. +`VersionManager::read_ts_` is the highest contiguous write timestamp that has +finished. Finishing includes commit, abort, and an empty transaction; only a +commit has graph effects. A `ReadTransaction` receives this watermark as its +timestamp, so every earlier committed insert is visible through record-level +timestamp filtering, while later inserts are hidden. -When reading graph data with a `ReadTransaction` or `UpdateTransaction`, only records with timestamp less than or equal to the transaction timestamp are visible. +Updates use snapshot publication as well as timestamps. The updated snapshot +is published before its timestamp can advance the read watermark. Readers that +started earlier keep their pinned old snapshot; readers that start afterward +pin the published snapshot and receive a timestamp at least as new as the +update when all preceding writes have finished. ## Synchronization -There is no synchronization between read and insert transactions in the normal state. All read and insert transactions can be executed concurrently. - -The `VersionManager` state machine has three effective states for read, insert, and update transactions: - -| State | Meaning | New Reads | New Inserts | New Updates | Existing Reads | -|-------|---------|-----------|-------------|-------------|----------------| -| `0` | Normal | allowed | allowed | allowed | continue | -| `1` | Update execution | allowed | blocked | blocked | continue | -| `2` from update | Update commit | blocked | blocked | blocked | continue | - -When an `UpdateTransaction` is created, it enters the update-exec phase (`update_state_`: `0 -> 1`). It waits for all in-flight insert transactions to finish, but does not block or wait for read transactions. New insert transactions and new update transactions are blocked during this phase; existing and new reads continue. - -When `VersionManager::begin_update_commit` is called, the update enters the commit phase (`update_state_`: `1 -> 2`). New reads and new inserts are blocked until the `UpdateTransaction` is committed or aborted. Already-acquired reads continue unaffected on their pinned snapshot. - -## Serializability - -For a `ReadTransaction`, it will be assigned a graph timestamp. All insert or update transactions with timestamp less than or equal to that timestamp have been committed and are visible through timestamp filtering and the pinned snapshot. - -For each `InsertTransaction` or `UpdateTransaction`, a unique timestamp will be assigned. When committing, a write-ahead log will be written to the disk and all modifications will be applied to the graph atomically. +`VersionManager` coordinates transactions with `update_state_` and active +reader/inserter counters. Its effective states are: + +| State | New reads | New inserts | New updates/compact | Existing reads | Existing inserts | +|-------|-----------|-------------|---------------------|----------------|------------------| +| `0` — normal | allowed | allowed | one transition wins | continue | continue | +| `1` — update execution | allowed | blocked | blocked | continue | drained before acquisition completes | +| `2` — update commit | blocked | blocked | blocked | continue for an ordinary update | none | +| `2` — compact/checkpoint | blocked | blocked | blocked | drained before in-place maintenance | drained before maintenance | + +Multiple reads and inserts can run concurrently in the normal state. Updates and +compact operations serialize by waiting until they can change `update_state_` +from `0`. New reads use a pre-check/increment/post-check sequence so they cannot +slip into state `2` during the transition. + +An ordinary update keeps `update_state_` at `1` during execution and WAL append, +then changes it to `2` only for snapshot publication. A checkpoint additionally +calls `drain_readers()` because it mutates or replaces storage in place. +Compact acquisition enters state `2` directly and drains both readers and +inserters. + +## Commit Ordering + +Each insert, update, or compact transaction receives a unique write timestamp. +Completed timestamps are tracked in a window, and `read_ts_` advances only +across a contiguous sequence of completed writes. This prevents a later, +faster writer from becoming visible ahead of an earlier transaction. + +Insert commit appends WAL before changing the live graph and marks its timestamp +complete only after replay finishes. Update commit appends WAL before publishing +its COW snapshot and marks its timestamp complete only after publication. These +orderings make each committed transaction visible as one logical unit even +though the underlying insert replay or snapshot publication consists of +multiple implementation steps. diff --git a/include/neug/transaction/compact_transaction.h b/include/neug/transaction/compact_transaction.h index 5c011b881..55c6b80ba 100644 --- a/include/neug/transaction/compact_transaction.h +++ b/include/neug/transaction/compact_transaction.h @@ -21,12 +21,12 @@ namespace neug { class PropertyGraph; -class IWalWriter; class IVersionManager; +class IWalWriter; class CompactTransaction { public: - CompactTransaction(GraphSnapshotStore& snapshot_store, IWalWriter& logger, + CompactTransaction(GraphSnapshotStore& snapshot_store, IWalWriter& wal_writer, IVersionManager& vm, timestamp_t timestamp); ~CompactTransaction(); @@ -38,7 +38,7 @@ class CompactTransaction { private: SnapshotGuard guard_; - IWalWriter& logger_; + IWalWriter& wal_writer_; IVersionManager& vm_; timestamp_t timestamp_; diff --git a/include/neug/transaction/insert_transaction.h b/include/neug/transaction/insert_transaction.h index 8b271d364..2c0970ef4 100644 --- a/include/neug/transaction/insert_transaction.h +++ b/include/neug/transaction/insert_transaction.h @@ -35,9 +35,9 @@ namespace neug { class PropertyGraph; -class IWalWriter; class IVersionManager; class Schema; +class IWalWriter; /** * @brief Transaction for inserting new vertices and edges into the graph. @@ -83,14 +83,15 @@ class InsertTransaction { * @param slot Reference to the pinned SnapshotSlot from PinCurrentSnapshot() * @param snapshot_store Reference to GraphSnapshotStore for releasing slot * @param alloc Reference to memory allocator - * @param logger Reference to WAL writer + * @param wal_writer Reference to the session-local WAL writer * @param vm Reference to version manager * @param timestamp Transaction timestamp * * @since v0.1.0 */ - InsertTransaction(SnapshotGuard guard, Allocator& alloc, IWalWriter& logger, - IVersionManager& vm, timestamp_t timestamp); + InsertTransaction(SnapshotGuard guard, Allocator& alloc, + IWalWriter& wal_writer, IVersionManager& vm, + timestamp_t timestamp); /** * @brief Destructor that calls Abort(). @@ -162,8 +163,7 @@ class InsertTransaction { * @return true if commit successful * * Implementation: Checks if any operations in arc_, writes WAL via logger_, - * clears borrowed graph references, releases guard_, calls - * vm_.release_insert_timestamp(), then calls clear(). + * releases the snapshot pin, releases the timestamp, then calls clear(). * * @since v0.1.0 */ @@ -219,7 +219,7 @@ class InsertTransaction { GraphView* view_; Allocator& alloc_; - IWalWriter& logger_; + IWalWriter& wal_writer_; IVersionManager& vm_; timestamp_t timestamp_; }; diff --git a/include/neug/transaction/update_timestamp_guard.h b/include/neug/transaction/update_timestamp_guard.h new file mode 100644 index 000000000..25c12678a --- /dev/null +++ b/include/neug/transaction/update_timestamp_guard.h @@ -0,0 +1,61 @@ +/** Copyright 2020 Alibaba Group Holding Limited. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +#pragma once + +#include "neug/utils/property/types.h" + +namespace neug { + +class IVersionManager; + +/** + * Move-only RAII owner of an update timestamp. + * + * The guard contains only transaction-layer concurrency state. Storage, WAL, + * checkpoint, and query-cache policies remain with their respective callers. + */ +class UpdateTimestampGuard { + public: + static UpdateTimestampGuard Acquire(IVersionManager& version_manager); + + ~UpdateTimestampGuard() noexcept; + + UpdateTimestampGuard(const UpdateTimestampGuard&) = delete; + UpdateTimestampGuard& operator=(const UpdateTimestampGuard&) = delete; + UpdateTimestampGuard(UpdateTimestampGuard&& other) noexcept; + UpdateTimestampGuard& operator=(UpdateTimestampGuard&& other) noexcept; + + timestamp_t timestamp() const noexcept { return timestamp_; } + bool active() const noexcept; + + void BeginCommit(); + void DrainReaders(); + void Release() noexcept; + /// Finish a successful checkpoint by resetting the timestamp timeline and + /// restoring update_state_ to 0. + void CompleteCheckpoint() noexcept; + + private: + UpdateTimestampGuard(IVersionManager& version_manager, + timestamp_t timestamp) noexcept; + + void disarm() noexcept; + + IVersionManager* version_manager_; + timestamp_t timestamp_; + bool commit_started_{false}; +}; + +} // namespace neug diff --git a/include/neug/transaction/update_transaction.h b/include/neug/transaction/update_transaction.h index e35fb1eb4..a0dc0d0bf 100644 --- a/include/neug/transaction/update_transaction.h +++ b/include/neug/transaction/update_transaction.h @@ -35,6 +35,7 @@ #include "neug/storages/graph/property_graph_cow_state.h" #include "neug/storages/graph_snapshot_store.h" #include "neug/transaction/transaction_utils.h" +#include "neug/transaction/update_timestamp_guard.h" #include "neug/transaction/wal/wal_builder.h" #include "neug/utils/property/table.h" #include "neug/utils/property/types.h" @@ -42,15 +43,14 @@ namespace neug { class PropertyGraph; -class IWalWriter; -class IVersionManager; class Schema; +class IWalWriter; /** * @brief Resource holder and lifecycle manager for update transactions. * * UpdateTransaction owns the COW-cloned PropertyGraph and all associated - * resources (WAL buffer, allocator, version manager, snapshot store). + * resources (WAL buffer, allocator, update timestamp guard, snapshot store). * Graph modification logic (DDL/DML) is implemented by StorageTPUpdateInterface * which accesses UpdateTransaction's private members via friend declaration. * @@ -78,19 +78,18 @@ class UpdateTransaction { * * @param cow_graph PropertyGraph COW clone * @param alloc Reference to memory allocator - * @param logger Reference to WAL writer - * @param vm Reference to version manager + * @param wal_writer Reference to the session-local WAL writer + * @param timestamp_guard RAII owner of the update timestamp * @param snapshot_store Reference to GraphSnapshotStore for commit * @param cache Reference to query cache - * @param timestamp Transaction timestamp - * * @note NeugDB is responsible for creating the COW copy via Clone() * @since v0.1.0 */ UpdateTransaction(std::shared_ptr cow_graph, Allocator& alloc, - IWalWriter& logger, IVersionManager& vm, + IWalWriter& wal_writer, + UpdateTimestampGuard timestamp_guard, GraphSnapshotStore& snapshot_store, - execution::LocalQueryCache& cache, timestamp_t timestamp); + execution::LocalQueryCache& cache); /** * @brief Destructor that calls Abort(). @@ -132,13 +131,13 @@ class UpdateTransaction { CsrView GetGenericOutgoingGraphView(label_t v_label, label_t neighbor_label, label_t edge_label) const { return cow_graph_->GetGenericOutgoingGraphView(v_label, neighbor_label, - edge_label, timestamp_); + edge_label, timestamp()); } CsrView GetGenericIncomingGraphView(label_t v_label, label_t neighbor_label, label_t edge_label) const { return cow_graph_->GetGenericIncomingGraphView(v_label, neighbor_label, - edge_label, timestamp_); + edge_label, timestamp()); } EdgeDataAccessor GetEdgeDataAccessor(label_t src_label, label_t dst_label, @@ -158,11 +157,10 @@ class UpdateTransaction { GraphView view_; Allocator& alloc_; - IWalWriter& logger_; - IVersionManager& vm_; + IWalWriter& wal_writer_; + UpdateTimestampGuard timestamp_guard_; GraphSnapshotStore& snapshot_store_; execution::LocalQueryCache& pipeline_cache_; - timestamp_t timestamp_; std::shared_ptr ckp_; WalBuilder wal_builder_; @@ -180,8 +178,6 @@ class StorageTPUpdateInterface : public StorageUpdateInterface { wal_(txn.wal_builder_) {} ~StorageTPUpdateInterface() = default; - void CreateCheckpoint() override; - private: // Marks go to the COW clone; abort discards them with the clone. void MarkVertexTableDirty(label_t label) override { diff --git a/include/neug/transaction/version_manager.h b/include/neug/transaction/version_manager.h index 473264859..442452b19 100644 --- a/include/neug/transaction/version_manager.h +++ b/include/neug/transaction/version_manager.h @@ -47,7 +47,13 @@ class IVersionManager { virtual void release_insert_timestamp(uint32_t ts) = 0; virtual uint32_t acquire_update_timestamp() = 0; virtual void begin_update_commit(uint32_t ts) = 0; - virtual void release_update_timestamp(uint32_t ts) = 0; + virtual void drain_readers() = 0; + /// Complete an ordinary update timestamp and restore update_state_ to 0. + virtual void release_update_timestamp(uint32_t ts) noexcept = 0; + /// Install a fresh timestamp timeline and restore update_state_ to 0 after a + /// successful checkpoint. The caller must have entered update commit and + /// drained all readers first. + virtual void reset_timeline_after_checkpoint(uint32_t ts) noexcept = 0; virtual uint32_t acquire_compact_timestamp() = 0; virtual void release_compact_timestamp(uint32_t ts) = 0; virtual void revert_compact_timestamp(uint32_t ts) = 0; @@ -64,22 +70,22 @@ class IVersionManager { * Concurrency (new acquisitions; in-flight ops are not interrupted): * * | | Read | Insert | Update-exec | Update-commit | Compact | - * | Read | yes | yes | yes | no* | no | - * | Insert | yes | yes | no | no | no | - * | Update-exec | yes | no | no | - | no | - * | Update-commit | no* | no | - | no | no | - * | Compact | no | no | no | no | no | + * | Read | yes | yes | yes | no* | no | + * | Insert | yes | yes | no | no | no | + * | Update-exec | yes | no | no | - | no | + * | Update-commit | no* | no | - | no | no | + * | Compact | no | no | no | no | no | * *New reads spin-wait; already-acquired reads continue. * * Mechanism: * - write_ts_: next available write timestamp (monotonically increasing). * Storage compaction may reset per-record visibility timestamps to zero, but - * transaction/WAL timestamps must never be reset within a WAL timeline. + * transaction/WAL timestamps reset only when an update publishes a complete + * baseline and rotates to a new, empty WAL timeline. * - read_ts_: highest timestamp fully committed and visible to all readers. * - active_readers_/active_inserters_: atomic counters for in-flight ops. - * - update_state_: 0=normal, 1=update-exec (inserters drained), - * 2=update-commit (new reads block; existing reads continue) / - * compact (readers+inserters drained). + * - update_state_: 0=normal, 1=update execution with inserters drained, + * 2=update commit or compact. * - acquire_read_timestamp uses a double-check pattern (pre-check + increment * + post-check) to prevent ABA races with begin_update_commit. * - begin_update_commit uses seq_cst store + drain spin to ensure @@ -102,7 +108,9 @@ class VersionManager : public IVersionManager { void release_insert_timestamp(uint32_t ts) override; uint32_t acquire_update_timestamp() override; void begin_update_commit(uint32_t ts) override; - void release_update_timestamp(uint32_t ts) override; + void drain_readers() override; + void release_update_timestamp(uint32_t ts) noexcept override; + void reset_timeline_after_checkpoint(uint32_t ts) noexcept override; uint32_t acquire_compact_timestamp() override; void release_compact_timestamp(uint32_t ts) override; void revert_compact_timestamp(uint32_t ts) override; diff --git a/include/neug/transaction/wal/dummy_wal_writer.h b/include/neug/transaction/wal/dummy_wal_writer.h index 7453689a8..eab15f162 100644 --- a/include/neug/transaction/wal/dummy_wal_writer.h +++ b/include/neug/transaction/wal/dummy_wal_writer.h @@ -31,7 +31,7 @@ class DummyWalWriter : public IWalWriter { std::string type() const override; - void open() override; + void open(const std::string& wal_uri) override; void close() override; bool append(const char* data, size_t length) override; diff --git a/include/neug/transaction/wal/local_wal_writer.h b/include/neug/transaction/wal/local_wal_writer.h index ea6b2e13f..1b80ed1af 100644 --- a/include/neug/transaction/wal/local_wal_writer.h +++ b/include/neug/transaction/wal/local_wal_writer.h @@ -34,9 +34,9 @@ class LocalWalWriter : public IWalWriter { fd_(-1), file_size_(0), file_used_(0) {} - ~LocalWalWriter() { close(); } + ~LocalWalWriter() noexcept override; - void open() override; + void open(const std::string& wal_uri) override; void close() override; bool append(const char* data, size_t length) override; std::string type() const override { return "file"; } diff --git a/include/neug/transaction/wal/wal.h b/include/neug/transaction/wal/wal.h index 74bd75397..4922134a0 100644 --- a/include/neug/transaction/wal/wal.h +++ b/include/neug/transaction/wal/wal.h @@ -62,7 +62,7 @@ class IWalWriter { * Open a wal file. In our design, each thread has its own wal file. * The uri could be a file_path or a remote connection string. */ - virtual void open() = 0; + virtual void open(const std::string& wal_uri) = 0; /** * Close the wal writer. If a remote connection is hold by the wal writer, diff --git a/include/neug/transaction/wal/wal_builder.h b/include/neug/transaction/wal/wal_builder.h index 73b76de6b..6a4bf26a2 100644 --- a/include/neug/transaction/wal/wal_builder.h +++ b/include/neug/transaction/wal/wal_builder.h @@ -33,15 +33,9 @@ namespace neug { /// buffer and increments the operation count. DDL Log methods additionally set /// schema_changed_ = true. /// -/// LogCheckpoint() only increments op_num (no serialization) — used for the -/// checkpoint-only commit path where the snapshot is published but no WAL -/// content is written. -/// /// UpdateTransaction::Commit() uses: /// - op_num() == 0 → nothing to do, early return /// - op_num() > 0 → must publish snapshot -/// - content_size() > 0 → finalize() + append WAL -/// - content_size() == 0 → skip WAL write (checkpoint-only) class WalBuilder { public: WalBuilder(); @@ -89,17 +83,14 @@ class WalBuilder { const Value& dst, label_t edge_label, int32_t oe_offset, int32_t ie_offset); - /// Checkpoint: only increments op_num, no WAL content serialized. - void LogCheckpoint() { ++op_num_; } - // --- Query state --- int op_num() const { return op_num_; } bool schema_changed() const { return schema_changed_; } - /// Size of the WAL content (excluding header). 0 means checkpoint-only. + /// Size of the WAL content excluding its header. size_t content_size() const { return arc_.GetSize() - sizeof(WalHeader); } - /// Finalize the WAL header. Call only when content_size() > 0. + /// Finalize the WAL header. Call only when op_num() > 0. void finalize(timestamp_t timestamp); /// Full buffer (header + content) after finalize(). diff --git a/include/neug/utils/access_mode.h b/include/neug/utils/access_mode.h index 652c6e957..578632ead 100644 --- a/include/neug/utils/access_mode.h +++ b/include/neug/utils/access_mode.h @@ -15,6 +15,8 @@ #pragma once +#include + #include "neug/utils/exception/exception.h" #include "neug/utils/string_utils.h" @@ -48,6 +50,21 @@ inline AccessMode ParseAccessMode(const std::string& access_mode_str) { } } +template +inline AccessMode ResolveAccessMode(AccessMode requested_mode, + ModeAnalyzer&& analyze_mode) { + return requested_mode == AccessMode::kUnKnown + ? std::forward(analyze_mode)() + : requested_mode; +} + +template +inline AccessMode ResolveAccessMode(const std::string& requested_mode, + ModeAnalyzer&& analyze_mode) { + return ResolveAccessMode(ParseAccessMode(requested_mode), + std::forward(analyze_mode)); +} + inline std::string AccessModeToString(AccessMode mode) { switch (mode) { case AccessMode::kRead: diff --git a/src/execution/execute/ops/admin/checkpoint.cc b/src/execution/execute/ops/admin/checkpoint.cc index 6e4885a87..aab207012 100644 --- a/src/execution/execute/ops/admin/checkpoint.cc +++ b/src/execution/execute/ops/admin/checkpoint.cc @@ -33,9 +33,13 @@ class CheckpointOpr : public IOperator { neug::result CheckpointOpr::Eval(IStorageInterface& graph_interface, const ParamsMap& params, Context&& ctx, OprTimer* timer) { - auto& graph = dynamic_cast(graph_interface); - graph.CreateCheckpoint(); - return neug::result(std::move(ctx)); + (void) graph_interface; + (void) params; + (void) ctx; + (void) timer; + RETURN_ERROR(neug::Status( + neug::StatusCode::ERR_ILLEGAL_OPERATION, + "CHECKPOINT must be executed by the database checkpoint executor")); } neug::result CheckpointOprBuilder::Build( @@ -48,4 +52,4 @@ neug::result CheckpointOprBuilder::Build( } // namespace execution -} // namespace neug \ No newline at end of file +} // namespace neug diff --git a/src/main/checkpoint_coordinator.cc b/src/main/checkpoint_coordinator.cc new file mode 100644 index 000000000..c2267f6e5 --- /dev/null +++ b/src/main/checkpoint_coordinator.cc @@ -0,0 +1,232 @@ +/** Copyright 2020 Alibaba Group Holding Limited. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "neug/main/checkpoint_coordinator.h" + +#include + +#include +#include +#include +#include + +#include "neug/storages/checkpoint.h" +#include "neug/storages/checkpoint_manager.h" +#include "neug/transaction/update_timestamp_guard.h" +#include "neug/utils/exception/exception.h" + +namespace neug { + +namespace { + +Status maintenance_preparation_failure(const std::exception& e) { + return Status(StatusCode::ERR_INTERNAL_ERROR, e.what()); +} + +[[noreturn]] void fail_stop_live_database(const char* message) noexcept { + LOG(FATAL) << message + << "; terminating to prevent access to an invalid live graph"; + std::abort(); +} + +void cleanup_retired_checkpoints( + CheckpointManager& checkpoint_manager) noexcept { + try { + checkpoint_manager.CleanupRetiredCheckpoints(); + } catch (const std::exception& e) { + LOG(WARNING) << "Checkpoint GC failed: " << e.what(); + } catch (...) { LOG(WARNING) << "Checkpoint GC failed"; } +} + +} // namespace + +const char* CheckpointCoordinator::reasonName(Reason reason) { + switch (reason) { + case Reason::kManual: + return "manual"; + case Reason::kRecovery: + return "recovery"; + case Reason::kShutdown: + return "shutdown"; + } + return "unknown"; +} + +CheckpointCoordinator::CheckpointCoordinator( + CheckpointManager& checkpoint_manager, GraphSnapshotStore& snapshot_store, + MemoryLevel memory_level, timestamp_t recovered_wal_timestamp, + PostReopenHandler post_reopen_handler) + : checkpoint_manager_(checkpoint_manager), + snapshot_store_(snapshot_store), + memory_level_(memory_level), + recovered_wal_timestamp_(recovered_wal_timestamp), + post_reopen_handler_(std::move(post_reopen_handler)) {} + +Status CheckpointCoordinator::ExecuteApManual() { + auto status = executeWithNewMaintenance(Reason::kManual); + if (status.ok()) { + recovered_wal_timestamp_ = 0; + } + return status; +} + +void CheckpointCoordinator::SetActivationHandler( + CheckpointActivationHandler handler) { + if (!handler) { + THROW_INVALID_ARGUMENT_EXCEPTION( + "Checkpoint activation handler must not be empty"); + } + std::lock_guard lock(activation_handler_mutex_); + if (activation_handler_) { + THROW_RUNTIME_ERROR("Checkpoint activation handler is already set"); + } + activation_handler_ = std::move(handler); +} + +void CheckpointCoordinator::ClearActivationHandler() { + std::lock_guard lock(activation_handler_mutex_); + activation_handler_ = nullptr; +} + +void CheckpointCoordinator::invokeActivationHandler( + const std::string& checkpoint_wal_uri) { + std::lock_guard lock(activation_handler_mutex_); + if (activation_handler_) { + activation_handler_(checkpoint_wal_uri); + } +} + +Status CheckpointCoordinator::ExecuteTpManual( + UpdateTimestampGuard timestamp_guard) { + Status status = [&]() -> Status { + try { + timestamp_guard.BeginCommit(); + timestamp_guard.DrainReaders(); + auto maintenance = snapshot_store_.AcquireCheckpointMaintenance(); + return execute(maintenance, Reason::kManual, + /*invoke_activation_handler=*/true); + } catch (const std::exception& e) { + return maintenance_preparation_failure(e); + } catch (...) { + return Status(StatusCode::ERR_INTERNAL_ERROR, + "Unknown checkpoint preparation failure"); + } + }(); + + if (status.ok()) { + recovered_wal_timestamp_ = 0; + } + if (status.ok()) { + // The post-reopen and activation handlers completed every database- and + // service-owned state transition while this guard still prevented new + // transactions from starting. + timestamp_guard.CompleteCheckpoint(); + } else { + // Preparation failures happen before execute() enters its destructive + // phase. execute() itself fail-stops after that boundary. + timestamp_guard.Release(); + } + return status; +} + +Status CheckpointCoordinator::ExecuteRecovery() { + auto status = executeWithNewMaintenance(Reason::kRecovery); + if (status.ok()) { + recovered_wal_timestamp_ = 0; + } + return status; +} + +Status CheckpointCoordinator::PrepareShutdown() { + auto status = executeWithNewMaintenance(Reason::kShutdown); + if (status.ok()) { + recovered_wal_timestamp_ = 0; + } + return status; +} + +Status CheckpointCoordinator::executeWithNewMaintenance( + Reason reason, bool invoke_activation_handler) { + try { + auto maintenance = snapshot_store_.AcquireCheckpointMaintenance(); + return execute(maintenance, reason, invoke_activation_handler); + } catch (const std::exception& e) { + return maintenance_preparation_failure(e); + } +} + +Status CheckpointCoordinator::execute( + GraphSnapshotStore::CheckpointMaintenanceHandle& maintenance, Reason reason, + bool invoke_activation_handler) { + auto staging_checkpoint = checkpoint_manager_.CreateStagingCheckpoint(); + const bool reopen_after_checkpoint = reason != Reason::kShutdown; + + // Once the destructive phase begins, a running database cannot safely + // continue after failure. Recovery and shutdown are not externally visible + // runtimes, so their callers may still unwind and destroy the consumed graph. + bool destructive_phase = false; + try { + auto& live_graph = maintenance.MutableCurrentSnapshot(); + + LOG(INFO) << "Executing " << reasonName(reason) << " checkpoint" + << (reopen_after_checkpoint ? " and reopening the current graph" + : " without reopening the graph"); + destructive_phase = true; + live_graph.Compact(); + live_graph.DumpAndClear(staging_checkpoint.checkpoint()); + auto published_checkpoint = staging_checkpoint.Commit(); + VLOG(1) << "Finish checkpoint: " << published_checkpoint->path(); + + if (reopen_after_checkpoint) { + // This is the intentional in-place checkpoint reopen path. It is only + // used after AcquireCheckpointMaintenance() has verified that the + // current slot has no ordinary pins and the caller holds the AP exclusive + // lock or a drained TP update guard. + maintenance.ReopenCurrentGraphFromCheckpoint(published_checkpoint, + memory_level_); + + // Correctness-critical rotation first. Infallible by contract; a + // throwing handler would escape into the destructive-phase fail-stop + // below, which is the intended behavior. + post_reopen_handler_(published_checkpoint->allocator_dir()); + + if (invoke_activation_handler) { + invokeActivationHandler(published_checkpoint->wal_dir()); + } + + cleanup_retired_checkpoints(checkpoint_manager_); + } + return Status::OK(); + } catch (const exception::IOException& e) { + if (destructive_phase && reason == Reason::kManual) { + fail_stop_live_database(e.what()); + } + return Status(StatusCode::ERR_IO_ERROR, e.what()); + } catch (const std::exception& e) { + if (destructive_phase && reason == Reason::kManual) { + fail_stop_live_database(e.what()); + } + return Status(StatusCode::ERR_INTERNAL_ERROR, e.what()); + } catch (...) { + if (destructive_phase && reason == Reason::kManual) { + fail_stop_live_database("Unknown destructive checkpoint failure"); + } + return Status(StatusCode::ERR_INTERNAL_ERROR, + destructive_phase ? "Unknown destructive checkpoint failure" + : "Unknown checkpoint preparation failure"); + } +} + +} // namespace neug diff --git a/src/main/connection.cc b/src/main/connection.cc index f5ff54ddb..1f1e826b0 100644 --- a/src/main/connection.cc +++ b/src/main/connection.cc @@ -17,7 +17,7 @@ #include "neug/main/neug_db.h" #include "neug/main/query_request.h" -#include "neug/utils/pb_utils.h" +#include "neug/utils/exception/exception.h" #include "neug/utils/yaml_utils.h" namespace neug { @@ -29,8 +29,14 @@ std::string Connection::GetSchema() const { } SnapshotGuard guard(snapshot_store_); auto yaml = guard.get().mutable_graph()->schema().to_yaml(); - std::string ret = neug::get_json_string_from_yaml(yaml.value()).value(); - return ret; + if (!yaml) { + THROW_RUNTIME_ERROR("Failed to get schema." + yaml.error().ToString()); + } + auto schema = get_json_string_from_yaml(yaml.value()); + if (!schema) { + THROW_RUNTIME_ERROR("Failed to get schema." + schema.error().ToString()); + } + return std::move(schema).value(); } void Connection::Close() { diff --git a/src/main/neug_db.cc b/src/main/neug_db.cc index f526f672f..bbe78642b 100644 --- a/src/main/neug_db.cc +++ b/src/main/neug_db.cc @@ -32,14 +32,14 @@ #include "neug/compiler/planner/graph_planner.h" #include "neug/execution/execute/plan_parser.h" #include "neug/execution/execute/query_cache.h" +#include "neug/main/checkpoint_coordinator.h" #include "neug/main/connection_manager.h" #include "neug/main/file_lock.h" #include "neug/main/query_processor.h" -#include "neug/server/neug_db_session.h" #include "neug/storages/allocators.h" +#include "neug/storages/checkpoint.h" #include "neug/storages/checkpoint_manager.h" #include "neug/storages/checkpoint_manifest.h" -#include "neug/storages/checkpoint_session.h" #include "neug/storages/graph/schema.h" #include "neug/transaction/compact_transaction.h" #include "neug/transaction/wal/wal.h" @@ -49,18 +49,11 @@ namespace neug { -inline std::string allocator_prefix(const std::string& allocator_dir, - int thread_id) { - return (std::filesystem::path(allocator_dir) / - ("allocator_" + std::to_string(thread_id) + "_")) - .string(); -} - class Connection; -static void IngestWalRange(PropertyGraph& graph, - std::vector>& allocators, - const IWalParser& parser, uint32_t from, - uint32_t to) { +static void IngestWalRange( + PropertyGraph& graph, + const std::vector>& allocators, + const IWalParser& parser, uint32_t from, uint32_t to) { if (from >= to) { return; } @@ -71,22 +64,22 @@ static void IngestWalRange(PropertyGraph& graph, GraphView view(graph); for (size_t j = from; j < to; ++j) { const auto& unit = parser.get_insert_wal(j); - InsertTransaction::IngestWal(view, j, unit.ptr, unit.size, *allocators[0]); + InsertTransaction::IngestWal(view, j, unit.ptr, unit.size, + *allocators.at(0)); if (j % 1000000 == 0) { LOG(INFO) << "Ingested " << j << " WALs"; } } } -NeugDB::NeugDB() - : last_compaction_ts_(0), - last_ts_(0), - closed_(true), - is_pure_memory_(false), - max_thread_num_(1) {} +NeugDB::NeugDB() : closed_(true), is_pure_memory_(false), max_thread_num_(1) {} NeugDB::~NeugDB() { - Close(); + try { + Close(); + } catch (const std::exception& e) { + LOG(ERROR) << "Checkpoint failed while destroying NeugDB: " << e.what(); + } catch (...) { LOG(ERROR) << "Checkpoint failed while destroying NeugDB"; } WalWriterFactory::Finalize(); WalParserFactory::Finalize(); // We put the removal of temp dir here to avoid the situation that @@ -95,8 +88,8 @@ NeugDB::~NeugDB() { // dir until the db is destructed. try { if (is_pure_memory_) { - VLOG(10) << "Removing temp NeugDB at: " << work_dir(); - remove_directory(checkpoint_mgr_.db_dir()); + VLOG(10) << "Removing temp NeugDB at: " << config_.data_dir; + remove_directory(config_.data_dir); } } catch (const std::exception& e) { LOG(WARNING) << "Failed to remove temp dir for " << work_dir() << ": " @@ -117,6 +110,9 @@ bool NeugDB::Open(const std::string& data_dir, int32_t max_thread_num, } bool NeugDB::Open(const NeugDBConfig& config) { + if (!closed_.load(std::memory_order_acquire)) { + THROW_RUNTIME_ERROR("NeugDB instance is already open."); + } config_ = config; preprocessConfig(); config_.data_dir = std::filesystem::absolute(config_.data_dir).string(); @@ -141,17 +137,24 @@ bool NeugDB::Open(const NeugDBConfig& config) { checkpoint_mgr_.Open(config_.data_dir, recover_workspace); VLOG(1) << "Opening NeuGDB at " << checkpoint_mgr_.db_dir(); neug::execution::PlanParser::get().init(); - openGraphAndIngestWals(); - if (last_ts_ > 0 && config.checkpoint_on_recovery && + const auto recovered_wal_timestamp = openGraphAndIngestWals(); + checkpoint_coordinator_ = std::make_unique( + checkpoint_mgr_, *snapshot_store_, config_.memory_level, + recovered_wal_timestamp, [this](const std::string& allocator_dir) { + reopenAllocators(allocator_dir); + }); + if (recovered_wal_timestamp > 0 && config.checkpoint_on_recovery && config_.mode == DBMode::READ_WRITE) { - LOG(INFO) << "Creating checkpoint after recovery at ts " << last_ts_; - createCheckpointAndRefreshLiveGraph(); + LOG(INFO) << "Creating checkpoint after recovery at ts " + << recovered_wal_timestamp; + createCheckpointAfterRecovery(); } if (config_.mode == DBMode::READ_WRITE) { checkpoint_mgr_.CleanupRetiredCheckpoints(); } initPlannerAndQueryProcessor(); } catch (...) { + checkpoint_coordinator_.reset(); snapshot_store_.reset(); allocators_.clear(); checkpoint_mgr_.Close(); @@ -163,12 +166,12 @@ bool NeugDB::Open(const NeugDBConfig& config) { } LOG(INFO) << "NeugDB opened successfully"; - closed_.store(false); + closed_.store(false, std::memory_order_release); return true; } void NeugDB::Close() { - if (closed_.exchange(true)) { + if (closed_.exchange(true, std::memory_order_acq_rel)) { return; } if (connection_manager_) { @@ -179,6 +182,9 @@ void NeugDB::Close() { if (query_processor_) { query_processor_.reset(); } + if (global_query_cache_) { + global_query_cache_.reset(); + } if (planner_) { planner_.reset(); } @@ -189,12 +195,14 @@ void NeugDB::Close() { createCheckpointOnClose(); } catch (const std::exception& e) { LOG(ERROR) << "Checkpoint on close failed: " << e.what(); - } + } catch (...) { LOG(ERROR) << "Checkpoint on close failed"; } } // Clear GraphSnapshotStore instead of graph_ + checkpoint_coordinator_.reset(); snapshot_store_.reset(); allocators_.clear(); + checkpoint_mgr_.Close(); if (file_lock_) { file_lock_->unlock(); @@ -206,6 +214,14 @@ std::shared_ptr NeugDB::Connect() { return connection_manager_->CreateConnection(); } +CheckpointCoordinator& NeugDB::checkpoint_coordinator() { + return *checkpoint_coordinator_; +} + +const CheckpointCoordinator& NeugDB::checkpoint_coordinator() const { + return *checkpoint_coordinator_; +} + void NeugDB::RemoveConnection(std::shared_ptr conn) { connection_manager_->RemoveConnection(conn); } @@ -218,7 +234,7 @@ void NeugDB::PrepareForServing() { } CloseAllConnection(); if (config_.mode == DBMode::READ_WRITE) { - createCheckpointAndRefreshLiveGraph(); + createCheckpointAfterRecovery(); } initQueryRuntime(); } @@ -264,21 +280,39 @@ void NeugDB::preprocessConfig() { } void NeugDB::initAllocators(const std::string& allocator_dir) { - // Initialize the default allocator for ingesting wals + assert(config_.max_thread_num > 0); allocators_.clear(); remove_directory(allocator_dir); std::filesystem::create_directories(allocator_dir); - assert(config_.max_thread_num > 0); - for (int i = 0; i < config_.max_thread_num; ++i) { + allocators_.reserve(static_cast(config_.max_thread_num)); + for (int32_t i = 0; i < config_.max_thread_num; ++i) { allocators_.emplace_back(std::make_shared( - config_.memory_level, config_.memory_level != MemoryLevel::kSyncToFile - ? "" - : allocator_prefix(allocator_dir, i))); + config_.memory_level, + config_.memory_level == MemoryLevel::kSyncToFile + ? allocator_prefix(allocator_dir, static_cast(i)) + : "")); } } -void NeugDB::openGraphAndIngestWals() { +void NeugDB::reopenAllocators(const std::string& allocator_dir) { + // Precompute every prefix before touching live allocator state: a + // mid-rotation allocation failure would otherwise leave earlier allocators + // on the new generation and later ones on the retired one. + std::vector prefixes; + prefixes.reserve(allocators_.size()); + for (size_t i = 0; i < allocators_.size(); ++i) { + prefixes.emplace_back(config_.memory_level == MemoryLevel::kSyncToFile + ? allocator_prefix(allocator_dir, i) + : ""); + } + for (size_t i = 0; i < allocators_.size(); ++i) { + allocators_[i]->Reopen(config_.memory_level, std::move(prefixes[i])); + } +} + +timestamp_t NeugDB::openGraphAndIngestWals() { max_thread_num_ = config_.max_thread_num; + timestamp_t recovered_wal_timestamp = 0; try { auto ckp = checkpoint_mgr_.CurrentCheckpoint(); if (ckp == nullptr) { @@ -306,6 +340,7 @@ void NeugDB::openGraphAndIngestWals() { neug::WalParserFactory::Init(); auto wal_parser = WalParserFactory::CreateWalParser(ckp->wal_dir()); ingestWals(*wal_parser, *graph); + recovered_wal_timestamp = wal_parser->last_ts(); // Create GraphSnapshotStore with the graph at timestamp 0 snapshot_store_ = @@ -317,6 +352,7 @@ void NeugDB::openGraphAndIngestWals() { LOG(ERROR) << "Exception: " << e.what(); THROW_INTERNAL_EXCEPTION(e.what()); } + return recovered_wal_timestamp; } void NeugDB::ingestWals(IWalParser& parser, PropertyGraph& graph) { @@ -331,10 +367,9 @@ void NeugDB::ingestWals(IWalParser& parser, PropertyGraph& graph) { } if (update_wal.size == 0) { graph.Compact(); - last_compaction_ts_ = update_wal.timestamp; } else { UpdateTransaction::IngestWal(graph, to_ts, update_wal.ptr, - update_wal.size, *allocators_[0]); + update_wal.size, *allocators_.at(0)); } from_ts = to_ts + 1; } @@ -342,7 +377,6 @@ void NeugDB::ingestWals(IWalParser& parser, PropertyGraph& graph) { IngestWalRange(graph, allocators_, parser, from_ts, parser.last_ts() + 1); } LOG(INFO) << "Finish ingesting wals up to timestamp: " << parser.last_ts(); - last_ts_ = parser.last_ts(); } void NeugDB::initPlanner() { @@ -362,8 +396,8 @@ void NeugDB::initQueryRuntime() { global_query_cache_ = std::make_shared(planner_); query_processor_ = std::make_shared( - *snapshot_store_, planner_, global_query_cache_, *allocators_[0], - max_thread_num_, config_.mode == DBMode::READ_ONLY); + *checkpoint_coordinator_, *snapshot_store_, planner_, global_query_cache_, + *allocators_.at(0), max_thread_num_, config_.mode == DBMode::READ_ONLY); connection_manager_ = std::make_unique( *snapshot_store_, planner_, query_processor_, config_); @@ -374,23 +408,7 @@ void NeugDB::initPlannerAndQueryProcessor() { initQueryRuntime(); } -std::shared_ptr NeugDB::consumeLiveGraphAndCommitCheckpoint( - CheckpointSession& checkpoint_session) { - SnapshotGuard guard(*snapshot_store_); - auto* live_graph = guard.get().mutable_graph(); - // Compact rewrites only already-dirty tables (does not mark); dump then - // publishes. ClearAllDirty runs only after a successful Commit. - live_graph->Compact(); - live_graph->DumpAndClear(checkpoint_session.staging_checkpoint()); - auto published_checkpoint = checkpoint_session.Commit(); - // Consumed graph is about to be dropped; ClearAllDirty is for the contract - // when a graph remains live after publish (AP/TP CreateCheckpoint paths). - live_graph->ClearAllDirty(); - guard.release(); - return published_checkpoint; -} - -void NeugDB::createCheckpointAndRefreshLiveGraph() { +void NeugDB::createCheckpointAfterRecovery() { std::lock_guard lock(mutex_); { SnapshotGuard guard(*snapshot_store_); @@ -399,49 +417,13 @@ void NeugDB::createCheckpointAndRefreshLiveGraph() { return; } } - auto previous_checkpoint = checkpoint_mgr_.CurrentCheckpoint(); - auto checkpoint_session = CheckpointSession::Begin(checkpoint_mgr_); - auto published_checkpoint = - consumeLiveGraphAndCommitCheckpoint(checkpoint_session); - - auto rollback_published_checkpoint = [&]() { - if (previous_checkpoint == nullptr) { - return false; + auto outcome = checkpoint_coordinator_->ExecuteRecovery(); + if (!outcome.ok()) { + if (outcome.error_code() == StatusCode::ERR_IO_ERROR) { + THROW_IO_EXCEPTION(outcome.error_message()); } - try { - checkpoint_mgr_.RestoreCurrentCheckpoint(previous_checkpoint); - checkpoint_mgr_.CleanupPublishedCheckpoint(published_checkpoint); - return true; - } catch (const std::exception& e) { - LOG(ERROR) << "Failed to restore previous checkpoint " - << previous_checkpoint->path() << ": " << e.what(); - } catch (...) { - LOG(ERROR) << "Failed to restore previous checkpoint " - << previous_checkpoint->path(); - } - return false; - }; - - try { - auto reopened_graph = std::make_shared(); - reopened_graph->Open(published_checkpoint, config_.memory_level); - snapshot_store_ = std::make_unique( - config_.storage_slot_num, std::move(reopened_graph)); - initAllocators(published_checkpoint->allocator_dir()); - } catch (...) { - snapshot_store_.reset(); - allocators_.clear(); - rollback_published_checkpoint(); - throw; + THROW_INTERNAL_EXCEPTION(outcome.error_message()); } - - // Replacing snapshot_store_ releases the consumed graph before the retired - // checkpoint directory is removed. - previous_checkpoint.reset(); - checkpoint_mgr_.CleanupRetiredCheckpoints(); - - last_ts_ = 0; - last_compaction_ts_ = 0; } void NeugDB::createCheckpointOnClose() { @@ -453,17 +435,23 @@ void NeugDB::createCheckpointOnClose() { return; } } - auto checkpoint_session = CheckpointSession::Begin(checkpoint_mgr_); - consumeLiveGraphAndCommitCheckpoint(checkpoint_session); - + auto outcome = checkpoint_coordinator_->PrepareShutdown(); + if (!outcome.ok()) { + if (outcome.error_code() == StatusCode::ERR_IO_ERROR) { + THROW_IO_EXCEPTION(outcome.error_message()); + } + THROW_INTERNAL_EXCEPTION(outcome.error_message()); + } // Close-path checkpointing does not reopen a live graph. Release all // snapshot/container/mmap resources before deleting the retired checkpoint. + checkpoint_coordinator_.reset(); snapshot_store_.reset(); allocators_.clear(); - checkpoint_mgr_.CleanupRetiredCheckpoints(); - - last_ts_ = 0; - last_compaction_ts_ = 0; + try { + checkpoint_mgr_.CleanupRetiredCheckpoints(); + } catch (const std::exception& e) { + LOG(WARNING) << "Checkpoint GC failed: " << e.what(); + } catch (...) { LOG(WARNING) << "Checkpoint GC failed"; } } } // namespace neug diff --git a/src/main/query_processor.cc b/src/main/query_processor.cc index fb8d89dc7..44119be29 100644 --- a/src/main/query_processor.cc +++ b/src/main/query_processor.cc @@ -17,17 +17,16 @@ #include "neug/execution/common/context.h" #include "neug/execution/common/operators/retrieve/sink.h" #include "neug/execution/execute/plan_parser.h" -#include "neug/main/neug_db.h" +#include "neug/main/checkpoint_coordinator.h" #include "neug/storages/graph/graph_stats.h" #include "neug/storages/graph/property_graph.h" -#include "neug/utils/pb_utils.h" namespace neug { -result>> +result> QueryProcessor::check_and_retrieve_pipeline(const PropertyGraph& pg, const std::string& query_string, - const std::string& user_access_mode, + AccessMode access_mode, int32_t num_threads) { if (num_threads == 0) { num_threads = max_thread_num_; @@ -40,13 +39,15 @@ QueryProcessor::check_and_retrieve_pipeline(const PropertyGraph& pg, "Number of threads must be greater than 0")); } - auto access_mode = user_access_mode.empty() - ? planner_->analyzeMode(query_string) - : ParseAccessMode(user_access_mode); GraphStats stats(pg); GS_AUTO(cache_value, global_query_cache_->Get(stats, query_string)); assert(cache_value); const auto& flags = cache_value->flags; + if (flags.checkpoint() && access_mode != AccessMode::kUpdate) { + RETURN_ERROR(neug::Status( + neug::StatusCode::ERR_INVALID_ARGUMENT, + "CHECKPOINT only accepts the default or update/u access mode")); + } // Explicit access_mode=read accepts read-only CALL (no procedure_call flag). // Unspecified mode: token-based analyzeMode still classifies CALL as update. if ((is_read_only_ || access_mode == AccessMode::kRead) && @@ -55,26 +56,30 @@ QueryProcessor::check_and_retrieve_pipeline(const PropertyGraph& pg, neug::Status(neug::StatusCode::ERR_INVALID_ARGUMENT, "Write queries are not supported in read-only mode")); } - return std::make_pair(access_mode, cache_value); + return cache_value; } result QueryProcessor::execute( const std::string& query_string, const std::string& user_access_mode, const execution::ParamsMap& parameters, int32_t num_threads) { - SnapshotGuard guard(snapshot_store_); - GS_AUTO(access_mode_pipeline, check_and_retrieve_pipeline( - *guard.get().mutable_graph(), query_string, - user_access_mode, num_threads)); - if (need_exclusive_lock(access_mode_pipeline.first)) { + const auto access_mode = ResolveAccessMode( + user_access_mode, [&]() { return planner_->analyzeMode(query_string); }); + if (need_exclusive_lock(access_mode)) { std::unique_lock lock(mutex_); - return execute_internal(guard, query_string, access_mode_pipeline.second, - access_mode_pipeline.first, parameters, - num_threads); + SnapshotGuard guard(snapshot_store_); + GS_AUTO(cache_value, check_and_retrieve_pipeline( + *guard.get().mutable_graph(), query_string, + access_mode, num_threads)); + return execute_internal(guard, query_string, cache_value, access_mode, + parameters, num_threads); } else { std::shared_lock lock(mutex_); - return execute_internal(guard, query_string, access_mode_pipeline.second, - access_mode_pipeline.first, parameters, - num_threads); + SnapshotGuard guard(snapshot_store_); + GS_AUTO(cache_value, check_and_retrieve_pipeline( + *guard.get().mutable_graph(), query_string, + access_mode, num_threads)); + return execute_internal(guard, query_string, cache_value, access_mode, + parameters, num_threads); } } @@ -82,34 +87,28 @@ result QueryProcessor::execute(const std::string& query_string, const std::string& user_access_mode, const rapidjson::Value& parameters, int32_t num_threads) { - SnapshotGuard guard(snapshot_store_); - GS_AUTO(access_mode_pipeline, check_and_retrieve_pipeline( - *guard.get().mutable_graph(), query_string, - user_access_mode, num_threads)); - const auto& param_types = access_mode_pipeline.second->params_type; - - execution::ParamsMap params_map; - if (parameters.IsObject()) { - for (const auto& member : parameters.GetObject()) { - std::string key = member.name.GetString(); - auto iter = param_types.find(key); - if (iter == param_types.end()) { - RETURN_ERROR(neug::Status(neug::StatusCode::ERR_INVALID_ARGUMENT, - "Unexpected parameter: " + key)); - } - params_map.emplace(key, Value::FromJson(member.value, iter->second)); - } - } - if (need_exclusive_lock(access_mode_pipeline.first)) { + const auto access_mode = ResolveAccessMode( + user_access_mode, [&]() { return planner_->analyzeMode(query_string); }); + if (need_exclusive_lock(access_mode)) { std::unique_lock lock(mutex_); - return execute_internal(guard, query_string, access_mode_pipeline.second, - access_mode_pipeline.first, params_map, - num_threads); + SnapshotGuard guard(snapshot_store_); + GS_AUTO(cache_value, check_and_retrieve_pipeline( + *guard.get().mutable_graph(), query_string, + access_mode, num_threads)); + auto params_map = + execution::build_params_map(parameters, cache_value->params_type); + return execute_internal(guard, query_string, cache_value, access_mode, + params_map, num_threads); } else { std::shared_lock lock(mutex_); - return execute_internal(guard, query_string, access_mode_pipeline.second, - access_mode_pipeline.first, params_map, - num_threads); + SnapshotGuard guard(snapshot_store_); + GS_AUTO(cache_value, check_and_retrieve_pipeline( + *guard.get().mutable_graph(), query_string, + access_mode, num_threads)); + auto params_map = + execution::build_params_map(parameters, cache_value->params_type); + return execute_internal(guard, query_string, cache_value, access_mode, + params_map, num_threads); } } @@ -120,7 +119,13 @@ result QueryProcessor::execute_internal( const execution::ParamsMap& parameters, int32_t num_threads) { auto& slot = guard.get(); auto& pg = *slot.mutable_graph(); - StorageAPUpdateInterface graph(pg, slot.mutable_view(), 0, allocator_); + // Use the recovered WAL watermark as the visibility timestamp so data + // replayed from WAL (stamped with its original timestamp) stays visible; + // it returns to 0 together with the per-record timestamps after the next + // checkpoint. + StorageAPUpdateInterface graph( + pg, slot.mutable_view(), + checkpoint_coordinator_.recovered_wal_timestamp(), allocator_); google::protobuf::Arena arena; neug::QueryResponse* response = @@ -134,6 +139,36 @@ result QueryProcessor::execute_internal( access_mode, pg, arena); } + if (cache_value->flags.checkpoint()) { + // The ordinary pin was needed for compilation. Release it before + // checkpoint maintenance; the AP exclusive query lock remains held. + guard.release(); + + const bool profile_checkpoint = + explain_mode == physical::ExplainMode::PROFILE; + execution::OprTimer checkpoint_timer; + execution::TimerUnit checkpoint_timer_unit; + if (profile_checkpoint) { + checkpoint_timer.set_name("Checkpoint"); + checkpoint_timer_unit.start(); + } + + auto status = checkpoint_coordinator_.ExecuteApManual(); + if (!status.ok()) { + RETURN_ERROR(status); + } + global_query_cache_->clear(); + + response->set_row_count(0); + response->mutable_schema()->CopyFrom(cache_value->result_schema); + if (profile_checkpoint) { + checkpoint_timer.record(checkpoint_timer_unit); + *response->mutable_profile_result() = + execution::OprTimer::ToProfileResult(&checkpoint_timer); + } + return QueryResult::From(response->SerializeAsString()); + } + // ================ NORMAL/PROFILE EXECUTION ================= std::unique_ptr timer_ptr = nullptr; if (explain_mode == physical::ExplainMode::PROFILE) { diff --git a/src/main/query_request.cc b/src/main/query_request.cc index 1bef5076a..43143d3e9 100644 --- a/src/main/query_request.cc +++ b/src/main/query_request.cc @@ -28,20 +28,7 @@ namespace neug { execution::ParamsMap ParamsParser::ParseFromJsonObj( const execution::ParamsMetaMap& meta, const rapidjson::Document& param_json_obj) { - execution::ParamsMap param_map; - if (!param_json_obj.IsObject()) { - return param_map; - } - for (auto itr = param_json_obj.MemberBegin(); - itr != param_json_obj.MemberEnd(); ++itr) { - auto key = itr->name.GetString(); - if (meta.count(key) <= 0) { - VLOG(1) << "Parameter key not found in meta: " << key; - } else { - param_map.emplace(key, Value::FromJson(itr->value, meta.at(key))); - } - } - return param_map; + return execution::build_params_map(param_json_obj, meta); } neug::Status RequestParser::ParseFromString(const std::string& req, diff --git a/src/server/brpc_service_mgr.cc b/src/server/brpc_service_mgr.cc index f9d634efd..e0e723bfe 100644 --- a/src/server/brpc_service_mgr.cc +++ b/src/server/brpc_service_mgr.cc @@ -50,6 +50,8 @@ int32_t status_code_to_http_code(neug::StatusCode code) { return brpc::HTTP_STATUS_BAD_REQUEST; case neug::StatusCode::ERR_COMPILATION: return brpc::HTTP_STATUS_INTERNAL_SERVER_ERROR; + case neug::StatusCode::ERR_SERVICE_UNAVAILABLE: + return brpc::HTTP_STATUS_SERVICE_UNAVAILABLE; default: return brpc::HTTP_STATUS_INTERNAL_SERVER_ERROR; } @@ -193,16 +195,26 @@ void InitializeBrpcServiceProtocols() { neug::result UnifiedServiceImpl::GetSchemaImpl( brpc::Controller* cntl) { - const auto& schema = neug_db_.schema(); - auto yaml = schema.to_yaml(); + (void) cntl; + auto session_guard = session_pool_.AcquireSession(); + auto read_txn = session_guard->GetReadTransaction(); + auto yaml = read_txn.schema().to_yaml(); if (!yaml) { + read_txn.Abort(); RETURN_ERROR(yaml.error()); } - return neug::get_json_string_from_yaml(yaml.value()); + auto json = get_json_string_from_yaml(yaml.value()); + if (!json) { + read_txn.Abort(); + RETURN_ERROR(json.error()); + } + read_txn.Commit(); + return json; } neug::result UnifiedServiceImpl::GetServiceStatusImpl( brpc::Controller* cntl) { + (void) cntl; // Implement the logic to get service status here // For now, return a placeholder string return std::string("{\"status\": \"OK\", \"version\": \"" NEUG_VERSION "\"}"); diff --git a/src/server/neug_db_service.cc b/src/server/neug_db_service.cc index c78fc22ae..e13aac005 100644 --- a/src/server/neug_db_service.cc +++ b/src/server/neug_db_service.cc @@ -15,6 +15,7 @@ #include "neug/server/neug_db_service.h" #include +#include "neug/main/checkpoint_coordinator.h" #include "neug/server/brpc_service_mgr.h" #define STRINGIFY(x) #x @@ -49,16 +50,26 @@ void NeugDBService::init(const ServiceConfig& config) { version_manager_ = std::make_shared(); version_manager_->init_ts( - db_.last_ts_, + db_.checkpoint_coordinator().recovered_wal_timestamp(), db_config_.max_thread_num); // We assume versions start from 1. session_pool_ = std::make_unique( - db_, db_.GetPlanner(), db_.GetQueryCache(), version_manager_, - db_.allocators_, db_config_); + db_.graph_snapshot_store(), db_.checkpoint_coordinator(), + db_.GetPlanner(), db_.GetQueryCache(), version_manager_, db_.allocators_, + db_.graph().checkpoint().wal_dir(), db_config_); hdl_mgr_ = std::make_unique(db_, *session_pool_); hdl_mgr_->Init(config); + service_config_ = config; + + // Install the service-owned checkpoint activation handler last so a failed + // init() cannot leave the coordinator referencing a destroyed session pool + // (~NeugDBService does not run if this constructor throws). + db_.checkpoint_coordinator().SetActivationHandler( + [pool = session_pool_.get()](const std::string& wal_uri) { + pool->RotateWalAndInvalidateCaches(wal_uri); + }); } NeugDBService::~NeugDBService() { @@ -67,6 +78,13 @@ NeugDBService::~NeugDBService() { hdl_mgr_->Stop(); hdl_mgr_.reset(); } + // Clear before destroying the session pool referenced by the handler. + // ClearActivationHandler() waits for an in-flight invocation to finish. + // NeugDB::Close() destroys the coordinator and its handler, so the service + // may outlive a closed database without trying to clear it. + if (db_.checkpoint_coordinator_) { + db_.checkpoint_coordinator().ClearActivationHandler(); + } } const ServiceConfig& NeugDBService::GetServiceConfig() const { diff --git a/src/server/neug_db_session.cc b/src/server/neug_db_session.cc index cf25697d9..a2f15092e 100644 --- a/src/server/neug_db_session.cc +++ b/src/server/neug_db_session.cc @@ -39,6 +39,7 @@ #include "neug/generated/proto/plan/physical.pb.h" #include "neug/generated/proto/plan/stored_procedure.pb.h" #include "neug/generated/proto/response/response.pb.h" +#include "neug/main/checkpoint_coordinator.h" #include "neug/main/query_request.h" #include "neug/main/query_result.h" #include "neug/storages/graph/graph_interface.h" @@ -52,36 +53,52 @@ #include "neug/transaction/version_manager.h" #include "neug/utils/access_mode.h" #include "neug/utils/encoder.h" +#include "neug/utils/exception/exception.h" #include "neug/utils/likely.h" -#include "neug/utils/pb_utils.h" #include "neug/utils/property/types.h" #include "neug/utils/result.h" namespace neug { +// Ordering invariant for all transaction factories below: acquire the +// VersionManager lease before touching snapshot, allocator, or WAL state, +// because checkpoint maintenance may rotate that state while no lease is held. neug::ReadTransaction NeugDBSession::GetReadTransaction() const { - uint32_t ts = version_manager_->acquire_read_timestamp(); - SnapshotGuard guard(db_.graph_snapshot_store()); + auto ts = version_manager_->acquire_read_timestamp(); + SnapshotGuard guard(snapshot_store_); return neug::ReadTransaction(std::move(guard), *version_manager_, ts); } neug::InsertTransaction NeugDBSession::GetInsertTransaction() { - uint32_t ts = version_manager_->acquire_insert_timestamp(); - SnapshotGuard guard(db_.graph_snapshot_store()); - return neug::InsertTransaction(std::move(guard), alloc_, logger_, + auto ts = version_manager_->acquire_insert_timestamp(); + SnapshotGuard guard(snapshot_store_); + return neug::InsertTransaction(std::move(guard), alloc_, wal_writer_, *version_manager_, ts); } neug::UpdateTransaction NeugDBSession::GetUpdateTransaction() { - uint32_t ts = version_manager_->acquire_update_timestamp(); - auto cow_graph = db_.graph_snapshot_store().CurrentSnapshot().Clone(); - return neug::UpdateTransaction(std::move(cow_graph), alloc_, logger_, - *version_manager_, db_.graph_snapshot_store(), - pipeline_cache_, ts); + return createUpdateTransaction(getUpdateTimestampGuard()); +} + +UpdateTimestampGuard NeugDBSession::getUpdateTimestampGuard() { + return UpdateTimestampGuard::Acquire(*version_manager_); +} + +UpdateTransaction NeugDBSession::createUpdateTransaction( + UpdateTimestampGuard timestamp_guard) { + auto cow_graph = snapshot_store_.CurrentSnapshot().Clone(); + return neug::UpdateTransaction(std::move(cow_graph), alloc_, wal_writer_, + std::move(timestamp_guard), snapshot_store_, + pipeline_cache_); } Status validate_flags(AccessMode mode, const physical::ExecutionFlag& flags, const NeugDBConfig& db_config) { + if (flags.checkpoint() && mode != AccessMode::kUpdate) { + return Status(StatusCode::ERR_INVALID_ARGUMENT, + "CHECKPOINT only accepts the default or update/u access " + "mode"); + } if (db_config.mode == DBMode::READ_ONLY) { if (!IsReadOnlyExecutionFlag(flags) || mode != AccessMode::kRead) { return neug::Status( @@ -109,30 +126,23 @@ Status validate_flags(AccessMode mode, const physical::ExecutionFlag& flags, return Status::OK(); } -template -inline neug::result ExecutePipelineInTransaction( - execution::LocalQueryCache& pipeline_cache, const GraphStats& stats, - const std::string& query, AccessMode mode, const NeugDBConfig& db_config, - const rapidjson::Document& param_json_obj, neug::MetaDatas& result_schema, - Transaction& txn, IStorageInterface& storage_interface, - neug::QueryResponse* response) { - GS_AUTO(cache_value, pipeline_cache.Get(stats, query)); - assert(cache_value != nullptr); +inline neug::result ExecutePreparedPipeline( + execution::CacheValue& cache_value, AccessMode mode, + const NeugDBConfig& db_config, const rapidjson::Document& param_json_obj, + IStorageInterface& storage_interface, neug::QueryResponse* response) { RETURN_STATUS_ERROR_IF_NOT_OK( - validate_flags(mode, cache_value->flags, db_config)); + validate_flags(mode, cache_value.flags, db_config)); - result_schema = cache_value->result_schema; auto params_map = - ParamsParser::ParseFromJsonObj(cache_value->params_type, param_json_obj); - const auto explain_mode = cache_value->explain_mode; + ParamsParser::ParseFromJsonObj(cache_value.params_type, param_json_obj); + const auto explain_mode = cache_value.explain_mode; // ================ EXPLAIN MODE ================= // Build the operator tree only; the query is not executed. if (explain_mode == physical::ExplainMode::EXPLAIN) { auto tree_result = - cache_value->pipeline.explain_tree(storage_interface, params_map); + cache_value.pipeline.explain_tree(storage_interface, params_map); if (!tree_result) { - txn.Abort(); RETURN_ERROR(tree_result.error()); } if (tree_result.value()) { @@ -140,10 +150,6 @@ inline neug::result ExecutePipelineInTransaction( execution::OprTimer::ToProfileResult(tree_result.value().get()); } response->set_row_count(0); - if (!txn.Commit()) { - LOG(ERROR) << "transaction commit failed."; - RETURN_ERROR(neug::Status::InternalError("Transaction commit failed.")); - } // Empty context: no data rows are produced for EXPLAIN. return execution::Context(); } @@ -154,16 +160,11 @@ inline neug::result ExecutePipelineInTransaction( timer_ptr = std::make_unique(); } - auto ctx_res = cache_value->pipeline.Execute( + auto ctx_res = cache_value.pipeline.Execute( storage_interface, execution::Context(), params_map, timer_ptr.get()); if (!ctx_res) { - txn.Abort(); RETURN_ERROR(ctx_res.error()); } - if (!txn.Commit()) { - LOG(ERROR) << "transaction commit failed."; - RETURN_ERROR(neug::Status::InternalError("Transaction commit failed.")); - } if (explain_mode == physical::ExplainMode::PROFILE && timer_ptr) { *response->mutable_profile_result() = @@ -172,9 +173,23 @@ inline neug::result ExecutePipelineInTransaction( return ctx_res; } +template +inline Status CommitTransaction(Transaction& txn) { + if (!txn.Commit()) { + LOG(ERROR) << "transaction commit failed."; + return Status::InternalError("Transaction commit failed."); + } + return Status::OK(); +} + neug::result NeugDBSession::Eval(const std::string& req) { const auto start = std::chrono::high_resolution_clock::now(); + // Concurrency boundary: SessionGuard prevents reuse of this session, but it + // does not synchronize with checkpoint maintenance. Until Get*Transaction() + // acquires a VersionManager lease, do not access snapshot_store_, alloc_, + // wal_writer_, or other checkpoint-rotated state. Keep only + // request-local parsing and access-mode analysis in this pre-lease region. std::string query; AccessMode mode = AccessMode::kUnKnown; rapidjson::Document param_json_obj; @@ -183,11 +198,11 @@ neug::result NeugDBSession::Eval(const std::string& req) { if (!parse_res.ok()) { RETURN_ERROR(parse_res); } - if (mode == AccessMode::kUnKnown) { + mode = ResolveAccessMode(mode, [&]() { // Token-based analyzeMode still treats "call" as update. Read-only CALL is // only accepted on the read path when access_mode=read is set explicitly. - mode = planner_->analyzeMode(query); - } + return planner_->analyzeMode(query); + }); // Explicit access_mode=read: GPhysicalAnalyzer sets flag.read (not // procedure_call) when Function::isReadOnly is true, so validate_flags() // accepts those plans on the read path. @@ -197,34 +212,94 @@ neug::result NeugDBSession::Eval(const std::string& req) { neug::QueryResponse* response = google::protobuf::Arena::CreateMessage(&arena); - neug::MetaDatas result_schema; if (mode == neug::AccessMode::kRead) { auto read_txn = GetReadTransaction(); neug::StorageReadInterface gri(read_txn.view(), read_txn.timestamp()); - GS_AUTO(ctx, - ExecutePipelineInTransaction( - pipeline_cache_, read_txn.statistic(), query, mode, db_config_, - param_json_obj, result_schema, read_txn, gri, response)); - response->mutable_schema()->CopyFrom(result_schema); + GS_AUTO(cache_value, pipeline_cache_.Get(read_txn.statistic(), query)); + GS_AUTO(ctx, ExecutePreparedPipeline(*cache_value, mode, db_config_, + param_json_obj, gri, response)); + response->mutable_schema()->CopyFrom(cache_value->result_schema); neug::execution::Sink::sink_results(ctx, gri, response); + // StorageReadInterface and graph-backed result columns reference the + // transaction's pinned GraphView. Keep the read lease until result + // materialization has finished so checkpoint/compact cannot reopen the + // graph underneath the sink. + RETURN_STATUS_ERROR_IF_NOT_OK(CommitTransaction(read_txn)); } else if (mode == AccessMode::kInsert) { auto insert_txn = GetInsertTransaction(); neug::StorageTPInsertInterface gii(insert_txn); - GS_AUTO(ctx, ExecutePipelineInTransaction( - pipeline_cache_, insert_txn.statistic(), query, mode, - db_config_, param_json_obj, result_schema, insert_txn, gii, - response)); + GS_AUTO(cache_value, pipeline_cache_.Get(insert_txn.statistic(), query)); + GS_AUTO(ctx, ExecutePreparedPipeline(*cache_value, mode, db_config_, + param_json_obj, gii, response)); + (void) ctx; + RETURN_STATUS_ERROR_IF_NOT_OK(CommitTransaction(insert_txn)); } else if (mode == AccessMode::kUpdate || mode == AccessMode::kSchema) { // Update mode CHECK(planner_ != nullptr); - auto update_txn = GetUpdateTransaction(); - neug::StorageTPUpdateInterface gui(update_txn); - GS_AUTO(ctx, ExecutePipelineInTransaction( - pipeline_cache_, update_txn.statistic(), query, mode, - db_config_, param_json_obj, result_schema, update_txn, gui, - response)); - response->mutable_schema()->CopyFrom(result_schema); - neug::execution::Sink::sink_results(ctx, gui, response); + // Peek the plan under a short-lived read lease. EXPLAIN only builds the + // operator tree and never touches storage, so it is served without the + // global update lease and without a COW clone. + auto peek_txn = GetReadTransaction(); + GS_AUTO(peek_value, pipeline_cache_.Get(peek_txn.statistic(), query)); + + if (peek_value->explain_mode == physical::ExplainMode::EXPLAIN) { + neug::StorageReadInterface gri(peek_txn.view(), peek_txn.timestamp()); + GS_AUTO(ctx, ExecutePreparedPipeline(*peek_value, mode, db_config_, + param_json_obj, gri, response)); + (void) ctx; + response->mutable_schema()->CopyFrom(peek_value->result_schema); + RETURN_STATUS_ERROR_IF_NOT_OK(CommitTransaction(peek_txn)); + } else { + // Release the read lease before acquiring the update lease: another + // updater's commit phase drains readers, and spinning on the update + // state while holding a read timestamp would self-deadlock. + RETURN_STATUS_ERROR_IF_NOT_OK(CommitTransaction(peek_txn)); + auto timestamp_guard = getUpdateTimestampGuard(); + // Re-fetch under the update lease: the snapshot may have rotated since + // the peek, so the plan must match the snapshot the update clones. + GS_AUTO(cache_value, + pipeline_cache_.Get(GraphStats(snapshot_store_.CurrentSnapshot()), + query)); + + if (cache_value->flags.checkpoint()) { + RETURN_STATUS_ERROR_IF_NOT_OK( + validate_flags(mode, cache_value->flags, db_config_)); + + const bool profile_checkpoint = + cache_value->explain_mode == physical::ExplainMode::PROFILE; + execution::OprTimer checkpoint_timer; + execution::TimerUnit checkpoint_timer_unit; + if (profile_checkpoint) { + checkpoint_timer.set_name("Checkpoint"); + checkpoint_timer_unit.start(); + } + + auto checkpoint_status = + checkpoint_coordinator_.ExecuteTpManual(std::move(timestamp_guard)); + if (!checkpoint_status.ok()) { + RETURN_ERROR(checkpoint_status); + } + + response->set_row_count(0); + response->mutable_schema()->CopyFrom(cache_value->result_schema); + if (profile_checkpoint) { + checkpoint_timer.record(checkpoint_timer_unit); + *response->mutable_profile_result() = + execution::OprTimer::ToProfileResult(&checkpoint_timer); + } + } else { + auto update_txn = createUpdateTransaction(std::move(timestamp_guard)); + neug::StorageTPUpdateInterface gui(update_txn); + GS_AUTO(ctx, ExecutePreparedPipeline(*cache_value, mode, db_config_, + param_json_obj, gui, response)); + response->mutable_schema()->CopyFrom(cache_value->result_schema); + neug::execution::Sink::sink_results(ctx, gui, response); + // StorageTPUpdateInterface references the transaction's COW graph and + // GraphView, which are reset by Commit(). Materialize the response + // first. + RETURN_STATUS_ERROR_IF_NOT_OK(CommitTransaction(update_txn)); + } + } } else { THROW_NOT_SUPPORTED_EXCEPTION( "Access mode not supported in NeugDBSession::Eval: " + @@ -244,8 +319,8 @@ neug::result NeugDBSession::Eval(const std::string& req) { int NeugDBSession::SessionId() const { return thread_id_; } neug::CompactTransaction NeugDBSession::GetCompactTransaction() { - neug::timestamp_t ts = version_manager_->acquire_compact_timestamp(); - return neug::CompactTransaction(db_.graph_snapshot_store(), logger_, + auto ts = version_manager_->acquire_compact_timestamp(); + return neug::CompactTransaction(snapshot_store_, wal_writer_, *version_manager_, ts); } @@ -255,4 +330,8 @@ double NeugDBSession::eval_duration() const { int64_t NeugDBSession::query_num() const { return query_num_.load(); } +void NeugDBSession::InvalidateGlobalQueryCache() { + pipeline_cache_.clearGlobalCache(); +} + } // namespace neug diff --git a/src/server/session_pool.cc b/src/server/session_pool.cc index f612cb825..bd8e5e0d4 100644 --- a/src/server/session_pool.cc +++ b/src/server/session_pool.cc @@ -46,6 +46,24 @@ SessionGuard SessionPool::AcquireSession() { return SessionGuard(&contexts_[session_id].session, this, session_id); } +void SessionPool::RotateWalAndInvalidateCaches(const std::string& wal_uri) { + // Close the complete old WAL generation before opening any writer in the + // new generation. SessionLocalContext retains ownership, so the IWalWriter& + // held by each NeugDBSession remains stable across the switch. + for (size_t i = 0; i < session_num_; ++i) { + CHECK(contexts_[i].logger != nullptr); + contexts_[i].logger->close(); + } + for (size_t i = 0; i < session_num_; ++i) { + contexts_[i].logger->open(wal_uri); + } + // Invalidate the compiled-plan caches through one session's local cache, + // the same path update transactions take on schema change. Any session can + // initiate the invalidation; the rest discard their local caches lazily. + // NeugDB guarantees max_thread_num >= 1, so contexts_[0] always exists. + contexts_[0].session.InvalidateGlobalQueryCache(); +} + void SessionPool::ReleaseSession(size_t session_id) { assert(session_id < session_num_ && "Releasing session_id is out of range!"); diff --git a/src/storages/graph/checkpoint_manager.cc b/src/storages/graph/checkpoint_manager.cc index 561625af5..87cb9d039 100644 --- a/src/storages/graph/checkpoint_manager.cc +++ b/src/storages/graph/checkpoint_manager.cc @@ -224,9 +224,15 @@ std::shared_ptr create_staging_checkpoint(const std::string& db_dir, path.string() + ": " + ec.message()); } - std::filesystem::create_directories(path); - CheckpointManifest::GenerateEmptyMeta((path / "meta").string()); - return Checkpoint::Open(path.string(), generation); + try { + std::filesystem::create_directories(path); + CheckpointManifest::GenerateEmptyMeta((path / "meta").string()); + return Checkpoint::Open(path.string(), generation); + } catch (...) { + remove_checkpoint_dir_best_effort( + path, "CheckpointManager::CreateStagingCheckpoint"); + throw; + } } std::shared_ptr publish_staging_checkpoint( @@ -288,8 +294,8 @@ std::shared_ptr publish_staging_checkpoint( try { final_checkpoint = Checkpoint::Open(final_path.string(), generation); } catch (...) { - remove_checkpoint_dir_best_effort( - final_path, "CheckpointManager::CommitStagingCheckpoint"); + // rename is the durable commit point. Keep the published generation even + // if opening its handle fails; the next database Open will validate it. throw; } @@ -359,6 +365,18 @@ std::shared_ptr CheckpointManager::StagingCheckpoint::checkpoint() return checkpoint_; } +std::string CheckpointManager::StagingCheckpoint::TargetPublishedPath() const { + std::lock_guard lock(manager_.mutex_); + if (checkpoint_ == nullptr || manager_.staging_checkpoint_ != checkpoint_) { + THROW_CHECKPOINT_EXCEPTION( + "CheckpointManager::StagingCheckpoint::TargetPublishedPath: inactive " + "or foreign staging checkpoint"); + } + return (std::filesystem::path(manager_.db_dir_) / + checkpoint_name(static_cast(checkpoint_->id()))) + .string(); +} + std::shared_ptr CheckpointManager::StagingCheckpoint::Commit( std::string* previous_checkpoint_path) { if (checkpoint_ == nullptr) { diff --git a/src/storages/graph/checkpoint_session.cc b/src/storages/graph/checkpoint_session.cc deleted file mode 100644 index 3dd96e386..000000000 --- a/src/storages/graph/checkpoint_session.cc +++ /dev/null @@ -1,56 +0,0 @@ -/** Copyright 2020 Alibaba Group Holding Limited. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#include "neug/storages/checkpoint_session.h" - -#include -#include - -#include "neug/storages/checkpoint.h" - -namespace neug { - -CheckpointSession::CheckpointSession( - CheckpointManager::StagingCheckpoint staging_checkpoint) - : staging_checkpoint_(std::move(staging_checkpoint)) {} - -CheckpointSession::~CheckpointSession() { staging_checkpoint_.Discard(); } - -CheckpointSession CheckpointSession::Begin(CheckpointManager& checkpoint_mgr) { - auto staging_checkpoint = checkpoint_mgr.CreateStagingCheckpoint(); - - return CheckpointSession(std::move(staging_checkpoint)); -} - -std::shared_ptr CheckpointSession::staging_checkpoint() const { - return staging_checkpoint_.checkpoint(); -} - -std::shared_ptr CheckpointSession::Commit() { - if (published_checkpoint_ != nullptr) { - return published_checkpoint_; - } - try { - published_checkpoint_ = staging_checkpoint_.Commit(); - - VLOG(1) << "Finish checkpoint: " << published_checkpoint_->path(); - return published_checkpoint_; - } catch (...) { - staging_checkpoint_.Discard(); - throw; - } -} - -} // namespace neug diff --git a/src/storages/graph/graph_interface.cc b/src/storages/graph/graph_interface.cc index 4ad4623b0..65d4748fd 100644 --- a/src/storages/graph/graph_interface.cc +++ b/src/storages/graph/graph_interface.cc @@ -85,20 +85,6 @@ Status StorageAPUpdateInterface::AddEdgeImpl( return status; } -void StorageAPUpdateInterface::CreateCheckpoint() { - if (!graph_.IsModified()) { - return; - } - auto ckp = graph_.checkpoint_ptr(); - auto memory_level = graph_.memory_level(); - graph_.DumpAndClear(ckp); - graph_.Open(ckp, memory_level); - mut_view_.Rebuild(graph_); - // Open rebuilds dirty bits to false; ClearAllDirty is redundant but keeps - // the post-publish contract explicit for in-place dump paths. - graph_.ClearAllDirty(); -} - Status StorageAPUpdateInterface::DeleteVertexImpl(label_t label, vid_t lid) { return graph_.DeleteVertex(label, lid, timestamp_); } diff --git a/src/storages/graph/graph_snapshot_store.cc b/src/storages/graph/graph_snapshot_store.cc index 3d143394a..a587d8dc4 100644 --- a/src/storages/graph/graph_snapshot_store.cc +++ b/src/storages/graph/graph_snapshot_store.cc @@ -18,7 +18,7 @@ #include #include -#include "neug/generated/proto/plan/error.pb.h" +#include "neug/utils/exception/exception.h" namespace neug { @@ -50,6 +50,37 @@ GraphSnapshotStore::~GraphSnapshotStore() { } } +GraphSnapshotStore::SnapshotSlot& +GraphSnapshotStore::currentSlotForMaintenance() { + const int slot_index = cur_slot_index_.load(std::memory_order_acquire); + auto& slot = slots_[slot_index]; + if (slot.reader_count_.load(std::memory_order_acquire) != 1) { + THROW_INTERNAL_EXCEPTION( + "Current graph snapshot is still pinned during maintenance"); + } + if (cur_slot_index_.load(std::memory_order_acquire) != slot_index) { + THROW_INTERNAL_EXCEPTION( + "Current graph snapshot changed during maintenance"); + } + + // A checkpoint reopens the current PropertyGraph in place and may remove + // the checkpoint generations backing older snapshots. External quiescence + // must therefore have drained and reclaimed every non-current slot, not + // merely removed readers from the current one. + for (int i = 0; i < slot_num_; ++i) { + if (i == slot_index) { + continue; + } + if (slots_[i].reader_count_.load(std::memory_order_acquire) != 0 || + slots_[i].storage_ != nullptr) { + THROW_INTERNAL_EXCEPTION( + "Stale graph snapshot remains live during maintenance"); + } + } + CHECK(slot.storage_ != nullptr); + return slot; +} + void GraphSnapshotStore::initFreeList() { // Slots 1 to slot_num_-1 are initially free for (int i = 1; i < slot_num_; ++i) { @@ -158,12 +189,62 @@ void GraphSnapshotStore::UnpinSnapshotByIndex(int slot_index) noexcept { } } -const PropertyGraph& GraphSnapshotStore::CurrentSnapshot() const { +const PropertyGraph& GraphSnapshotStore::CurrentSnapshot() const noexcept { int slot_index = cur_slot_index_.load(std::memory_order_acquire); CHECK(slots_[slot_index].storage_ != nullptr); return *slots_[slot_index].storage_; } +GraphSnapshotStore::CheckpointMaintenanceHandle::CheckpointMaintenanceHandle( + GraphSnapshotStore& store) noexcept + : store_(&store) {} + +GraphSnapshotStore::CheckpointMaintenanceHandle::CheckpointMaintenanceHandle( + CheckpointMaintenanceHandle&& other) noexcept + : store_(other.store_) { + other.store_ = nullptr; +} + +GraphSnapshotStore::CheckpointMaintenanceHandle& +GraphSnapshotStore::CheckpointMaintenanceHandle::operator=( + CheckpointMaintenanceHandle&& other) noexcept { + if (this != &other) { + store_ = other.store_; + other.store_ = nullptr; + } + return *this; +} + +PropertyGraph& +GraphSnapshotStore::CheckpointMaintenanceHandle::MutableCurrentSnapshot() { + CHECK(store_ != nullptr); + return store_->mutableCurrentSnapshot(); +} + +void GraphSnapshotStore::CheckpointMaintenanceHandle:: + ReopenCurrentGraphFromCheckpoint(std::shared_ptr checkpoint, + MemoryLevel memory_level) { + CHECK(store_ != nullptr); + store_->reopenCurrentGraphFromCheckpoint(std::move(checkpoint), memory_level); +} + +GraphSnapshotStore::CheckpointMaintenanceHandle +GraphSnapshotStore::AcquireCheckpointMaintenance() { + (void) currentSlotForMaintenance(); + return CheckpointMaintenanceHandle(*this); +} + +PropertyGraph& GraphSnapshotStore::mutableCurrentSnapshot() { + return *currentSlotForMaintenance().mutable_graph(); +} + +void GraphSnapshotStore::reopenCurrentGraphFromCheckpoint( + std::shared_ptr checkpoint, MemoryLevel memory_level) { + auto& slot = currentSlotForMaintenance(); + slot.mutable_graph()->Open(std::move(checkpoint), memory_level); + slot.mutable_view().Rebuild(*slot.mutable_graph()); +} + Status GraphSnapshotStore::PublishSnapshot( const std::shared_ptr& new_pg) { int slot_index = getFreeSlot(); diff --git a/src/transaction/compact_transaction.cc b/src/transaction/compact_transaction.cc index 3db272ea0..8e36c8f84 100644 --- a/src/transaction/compact_transaction.cc +++ b/src/transaction/compact_transaction.cc @@ -27,9 +27,13 @@ namespace neug { CompactTransaction::CompactTransaction(GraphSnapshotStore& snapshot_store, - IWalWriter& logger, IVersionManager& vm, + IWalWriter& wal_writer, + IVersionManager& vm, timestamp_t timestamp) - : guard_(snapshot_store), logger_(logger), vm_(vm), timestamp_(timestamp) { + : guard_(snapshot_store), + wal_writer_(wal_writer), + vm_(vm), + timestamp_(timestamp) { arc_.Resize(sizeof(WalHeader)); } @@ -44,7 +48,7 @@ bool CompactTransaction::Commit() { header->timestamp = timestamp_; header->type = 1; - if (!logger_.append(arc_.GetBuffer(), arc_.GetSize())) { + if (!wal_writer_.append(arc_.GetBuffer(), arc_.GetSize())) { LOG(ERROR) << "Failed to append wal log"; Abort(); return false; @@ -54,7 +58,7 @@ bool CompactTransaction::Commit() { LOG(INFO) << "before compact - " << timestamp_; { // In-place compact. Keep borrowed snapshot references scoped before the - // timestamp lease is released. + // timestamp is released. auto& slot = guard_.get(); slot.mutable_graph()->Compact(); slot.mutable_view().Rebuild(*slot.mutable_graph()); diff --git a/src/transaction/insert_transaction.cc b/src/transaction/insert_transaction.cc index 2ef0cf57b..363ad45a3 100644 --- a/src/transaction/insert_transaction.cc +++ b/src/transaction/insert_transaction.cc @@ -34,12 +34,12 @@ namespace neug { InsertTransaction::InsertTransaction(SnapshotGuard guard, Allocator& alloc, - IWalWriter& logger, IVersionManager& vm, - timestamp_t timestamp) + IWalWriter& wal_writer, + IVersionManager& vm, timestamp_t timestamp) : guard_(std::move(guard)), view_(&guard_.get().mutable_view()), alloc_(alloc), - logger_(logger), + wal_writer_(wal_writer), vm_(vm), timestamp_(timestamp) { arc_.Resize(sizeof(WalHeader)); @@ -168,7 +168,7 @@ bool InsertTransaction::Commit() { header->type = 0; header->timestamp = timestamp_; - if (!logger_.append(arc_.GetBuffer(), arc_.GetSize())) { + if (!wal_writer_.append(arc_.GetBuffer(), arc_.GetSize())) { LOG(ERROR) << "Failed to append wal log"; Abort(); return false; @@ -236,11 +236,9 @@ void InsertTransaction::IngestWal(GraphView& view, uint32_t timestamp, void InsertTransaction::clear() { arc_.Clear(); - arc_.Resize(sizeof(WalHeader)); added_vertices_.clear(); added_vertices_base_.clear(); vertex_nums_.clear(); - timestamp_ = INVALID_TIMESTAMP; } diff --git a/src/transaction/update_timestamp_guard.cc b/src/transaction/update_timestamp_guard.cc new file mode 100644 index 000000000..c6b611009 --- /dev/null +++ b/src/transaction/update_timestamp_guard.cc @@ -0,0 +1,97 @@ +/** Copyright 2020 Alibaba Group Holding Limited. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "neug/transaction/update_timestamp_guard.h" + +#include +#include + +#include "neug/transaction/version_manager.h" +#include "neug/utils/exception/exception.h" + +namespace neug { + +UpdateTimestampGuard UpdateTimestampGuard::Acquire( + IVersionManager& version_manager) { + return UpdateTimestampGuard(version_manager, + version_manager.acquire_update_timestamp()); +} + +UpdateTimestampGuard::UpdateTimestampGuard(IVersionManager& version_manager, + timestamp_t timestamp) noexcept + : version_manager_(&version_manager), timestamp_(timestamp) {} + +UpdateTimestampGuard::~UpdateTimestampGuard() noexcept { Release(); } + +UpdateTimestampGuard::UpdateTimestampGuard( + UpdateTimestampGuard&& other) noexcept + : version_manager_(std::exchange(other.version_manager_, nullptr)), + timestamp_(std::exchange(other.timestamp_, INVALID_TIMESTAMP)), + commit_started_(std::exchange(other.commit_started_, false)) {} + +UpdateTimestampGuard& UpdateTimestampGuard::operator=( + UpdateTimestampGuard&& other) noexcept { + if (this != &other) { + Release(); + version_manager_ = std::exchange(other.version_manager_, nullptr); + timestamp_ = std::exchange(other.timestamp_, INVALID_TIMESTAMP); + commit_started_ = std::exchange(other.commit_started_, false); + } + return *this; +} + +bool UpdateTimestampGuard::active() const noexcept { + return version_manager_ != nullptr && timestamp_ != INVALID_TIMESTAMP; +} + +void UpdateTimestampGuard::BeginCommit() { + if (!active() || commit_started_) { + THROW_INTERNAL_EXCEPTION( + "BeginCommit requires an active update execution guard"); + } + version_manager_->begin_update_commit(timestamp_); + commit_started_ = true; +} + +void UpdateTimestampGuard::DrainReaders() { + if (!active() || !commit_started_) { + THROW_INTERNAL_EXCEPTION( + "DrainReaders requires an active update commit guard"); + } + version_manager_->drain_readers(); +} + +void UpdateTimestampGuard::Release() noexcept { + if (!active()) { + return; + } + version_manager_->release_update_timestamp(timestamp_); + disarm(); +} + +void UpdateTimestampGuard::CompleteCheckpoint() noexcept { + assert(active() && commit_started_ && + "CompleteCheckpoint requires an active update commit guard"); + version_manager_->reset_timeline_after_checkpoint(timestamp_); + disarm(); +} + +void UpdateTimestampGuard::disarm() noexcept { + version_manager_ = nullptr; + timestamp_ = INVALID_TIMESTAMP; + commit_started_ = false; +} + +} // namespace neug diff --git a/src/transaction/update_transaction.cc b/src/transaction/update_transaction.cc index 15b306608..13eb7fe9c 100644 --- a/src/transaction/update_transaction.cc +++ b/src/transaction/update_transaction.cc @@ -155,31 +155,31 @@ fetch_edges_related_to_vertex(const StorageReadInterface& graph, // ============================================================================= UpdateTransaction::UpdateTransaction(std::shared_ptr cow_graph, - Allocator& alloc, IWalWriter& logger, - IVersionManager& vm, + Allocator& alloc, IWalWriter& wal_writer, + UpdateTimestampGuard timestamp_guard, GraphSnapshotStore& snapshot_store, - execution::LocalQueryCache& cache, - timestamp_t timestamp) + execution::LocalQueryCache& cache) : cow_graph_(std::move(cow_graph)), cow_state_(PropertyGraphCowState::FromSchema(cow_graph_->schema())), view_(*cow_graph_), alloc_(alloc), - logger_(logger), - vm_(vm), + wal_writer_(wal_writer), + timestamp_guard_(std::move(timestamp_guard)), snapshot_store_(snapshot_store), pipeline_cache_(cache), - timestamp_(timestamp), ckp_(cow_graph_->checkpoint_ptr()) {} UpdateTransaction::~UpdateTransaction() { Abort(); } -timestamp_t UpdateTransaction::timestamp() const { return timestamp_; } +timestamp_t UpdateTransaction::timestamp() const { + return timestamp_guard_.timestamp(); +} bool UpdateTransaction::Commit() { - if (timestamp_ == INVALID_TIMESTAMP) { + if (!timestamp_guard_.active()) { return true; } - if (wal_builder_.op_num() == 0 && wal_builder_.content_size() == 0) { + if (wal_builder_.op_num() == 0) { release(); return true; } @@ -190,14 +190,17 @@ bool UpdateTransaction::Commit() { return false; } - wal_builder_.finalize(timestamp_); - if (!logger_.append(wal_builder_.data(), wal_builder_.size())) { + wal_builder_.finalize(timestamp()); + if (!wal_writer_.append(wal_builder_.data(), wal_builder_.size())) { LOG(ERROR) << "Failed to append wal log"; Abort(); return false; } - vm_.begin_update_commit(timestamp_); + // The update-execution state already prevents checkpoint/WAL rotation while + // the record is appended. Block new readers only for publication so a stream + // of updates cannot starve them on WAL I/O. + timestamp_guard_.BeginCommit(); if (wal_builder_.schema_changed()) { pipeline_cache_.clearGlobalCache(); @@ -224,12 +227,11 @@ bool UpdateTransaction::Commit() { void UpdateTransaction::Abort() { release(); } void UpdateTransaction::release() { - if (timestamp_ != INVALID_TIMESTAMP) { - wal_builder_.clear(); - vm_.release_update_timestamp(timestamp_); - timestamp_ = INVALID_TIMESTAMP; - } + wal_builder_.clear(); + view_ = GraphView(); cow_graph_.reset(); + ckp_.reset(); + timestamp_guard_.Release(); } Status StorageTPUpdateInterface::CreateVertexTypeImpl( @@ -712,7 +714,7 @@ Status StorageTPUpdateInterface::DeleteEdgeImpl( Value UpdateTransaction::GetVertexProperty(label_t label, vid_t lid, int col_id) const { auto col = cow_graph_->GetVertexPropertyColumn(label, col_id); - if (!cow_graph_->IsValidLid(label, lid, timestamp_)) { + if (!cow_graph_->IsValidLid(label, lid, timestamp())) { THROW_INVALID_ARGUMENT_EXCEPTION( "Vertex lid is not valid in this transaction"); } @@ -723,12 +725,12 @@ Value UpdateTransaction::GetVertexProperty(label_t label, vid_t lid, } Value UpdateTransaction::GetVertexId(label_t label, vid_t lid) const { - return cow_graph_->GetOid(label, lid, timestamp_); + return cow_graph_->GetOid(label, lid, timestamp()); } bool UpdateTransaction::GetVertexIndex(label_t label, const Value& id, vid_t& index) const { - return cow_graph_->get_lid(label, id, index, timestamp_); + return cow_graph_->get_lid(label, id, index, timestamp()); } Status StorageTPUpdateInterface::UpdateVertexPropertyImpl(label_t label, @@ -1179,25 +1181,6 @@ Status StorageTPUpdateInterface::prepareVertexDelete( return Status::OK(); } -void StorageTPUpdateInterface::CreateCheckpoint() { - if (wal_.op_num() != 0) { - THROW_INTERNAL_EXCEPTION( - "Checkpoint should be created in a update " - "transaction without any updates"); - } - if (!cow_graph_->IsModified()) { - wal_.LogCheckpoint(); - return; - } - auto ckp = cow_graph_->checkpoint_ptr(); - auto memory_level = cow_graph_->memory_level(); - cow_graph_->DumpAndClear(ckp); - cow_graph_->Open(ckp, memory_level); - mut_view_.Rebuild(*cow_graph_); - cow_graph_->ClearAllDirty(); - wal_.LogCheckpoint(); -} - Status StorageTPUpdateInterface::BatchAddVerticesImpl( label_t v_label_id, std::shared_ptr supplier) { LOG(ERROR) << "BatchAddVertices is not supported in TP mode currently."; diff --git a/src/transaction/version_manager.cc b/src/transaction/version_manager.cc index 05c9e2b47..fdf569b93 100644 --- a/src/transaction/version_manager.cc +++ b/src/transaction/version_manager.cc @@ -16,6 +16,7 @@ #include "neug/transaction/version_manager.h" #include +#include #include #include @@ -175,13 +176,15 @@ uint32_t VersionManager::acquire_update_timestamp() { } void VersionManager::begin_update_commit(uint32_t ts) { - (void) ts; + if (update_state_.load(std::memory_order_acquire) != 1 || + write_ts_.load(std::memory_order_acquire) != ts + 1) { + THROW_INTERNAL_EXCEPTION( + "begin_update_commit called without the matching update timestamp"); + } - // Enter commit state (1 -> 2) — blocks new reads, does NOT wait for existing - // readers. Use seq_cst to ensure the store is globally visible before we - // proceed, which closes the ABA window for concurrent acquire_read_timestamp - // callers that may have passed their pre-check but not yet reached their - // double-check. + // Enter commit state (1 -> 2). New reads block, but existing reads continue + // until an explicit drain_readers() call. Use seq_cst to close the ABA + // window for acquire_read_timestamp callers between their two checks. update_state_.store(2, std::memory_order_seq_cst); // Drain ABA-window readers: any reader that incremented active_readers_ @@ -201,10 +204,33 @@ void VersionManager::begin_update_commit(uint32_t ts) { } } -void VersionManager::release_update_timestamp(uint32_t ts) { +void VersionManager::drain_readers() { + if (update_state_.load(std::memory_order_acquire) != 2) { + THROW_INTERNAL_EXCEPTION( + "drain_readers called outside update commit state"); + } + while (active_readers_.load(std::memory_order_acquire) > 0) { + // Tight spin loop for minimal latency. + } +} + +void VersionManager::release_update_timestamp(uint32_t ts) noexcept { complete_write_timestamp(ts); + update_state_.store(0, std::memory_order_release); +} - // Restore to normal state (1 -> 0 or 2 -> 0) +void VersionManager::reset_timeline_after_checkpoint(uint32_t ts) noexcept { + assert(update_state_.load(std::memory_order_acquire) == 2 && + "checkpoint timeline reset requested outside update commit state"); + assert(active_readers_.load(std::memory_order_acquire) == 0 && + active_inserters_.load(std::memory_order_acquire) == 0 && + "checkpoint timeline reset requested with active transactions"); + assert(write_ts_.load(std::memory_order_acquire) == ts + 1 && + "checkpoint timeline reset requested for a stale update timestamp"); + + write_ts_.store(1, std::memory_order_relaxed); + read_ts_.store(0, std::memory_order_relaxed); + ts_window_.init(); update_state_.store(0, std::memory_order_release); } @@ -215,7 +241,7 @@ uint32_t VersionManager::acquire_compact_timestamp() { if (update_state_.compare_exchange_strong(expected, 2, std::memory_order_acq_rel, std::memory_order_acquire)) { - break; // Successfully entered compact phase + break; } // Tight spin loop for minimal latency } @@ -235,29 +261,20 @@ uint32_t VersionManager::acquire_compact_timestamp() { } void VersionManager::release_compact_timestamp(uint32_t ts) { - // Compact must be in state 2 if (update_state_.load(std::memory_order_acquire) != 2) { THROW_INTERNAL_EXCEPTION( "release_compact_timestamp called while not in compact state"); } - complete_write_timestamp(ts); - - // Restore to normal state (2 -> 0) update_state_.store(0, std::memory_order_release); } void VersionManager::revert_compact_timestamp(uint32_t ts) { - // Compact must be in state 2 if (update_state_.load(std::memory_order_acquire) != 2) { THROW_INTERNAL_EXCEPTION( "revert_compact_timestamp called while not in compact state"); } - - // Close the timestamp gap so later commits can advance read_ts_. complete_write_timestamp(ts); - - // Revert to normal state (2 -> 0) update_state_.store(0, std::memory_order_release); } diff --git a/src/transaction/wal/dummy_wal_writer.cc b/src/transaction/wal/dummy_wal_writer.cc index 237253a04..6b075f5f7 100644 --- a/src/transaction/wal/dummy_wal_writer.cc +++ b/src/transaction/wal/dummy_wal_writer.cc @@ -17,7 +17,7 @@ namespace neug { std::string DummyWalWriter::type() const { return "dummy"; } -void DummyWalWriter::open() {} +void DummyWalWriter::open(const std::string&) {} void DummyWalWriter::close() {} bool DummyWalWriter::append(const char* data, size_t length) { return true; } } // namespace neug diff --git a/src/transaction/wal/local_wal_writer.cc b/src/transaction/wal/local_wal_writer.cc index 989ee66d1..bd2be89d9 100644 --- a/src/transaction/wal/local_wal_writer.cc +++ b/src/transaction/wal/local_wal_writer.cc @@ -35,7 +35,18 @@ std::unique_ptr LocalWalWriter::Make(const std::string& wal_uri, return std::unique_ptr(new LocalWalWriter(wal_uri, thread_id)); } -void LocalWalWriter::open() { +LocalWalWriter::~LocalWalWriter() noexcept { + try { + close(); + } catch (const std::exception& e) { + LOG(ERROR) << "Failed to close WAL writer during destruction: " << e.what(); + } catch (...) { + LOG(ERROR) << "Failed to close WAL writer during destruction."; + } +} + +void LocalWalWriter::open(const std::string& wal_uri) { + wal_uri_ = wal_uri; auto prefix = get_wal_uri_path(wal_uri_); if (!std::filesystem::exists(prefix)) { std::filesystem::create_directories(prefix); @@ -64,12 +75,16 @@ void LocalWalWriter::open() { void LocalWalWriter::close() { if (fd_ != -1) { - if (::close(fd_) != 0) { - THROW_IO_EXCEPTION("Failed to close file" + std::string(strerror(errno))); - } + // Retire the descriptor before calling close(). Retrying close() after an + // error is unsafe because the descriptor may already have been released + // and reused by another thread. + const int fd = fd_; fd_ = -1; file_size_ = 0; file_used_ = 0; + if (::close(fd) != 0) { + THROW_IO_EXCEPTION("Failed to close file" + std::string(strerror(errno))); + } } } diff --git a/tests/compiler/flag_test.cpp b/tests/compiler/flag_test.cpp index fe201aac2..3f4a5ef1d 100644 --- a/tests/compiler/flag_test.cpp +++ b/tests/compiler/flag_test.cpp @@ -17,6 +17,7 @@ #include #include "gopt_test.h" #include "neug/compiler/gopt/g_physical_analyzer.h" +#include "neug/compiler/planner/gopt_planner.h" namespace neug { namespace gopt { @@ -46,7 +47,7 @@ TEST_F(FlagTest, MatchCreateRelationship1) { EXPECT_FALSE(flag.schema); EXPECT_FALSE(flag.batch); EXPECT_FALSE(flag.create_temp_table); - EXPECT_FALSE(flag.transaction); + EXPECT_FALSE(flag.checkpoint); EXPECT_FALSE(flag.procedure_call); } @@ -63,7 +64,7 @@ TEST_F(FlagTest, MatchCreateRelationship2) { EXPECT_FALSE(flag.schema); EXPECT_FALSE(flag.batch); EXPECT_FALSE(flag.create_temp_table); - EXPECT_FALSE(flag.transaction); + EXPECT_FALSE(flag.checkpoint); EXPECT_FALSE(flag.procedure_call); } @@ -80,7 +81,7 @@ TEST_F(FlagTest, MatchCreateRelationship5) { EXPECT_FALSE(flag.schema); EXPECT_FALSE(flag.batch); EXPECT_FALSE(flag.create_temp_table); - EXPECT_FALSE(flag.transaction); + EXPECT_FALSE(flag.checkpoint); EXPECT_FALSE(flag.procedure_call); } @@ -98,7 +99,7 @@ TEST_F(FlagTest, CreateNodesAndRelationship) { EXPECT_FALSE(flag.schema); EXPECT_FALSE(flag.batch); EXPECT_FALSE(flag.create_temp_table); - EXPECT_FALSE(flag.transaction); + EXPECT_FALSE(flag.checkpoint); EXPECT_FALSE(flag.procedure_call); } @@ -115,7 +116,7 @@ TEST_F(FlagTest, MatchCreateRelationship3) { EXPECT_FALSE(flag.schema); EXPECT_FALSE(flag.batch); EXPECT_FALSE(flag.create_temp_table); - EXPECT_FALSE(flag.transaction); + EXPECT_FALSE(flag.checkpoint); EXPECT_FALSE(flag.procedure_call); } @@ -133,7 +134,7 @@ TEST_F(FlagTest, MatchCreateRelationship4) { EXPECT_FALSE(flag.schema); EXPECT_FALSE(flag.batch); EXPECT_FALSE(flag.create_temp_table); - EXPECT_FALSE(flag.transaction); + EXPECT_FALSE(flag.checkpoint); EXPECT_FALSE(flag.procedure_call); } @@ -149,7 +150,7 @@ TEST_F(FlagTest, CreateSingleNode) { EXPECT_FALSE(flag.schema); EXPECT_FALSE(flag.batch); EXPECT_FALSE(flag.create_temp_table); - EXPECT_FALSE(flag.transaction); + EXPECT_FALSE(flag.checkpoint); EXPECT_FALSE(flag.procedure_call); } @@ -166,7 +167,7 @@ TEST_F(FlagTest, CopyFrom) { EXPECT_FALSE(flag.update); EXPECT_FALSE(flag.schema); EXPECT_FALSE(flag.create_temp_table); - EXPECT_FALSE(flag.transaction); + EXPECT_FALSE(flag.checkpoint); EXPECT_FALSE(flag.procedure_call); } @@ -182,7 +183,7 @@ TEST_F(FlagTest, CopyTo) { EXPECT_FALSE(flag.update); EXPECT_FALSE(flag.schema); EXPECT_FALSE(flag.create_temp_table); - EXPECT_FALSE(flag.transaction); + EXPECT_FALSE(flag.checkpoint); EXPECT_FALSE(flag.procedure_call); } @@ -199,7 +200,7 @@ TEST_F(FlagTest, LoadFrom) { EXPECT_FALSE(flag.update); EXPECT_FALSE(flag.schema); EXPECT_FALSE(flag.create_temp_table); - EXPECT_FALSE(flag.transaction); + EXPECT_FALSE(flag.checkpoint); EXPECT_FALSE(flag.procedure_call); } @@ -209,7 +210,7 @@ TEST_F(FlagTest, Checkpoint) { auto logical = planLogical(query, schemaData, statsData, rules); GPhysicalAnalyzer analyzer(getCatalog()); auto flag = analyzer.analyze(*logical); - EXPECT_TRUE(flag.transaction); + EXPECT_TRUE(flag.checkpoint); EXPECT_FALSE(flag.read); EXPECT_FALSE(flag.insert); EXPECT_FALSE(flag.update); @@ -219,6 +220,11 @@ TEST_F(FlagTest, Checkpoint) { EXPECT_FALSE(flag.procedure_call); } +TEST_F(FlagTest, CheckpointAnalyzeQuery) { + GOptPlanner planner; + EXPECT_EQ(planner.analyzeMode("CHECKPOINT;"), AccessMode::kUpdate); +} + // Test 11: MATCH with RETURN (read operation) TEST_F(FlagTest, MatchReturn) { std::string query = "Match (a:person {id:1})-[:knows]->(p1) Return count(*);"; @@ -231,7 +237,7 @@ TEST_F(FlagTest, MatchReturn) { EXPECT_FALSE(flag.schema); EXPECT_FALSE(flag.batch); EXPECT_FALSE(flag.create_temp_table); - EXPECT_FALSE(flag.transaction); + EXPECT_FALSE(flag.checkpoint); EXPECT_FALSE(flag.procedure_call); } @@ -248,7 +254,7 @@ TEST_F(FlagTest, LoadJson) { EXPECT_FALSE(flag.update); EXPECT_FALSE(flag.schema); EXPECT_FALSE(flag.create_temp_table); - EXPECT_FALSE(flag.transaction); + EXPECT_FALSE(flag.checkpoint); } // Test 13: INSTALL JSON (procedure_call) @@ -264,7 +270,7 @@ TEST_F(FlagTest, InstallJson) { EXPECT_FALSE(flag.schema); EXPECT_FALSE(flag.batch); EXPECT_FALSE(flag.create_temp_table); - EXPECT_FALSE(flag.transaction); + EXPECT_FALSE(flag.checkpoint); } // Test 9: CALL read-only procedure (read, not procedure_call) @@ -280,7 +286,7 @@ TEST_F(FlagTest, CallProcedure) { EXPECT_FALSE(flag.schema); EXPECT_FALSE(flag.batch); EXPECT_FALSE(flag.create_temp_table); - EXPECT_FALSE(flag.transaction); + EXPECT_FALSE(flag.checkpoint); } // Mutating CALL keeps procedure_call so access_mode=read is rejected. @@ -297,7 +303,7 @@ TEST_F(FlagTest, CallMutatingProcedure) { EXPECT_FALSE(flag.schema); EXPECT_FALSE(flag.batch); EXPECT_FALSE(flag.create_temp_table); - EXPECT_FALSE(flag.transaction); + EXPECT_FALSE(flag.checkpoint); } TEST_F(FlagTest, CreateTable) { @@ -314,7 +320,7 @@ TEST_F(FlagTest, CreateTable) { EXPECT_FALSE(flag.update); EXPECT_FALSE(flag.batch); EXPECT_FALSE(flag.create_temp_table); - EXPECT_FALSE(flag.transaction); + EXPECT_FALSE(flag.checkpoint); } TEST_F(FlagTest, SetProperty) { @@ -331,7 +337,7 @@ TEST_F(FlagTest, SetProperty) { EXPECT_FALSE(flag.schema); EXPECT_FALSE(flag.batch); EXPECT_FALSE(flag.create_temp_table); - EXPECT_FALSE(flag.transaction); + EXPECT_FALSE(flag.checkpoint); } TEST_F(FlagTest, IU_1) { @@ -374,7 +380,7 @@ TEST_F(FlagTest, IU_1) { EXPECT_FALSE(flag.schema); EXPECT_FALSE(flag.batch); EXPECT_FALSE(flag.create_temp_table); - EXPECT_FALSE(flag.transaction); + EXPECT_FALSE(flag.checkpoint); } TEST_F(FlagTest, IU_4) { @@ -396,7 +402,7 @@ TEST_F(FlagTest, IU_4) { EXPECT_FALSE(flag.schema); EXPECT_FALSE(flag.batch); EXPECT_FALSE(flag.create_temp_table); - EXPECT_FALSE(flag.transaction); + EXPECT_FALSE(flag.checkpoint); } TEST_F(FlagTest, IU_6) { @@ -427,7 +433,7 @@ TEST_F(FlagTest, IU_6) { EXPECT_FALSE(flag.schema); EXPECT_FALSE(flag.batch); EXPECT_FALSE(flag.create_temp_table); - EXPECT_FALSE(flag.transaction); + EXPECT_FALSE(flag.checkpoint); } TEST_F(FlagTest, IU_7) { @@ -474,7 +480,7 @@ TEST_F(FlagTest, IU_7) { EXPECT_FALSE(flag.schema); EXPECT_FALSE(flag.batch); EXPECT_FALSE(flag.create_temp_table); - EXPECT_FALSE(flag.transaction); + EXPECT_FALSE(flag.checkpoint); } } // namespace gopt } // namespace neug diff --git a/tests/storage/test_checkpoint.cc b/tests/storage/test_checkpoint.cc index cf509dc7f..8a0b4e6cd 100644 --- a/tests/storage/test_checkpoint.cc +++ b/tests/storage/test_checkpoint.cc @@ -33,18 +33,19 @@ #include "neug/main/connection.h" #include "neug/main/neug_db.h" #include "neug/server/neug_db_service.h" +#include "neug/server/neug_db_session.h" #include "neug/storages/allocators.h" #include "neug/storages/checkpoint.h" #include "neug/storages/checkpoint_file_manager.h" #include "neug/storages/checkpoint_manager.h" #include "neug/storages/checkpoint_manifest.h" -#include "neug/storages/checkpoint_session.h" #include "neug/storages/container/file_header.h" #include "neug/storages/csr/mutable_csr.h" #include "neug/storages/graph/graph_interface.h" #include "neug/storages/graph/property_graph.h" #include "neug/storages/graph/schema.h" #include "neug/storages/module_descriptor.h" +#include "neug/utils/exception/exception.h" #include "neug/utils/property/column.h" #include "unittest/utils.h" @@ -159,10 +160,8 @@ void AssertSingleInt64Result(const neug::QueryResponse& table, neug::test::AssertInt64Column(table, 0, {expected}); } -void InsertItemThroughServiceWal(neug::NeugDB& db, int64_t id) { - neug::NeugDBService service(db); - auto sess = service.AcquireSession(); - auto txn = sess->GetInsertTransaction(); +void InsertItemThroughSession(neug::NeugDBSession& session, int64_t id) { + auto txn = session.GetInsertTransaction(); neug::StorageTPInsertInterface interface(txn); const auto item_label = txn.schema().get_vertex_label_id("Item"); neug::vid_t vid = 0; @@ -170,6 +169,12 @@ void InsertItemThroughServiceWal(neug::NeugDB& db, int64_t id) { ASSERT_TRUE(txn.Commit()); } +void InsertItemThroughServiceWal(neug::NeugDB& db, int64_t id) { + neug::NeugDBService service(db); + auto session = service.AcquireSession(); + InsertItemThroughSession(*session.get(), id); +} + void AssertCreatedEdgesSnapshotResult( const neug::QueryResponse& table, const std::vector& ids, const std::vector& since, @@ -557,6 +562,15 @@ static std::vector list_checkpoint_dirs( return dirs; } +static std::filesystem::path create_published_checkpoint_blocker( + const std::string& db_dir, int32_t generation) { + const auto blocker = std::filesystem::path(db_dir) / + ("checkpoint-" + std::to_string(generation)); + std::filesystem::create_directories(blocker); + std::ofstream(blocker / "blocker") << "not a checkpoint"; + return blocker; +} + static size_t count_regular_files(const std::string& dir) { size_t n = 0; for (const auto& e : std::filesystem::directory_iterator(dir)) { @@ -822,7 +836,7 @@ TEST(CheckpointGCTest, commit_staging_checkpoint_rejects_inactive_handle) { EXPECT_EQ(mgr.CurrentCheckpoint()->id(), 0); } -TEST(CheckpointGCTest, staging_and_session_cleanup_cover_failure_edges) { +TEST(CheckpointGCTest, staging_cleanup_covers_failure_edges) { auto db_path = make_checkpoint_gc_test_dir("checkpoint_gc"); std::string closed_staging_path; { @@ -869,7 +883,7 @@ TEST(CheckpointGCTest, staging_and_session_cleanup_cover_failure_edges) { std::filesystem::remove_all(collision); } - auto session = neug::CheckpointSession::Begin(mgr); + auto staging = mgr.CreateStagingCheckpoint(); { neug::CheckpointManifest meta; neug::Schema schema; @@ -877,11 +891,10 @@ TEST(CheckpointGCTest, staging_and_session_cleanup_cover_failure_edges) { neug::ModuleDescriptor desc; desc.module_type = "missing_module_type_for_checkpoint_gc_test"; meta.set_module("bad_module", std::move(desc)); - session.staging_checkpoint()->UpdateMeta(std::move(meta)); + staging.checkpoint()->UpdateMeta(std::move(meta)); } - auto published = session.Commit(); - EXPECT_EQ(session.Commit(), published); + auto published = staging.Commit(); auto published_path = published->path(); neug::PropertyGraph graph; EXPECT_THROW(graph.Open(published, neug::MemoryLevel::kInMemory), @@ -1063,12 +1076,11 @@ TEST(CheckpointGCTest, ASSERT_TRUE(std::filesystem::exists(old_path)); ASSERT_TRUE(std::filesystem::exists(old_runtime_path)); - auto session = neug::CheckpointSession::Begin(mgr); - write_valid_empty_manifest(session.staging_checkpoint()); - auto new_ckp = session.Commit(); + auto staging = mgr.CreateStagingCheckpoint(); + write_valid_empty_manifest(staging.checkpoint()); + auto new_ckp = staging.Commit(); EXPECT_EQ(mgr.CurrentCheckpoint(), new_ckp); - EXPECT_EQ(session.Commit(), new_ckp); EXPECT_TRUE(std::filesystem::exists(old_path)); EXPECT_TRUE(std::filesystem::exists(old_runtime_path)); EXPECT_TRUE(std::filesystem::exists(new_ckp->path())); @@ -1552,6 +1564,153 @@ TEST(CheckpointOptTest, } } +TEST(CheckpointOptTest, + manual_checkpoint_reopens_same_graph_and_advances_generation) { + auto db_path = make_checkpoint_gc_test_dir("checkpoint_in_place"); + neug::NeugDB db; + neug::NeugDBConfig config(db_path); + config.checkpoint_on_close = false; + db.Open(config); + auto conn = db.Connect(); + seed_checkpoint_opt_graph(conn); + + const auto* graph_before = &db.graph(); + auto res = conn->Query("CHECKPOINT;"); + ASSERT_TRUE(res) << res.error().ToString(); + EXPECT_EQ(&db.graph(), graph_before); + AssertSingleCurrentCheckpoint(db_path); + EXPECT_EQ(list_checkpoint_dirs(db_path)[0].filename(), "checkpoint-1"); + + res = conn->Query("CHECKPOINT;"); + ASSERT_TRUE(res) << res.error().ToString(); + EXPECT_EQ(&db.graph(), graph_before); + AssertSingleCurrentCheckpoint(db_path); + EXPECT_EQ(list_checkpoint_dirs(db_path)[0].filename(), "checkpoint-2"); + + conn->Close(); + db.Close(); +} + +TEST(CheckpointOptTest, + sync_to_file_checkpoint_reopens_allocator_into_new_generation) { + auto db_path = make_checkpoint_gc_test_dir("allocator_reopen"); + neug::NeugDBConfig config(db_path, 1); + config.memory_level = neug::MemoryLevel::kSyncToFile; + config.checkpoint_on_close = false; + + { + neug::NeugDB db; + ASSERT_TRUE(db.Open(config)); + auto conn = db.Connect(); + seed_checkpoint_opt_graph(conn); + auto res = conn->Query("CREATE REL TABLE Links(FROM Item TO Item);"); + ASSERT_TRUE(res) << res.error().ToString(); + res = conn->Query( + "MATCH (a:Item), (b:Item) WHERE a.id=1 AND b.id=2 " + "CREATE (a)-[:Links]->(b);"); + ASSERT_TRUE(res) << res.error().ToString(); + + auto dirs = list_checkpoint_dirs(db_path); + ASSERT_EQ(dirs.size(), 1u); + EXPECT_GT(count_regular_files((dirs[0] / "allocator").string()), 0u); + + res = conn->Query("CHECKPOINT;"); + ASSERT_TRUE(res) << res.error().ToString(); + AssertSingleCurrentCheckpoint(db_path); + dirs = list_checkpoint_dirs(db_path); + ASSERT_EQ(dirs[0].filename(), "checkpoint-1"); + + res = conn->Query("CREATE (:Item {id: 3});"); + ASSERT_TRUE(res) << res.error().ToString(); + res = conn->Query( + "MATCH (a:Item), (b:Item) WHERE a.id=3 AND b.id=1 " + "CREATE (a)-[:Links]->(b);"); + ASSERT_TRUE(res) << res.error().ToString(); + EXPECT_GT(count_regular_files((dirs[0] / "allocator").string()), 0u); + + res = conn->Query("CHECKPOINT;"); + ASSERT_TRUE(res) << res.error().ToString(); + AssertSingleCurrentCheckpoint(db_path); + dirs = list_checkpoint_dirs(db_path); + ASSERT_EQ(dirs[0].filename(), "checkpoint-2"); + + conn->Close(); + db.Close(); + } + + { + neug::NeugDB db; + ASSERT_TRUE(db.Open(config)); + auto conn = db.Connect(); + auto res = conn->Query("MATCH (:Item)-[e:Links]->(:Item) RETURN COUNT(e);"); + ASSERT_TRUE(res) << res.error().ToString(); + AssertSingleInt64Result(res.value().response(), 2); + conn->Close(); + db.Close(); + } +} + +TEST(CheckpointOptTest, tp_checkpoint_routes_from_compiled_execution_flag) { + auto db_path = make_checkpoint_gc_test_dir("tp_checkpoint_flag_routing"); + neug::NeugDBConfig config(db_path); + config.checkpoint_on_close = false; + config.max_thread_num = 1; + + neug::NeugDB db; + db.Open(config); + auto conn = db.Connect(); + seed_checkpoint_opt_graph(conn); + conn->Close(); + + const auto* graph_before = &db.graph(); + { + neug::NeugDBService service(db); + auto session = service.AcquireSession(); + + auto explain = + session->Eval(R"({"query":"EXPLAIN CHECKPOINT;","parameters":{}})"); + ASSERT_TRUE(explain) << explain.error().ToString(); + EXPECT_EQ(&db.graph(), graph_before); + AssertSingleCurrentCheckpoint(db_path); + EXPECT_EQ(list_checkpoint_dirs(db_path)[0].filename(), "checkpoint-0"); + + auto checkpoint = + session->Eval(R"({"query":"CHECKPOINT;","parameters":{}})"); + ASSERT_TRUE(checkpoint) << checkpoint.error().ToString(); + EXPECT_EQ(&db.graph(), graph_before); + AssertSingleCurrentCheckpoint(db_path); + EXPECT_EQ(list_checkpoint_dirs(db_path)[0].filename(), "checkpoint-1"); + + auto profile = session->Eval( + R"({"query":"PROFILE CHECKPOINT;","access_mode":"u","parameters":{}})"); + ASSERT_TRUE(profile) << profile.error().ToString(); + neug::QueryResponse profile_response; + ASSERT_TRUE(profile_response.ParseFromString(profile.value())); + EXPECT_EQ(profile_response.row_count(), 0); + ASSERT_TRUE(profile_response.has_profile_result()); + ASSERT_EQ(profile_response.profile_result().operators_size(), 1); + EXPECT_EQ(profile_response.profile_result().operators(0).operator_name(), + "Checkpoint"); + EXPECT_EQ(&db.graph(), graph_before); + AssertSingleCurrentCheckpoint(db_path); + EXPECT_EQ(list_checkpoint_dirs(db_path)[0].filename(), "checkpoint-2"); + + for (const auto* invalid_mode : {"read", "insert", "schema"}) { + const auto request = + std::string(R"({"query":"CHECKPOINT;","access_mode":")") + + invalid_mode + R"(","parameters":{}})"; + auto invalid_checkpoint = session->Eval(request); + ASSERT_FALSE(invalid_checkpoint) << invalid_mode; + EXPECT_EQ(invalid_checkpoint.error().error_code(), + neug::StatusCode::ERR_INVALID_ARGUMENT) + << invalid_mode; + } + EXPECT_EQ(list_checkpoint_dirs(db_path)[0].filename(), "checkpoint-2"); + } + + db.Close(); +} + template class CheckpointTestStringProp : public CheckpointTestBase {}; @@ -1864,12 +2023,7 @@ class CheckpointSafetyTest : public CheckpointTestBase { protected: std::string db_dir_; - void SetUp() override { - if (getuid() == 0) { - GTEST_SKIP() << "Cannot test permission-based failures as root"; - } - db_dir_ = this->MakeUniqueDir("ckp_safety"); - } + void SetUp() override { db_dir_ = this->MakeUniqueDir("ckp_safety"); } void TearDown() override { // Restore permissions recursively before cleanup. @@ -1898,6 +2052,9 @@ TYPED_TEST_SUITE(CheckpointSafetyTest, AllMemoryLevels); // Fix #528: UpdateMeta re-throws on I/O failure and preserves old meta. TYPED_TEST(CheckpointSafetyTest, update_meta_rethrows_on_failure) { + if (getuid() == 0) { + GTEST_SKIP() << "Cannot test permission-based failures as root"; + } neug::CheckpointManager mgr; mgr.Open(this->db_dir_); auto staging = mgr.CreateStagingCheckpoint(); @@ -1945,7 +2102,10 @@ TYPED_TEST(CheckpointSafetyTest, // Fix #528 integration: A failed CHECKPOINT does not corrupt on-disk data — // recovery on restart succeeds. TYPED_TEST(CheckpointSafetyTest, - in_place_checkpoint_failure_preserves_data_on_reopen) { + checkpoint_prepare_failure_keeps_database_available) { + if (getuid() == 0) { + GTEST_SKIP() << "Cannot test permission-based failures as root"; + } // Phase 1: Create table, insert data, and produce a valid checkpoint. { neug::NeugDB db; @@ -1962,43 +2122,32 @@ TYPED_TEST(CheckpointSafetyTest, db.Close(); } - // Phase 2: Reopen, mutate so the table is dirty, then trigger a failing - // in-place CHECKPOINT. Clean graphs skip dump (dirty-bit early return), so a - // write is required before chmod'ing the checkpoint dir. - // The standalone CHECKPOINT operator still dumps through the AP storage - // interface in this split PR; DB-close/recovery checkpoints use the new - // staging path. - // After a failed in-place CHECKPOINT, the in-memory graph may be left in an - // inconsistent state. Use checkpoint_on_close=false so Close() does not try - // to checkpoint it again while permissions are being restored. + // Phase 2: Reopen, mutate so the table is dirty, then trigger a checkpoint + // preparation failure before the snapshot build starts. The live graph must + // remain usable and retain the uncheckpointed mutation. { neug::NeugDB db; db.Open(this->MakeConfigNoCheckpointOnClose(this->db_dir_)); auto conn = db.Connect(); this->ExpectQuery(*conn, "CREATE (:Item {id: 300});"); - std::string ckp_dir; - for (const auto& entry : - std::filesystem::directory_iterator(this->db_dir_)) { - auto name = entry.path().filename().string(); - if (entry.is_directory() && name.find("checkpoint-") == 0 && - name.find(".next") == std::string::npos) { - ckp_dir = entry.path().string(); - break; - } - } - ASSERT_FALSE(ckp_dir.empty()); - - std::filesystem::permissions(ckp_dir, + std::filesystem::permissions(this->db_dir_, std::filesystem::perms::owner_read | std::filesystem::perms::owner_exec); - // CHECKPOINT should fail because UpdateMeta cannot write meta.tmp. + // CHECKPOINT should fail while creating checkpoint-(N+1).next. auto res = conn->Query("CHECKPOINT;"); EXPECT_FALSE(res) << "Expected CHECKPOINT to fail, but it succeeded"; + EXPECT_FALSE(has_staging_checkpoint_dirs(this->db_dir_)); + + auto table = this->RunQuery(*conn, "MATCH (v:Item) RETURN v.id;"); + EXPECT_EQ(table.row_count(), 3); - // Restore permissions so Close() and subsequent reopen work. - std::filesystem::permissions(ckp_dir, std::filesystem::perms::owner_all); + // Restore permissions and verify checkpointing can be retried. + std::filesystem::permissions(this->db_dir_, + std::filesystem::perms::owner_all); + res = conn->Query("CHECKPOINT;"); + ASSERT_TRUE(res) << res.error().ToString(); conn->Close(); db.Close(); } @@ -2009,12 +2158,105 @@ TYPED_TEST(CheckpointSafetyTest, db.Open(this->MakeConfigNoCheckpointOnClose(this->db_dir_)); auto conn = db.Connect(); auto table = this->RunQuery(*conn, "MATCH (v:Item) RETURN v.id;"); - EXPECT_EQ(table.row_count(), 2); + EXPECT_EQ(table.row_count(), 3); + conn->Close(); + db.Close(); + } +} + +TYPED_TEST(CheckpointSafetyTest, + checkpoint_on_close_failure_is_suppressed_after_cleanup) { + auto config = this->MakeConfig(this->db_dir_); + neug::NeugDB db; + db.Open(config); + auto conn = db.Connect(); + this->ExpectQuery(*conn, + "CREATE NODE TABLE Item(id INT64, PRIMARY KEY(id));"); + this->ExpectQuery(*conn, "CREATE (:Item {id: 100});"); + conn->Close(); + + // checkpoint-0 is the initial durable generation. Block publication of + // checkpoint-1 so the best-effort checkpoint fails after consuming the + // live graph. + create_published_checkpoint_blocker(this->db_dir_, 1); + EXPECT_NO_THROW(db.Close()); + EXPECT_TRUE(db.IsClosed()); + + // Close() must suppress the checkpoint error, release the file lock and all + // runtime resources, and let the same object recover through the ordinary + // Open path. + EXPECT_NO_THROW(db.Open(this->MakeConfigNoCheckpointOnClose(this->db_dir_))); + EXPECT_TRUE(db.IsClosed() == false); + EXPECT_NO_THROW(db.Close()); +} + +TYPED_TEST(CheckpointSafetyTest, + destructive_checkpoint_failure_terminates_process) { + { + neug::NeugDB db; + db.Open(this->MakeConfigNoCheckpointOnClose(this->db_dir_)); + auto conn = db.Connect(); + this->ExpectQuery(*conn, + "CREATE NODE TABLE Item(id INT64, PRIMARY KEY(id));"); + this->ExpectQuery(*conn, "CREATE (:Item {id: 100});"); + this->ExpectQuery(*conn, "CHECKPOINT;"); + + // A pre-existing final generation makes publish fail after compact/dump, + // exercising the destructive fail-stop path with a real filesystem + // conflict rather than a product-code test hook. + create_published_checkpoint_blocker(this->db_dir_, 2); + EXPECT_DEATH({ (void) conn->Query("CHECKPOINT;"); }, + "terminating to prevent access to an invalid live graph"); + + conn->Close(); + db.Close(); + } + + // Restart recovery discards incomplete staging state and uses the last + // durable checkpoint as its baseline. + { + neug::NeugDB db; + db.Open(this->MakeConfigNoCheckpointOnClose(this->db_dir_)); + auto conn = db.Connect(); + AssertSingleInt64Result( + this->RunQuery(*conn, "MATCH (v:Item) RETURN count(v);"), 1); conn->Close(); db.Close(); } } +TYPED_TEST(CheckpointSafetyTest, checkpoint_gc_failure_is_best_effort) { + if (getuid() == 0) { + GTEST_SKIP() << "Cannot test permission-based failures as root"; + } + + neug::NeugDB db; + db.Open(this->MakeConfigNoCheckpointOnClose(this->db_dir_)); + auto conn = db.Connect(); + this->ExpectQuery(*conn, + "CREATE NODE TABLE Item(id INT64, PRIMARY KEY(id));"); + this->ExpectQuery(*conn, "CREATE (:Item {id: 1});"); + + const auto retired_checkpoint = + std::filesystem::path(this->db_dir_) / "checkpoint-0"; + ASSERT_TRUE(std::filesystem::exists(retired_checkpoint)); + std::filesystem::permissions( + retired_checkpoint, + std::filesystem::perms::owner_read | std::filesystem::perms::owner_exec); + auto result = conn->Query("CHECKPOINT;"); + ASSERT_TRUE(result) << result.error().ToString(); + EXPECT_EQ(list_checkpoint_dirs(this->db_dir_).size(), 2u); + + std::filesystem::permissions(retired_checkpoint, + std::filesystem::perms::owner_all); + result = conn->Query("CHECKPOINT;"); + ASSERT_TRUE(result) << result.error().ToString(); + AssertSingleCurrentCheckpoint(this->db_dir_); + + conn->Close(); + db.Close(); +} + // Fix #530: Open discards an incomplete (empty-meta) checkpoint and recovers. TYPED_TEST(CheckpointSafetyTest, open_discards_incomplete_checkpoint_and_recovers) { diff --git a/tests/storage/test_graph_snapshot_store_concurrency.cc b/tests/storage/test_graph_snapshot_store_concurrency.cc index 1e59c22c2..18713a292 100644 --- a/tests/storage/test_graph_snapshot_store_concurrency.cc +++ b/tests/storage/test_graph_snapshot_store_concurrency.cc @@ -29,15 +29,18 @@ #include #include #include +#include #include #include #include #include "neug/generated/proto/plan/error.pb.h" +#include "neug/main/checkpoint_coordinator.h" #include "neug/storages/checkpoint_manager.h" #include "neug/storages/graph/operation_params.h" #include "neug/storages/graph/property_graph.h" #include "neug/storages/graph_snapshot_store.h" +#include "neug/utils/exception/exception.h" #include "neug/utils/result.h" #include "unittest/utils.h" @@ -92,6 +95,17 @@ class GraphSnapshotStoreConcurrencyTest : public ::testing::Test { } }; +TEST_F(GraphSnapshotStoreConcurrencyTest, + MaintenanceAccessRejectsExistingReader) { + auto& pinned = store_->PinCurrentSnapshot(); + CheckpointCoordinator coordinator( + checkpoint_mgr_, *store_, MemoryLevel::kInMemory, + /*recovered_wal_timestamp=*/0, [](const std::string&) {}); + auto status = coordinator.ExecuteApManual(); + EXPECT_EQ(status.error_code(), StatusCode::ERR_INTERNAL_ERROR); + store_->UnpinSnapshot(pinned); +} + // 1. Heavy concurrent pin/unpin vs publish. Verifies PinCurrentSnapshot // always returns a handle whose view points to a live PG with the seeded // schema — i.e., readers never observe a freed or partially-published slot. diff --git a/tests/transaction/test_acid.cc b/tests/transaction/test_acid.cc index 6c393b01f..bd221097c 100644 --- a/tests/transaction/test_acid.cc +++ b/tests/transaction/test_acid.cc @@ -2995,8 +2995,8 @@ TEST_F(NeugDBACIDTest, ConcurrentReadsAndCommitsObserveConsistentValues) { // deployment target which this build doesn't set). // // The UpdateTransaction must be fully owned by the writer thread: - // GetUpdateTransaction acquires VersionManager's update_state_ - // exclusively (CAS 0→1) and Commit resets it (→0). + // GetUpdateTransaction changes VersionManager's update state from 0 to 1, + // and Commit restores it to 0. // Acquire+stage before the barrier preserves the // "post-race value staged in cow_graph" timing the test wants. std::atomic ready{0}; diff --git a/tests/transaction/test_compact_transaction.cc b/tests/transaction/test_compact_transaction.cc index 078abfa4a..21fd6ddc9 100644 --- a/tests/transaction/test_compact_transaction.cc +++ b/tests/transaction/test_compact_transaction.cc @@ -304,7 +304,7 @@ TEST_F(CompactTransactionTest, IdempotentCommitAndAbort) { // held, and proceeds once released. static void AssertCompactBlocksAcquire( neug::VersionManager& vm, - std::function acquire_fn, + std::function acquire_fn, std::function release_fn) { std::atomic worker_started{false}; std::atomic worker_acquired{false}; @@ -315,9 +315,9 @@ static void AssertCompactBlocksAcquire( // Worker thread: try to acquire another timestamp std::thread worker([&]() { worker_started.store(true); - acquire_fn(vm); + const auto timestamp = acquire_fn(vm); worker_acquired.store(true); - release_fn(vm, 0); // release immediately; ts value unused for read + release_fn(vm, timestamp); }); // Wait for worker to start @@ -342,7 +342,7 @@ TEST_F(CompactTransactionTest, CompactBlocksRead) { vm.init_ts(0, 1); AssertCompactBlocksAcquire( - vm, [](neug::VersionManager& v) { v.acquire_read_timestamp(); }, + vm, [](neug::VersionManager& v) { return v.acquire_read_timestamp(); }, [](neug::VersionManager& v, uint32_t) { v.release_read_timestamp(); }); } @@ -350,7 +350,7 @@ TEST_F(CompactTransactionTest, CompactBlocksInsert) { neug::VersionManager vm; vm.init_ts(0, 1); AssertCompactBlocksAcquire( - vm, [](neug::VersionManager& v) { v.acquire_insert_timestamp(); }, + vm, [](neug::VersionManager& v) { return v.acquire_insert_timestamp(); }, [](neug::VersionManager& v, uint32_t ts) { v.release_insert_timestamp(ts); }); @@ -360,7 +360,7 @@ TEST_F(CompactTransactionTest, CompactBlocksUpdate) { neug::VersionManager vm; vm.init_ts(0, 1); AssertCompactBlocksAcquire( - vm, [](neug::VersionManager& v) { v.acquire_update_timestamp(); }, + vm, [](neug::VersionManager& v) { return v.acquire_update_timestamp(); }, [](neug::VersionManager& v, uint32_t ts) { v.release_update_timestamp(ts); }); @@ -370,7 +370,7 @@ TEST_F(CompactTransactionTest, CompactBlocksCompact) { neug::VersionManager vm; vm.init_ts(0, 1); AssertCompactBlocksAcquire( - vm, [](neug::VersionManager& v) { v.acquire_compact_timestamp(); }, + vm, [](neug::VersionManager& v) { return v.acquire_compact_timestamp(); }, [](neug::VersionManager& v, uint32_t ts) { v.release_compact_timestamp(ts); }); diff --git a/tests/transaction/test_transaction_release_order.cc b/tests/transaction/test_transaction_release_order.cc index 842a62711..097237348 100644 --- a/tests/transaction/test_transaction_release_order.cc +++ b/tests/transaction/test_transaction_release_order.cc @@ -55,7 +55,9 @@ class ReleaseOrderVersionManager : public IVersionManager { uint32_t acquire_insert_timestamp() override { return 1; } uint32_t acquire_update_timestamp() override { return 1; } void begin_update_commit(uint32_t) override {} - void release_update_timestamp(uint32_t) override {} + void drain_readers() override {} + void release_update_timestamp(uint32_t) noexcept override {} + void reset_timeline_after_checkpoint(uint32_t) noexcept override {} uint32_t acquire_compact_timestamp() override { return 1; } void release_read_timestamp() override { record_release(); } diff --git a/tests/transaction/test_update_transaction.cc b/tests/transaction/test_update_transaction.cc index 9b907dc12..3f1d0a5a2 100644 --- a/tests/transaction/test_update_transaction.cc +++ b/tests/transaction/test_update_transaction.cc @@ -2662,21 +2662,6 @@ TEST_F(UpdateTransactionTest, DeleteVertexWithMultipleEdgeTypes) { db.Close(); } -TEST_F(UpdateTransactionTest, TestCheckpoint) { - neug::NeugDB db; - neug::NeugDBConfig config(db_dir); - config.memory_level = neug::MemoryLevel::kInMemory; - db.Open(config); - auto svc = std::make_shared(db); - - { - auto sess = svc->AcquireSession(); - auto txn = sess->GetUpdateTransaction(); - neug::StorageTPUpdateInterface interface(txn); - interface.CreateCheckpoint(); - } -} - TEST_F(UpdateTransactionTest, TestUnsupportedInterface) { neug::NeugDB db; neug::NeugDBConfig config(db_dir); diff --git a/tests/transaction/test_wal_replay.cc b/tests/transaction/test_wal_replay.cc index fde5a40f4..c0eca12ae 100644 --- a/tests/transaction/test_wal_replay.cc +++ b/tests/transaction/test_wal_replay.cc @@ -14,19 +14,35 @@ */ #include "neug/common/types/value.h" +#include "neug/main/checkpoint_coordinator.h" #include "neug/neug.h" #include "neug/server/neug_db_service.h" +#include "neug/storages/allocators.h" +#include "neug/storages/checkpoint.h" +#include "neug/storages/checkpoint_manager.h" #include "neug/storages/graph/graph_interface.h" +#include "neug/storages/graph/property_graph.h" #include "neug/storages/graph_snapshot_store.h" +#include "neug/transaction/update_timestamp_guard.h" #include "neug/transaction/version_manager.h" +#include "neug/transaction/wal/wal.h" #include +#include +#include +#include #include #include +#include +#include #include #include +#include +#include +#include #include "gtest/gtest.h" +#include "unittest/utils.h" namespace { @@ -40,6 +56,45 @@ std::string make_test_dir() { return (std::filesystem::temp_directory_path() / dir_name).string(); } +TEST(WalWriterTest, ReopensSameInstanceOnNewTimeline) { + const auto test_dir = make_test_dir(); + const auto old_wal_dir = + (std::filesystem::path(test_dir) / "checkpoint-0" / "wal").string(); + const auto new_wal_dir = + (std::filesystem::path(test_dir) / "checkpoint-1" / "wal").string(); + constexpr uint32_t old_marker = 17; + constexpr uint32_t new_marker = 29; + + { + auto writer = neug::WalWriterFactory::CreateWalWriter(old_wal_dir, 0); + auto* const identity = writer.get(); + writer->open(old_wal_dir); + ASSERT_TRUE(writer->append(reinterpret_cast(&old_marker), + sizeof(old_marker))); + writer->close(); + + writer->open(new_wal_dir); + EXPECT_EQ(writer.get(), identity); + ASSERT_TRUE(writer->append(reinterpret_cast(&new_marker), + sizeof(new_marker))); + writer->close(); + } + + const auto read_marker = [](const std::string& wal_dir) { + const auto begin = std::filesystem::directory_iterator(wal_dir); + const auto end = std::filesystem::directory_iterator(); + EXPECT_NE(begin, end); + std::ifstream wal_file(begin->path(), std::ios::binary); + uint32_t marker = 0; + wal_file.read(reinterpret_cast(&marker), sizeof(marker)); + return marker; + }; + EXPECT_EQ(read_marker(old_wal_dir), old_marker); + EXPECT_EQ(read_marker(new_wal_dir), new_marker); + + std::filesystem::remove_all(test_dir); +} + neug::NeugDBConfig make_config(const std::string& db_dir) { neug::NeugDBConfig config(db_dir, 1); config.memory_level = neug::MemoryLevel::kInMemory; @@ -274,6 +329,273 @@ TEST(WalReplayVersionManagerTest, expect_compact_completes_timestamp_and_preserves_next_insert(false); } +TEST(WalReplayVersionManagerTest, ResetTimelineStartsFreshTimestampTimeline) { + neug::VersionManager version_manager; + version_manager.init_ts(40, 1); + + const auto old_insert_ts = version_manager.acquire_insert_timestamp(); + EXPECT_EQ(old_insert_ts, 41); + version_manager.release_insert_timestamp(old_insert_ts); + + auto update_guard = neug::UpdateTimestampGuard::Acquire(version_manager); + EXPECT_EQ(update_guard.timestamp(), 42); + update_guard.BeginCommit(); + update_guard.DrainReaders(); + update_guard.CompleteCheckpoint(); + + const auto baseline_read_ts = version_manager.acquire_read_timestamp(); + EXPECT_EQ(baseline_read_ts, 0); + version_manager.release_read_timestamp(); + + const auto new_insert_ts = version_manager.acquire_insert_timestamp(); + EXPECT_EQ(new_insert_ts, 1); + version_manager.release_insert_timestamp(new_insert_ts); + + const auto new_read_ts = version_manager.acquire_read_timestamp(); + EXPECT_EQ(new_read_ts, new_insert_ts); + version_manager.release_read_timestamp(); +} + +TEST(WalReplayVersionManagerTest, + MovedUpdateGuardDestructorCompletesWithoutResettingTimeline) { + neug::VersionManager version_manager; + version_manager.init_ts(40, 1); + + uint32_t update_ts = 0; + { + auto original = neug::UpdateTimestampGuard::Acquire(version_manager); + update_ts = original.timestamp(); + auto moved = std::move(original); + EXPECT_FALSE(original.active()); + EXPECT_TRUE(moved.active()); + } + + const auto read_after_release = version_manager.acquire_read_timestamp(); + EXPECT_EQ(read_after_release, update_ts); + version_manager.release_read_timestamp(); + + const auto next_insert_ts = version_manager.acquire_insert_timestamp(); + EXPECT_EQ(next_insert_ts, update_ts + 1); + version_manager.release_insert_timestamp(next_insert_ts); +} + +TEST(WalReplayVersionManagerTest, UpdateGuardReleaseIsIdempotent) { + neug::VersionManager version_manager; + version_manager.init_ts(40, 1); + + auto update_guard = neug::UpdateTimestampGuard::Acquire(version_manager); + const auto update_ts = update_guard.timestamp(); + update_guard.Release(); + update_guard.Release(); + EXPECT_FALSE(update_guard.active()); + + const auto read_after_release = version_manager.acquire_read_timestamp(); + EXPECT_EQ(read_after_release, update_ts); + version_manager.release_read_timestamp(); +} + +TEST(WalReplayVersionManagerTest, + OrdinaryUpdateCommitDoesNotWaitForExistingReader) { + using namespace std::chrono_literals; + + neug::VersionManager version_manager; + version_manager.init_ts(0, 1); + (void) version_manager.acquire_read_timestamp(); + auto update_guard = neug::UpdateTimestampGuard::Acquire(version_manager); + + auto commit = std::async(std::launch::async, [&]() { + update_guard.BeginCommit(); + update_guard.Release(); + }); + const auto status = commit.wait_for(100ms); + version_manager.release_read_timestamp(); + + EXPECT_EQ(status, std::future_status::ready); + commit.get(); +} + +TEST(WalReplayVersionManagerTest, + DrainReadersWaitsForExistingReaderAndBlocksNewReader) { + using namespace std::chrono_literals; + + neug::VersionManager version_manager; + version_manager.init_ts(40, 1); + (void) version_manager.acquire_read_timestamp(); + + auto update_guard = neug::UpdateTimestampGuard::Acquire(version_manager); + update_guard.BeginCommit(); + + std::atomic drain_started{false}; + std::atomic drain_finished{false}; + std::thread drain_thread([&]() { + drain_started.store(true, std::memory_order_release); + update_guard.DrainReaders(); + drain_finished.store(true, std::memory_order_release); + }); + while (!drain_started.load(std::memory_order_acquire)) { + std::this_thread::yield(); + } + + auto new_reader = std::async(std::launch::async, [&]() { + const auto timestamp = version_manager.acquire_read_timestamp(); + version_manager.release_read_timestamp(); + return timestamp; + }); + EXPECT_EQ(new_reader.wait_for(20ms), std::future_status::timeout); + EXPECT_FALSE(drain_finished.load(std::memory_order_acquire)); + + version_manager.release_read_timestamp(); + drain_thread.join(); + EXPECT_TRUE(drain_finished.load(std::memory_order_acquire)); + EXPECT_EQ(new_reader.wait_for(20ms), std::future_status::timeout); + + update_guard.CompleteCheckpoint(); + EXPECT_EQ(new_reader.get(), 0); +} + +class CheckpointActivationHandlerTest : public ::testing::Test { + protected: + void SetUp() override { + test_dir_ = make_test_dir(); + std::filesystem::remove_all(test_dir_); + std::filesystem::create_directories(test_dir_); + + checkpoint_manager_.Open(test_dir_); + graph_ = std::make_shared(); + graph_->Open(make_checkpoint(checkpoint_manager_), + neug::MemoryLevel::kInMemory); + snapshot_store_ = std::make_unique(2, graph_); + coordinator_ = std::make_unique( + checkpoint_manager_, *snapshot_store_, neug::MemoryLevel::kInMemory, + /*recovered_wal_timestamp=*/0, + [this](const std::string& allocator_dir) { + handler_calls_.push_back(allocator_dir); + }); + } + + void TearDown() override { + coordinator_.reset(); + snapshot_store_.reset(); + graph_.reset(); + checkpoint_manager_.Close(); + std::filesystem::remove_all(test_dir_); + } + + std::string test_dir_; + neug::CheckpointManager checkpoint_manager_; + std::shared_ptr graph_; + std::unique_ptr snapshot_store_; + std::unique_ptr coordinator_; + std::vector handler_calls_; +}; + +TEST_F(CheckpointActivationHandlerTest, + TpManualRunsPostReopenBeforeActivationHandler) { + size_t activation_calls = 0; + std::string observed_wal_uri; + coordinator_->SetActivationHandler([&](const std::string& wal_uri) { + // The mandatory post-reopen handler always runs first. + EXPECT_EQ(handler_calls_.size(), 1u); + ++activation_calls; + observed_wal_uri = wal_uri; + }); + + neug::VersionManager version_manager; + version_manager.init_ts(0, 1); + auto update_guard = neug::UpdateTimestampGuard::Acquire(version_manager); + ASSERT_TRUE(coordinator_->ExecuteTpManual(std::move(update_guard)).ok()); + + EXPECT_EQ(activation_calls, 1u); + const auto current_checkpoint = checkpoint_manager_.CurrentCheckpoint(); + ASSERT_NE(current_checkpoint, nullptr); + EXPECT_EQ(observed_wal_uri, current_checkpoint->wal_dir()); + ASSERT_EQ(handler_calls_.size(), 1u); + EXPECT_EQ(handler_calls_[0], current_checkpoint->allocator_dir()); +} + +TEST_F(CheckpointActivationHandlerTest, + ApManualAndRecoveryRunOnlyPostReopenHandler) { + size_t activation_calls = 0; + coordinator_->SetActivationHandler( + [&](const std::string&) { ++activation_calls; }); + + ASSERT_TRUE(coordinator_->ExecuteApManual().ok()); + ASSERT_TRUE(coordinator_->ExecuteRecovery().ok()); + EXPECT_EQ(handler_calls_.size(), 2u); + EXPECT_EQ(activation_calls, 0u); +} + +TEST(CheckpointCoordinatorTest, + PreparationFailureKeepsOldWalAndTimestampTimelineUsable) { + const auto test_dir = make_test_dir(); + std::filesystem::remove_all(test_dir); + std::filesystem::create_directories(test_dir); + + neug::CheckpointManager checkpoint_manager; + checkpoint_manager.Open(test_dir); + auto graph = std::make_shared(); + graph->Open(make_checkpoint(checkpoint_manager), + neug::MemoryLevel::kInMemory); + neug::GraphSnapshotStore snapshot_store(2, graph); + std::vector> allocators; + allocators.emplace_back( + std::make_shared(neug::MemoryLevel::kInMemory, "")); + constexpr size_t allocator_marker_size = 64; + ASSERT_NE(allocators[0]->allocate(allocator_marker_size), nullptr); + bool allocator_reopened = false; + bool cache_invalidated = false; + neug::CheckpointCoordinator coordinator( + checkpoint_manager, snapshot_store, neug::MemoryLevel::kInMemory, + /*recovered_wal_timestamp=*/0, [&](const std::string&) { + allocators[0]->Reopen(neug::MemoryLevel::kInMemory, ""); + allocator_reopened = true; + }); + + const auto old_wal_dir = + (std::filesystem::path(test_dir) / "old-wal").string(); + auto wal_writer = neug::WalWriterFactory::CreateWalWriter(old_wal_dir, 0); + wal_writer->open(old_wal_dir); + constexpr uint32_t before_marker = 17; + constexpr uint32_t after_marker = 29; + ASSERT_TRUE(wal_writer->append(reinterpret_cast(&before_marker), + sizeof(before_marker))); + + // Keep the checkpoint manager's only staging slot occupied. ExecuteTpManual + // must fail before destructive graph maintenance, release the update guard + // normally, and leave the existing WAL writer untouched. + auto conflicting_staging = checkpoint_manager.CreateStagingCheckpoint(); + neug::VersionManager version_manager; + version_manager.init_ts(40, 1); + auto update_guard = neug::UpdateTimestampGuard::Acquire(version_manager); + const auto update_ts = update_guard.timestamp(); + coordinator.SetActivationHandler([&](const std::string& wal_uri) { + wal_writer->close(); + wal_writer->open(wal_uri); + cache_invalidated = true; + }); + auto status = coordinator.ExecuteTpManual(std::move(update_guard)); + + EXPECT_FALSE(status.ok()); + EXPECT_FALSE(allocator_reopened); + EXPECT_EQ(allocators[0]->allocated_memory(), allocator_marker_size); + EXPECT_FALSE(cache_invalidated); + EXPECT_TRUE(wal_writer->append(reinterpret_cast(&after_marker), + sizeof(after_marker))); + + const auto read_ts = version_manager.acquire_read_timestamp(); + EXPECT_EQ(read_ts, update_ts); + version_manager.release_read_timestamp(); + const auto next_insert_ts = version_manager.acquire_insert_timestamp(); + EXPECT_EQ(next_insert_ts, update_ts + 1); + version_manager.release_insert_timestamp(next_insert_ts); + + wal_writer->close(); + coordinator.ClearActivationHandler(); + conflicting_staging.Discard(); + checkpoint_manager.Close(); + std::filesystem::remove_all(test_dir); +} + TEST_F(WalReplayTest, CloseCheckpointAlwaysResetsServiceTimeline) { { auto config = make_config(db_dir_); diff --git a/tools/python_bind/neug/database.py b/tools/python_bind/neug/database.py index b0c58dae6..42384df63 100644 --- a/tools/python_bind/neug/database.py +++ b/tools/python_bind/neug/database.py @@ -390,7 +390,12 @@ def async_connect(self) -> AsyncConnection: def close(self, log=True): """ - Close the database connection. + Close the database and all of its connections. + + For a read-write database with ``checkpoint_on_close=True``, this method + creates a checkpoint before releasing database resources. + The method is idempotent. + Automatic checkpoint creation is best effort: failures are logged and do not propagate to the caller. """ db_path = getattr(self, "_db_path", None) if log and db_path and db_path.strip() != "": diff --git a/tools/python_bind/src/py_database.cc b/tools/python_bind/src/py_database.cc index d9285db05..f81d327f2 100644 --- a/tools/python_bind/src/py_database.cc +++ b/tools/python_bind/src/py_database.cc @@ -18,6 +18,16 @@ namespace neug { +PyDatabase::~PyDatabase() noexcept { + try { + close(); + } catch (const std::exception& e) { + LOG(ERROR) << "Failed to close PyDatabase during destruction: " << e.what(); + } catch (...) { + LOG(ERROR) << "Failed to close PyDatabase during destruction"; + } +} + void PyDatabase::initialize(pybind11::handle& m) { pybind11::class_>( m, "PyDatabase", @@ -55,8 +65,9 @@ void PyDatabase::initialize(pybind11::handle& m) { "Returns:\n" " PyConnection: A connection to the database.\n") .def("close", &PyDatabase::close, - "Close the database connection and " - "release resources.\n") + "Close the database and release all resources.\n\n" + "checkpoint_on_close requests an automatic checkpoint.\n" + "Automatic checkpoint failures are logged and suppressed.\n") .def("max_thread_num", &PyDatabase::max_thread_num, "Return the effective database max_thread_num.\n") .def("serve", &PyDatabase::serve, pybind11::arg("port") = 10000, diff --git a/tools/python_bind/src/py_database.h b/tools/python_bind/src/py_database.h index 07653d895..9c2991a58 100644 --- a/tools/python_bind/src/py_database.h +++ b/tools/python_bind/src/py_database.h @@ -60,7 +60,7 @@ class PyDatabase : public std::enable_shared_from_this { database->Open(config); } - ~PyDatabase() { close(); } + ~PyDatabase() noexcept; PyConnection connect(); diff --git a/tools/python_bind/tests/test_persistence.py b/tools/python_bind/tests/test_persistence.py index 02585db25..4e61ae8b6 100644 --- a/tools/python_bind/tests/test_persistence.py +++ b/tools/python_bind/tests/test_persistence.py @@ -16,6 +16,7 @@ # limitations under the License. # +import csv import logging import shutil @@ -26,6 +27,97 @@ logger = logging.getLogger(__name__) +_ISSUE_651_FILE_ID = "b31986a61becd3c2c030ccadeca6f1f4a2419ffb" +_ISSUE_651_FUNCTION_ID = "78ff0eb259fb0b9e7d24c5342ef277196ce8e4ad" + + +def _write_csv(path, columns, row): + with path.open("w", newline="", encoding="utf-8") as file: + writer = csv.DictWriter(file, fieldnames=columns, quoting=csv.QUOTE_ALL) + writer.writeheader() + writer.writerow(row) + + +def _scalar(executor, query): + records = list(executor.execute(query)) + assert records, f"Expected at least one row for query: {query}" + return records[0][0] + + +def _published_checkpoint_dirs(db_dir): + return sorted( + path + for path in db_dir.iterdir() + if path.is_dir() + and path.name.startswith("checkpoint-") + and not path.name.endswith(".next") + ) + + +def _assert_single_published_checkpoint(db_dir, expected_generation=None): + checkpoints = _published_checkpoint_dirs(db_dir) + assert len(checkpoints) == 1 + assert not any(path.name.endswith(".next") for path in db_dir.iterdir()) + if expected_generation is not None: + assert checkpoints[0].name == f"checkpoint-{expected_generation}" + + +def _create_issue_651_schema(executor): + for table in ["File", "Function", "Class", "Module"]: + executor.execute(f"CREATE NODE TABLE {table}(id STRING, PRIMARY KEY(id));") + executor.execute("CREATE REL TABLE DEFINES_FUNC(FROM File TO Function);") + executor.execute("CREATE REL TABLE DEFINES_CLASS(FROM File TO Class);") + executor.execute("CREATE REL TABLE BELONGS_TO(FROM File TO Module);") + + +def _assert_issue_651_pk_lookups(executor): + assert _scalar(executor, "MATCH (n:File) RETURN count(n);") == 1 + assert ( + _scalar( + executor, + f"MATCH (n:File {{id: '{_ISSUE_651_FILE_ID}'}}) RETURN count(n);", + ) + == 1 + ) + assert _scalar(executor, "MATCH (n:Function) RETURN count(n);") == 1 + assert ( + _scalar( + executor, + f"MATCH (n:Function {{id: '{_ISSUE_651_FUNCTION_ID}'}}) RETURN count(n);", + ) + == 1 + ) + + +def _run_issue_651_checkpoint_copy_scenario(executor, tmp_path, db_dir): + file_csv = tmp_path / "File.csv" + function_csv = tmp_path / "Function.csv" + _write_csv(file_csv, ["id"], {"id": _ISSUE_651_FILE_ID}) + _write_csv(function_csv, ["id"], {"id": _ISSUE_651_FUNCTION_ID}) + + _create_issue_651_schema(executor) + executor.execute( + f'COPY File FROM "{file_csv}" ' '(header=true, delim=",", escaping=false);' + ) + executor.execute("CHECKPOINT;") + _assert_single_published_checkpoint(db_dir, 1) + assert _scalar(executor, "MATCH (n:File) RETURN count(n);") == 1 + assert ( + _scalar( + executor, + f"MATCH (n:File {{id: '{_ISSUE_651_FILE_ID}'}}) RETURN count(n);", + ) + == 1 + ) + + executor.execute( + f'COPY Function FROM "{function_csv}" ' + '(header=true, delim=",", escaping=false);' + ) + executor.execute("CHECKPOINT;") + _assert_single_published_checkpoint(db_dir, 2) + _assert_issue_651_pk_lookups(executor) + def test_checkpoint(tmp_path): db_dir = str(tmp_path / "test_checkpoint") @@ -118,6 +210,55 @@ def test_dirty_vertex_links_clean_edge_on_close(tmp_path): db.close() +def test_ap_checkpoint_preserves_pk_lookup_after_copy_between_checkpoints(tmp_path): + db_dir = tmp_path / "test_issue_651_ap_checkpoint_pk_lookup" + shutil.rmtree(db_dir, ignore_errors=True) + db = Database(db_path=str(db_dir), mode="w", checkpoint_on_close=False) + conn = db.connect() + try: + _run_issue_651_checkpoint_copy_scenario(conn, tmp_path, db_dir) + finally: + conn.close() + db.close() + + reopened_db = Database(db_path=str(db_dir), mode="w", checkpoint_on_close=False) + reopened_conn = reopened_db.connect() + try: + _assert_issue_651_pk_lookups(reopened_conn) + _assert_single_published_checkpoint(db_dir, 2) + finally: + reopened_conn.close() + reopened_db.close() + + +def test_ap_checkpoint_access_modes_and_explain_profile(tmp_path): + db_dir = tmp_path / "test_checkpoint_access_modes" + db = Database(db_path=str(db_dir), mode="w", checkpoint_on_close=False) + conn = db.connect() + try: + before_explain = _published_checkpoint_dirs(db_dir) + explain = conn.execute("EXPLAIN CHECKPOINT;") + assert explain.has_profile_result() + assert _published_checkpoint_dirs(db_dir) == before_explain + + conn.execute("CHECKPOINT;", access_mode="update") + conn.execute("CHECKPOINT;", access_mode="u") + + for mode in ["read", "r", "insert", "i", "schema", "s"]: + with pytest.raises(Exception, match="CHECKPOINT only accepts"): + conn.execute("CHECKPOINT;", access_mode=mode) + + profile = conn.execute("PROFILE CHECKPOINT;") + assert profile.has_profile_result() + metrics = profile.get_profile_metrics() + assert len(metrics["operators"]) == 1 + assert metrics["operators"][0]["operator_name"] == "Checkpoint" + _assert_single_published_checkpoint(db_dir, 3) + finally: + conn.close() + db.close() + + def test_recreate_vertex(tmp_path): db_dir = str(tmp_path / "test_recreate_vertex") logger.info("Starting test_recreate_vertex") diff --git a/tools/python_bind/tests/test_tp_service.py b/tools/python_bind/tests/test_tp_service.py index c70be9407..604b444ad 100644 --- a/tools/python_bind/tests/test_tp_service.py +++ b/tools/python_bind/tests/test_tp_service.py @@ -1216,6 +1216,140 @@ def test_checkpoint(tmp_path): db.close() +def _single_checkpoint_dir(db_dir, expected_generation): + checkpoints = sorted( + path + for path in db_dir.iterdir() + if path.is_dir() + and path.name.startswith("checkpoint-") + and not path.name.endswith(".next") + ) + assert [path.name for path in checkpoints] == [f"checkpoint-{expected_generation}"] + assert not any(path.name.endswith(".next") for path in db_dir.iterdir()) + return checkpoints[0] + + +def test_tp_checkpoint_rotates_wal_and_resets_timeline(tmp_path, unused_tcp_port): + db_dir = tmp_path / "test_tp_checkpoint_wal_rotation" + db = Database( + db_path=str(db_dir), mode="w", checkpoint_on_close=False, max_thread_num=4 + ) + session = None + try: + endpoint = db.serve( + host="localhost", + port=unused_tcp_port, + blocking=False, + auto_compaction=False, + ) + wait_for_server_ready(endpoint) + session = Session.open(endpoint, timeout="10s") + + session.execute("CREATE NODE TABLE Person(id INT64, PRIMARY KEY(id));") + session.execute("CREATE (:Person {id: 1});") + session.execute("CHECKPOINT;") + first_checkpoint = _single_checkpoint_dir(db_dir, 1) + first_timeline_wals = sorted((first_checkpoint / "wal").glob("*.wal")) + assert len(first_timeline_wals) == 4 + for wal_path in first_timeline_wals: + with wal_path.open("rb") as wal_file: + assert int.from_bytes(wal_file.read(4), "little") == 0 + + session.execute("ALTER TABLE Person ADD name STRING;") + session.execute("MATCH (p:Person {id: 1}) SET p.name = 'one';") + session.execute("CREATE (:Person {id: 2, name: 'two'});") + + assert sorted((first_checkpoint / "wal").glob("*.wal")) == first_timeline_wals + + session.execute("CHECKPOINT;") + current_checkpoint = _single_checkpoint_dir(db_dir, 2) + current_wals = sorted((current_checkpoint / "wal").glob("*.wal")) + assert len(current_wals) == 4 + for wal_path in current_wals: + with wal_path.open("rb") as wal_file: + assert int.from_bytes(wal_file.read(4), "little") == 0 + + # Issue #651 regression: PK index point lookups must survive the + # checkpoint that runs between writes. A plain count() would still + # pass even if the checkpoint dropped the primary key index. + res = session.execute("MATCH (p:Person {id: 2}) RETURN p.name;") + assert len(res) == 1 + assert res[0][0] == "two" + res = session.execute("MATCH (p:Person {id: 1}) RETURN p.name;") + assert len(res) == 1 + assert res[0][0] == "one" + + # The first transaction in the new complete-baseline timeline must use + # timestamp 1 and write only beneath the current generation. + session.execute("CREATE (:Person {id: 3, name: 'three'});") + first_timestamps = [] + for wal_path in current_wals: + with wal_path.open("rb") as wal_file: + first_timestamps.append(int.from_bytes(wal_file.read(4), "little")) + assert sorted(first_timestamps) == [0, 0, 0, 1] + finally: + if session is not None: + session.close() + db.stop_serving() + db.close() + + reopened_db = Database(db_path=str(db_dir), mode="w", checkpoint_on_close=False) + reopened_conn = reopened_db.connect() + try: + rows = list( + reopened_conn.execute("MATCH (p:Person) RETURN p.id, p.name ORDER BY p.id;") + ) + assert rows == [[1, "one"], [2, "two"], [3, "three"]] + + # Issue #651 regression: PK index point lookups must also survive + # close/reopen after the checkpoint-rotated timeline. + for pk, expected_name in ((1, "one"), (2, "two"), (3, "three")): + rows = list( + reopened_conn.execute(f"MATCH (p:Person {{id: {pk}}}) RETURN p.name;") + ) + assert rows == [[expected_name]] + finally: + reopened_conn.close() + reopened_db.close() + + +def test_tp_checkpoint_invalidates_cached_plans_after_label_compaction( + tmp_path, unused_tcp_port +): + db_dir = tmp_path / "test_tp_checkpoint_cache_invalidation" + db = Database( + db_path=str(db_dir), mode="w", checkpoint_on_close=False, max_thread_num=2 + ) + session = None + try: + endpoint = db.serve( + host="localhost", + port=unused_tcp_port, + blocking=False, + auto_compaction=False, + ) + wait_for_server_ready(endpoint) + session = Session.open(endpoint, timeout="10s") + + session.execute("CREATE NODE TABLE A(id INT64, PRIMARY KEY(id));") + session.execute("CREATE NODE TABLE B(id INT64, name STRING, PRIMARY KEY(id));") + session.execute("CREATE (:B {id: 1, name: 'before'});") + session.execute("DROP TABLE A;") + + query = "MATCH (b:B {id: 1}) RETURN b.name;" + assert list(session.execute(query)) == [["before"]] + + # Compacting the schema removes A's tombstone and renumbers B from + # label 1 to label 0. The cached plan must be rebuilt for that schema. + session.execute("CHECKPOINT;") + assert list(session.execute(query)) == [["before"]] + finally: + if session is not None: + session.close() + db.stop_serving() + db.close() + + # --------------------------------------------------------------------------- # Tests merged from test_service.py (embedded + service mode integration) # ---------------------------------------------------------------------------