From 43ddb4c4cfbff8e6163dd0fe0b4d361e7c630c05 Mon Sep 17 00:00:00 2001 From: Enes Hoxha Date: Thu, 29 Jan 2026 15:47:52 +0100 Subject: [PATCH] Add Cortex.States.DuckDb: DuckDB-backed state store Added new Cortex.States.DuckDb project implementing a key-value state store for the Cortex Data Framework using DuckDB as the storage engine. Updated Cortex.sln to include the project under the "States" folder. Provided full documentation (README, Cortex.States.DuckDb.md), NuGet metadata, and project assets (icon, license). Implementation supports custom serialization, batch ops, export, checkpointing, and both in-memory and persistent modes. Added builder/factory patterns and extension methods for easy integration. --- Cortex.sln | 7 + README.md | 3 + docs/Cortex.States.DuckDb.md | 310 ++++++++ src/Cortex.States.DuckDb/Assets/cortex.png | Bin 0 -> 63537 bytes src/Cortex.States.DuckDb/Assets/license.md | 20 + .../Cortex.States.DuckDb.csproj | 54 ++ .../DuckDbKeyValueStateStore.cs | 670 ++++++++++++++++++ .../DuckDbKeyValueStateStoreOptions.cs | 137 ++++ .../DuckDbStateStoreExtensions.cs | 253 +++++++ 9 files changed, 1454 insertions(+) create mode 100644 docs/Cortex.States.DuckDb.md create mode 100644 src/Cortex.States.DuckDb/Assets/cortex.png create mode 100644 src/Cortex.States.DuckDb/Assets/license.md create mode 100644 src/Cortex.States.DuckDb/Cortex.States.DuckDb.csproj create mode 100644 src/Cortex.States.DuckDb/DuckDbKeyValueStateStore.cs create mode 100644 src/Cortex.States.DuckDb/DuckDbKeyValueStateStoreOptions.cs create mode 100644 src/Cortex.States.DuckDb/DuckDbStateStoreExtensions.cs diff --git a/Cortex.sln b/Cortex.sln index ec50673..cca087c 100644 --- a/Cortex.sln +++ b/Cortex.sln @@ -72,6 +72,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Serialization", "Serializat EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cortex.Streams.Mediator", "src\Cortex.Streams.Mediator\Cortex.Streams.Mediator.csproj", "{84410C57-0F59-F31F-B921-4C1F3D3FF144}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cortex.States.DuckDb", "src\Cortex.States.DuckDb\Cortex.States.DuckDb.csproj", "{4FAE6C5E-53EE-4CCE-85A6-B7551A92C488}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -202,6 +204,10 @@ Global {84410C57-0F59-F31F-B921-4C1F3D3FF144}.Debug|Any CPU.Build.0 = Debug|Any CPU {84410C57-0F59-F31F-B921-4C1F3D3FF144}.Release|Any CPU.ActiveCfg = Release|Any CPU {84410C57-0F59-F31F-B921-4C1F3D3FF144}.Release|Any CPU.Build.0 = Release|Any CPU + {4FAE6C5E-53EE-4CCE-85A6-B7551A92C488}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4FAE6C5E-53EE-4CCE-85A6-B7551A92C488}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4FAE6C5E-53EE-4CCE-85A6-B7551A92C488}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4FAE6C5E-53EE-4CCE-85A6-B7551A92C488}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -233,6 +239,7 @@ Global {44A166BD-01E9-4A4B-9BC5-7DE01B472E73} = {1C5D462D-168D-4D3F-B96E-CCE5517DB197} {472BC645-9E2F-4205-A571-4D9184747EC5} = {7F9E0AEA-721E-46F8-90ED-8EA8423647FB} {84410C57-0F59-F31F-B921-4C1F3D3FF144} = {4C68702C-1661-4AD9-83FD-E0B52B791969} + {4FAE6C5E-53EE-4CCE-85A6-B7551A92C488} = {C31F8C0F-8BCF-4959-9BA1-8645D058EAA0} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {E20303B6-8AC9-4FFF-B645-4608309ADA94} diff --git a/README.md b/README.md index f6fa9ee..307a537 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,9 @@ - **Cortex.States.SQLite:** Persistent state storage using SQLite. [![NuGet Version](https://img.shields.io/nuget/v/Cortex.States.SQLite?label=Cortex.States.SQLite)](https://www.nuget.org/packages/Cortex.States.SQLite) +- **Cortex.States.DuckDb:** Persistent state storage using DuckDb. +[![NuGet Version](https://img.shields.io/nuget/v/Cortex.States.DuckDb?label=Cortex.States.DuckDb)](https://www.nuget.org/packages/Cortex.States.DuckDb) + - **Cortex.Telemetry:** Core library to add support for Tracing and Matrics. [![NuGet Version](https://img.shields.io/nuget/v/Cortex.Telemetry?label=Cortex.Telemetry)](https://www.nuget.org/packages/Cortex.Telemetry) diff --git a/docs/Cortex.States.DuckDb.md b/docs/Cortex.States.DuckDb.md new file mode 100644 index 0000000..5c58877 --- /dev/null +++ b/docs/Cortex.States.DuckDb.md @@ -0,0 +1,310 @@ +# Cortex.States.DuckDb + +[![NuGet Version](https://img.shields.io/nuget/v/Cortex.States.DuckDb?label=Cortex.States.DuckDb)](https://www.nuget.org/packages/Cortex.States.DuckDb) + +**Cortex.States.DuckDb** is a state store implementation for the Cortex Data Framework that uses [DuckDB](https://duckdb.org/) as the underlying storage engine. DuckDB is an in-process analytical database management system designed for fast analytical queries, making it an excellent choice for scenarios requiring both transactional state management and analytical capabilities. + +## Features + +- **High-Performance Analytics**: Leverages DuckDB's columnar storage and vectorized query execution +- **In-Memory & Persistent Storage**: Supports both in-memory databases for fast processing and file-based persistence +- **Native Export Capabilities**: Export data directly to Parquet or CSV formats +- **Batch Operations**: Efficient bulk insert and delete operations with transaction support +- **Thread-Safe**: Built-in thread safety for concurrent access +- **Flexible Serialization**: Customizable key and value serialization +- **Fluent Builder API**: Easy configuration through builder pattern + +## Installation + +### Using the .NET CLI + +```bash +dotnet add package Cortex.States.DuckDb +``` + +### Using the Package Manager Console + +```powershell +Install-Package Cortex.States.DuckDb +``` + +## Quick Start + +### Basic Usage + +```csharp +using Cortex.States.DuckDb; + +// Create a persistent DuckDB state store +var stateStore = new DuckDbKeyValueStateStore( + name: "MyStateStore", + databasePath: "./data/mystore.duckdb", + tableName: "KeyValueStore" +); + +// Store values +stateStore.Put("counter", 42); +stateStore.Put("total", 100); + +// Retrieve values +var counter = stateStore.Get("counter"); // Returns 42 + +// Check if key exists +if (stateStore.ContainsKey("counter")) +{ + Console.WriteLine("Counter exists!"); +} + +// Remove a value +stateStore.Remove("counter"); + +// Get all keys +foreach (var key in stateStore.GetKeys()) +{ + Console.WriteLine($"Key: {key}"); +} + +// Don't forget to dispose +stateStore.Dispose(); +``` + +### Using the Fluent Builder + +```csharp +using Cortex.States.DuckDb; + +// Create store using fluent builder +var stateStore = DuckDbKeyValueStateStoreBuilder + .Create("OrderStore") + .WithDatabasePath("./data/orders.duckdb") + .WithTableName("Orders") + .WithIndex(true) + .WithMaxMemory("2GB") + .WithThreads(4) + .Build(); + +// Use the store +stateStore.Put("ORD-001", new OrderSummary { Total = 99.99m, Status = "Completed" }); +``` + +### In-Memory Database + +```csharp +using Cortex.States.DuckDb; + +// Create an in-memory store for fast processing +var inMemoryStore = DuckDbKeyValueStateStoreBuilder + .Create("TemporaryStore") + .UseInMemory() + .WithTableName("TempData") + .Build(); + +// Perfect for temporary computations +inMemoryStore.Put("sum", 1234.56m); +``` + +### Using with Options + +```csharp +using Cortex.States.DuckDb; + +// Create options for fine-grained control +var options = new DuckDbKeyValueStateStoreOptions +{ + DatabasePath = "./data/analytics.duckdb", + TableName = "AnalyticsState", + CreateIndex = true, + MaxMemory = "4GB", + Threads = 8, + AccessMode = DuckDbAccessMode.ReadWrite +}; + +var stateStore = new DuckDbKeyValueStateStore( + name: "AnalyticsStore", + options: options +); +``` + +### Factory Methods + +```csharp +using Cortex.States.DuckDb; + +// Quick creation methods +var persistentStore = DuckDbStateStoreExtensions + .CreatePersistentDuckDbStore("ProductStore", "./data/products.duckdb", "Products"); + +var inMemoryStore = DuckDbStateStoreExtensions + .CreateInMemoryDuckDbStore("SessionStore", "Sessions"); +``` + +## Advanced Features + +### Batch Operations + +```csharp +// Efficient bulk insert +var items = new List> +{ + new("price-1", 10.99m), + new("price-2", 20.99m), + new("price-3", 30.99m) +}; + +stateStore.PutMany(items); + +// Bulk delete +stateStore.RemoveMany(new[] { "price-1", "price-2" }); +``` + +### Export to Parquet/CSV + +DuckDB has native support for Parquet and CSV formats, making data export seamless: + +```csharp +// Export to Parquet (ideal for analytics) +stateStore.ExportToParquet("./exports/state-backup.parquet"); + +// Export to CSV (ideal for data sharing) +stateStore.ExportToCsv("./exports/state-backup.csv"); +``` + +### Count and Clear + +```csharp +// Get total count +var count = stateStore.Count(); +Console.WriteLine($"Total items: {count}"); + +// Clear all items +stateStore.Clear(); +``` + +### Checkpoint + +For persistent databases, you can force a checkpoint to ensure all data is written to disk: + +```csharp +stateStore.Checkpoint(); +``` + +## Integration with Cortex Streams + +Use DuckDB state store with Cortex Streams for stateful stream processing: + +```csharp +using Cortex.Streams; +using Cortex.States.DuckDb; + +// Create the state store +var stateStore = new DuckDbKeyValueStateStore( + name: "WordCountStore", + databasePath: "./data/wordcount.duckdb", + tableName: "WordCounts" +); + +// Use in a stream pipeline +var stream = StreamBuilder.CreateNewStream("WordCountStream") + .Stream() + .FlatMap(line => line.Split(' ')) + .GroupBy(word => word) + .Aggregate( + stateStore, + (count, word) => count + 1, + initialValue: 0) + .Sink(result => Console.WriteLine($"{result.Key}: {result.Value}")) + .Build(); + +stream.Start(); +``` + +## Custom Serialization + +You can provide custom serializers for complex types: + +```csharp +using System.Text.Json; + +var stateStore = new DuckDbKeyValueStateStore( + name: "ComplexStore", + databasePath: "./data/complex.duckdb", + tableName: "ComplexData", + keySerializer: key => key.ToString(), + keyDeserializer: str => Guid.Parse(str), + valueSerializer: value => JsonSerializer.Serialize(value), + valueDeserializer: str => JsonSerializer.Deserialize(str)! +); +``` + +## Configuration Options + +| Option | Description | Default | +|--------|-------------|---------| +| `DatabasePath` | Path to the DuckDB database file. Use `:memory:` for in-memory | Required | +| `TableName` | Name of the table for key-value storage | Required | +| `UseInMemory` | Use in-memory database instead of file | `false` | +| `CreateIndex` | Create index on key column for faster lookups | `true` | +| `MaxMemory` | Maximum memory limit (e.g., "1GB", "512MB") | Auto | +| `Threads` | Number of threads (0 = auto) | `0` | +| `AccessMode` | Database access mode (Automatic, ReadWrite, ReadOnly) | `Automatic` | + +## When to Use DuckDB State Store + +DuckDB is particularly well-suited for: + +- **Analytical workloads**: When you need to run analytical queries on your state +- **Large datasets**: Efficient columnar storage for large amounts of data +- **Data export requirements**: Native Parquet/CSV export capabilities +- **Embedded analytics**: In-process database without external dependencies +- **Temporary processing**: Fast in-memory mode for intermediate computations + +Consider other state stores when: + +- You need distributed state across multiple nodes (use Cassandra, MongoDB) +- You require extreme write throughput (use RocksDB) +- You need full ACID transactions across multiple operations (use PostgreSQL, SQL Server) + +## Thread Safety + +The `DuckDbKeyValueStateStore` is thread-safe and can be used concurrently from multiple threads. For in-memory databases, a persistent connection is maintained to ensure data consistency. + +## Error Handling + +```csharp +try +{ + var value = stateStore.Get("non-existent-key"); + if (value == null) + { + Console.WriteLine("Key not found"); + } +} +catch (InvalidOperationException ex) +{ + Console.WriteLine($"Store not initialized: {ex.Message}"); +} +``` + +## Best Practices + +1. **Dispose properly**: Always dispose of the state store when done to release resources +2. **Use batch operations**: For bulk inserts/deletes, use `PutMany` and `RemoveMany` +3. **Choose appropriate storage**: Use in-memory for temporary data, file-based for persistence +4. **Set memory limits**: Configure `MaxMemory` for large datasets to prevent excessive memory usage +5. **Regular checkpoints**: Call `Checkpoint()` periodically for critical data in persistent mode + +## Requirements + +- .NET 7.0 or later +- DuckDB.NET.Data package (automatically included) + +## License + +MIT License - see the [license file](../src/Cortex.States.DuckDb/Assets/license.md) for details. + +## Related Packages + +- [Cortex.States](https://www.nuget.org/packages/Cortex.States) - Core state management +- [Cortex.States.RocksDb](https://www.nuget.org/packages/Cortex.States.RocksDb) - RocksDB state store +- [Cortex.States.SQLite](https://www.nuget.org/packages/Cortex.States.SQLite) - SQLite state store +- [Cortex.Streams](https://www.nuget.org/packages/Cortex.Streams) - Core streaming capabilities diff --git a/src/Cortex.States.DuckDb/Assets/cortex.png b/src/Cortex.States.DuckDb/Assets/cortex.png new file mode 100644 index 0000000000000000000000000000000000000000..101a1fb10887915ba6cd81f7493120090cfab590 GIT binary patch literal 63537 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 V*Ppl8Hi;(v?^jgV{@+>5{|{p6e{lc+ literal 0 HcmV?d00001 diff --git a/src/Cortex.States.DuckDb/Assets/license.md b/src/Cortex.States.DuckDb/Assets/license.md new file mode 100644 index 0000000..caa98b4 --- /dev/null +++ b/src/Cortex.States.DuckDb/Assets/license.md @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2026 Buildersoft + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/src/Cortex.States.DuckDb/Cortex.States.DuckDb.csproj b/src/Cortex.States.DuckDb/Cortex.States.DuckDb.csproj new file mode 100644 index 0000000..dfca926 --- /dev/null +++ b/src/Cortex.States.DuckDb/Cortex.States.DuckDb.csproj @@ -0,0 +1,54 @@ + + + + net9.0;net8.0;net7.0 + + 3.0.0 + 3.0.0 + Buildersoft Cortex Framework + Buildersoft + Buildersoft,EnesHoxha + Copyright © Buildersoft 2026 + + Cortex Data Framework is a robust, extensible platform designed to facilitate real-time data streaming, processing, and state management. It provides developers with a comprehensive suite of tools and libraries to build scalable, high-performance data pipelines tailored to diverse use cases. By abstracting underlying streaming technologies and state management solutions, Cortex Data Framework enables seamless integration, simplified development workflows, and enhanced maintainability for complex data-driven applications. + + https://github.com/buildersoftio/cortex + cortex mediator eda streaming distributed streams states duckdb analytics olap + + 3.0.0 + license.md + cortex.png + Cortex.States.DuckDb + True + True + True + + Just as the Cortex in our brains handles complex processing efficiently, Cortex Data Framework brings brainpower to your data management! + https://buildersoft.io/ + README.md + + + + + True + \ + + + True + + + + True + + + + + + + + + + + + + diff --git a/src/Cortex.States.DuckDb/DuckDbKeyValueStateStore.cs b/src/Cortex.States.DuckDb/DuckDbKeyValueStateStore.cs new file mode 100644 index 0000000..116801a --- /dev/null +++ b/src/Cortex.States.DuckDb/DuckDbKeyValueStateStore.cs @@ -0,0 +1,670 @@ +using DuckDB.NET.Data; +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Cortex.States.DuckDb +{ + /// + /// A key-value state store implementation backed by DuckDB. + /// DuckDB is an in-process analytical database management system designed for fast analytics. + /// + /// The type of keys in the store. + /// The type of values in the store. + public class DuckDbKeyValueStateStore : IDataStore, IDisposable + { + private readonly string _connectionString; + private readonly string _tableName; + private readonly Func _keySerializer; + private readonly Func _valueSerializer; + private readonly Func _keyDeserializer; + private readonly Func _valueDeserializer; + private readonly DuckDbKeyValueStateStoreOptions _options; + private readonly DuckDBConnection _persistentConnection; + private readonly object _connectionLock = new object(); + + private static readonly SemaphoreSlim _initializationLock = new SemaphoreSlim(1, 1); + private volatile bool _isInitialized; + private bool _disposed; + + /// + /// Gets the name of the state store. + /// + public string Name { get; } + + /// + /// Initializes a new instance of the DuckDbKeyValueStateStore. + /// + /// A friendly name for the store. + /// + /// The file path to the DuckDB database. + /// Use ":memory:" for an in-memory database, or provide a file path for persistence. + /// + /// The name of the table to use for storing state entries. + /// Optional key serializer. If not provided, JSON serialization is used. + /// Optional value serializer. If not provided, JSON serialization is used. + /// Optional key deserializer. If not provided, JSON deserialization is used. + /// Optional value deserializer. If not provided, JSON deserialization is used. + public DuckDbKeyValueStateStore( + string name, + string databasePath, + string tableName, + Func keySerializer = null, + Func valueSerializer = null, + Func keyDeserializer = null, + Func valueDeserializer = null) + : this(name, new DuckDbKeyValueStateStoreOptions + { + DatabasePath = databasePath, + TableName = tableName + }, keySerializer, valueSerializer, keyDeserializer, valueDeserializer) + { + } + + /// + /// Initializes a new instance of the DuckDbKeyValueStateStore with options. + /// + /// A friendly name for the store. + /// Configuration options for the DuckDB state store. + /// Optional key serializer. If not provided, JSON serialization is used. + /// Optional value serializer. If not provided, JSON serialization is used. + /// Optional key deserializer. If not provided, JSON deserialization is used. + /// Optional value deserializer. If not provided, JSON deserialization is used. + public DuckDbKeyValueStateStore( + string name, + DuckDbKeyValueStateStoreOptions options, + Func keySerializer = null, + Func valueSerializer = null, + Func keyDeserializer = null, + Func valueDeserializer = null) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentNullException(nameof(name)); + if (options == null) + throw new ArgumentNullException(nameof(options)); + if (string.IsNullOrWhiteSpace(options.DatabasePath)) + throw new ArgumentException("DatabasePath is required", nameof(options)); + if (string.IsNullOrWhiteSpace(options.TableName)) + throw new ArgumentException("TableName is required", nameof(options)); + + Name = name; + _options = options; + _tableName = options.TableName; + + // Build connection string + _connectionString = BuildConnectionString(options); + + // Assign custom or default (JSON-based) serializers/deserializers + _keySerializer = keySerializer ?? (key => JsonSerializer.Serialize(key)); + _valueSerializer = valueSerializer ?? (value => JsonSerializer.Serialize(value)); + _keyDeserializer = keyDeserializer ?? (str => JsonSerializer.Deserialize(str)); + _valueDeserializer = valueDeserializer ?? (str => JsonSerializer.Deserialize(str)); + + // Create a persistent connection for in-memory databases + if (options.UseInMemory || options.DatabasePath == ":memory:") + { + _persistentConnection = new DuckDBConnection(_connectionString); + _persistentConnection.Open(); + } + + // Initialize the table + InitializeAsync().GetAwaiter().GetResult(); + } + + private static string BuildConnectionString(DuckDbKeyValueStateStoreOptions options) + { + if (options.UseInMemory || options.DatabasePath == ":memory:") + { + return "DataSource=:memory:"; + } + + return $"DataSource={options.DatabasePath}"; + } + + private DuckDBConnection GetConnection() + { + if (_persistentConnection != null) + { + return _persistentConnection; + } + + var connection = new DuckDBConnection(_connectionString); + connection.Open(); + return connection; + } + + private void ReleaseConnection(DuckDBConnection connection) + { + // Only close and dispose if it's not the persistent connection + if (connection != _persistentConnection) + { + connection.Close(); + connection.Dispose(); + } + } + + private async Task InitializeAsync() + { + if (_isInitialized) return; + + await _initializationLock.WaitAsync().ConfigureAwait(false); + try + { + if (_isInitialized) return; + + var connection = GetConnection(); + try + { + // Create the table if it does not exist + var createTableSql = $@" + CREATE TABLE IF NOT EXISTS ""{_tableName}"" ( + key VARCHAR PRIMARY KEY, + value VARCHAR + );"; + + using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = createTableSql; + await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); + } + + // Create index for faster lookups if configured + if (_options.CreateIndex) + { + var createIndexSql = $@" + CREATE INDEX IF NOT EXISTS idx_{_tableName}_key + ON ""{_tableName}"" (key);"; + + using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = createIndexSql; + await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); + } + } + } + finally + { + ReleaseConnection(connection); + } + + _isInitialized = true; + } + finally + { + _initializationLock.Release(); + } + } + + private void EnsureInitialized() + { + if (!_isInitialized) + { + throw new InvalidOperationException("DuckDbKeyValueStateStore is not properly initialized."); + } + } + + /// + /// Gets the value associated with the specified key. + /// + /// The key of the value to get. + /// The value associated with the specified key, or default if the key is not found. + public TValue Get(TKey key) + { + EnsureInitialized(); + + var serializedKey = _keySerializer(key); + var connection = GetConnection(); + + try + { + var sql = $@"SELECT value FROM ""{_tableName}"" WHERE key = $key;"; + using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = sql; + cmd.Parameters.Add(new DuckDBParameter("key", serializedKey)); + + var result = cmd.ExecuteScalar(); + if (result == null || result == DBNull.Value) + return default; + + return _valueDeserializer(result.ToString()); + } + } + finally + { + ReleaseConnection(connection); + } + } + + /// + /// Adds or updates the value associated with the specified key. + /// + /// The key of the value to add or update. + /// The value to add or update. + public void Put(TKey key, TValue value) + { + EnsureInitialized(); + + var serializedKey = _keySerializer(key); + var serializedValue = _valueSerializer(value); + var connection = GetConnection(); + + try + { + // DuckDB supports INSERT OR REPLACE syntax + var sql = $@" + INSERT OR REPLACE INTO ""{_tableName}"" (key, value) + VALUES ($key, $value);"; + + using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = sql; + cmd.Parameters.Add(new DuckDBParameter("key", serializedKey)); + cmd.Parameters.Add(new DuckDBParameter("value", serializedValue)); + cmd.ExecuteNonQuery(); + } + } + finally + { + ReleaseConnection(connection); + } + } + + /// + /// Determines whether the store contains the specified key. + /// + /// The key to locate in the store. + /// true if the store contains an element with the specified key; otherwise, false. + public bool ContainsKey(TKey key) + { + EnsureInitialized(); + + var serializedKey = _keySerializer(key); + var connection = GetConnection(); + + try + { + var sql = $@"SELECT COUNT(*) FROM ""{_tableName}"" WHERE key = $key;"; + using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = sql; + cmd.Parameters.Add(new DuckDBParameter("key", serializedKey)); + + var count = Convert.ToInt64(cmd.ExecuteScalar()); + return count > 0; + } + } + finally + { + ReleaseConnection(connection); + } + } + + /// + /// Removes the value with the specified key from the store. + /// + /// The key of the element to remove. + public void Remove(TKey key) + { + EnsureInitialized(); + + var serializedKey = _keySerializer(key); + var connection = GetConnection(); + + try + { + var sql = $@"DELETE FROM ""{_tableName}"" WHERE key = $key;"; + using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = sql; + cmd.Parameters.Add(new DuckDBParameter("key", serializedKey)); + cmd.ExecuteNonQuery(); + } + } + finally + { + ReleaseConnection(connection); + } + } + + /// + /// Returns all key-value pairs in the store. + /// + /// An enumerable of all key-value pairs in the store. + public IEnumerable> GetAll() + { + EnsureInitialized(); + + var results = new List>(); + var connection = GetConnection(); + + try + { + var sql = $@"SELECT key, value FROM ""{_tableName}"";"; + using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = sql; + using (var reader = cmd.ExecuteReader()) + { + while (reader.Read()) + { + var serializedKey = reader.GetString(0); + var serializedValue = reader.IsDBNull(1) ? null : reader.GetString(1); + + var key = _keyDeserializer(serializedKey); + var value = serializedValue == null ? default : _valueDeserializer(serializedValue); + + results.Add(new KeyValuePair(key, value)); + } + } + } + + return results; + } + finally + { + ReleaseConnection(connection); + } + } + + /// + /// Returns all keys in the store. + /// + /// An enumerable of all keys in the store. + public IEnumerable GetKeys() + { + EnsureInitialized(); + + var results = new List(); + var connection = GetConnection(); + + try + { + var sql = $@"SELECT key FROM ""{_tableName}"";"; + using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = sql; + using (var reader = cmd.ExecuteReader()) + { + while (reader.Read()) + { + var serializedKey = reader.GetString(0); + results.Add(_keyDeserializer(serializedKey)); + } + } + } + + return results; + } + finally + { + ReleaseConnection(connection); + } + } + + /// + /// Adds or updates multiple key-value pairs in a batch operation. + /// + /// The key-value pairs to add or update. + public void PutMany(IEnumerable> items) + { + EnsureInitialized(); + + var connection = GetConnection(); + + try + { + using (var transaction = connection.BeginTransaction()) + { + try + { + foreach (var item in items) + { + var serializedKey = _keySerializer(item.Key); + var serializedValue = _valueSerializer(item.Value); + + var sql = $@" + INSERT OR REPLACE INTO ""{_tableName}"" (key, value) + VALUES ($key, $value);"; + + using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = sql; + cmd.Transaction = transaction; + cmd.Parameters.Add(new DuckDBParameter("key", serializedKey)); + cmd.Parameters.Add(new DuckDBParameter("value", serializedValue)); + cmd.ExecuteNonQuery(); + } + } + + transaction.Commit(); + } + catch + { + transaction.Rollback(); + throw; + } + } + } + finally + { + ReleaseConnection(connection); + } + } + + /// + /// Removes multiple keys from the store in a batch operation. + /// + /// The keys to remove. + public void RemoveMany(IEnumerable keys) + { + EnsureInitialized(); + + var connection = GetConnection(); + + try + { + using (var transaction = connection.BeginTransaction()) + { + try + { + foreach (var key in keys) + { + var serializedKey = _keySerializer(key); + + var sql = $@"DELETE FROM ""{_tableName}"" WHERE key = $key;"; + using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = sql; + cmd.Transaction = transaction; + cmd.Parameters.Add(new DuckDBParameter("key", serializedKey)); + cmd.ExecuteNonQuery(); + } + } + + transaction.Commit(); + } + catch + { + transaction.Rollback(); + throw; + } + } + } + finally + { + ReleaseConnection(connection); + } + } + + /// + /// Gets the count of items in the store. + /// + /// The number of items in the store. + public long Count() + { + EnsureInitialized(); + + var connection = GetConnection(); + + try + { + var sql = $@"SELECT COUNT(*) FROM ""{_tableName}"";"; + using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = sql; + return Convert.ToInt64(cmd.ExecuteScalar()); + } + } + finally + { + ReleaseConnection(connection); + } + } + + /// + /// Clears all items from the store. + /// + public void Clear() + { + EnsureInitialized(); + + var connection = GetConnection(); + + try + { + var sql = $@"DELETE FROM ""{_tableName}"";"; + using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = sql; + cmd.ExecuteNonQuery(); + } + } + finally + { + ReleaseConnection(connection); + } + } + + /// + /// Exports the state store data to a Parquet file. + /// DuckDB has native support for Parquet format. + /// + /// The path to the Parquet file to create. + public void ExportToParquet(string filePath) + { + EnsureInitialized(); + + var connection = GetConnection(); + + try + { + var sql = $@"COPY ""{_tableName}"" TO '{filePath}' (FORMAT PARQUET);"; + using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = sql; + cmd.ExecuteNonQuery(); + } + } + finally + { + ReleaseConnection(connection); + } + } + + /// + /// Exports the state store data to a CSV file. + /// + /// The path to the CSV file to create. + public void ExportToCsv(string filePath) + { + EnsureInitialized(); + + var connection = GetConnection(); + + try + { + var sql = $@"COPY ""{_tableName}"" TO '{filePath}' (FORMAT CSV, HEADER);"; + using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = sql; + cmd.ExecuteNonQuery(); + } + } + finally + { + ReleaseConnection(connection); + } + } + + /// + /// Creates a checkpoint to ensure all data is written to disk. + /// Only applicable for persistent databases. + /// + public void Checkpoint() + { + if (_options.UseInMemory || _options.DatabasePath == ":memory:") + { + return; // No checkpoint needed for in-memory databases + } + + var connection = GetConnection(); + + try + { + using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = "CHECKPOINT;"; + cmd.ExecuteNonQuery(); + } + } + finally + { + ReleaseConnection(connection); + } + } + + /// + /// Releases all resources used by the DuckDbKeyValueStateStore. + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Releases the unmanaged resources used by the DuckDbKeyValueStateStore and optionally releases the managed resources. + /// + /// true to release both managed and unmanaged resources; false to release only unmanaged resources. + protected virtual void Dispose(bool disposing) + { + if (_disposed) + return; + + if (disposing) + { + try + { + // Create checkpoint before closing for persistent databases + if (!_options.UseInMemory && _options.DatabasePath != ":memory:") + { + Checkpoint(); + } + } + catch + { + // Ignore checkpoint errors during disposal + } + + _persistentConnection?.Close(); + _persistentConnection?.Dispose(); + _initializationLock?.Dispose(); + } + + _disposed = true; + } + } +} diff --git a/src/Cortex.States.DuckDb/DuckDbKeyValueStateStoreOptions.cs b/src/Cortex.States.DuckDb/DuckDbKeyValueStateStoreOptions.cs new file mode 100644 index 0000000..b6bd9b0 --- /dev/null +++ b/src/Cortex.States.DuckDb/DuckDbKeyValueStateStoreOptions.cs @@ -0,0 +1,137 @@ +using System; + +namespace Cortex.States.DuckDb +{ + /// + /// Configuration options for the DuckDB key-value state store. + /// + public class DuckDbKeyValueStateStoreOptions + { + /// + /// Gets or sets the path to the DuckDB database file. + /// Use ":memory:" for an in-memory database. + /// + /// + /// "./data/mystore.duckdb" for a file-based database + /// ":memory:" for an in-memory database + /// + public string DatabasePath { get; set; } + + /// + /// Gets or sets the name of the table to use for storing key-value pairs. + /// + public string TableName { get; set; } + + /// + /// Gets or sets a value indicating whether to use an in-memory database. + /// When true, the DatabasePath is ignored and ":memory:" is used. + /// Default is false. + /// + public bool UseInMemory { get; set; } = false; + + /// + /// Gets or sets a value indicating whether to create an index on the key column. + /// Improves lookup performance for large datasets. + /// Default is true. + /// + public bool CreateIndex { get; set; } = true; + + /// + /// Gets or sets the number of threads DuckDB should use. + /// Set to 0 to use all available threads. + /// Default is 0 (auto). + /// + public int Threads { get; set; } = 0; + + /// + /// Gets or sets the maximum memory limit for DuckDB. + /// Examples: "1GB", "512MB", "2GB" + /// Leave null for default (80% of system memory). + /// + public string MaxMemory { get; set; } + + /// + /// Gets or sets a value indicating whether to enable object cache. + /// Improves performance for repeated queries. + /// Default is true. + /// + public bool EnableObjectCache { get; set; } = true; + + /// + /// Gets or sets the access mode for the database. + /// + public DuckDbAccessMode AccessMode { get; set; } = DuckDbAccessMode.Automatic; + + /// + /// Creates a new instance of DuckDbKeyValueStateStoreOptions for an in-memory database. + /// + /// The name of the table to use for storing key-value pairs. + /// A new options instance configured for in-memory use. + public static DuckDbKeyValueStateStoreOptions InMemory(string tableName) + { + if (string.IsNullOrWhiteSpace(tableName)) + throw new ArgumentNullException(nameof(tableName)); + + return new DuckDbKeyValueStateStoreOptions + { + DatabasePath = ":memory:", + TableName = tableName, + UseInMemory = true + }; + } + + /// + /// Creates a new instance of DuckDbKeyValueStateStoreOptions for a file-based database. + /// + /// The path to the DuckDB database file. + /// The name of the table to use for storing key-value pairs. + /// A new options instance configured for file-based persistence. + public static DuckDbKeyValueStateStoreOptions Persistent(string databasePath, string tableName) + { + if (string.IsNullOrWhiteSpace(databasePath)) + throw new ArgumentNullException(nameof(databasePath)); + if (string.IsNullOrWhiteSpace(tableName)) + throw new ArgumentNullException(nameof(tableName)); + + return new DuckDbKeyValueStateStoreOptions + { + DatabasePath = databasePath, + TableName = tableName, + UseInMemory = false + }; + } + + /// + /// Validates the options and throws if they are invalid. + /// + public void Validate() + { + if (string.IsNullOrWhiteSpace(TableName)) + throw new ArgumentException("TableName is required", nameof(TableName)); + + if (!UseInMemory && string.IsNullOrWhiteSpace(DatabasePath)) + throw new ArgumentException("DatabasePath is required when not using in-memory mode", nameof(DatabasePath)); + } + } + + /// + /// Specifies the access mode for the DuckDB database. + /// + public enum DuckDbAccessMode + { + /// + /// DuckDB automatically determines the access mode. + /// + Automatic = 0, + + /// + /// Opens the database in read-write mode. + /// + ReadWrite = 1, + + /// + /// Opens the database in read-only mode. + /// + ReadOnly = 2 + } +} diff --git a/src/Cortex.States.DuckDb/DuckDbStateStoreExtensions.cs b/src/Cortex.States.DuckDb/DuckDbStateStoreExtensions.cs new file mode 100644 index 0000000..ec066df --- /dev/null +++ b/src/Cortex.States.DuckDb/DuckDbStateStoreExtensions.cs @@ -0,0 +1,253 @@ +using System; + +namespace Cortex.States.DuckDb +{ + /// + /// Extension methods for creating and configuring DuckDB state stores. + /// + public static class DuckDbStateStoreExtensions + { + /// + /// Creates a new DuckDB key-value state store with a persistent database. + /// + /// The type of keys in the store. + /// The type of values in the store. + /// A friendly name for the store. + /// The path to the DuckDB database file. + /// The name of the table to use for storing key-value pairs. + /// A new DuckDB key-value state store instance. + public static DuckDbKeyValueStateStore CreateDuckDbStore( + string name, + string databasePath, + string tableName) + { + return new DuckDbKeyValueStateStore(name, databasePath, tableName); + } + + /// + /// Creates a new DuckDB key-value state store with configuration options. + /// + /// The type of keys in the store. + /// The type of values in the store. + /// A friendly name for the store. + /// An action to configure the options. + /// A new DuckDB key-value state store instance. + public static DuckDbKeyValueStateStore CreateDuckDbStore( + string name, + Action configureOptions) + { + var options = new DuckDbKeyValueStateStoreOptions(); + configureOptions(options); + options.Validate(); + + return new DuckDbKeyValueStateStore(name, options); + } + + /// + /// Creates a new in-memory DuckDB key-value state store. + /// + /// The type of keys in the store. + /// The type of values in the store. + /// A friendly name for the store. + /// The name of the table to use for storing key-value pairs. + /// A new in-memory DuckDB key-value state store instance. + public static DuckDbKeyValueStateStore CreateInMemoryDuckDbStore( + string name, + string tableName) + { + var options = DuckDbKeyValueStateStoreOptions.InMemory(tableName); + return new DuckDbKeyValueStateStore(name, options); + } + + /// + /// Creates a new persistent DuckDB key-value state store. + /// + /// The type of keys in the store. + /// The type of values in the store. + /// A friendly name for the store. + /// The path to the DuckDB database file. + /// The name of the table to use for storing key-value pairs. + /// A new persistent DuckDB key-value state store instance. + public static DuckDbKeyValueStateStore CreatePersistentDuckDbStore( + string name, + string databasePath, + string tableName) + { + var options = DuckDbKeyValueStateStoreOptions.Persistent(databasePath, tableName); + return new DuckDbKeyValueStateStore(name, options); + } + } + + /// + /// Builder class for creating DuckDB key-value state stores with fluent configuration. + /// + /// The type of keys in the store. + /// The type of values in the store. + public class DuckDbKeyValueStateStoreBuilder + { + private string _name; + private readonly DuckDbKeyValueStateStoreOptions _options = new DuckDbKeyValueStateStoreOptions(); + private Func _keySerializer; + private Func _valueSerializer; + private Func _keyDeserializer; + private Func _valueDeserializer; + + /// + /// Creates a new builder instance. + /// + /// The name of the state store. + public DuckDbKeyValueStateStoreBuilder(string name) + { + _name = name ?? throw new ArgumentNullException(nameof(name)); + } + + /// + /// Creates a new builder for a DuckDB key-value state store. + /// + /// The name of the state store. + /// A new builder instance. + public static DuckDbKeyValueStateStoreBuilder Create(string name) + { + return new DuckDbKeyValueStateStoreBuilder(name); + } + + /// + /// Configures the store to use an in-memory database. + /// + /// The builder instance for chaining. + public DuckDbKeyValueStateStoreBuilder UseInMemory() + { + _options.UseInMemory = true; + _options.DatabasePath = ":memory:"; + return this; + } + + /// + /// Configures the store to use a persistent database at the specified path. + /// + /// The path to the DuckDB database file. + /// The builder instance for chaining. + public DuckDbKeyValueStateStoreBuilder WithDatabasePath(string databasePath) + { + _options.DatabasePath = databasePath ?? throw new ArgumentNullException(nameof(databasePath)); + _options.UseInMemory = false; + return this; + } + + /// + /// Configures the table name to use for storing key-value pairs. + /// + /// The name of the table. + /// The builder instance for chaining. + public DuckDbKeyValueStateStoreBuilder WithTableName(string tableName) + { + _options.TableName = tableName ?? throw new ArgumentNullException(nameof(tableName)); + return this; + } + + /// + /// Configures whether to create an index on the key column. + /// + /// true to create an index; otherwise, false. + /// The builder instance for chaining. + public DuckDbKeyValueStateStoreBuilder WithIndex(bool createIndex = true) + { + _options.CreateIndex = createIndex; + return this; + } + + /// + /// Configures the maximum memory limit for DuckDB. + /// + /// The maximum memory limit (e.g., "1GB", "512MB"). + /// The builder instance for chaining. + public DuckDbKeyValueStateStoreBuilder WithMaxMemory(string maxMemory) + { + _options.MaxMemory = maxMemory; + return this; + } + + /// + /// Configures the number of threads DuckDB should use. + /// + /// The number of threads. Use 0 for auto-detect. + /// The builder instance for chaining. + public DuckDbKeyValueStateStoreBuilder WithThreads(int threads) + { + _options.Threads = threads; + return this; + } + + /// + /// Configures the access mode for the database. + /// + /// The access mode. + /// The builder instance for chaining. + public DuckDbKeyValueStateStoreBuilder WithAccessMode(DuckDbAccessMode accessMode) + { + _options.AccessMode = accessMode; + return this; + } + + /// + /// Configures a custom key serializer. + /// + /// The key serializer function. + /// The builder instance for chaining. + public DuckDbKeyValueStateStoreBuilder WithKeySerializer(Func serializer) + { + _keySerializer = serializer; + return this; + } + + /// + /// Configures a custom value serializer. + /// + /// The value serializer function. + /// The builder instance for chaining. + public DuckDbKeyValueStateStoreBuilder WithValueSerializer(Func serializer) + { + _valueSerializer = serializer; + return this; + } + + /// + /// Configures a custom key deserializer. + /// + /// The key deserializer function. + /// The builder instance for chaining. + public DuckDbKeyValueStateStoreBuilder WithKeyDeserializer(Func deserializer) + { + _keyDeserializer = deserializer; + return this; + } + + /// + /// Configures a custom value deserializer. + /// + /// The value deserializer function. + /// The builder instance for chaining. + public DuckDbKeyValueStateStoreBuilder WithValueDeserializer(Func deserializer) + { + _valueDeserializer = deserializer; + return this; + } + + /// + /// Builds and returns the configured DuckDB key-value state store. + /// + /// A new DuckDB key-value state store instance. + public DuckDbKeyValueStateStore Build() + { + _options.Validate(); + + return new DuckDbKeyValueStateStore( + _name, + _options, + _keySerializer, + _valueSerializer, + _keyDeserializer, + _valueDeserializer); + } + } +}