diff --git a/Directory.Build.props b/Directory.Build.props index ed73ab1d..eb286756 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -65,6 +65,22 @@ + + + false + + + + RELEASE-NOTE.md + + + + + True + + + + false diff --git a/DuckDB.NET.Bindings/Bindings.csproj b/DuckDB.NET.Bindings/Bindings.csproj index 82bad60b..927d520c 100644 --- a/DuckDB.NET.Bindings/Bindings.csproj +++ b/DuckDB.NET.Bindings/Bindings.csproj @@ -18,6 +18,11 @@ enable + + true + $(NoWarn);1591 + + $(Description) $(NoNativeText) @@ -36,7 +41,7 @@ <_NativeLib Include="linux-x64" LibUrl="$(DuckDbArtifactRoot)/duckdb-binaries-linux-amd64.zip" InnerZipName="libduckdb-linux-amd64.zip" Condition="$([MSBuild]::IsOSPlatform('Linux'))" /> <_NativeLib Include="osx" LibUrl="$(DuckDbArtifactRoot)/duckdb-binaries-osx.zip" InnerZipName="libduckdb-osx-universal.zip" Condition="$([MSBuild]::IsOSPlatform('OSX'))" /> - + diff --git a/DuckDB.NET.Bindings/DownloadNativeLibs.targets b/DuckDB.NET.Bindings/DownloadNativeLibs.targets index b344ff87..4ddbb86b 100644 --- a/DuckDB.NET.Bindings/DownloadNativeLibs.targets +++ b/DuckDB.NET.Bindings/DownloadNativeLibs.targets @@ -1,5 +1,10 @@  + + 5 + 1000 + + @@ -18,8 +23,8 @@ diff --git a/DuckDB.NET.Bindings/DuckDBNativeObjects.cs b/DuckDB.NET.Bindings/DuckDBNativeObjects.cs index 9dba9689..6ce5311a 100644 --- a/DuckDB.NET.Bindings/DuckDBNativeObjects.cs +++ b/DuckDB.NET.Bindings/DuckDBNativeObjects.cs @@ -1,4 +1,6 @@ -namespace DuckDB.NET.Native; +using System.ComponentModel; + +namespace DuckDB.NET.Native; public enum DuckDBState { @@ -249,4 +251,103 @@ public struct DuckDBQueryProgress public double Percentage { get; } public ulong RowsProcessed { get; } public ulong TotalRowsToProcess { get; } +} + +public enum DuckDBMetricType +{ + // Core metrics + CpuTime = 2, + CumulativeCardinality = 4, + CumulativeRowsScanned = 7, + ExtraInfo = 3, + Latency = 11, + QueryName = 0, + ResultSetSize = 10, + [Description("Not available yet see(https://github.com/duckdb/duckdb/issues/22592)")] + RowsReturned = 12, + // Execution metrics + BlockedThreadTime = 1, + SystemPeakBufferMemory = 14, + SystemPeakTempDirSize = 15, + TotalMemoryAllocated = 91, + // File metrics + AttachLoadStorageLatency = 92, + AttachReplayWalLatency = 93, + CheckpointLatency = 94, + CommitLocalStorageLatency = 95, + TotalBytesRead = 16, + TotalBytesWritten = 17, + WaitingToAttachLatency = 96, + WalReplayEntryCount = 97, + WriteToWalLatency = 98, + // Operator metrics + OperatorCardinality = 6, + OperatorName = 13, + OperatorRowsScanned = 8, + OperatorTiming = 9, + OperatorType = 5, + // Optimizer metrics + OptimizerExpressionRewriter = 26, + OptimizerFilterPullup = 27, + OptimizerFilterPushdown = 28, + OptimizerEmptyResultPullup = 29, + OptimizerCteFilterPusher = 30, + OptimizerRegexRange = 31, + OptimizerInClause = 32, + OptimizerJoinOrder = 33, + OptimizerDeliminator = 34, + OptimizerUnnestRewriter = 35, + OptimizerUnusedColumns = 36, + OptimizerStatisticsPropagation = 37, + OptimizerCommonSubexpressions = 38, + OptimizerCommonAggregate = 39, + OptimizerColumnLifetime = 40, + OptimizerBuildSideProbeSide = 41, + OptimizerLimitPushdown = 42, + OptimizerTopN = 43, + OptimizerCompressedMaterialization = 44, + OptimizerDuplicateGroups = 45, + OptimizerReorderFilter = 46, + OptimizerSamplingPushdown = 47, + OptimizerJoinFilterPushdown = 48, + OptimizerExtension = 49, + OptimizerMaterializedCte = 50, + OptimizerSumRewriter = 51, + OptimizerLateMaterialization = 52, + OptimizerCteInlining = 53, + OptimizerRowGroupPruner = 54, + OptimizerTopNWindowElimination = 55, + OptimizerCommonSubplan = 56, + OptimizerJoinElimination = 57, + OptimizerWindowSelfJoin = 58, + // PhaseTiming metrics + AllOptimizers = 18, + CumulativeOptimizerTiming = 19, + PhysicalPlanner = 22, + PhysicalPlannerColumnBinding = 23, + PhysicalPlannerCreatePlan = 25, + PhysicalPlannerResolveTypes = 24, + Planner = 20, + PlannerBinding = 21 +} + +public enum DuckDBProfilingFormat +{ + QueryTree, + Json, + QueryTreeOptimizer, + NoOutput +} + +public enum DuckDBProfilingMode +{ + Standard, + Detailed, + All +} + +public enum DuckDBProfilingCoverage +{ + Select, + All } \ No newline at end of file diff --git a/DuckDB.NET.Bindings/DuckDBWrapperObjects.cs b/DuckDB.NET.Bindings/DuckDBWrapperObjects.cs index e42f90c6..42164ab6 100644 --- a/DuckDB.NET.Bindings/DuckDBWrapperObjects.cs +++ b/DuckDB.NET.Bindings/DuckDBWrapperObjects.cs @@ -1,4 +1,6 @@ -namespace DuckDB.NET.Native; +using System.Collections.Generic; + +namespace DuckDB.NET.Native; public class DuckDBDatabase() : SafeHandleZeroOrMinusOneIsInvalid(true) { @@ -59,6 +61,17 @@ protected override bool ReleaseHandle() } } +public class DuckDBProfilingInfo() : SafeHandleZeroOrMinusOneIsInvalid(true) +{ + // No explicit destroy/free function for duckdb_profiling_info in the C API as of now. + // If DuckDB adds a destroy function in the future, call it here. + protected override bool ReleaseHandle() + { + // No-op: DuckDB does not require explicit destruction of profiling info handles. + return true; + } +} + public class DuckDBLogicalType() : SafeHandleZeroOrMinusOneIsInvalid(true) { protected override bool ReleaseHandle() @@ -161,6 +174,25 @@ public T GetValue() static T Cast(TSource value) => Unsafe.As(ref value); } + public Dictionary GetMapValue() where TKey : notnull + { + var result = new Dictionary(); + + ulong entryCount = NativeMethods.Value.DuckDBGetMapSize(this); + for (ulong i = 0; i < entryCount; i++) + { + using var key = NativeMethods.Value.DuckDBGetMapKey(this, i); + using var value = NativeMethods.Value.DuckDBGetMapValue(this, i); + + var keyValue = key.GetValue(); + var valueValue = value.GetValue(); + + result.Add(keyValue, valueValue); + } + + return result; + } + private DateTime GetTimestampValue(DuckDBTimestampStruct timestampStruct, DuckDBType duckDBType) { var additionalTicks = 0; @@ -194,6 +226,7 @@ private DateTimeOffset GetTimeTzValue() var timeTz = NativeMethods.DateTimeHelpers.DuckDBFromTimeTz(timeTzStruct); return new DateTimeOffset(timeTz.Time.ToDateTime(), TimeSpan.FromSeconds(timeTz.Offset)); } + } public class DuckDBClientContext() : SafeHandleZeroOrMinusOneIsInvalid(true) diff --git a/DuckDB.NET.Bindings/NativeMethods/NativeMethods.ProfilingInfo.cs b/DuckDB.NET.Bindings/NativeMethods/NativeMethods.ProfilingInfo.cs new file mode 100644 index 00000000..b170dc9b --- /dev/null +++ b/DuckDB.NET.Bindings/NativeMethods/NativeMethods.ProfilingInfo.cs @@ -0,0 +1,31 @@ + +namespace DuckDB.NET.Native; + +public partial class NativeMethods +{ + //https://duckdb.org/docs/current/clients/c/api#profiling-info + public static partial class ProfilingInfo + { + + [LibraryImport(DuckDbLibrary, EntryPoint = "duckdb_get_profiling_info")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial DuckDBProfilingInfo DuckDBGetProfilingInfo(DuckDBNativeConnection connection); + + [LibraryImport(DuckDbLibrary, EntryPoint = "duckdb_profiling_info_get_value")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial DuckDBValue DuckDBProfilingInfoGetValue(DuckDBProfilingInfo info, [MarshalAs(UnmanagedType.LPStr)] string key); + + [LibraryImport(DuckDbLibrary, EntryPoint = "duckdb_profiling_info_get_metrics")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial DuckDBValue DuckDBProfilingInfoGetMetrics(DuckDBProfilingInfo info); + + [LibraryImport(DuckDbLibrary, EntryPoint = "duckdb_profiling_info_get_child_count")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial ulong DuckDBProfilingInfoGetChildCount(DuckDBProfilingInfo info); + + [LibraryImport(DuckDbLibrary, EntryPoint = "duckdb_profiling_info_get_child")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial DuckDBProfilingInfo DuckDBProfilingInfoGetChild(DuckDBProfilingInfo info, ulong index); + + } +} \ No newline at end of file diff --git a/DuckDB.NET.Bindings/NativeMethods/NativeMethods.Value.cs b/DuckDB.NET.Bindings/NativeMethods/NativeMethods.Value.cs index 518fe61f..df14d9e3 100644 --- a/DuckDB.NET.Bindings/NativeMethods/NativeMethods.Value.cs +++ b/DuckDB.NET.Bindings/NativeMethods/NativeMethods.Value.cs @@ -215,6 +215,18 @@ public static partial class Value [return: MarshalUsing(typeof(DuckDBCallerOwnedStringMarshaller))] public static partial string DuckDBGetVarchar(DuckDBValue value); + [LibraryImport(DuckDbLibrary, EntryPoint = "duckdb_get_map_size")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial ulong DuckDBGetMapSize(DuckDBValue value); + + [LibraryImport(DuckDbLibrary, EntryPoint = "duckdb_get_map_key")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial DuckDBValue DuckDBGetMapKey(DuckDBValue value, ulong index); + + [LibraryImport(DuckDbLibrary, EntryPoint = "duckdb_get_map_value")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial DuckDBValue DuckDBGetMapValue(DuckDBValue value, ulong index); + [LibraryImport(DuckDbLibrary, EntryPoint = "duckdb_create_list_value")] [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] public static partial DuckDBValue DuckDBCreateListValue(DuckDBLogicalType logicalType, IntPtr[] values, long count); @@ -223,6 +235,10 @@ public static partial class Value [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] public static partial DuckDBValue DuckDBCreateArrayValue(DuckDBLogicalType logicalType, IntPtr[] values, long count); + [LibraryImport(DuckDbLibrary, EntryPoint = "duckdb_create_map_value")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial DuckDBValue DuckDBCreateMapValue(DuckDBLogicalType logicalType, IntPtr[] keys, IntPtr[] values, long count); + // Maybe [SuppressGCTransition]: new Value — one small allocation [LibraryImport(DuckDbLibrary, EntryPoint = "duckdb_create_null_value")] [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] @@ -251,5 +267,14 @@ public static DuckDBValue DuckDBCreateArrayValue(DuckDBLogicalType logicalType, return duckDBValue; } + + public static DuckDBValue DuckDBCreateMapValue(DuckDBLogicalType logicalType, DuckDBValue[] keys, DuckDBValue[] values, int count) + { + var duckDBValue = DuckDBCreateMapValue(logicalType, keys.Select(item => item.DangerousGetHandle()).ToArray(), values.Select(item => item.DangerousGetHandle()).ToArray(), count); + + duckDBValue.SetChildValues([.. keys, .. values]); + + return duckDBValue; + } } } diff --git a/DuckDB.NET.Data/Common/TimerUtils.cs b/DuckDB.NET.Data/Common/TimerUtils.cs new file mode 100644 index 00000000..f18f3b40 --- /dev/null +++ b/DuckDB.NET.Data/Common/TimerUtils.cs @@ -0,0 +1,76 @@ +using System.Diagnostics; + +namespace DuckDB.NET.Data.Common; + +internal sealed class TimerUtils +{ + private static readonly long Frequency = Stopwatch.Frequency; + + /// + /// Gets the current timestamp value for high-resolution timing operations. + /// + /// The returned value can be used with Stopwatch.Frequency to calculate elapsed time intervals with high + /// precision. This method is intended for scenarios where precise timing is required, such as performance + /// measurements. + /// A long integer representing the current timestamp, as provided by the system's high-resolution performance counter. + internal static long TimerCurrent() => Stopwatch.GetTimestamp(); + + /// + /// Converts a timer value to its equivalent duration in milliseconds. + /// + /// The conversion uses the timer's frequency to determine the number of milliseconds represented by the + /// timer value. Ensure that the timer value and frequency are based on the same timer source. + /// The timer value to convert, typically representing elapsed timer ticks. + /// The equivalent duration in milliseconds calculated from the specified timer value. + internal static long TimerToMilliseconds(long timerValue) + { + // sentinel handling: preserve "infinite" indicator + if (timerValue == long.MaxValue) + { + return long.MaxValue; + } + + return timerValue * 1000 / Frequency; + } + + /// + /// Converts a timer tick duration (as returned by ) to a . + /// + /// The timer tick duration to convert. + /// A representing the duration of the provided timer ticks. + internal static TimeSpan TimerToTimeSpan(long timerValue) + { + // sentinel handling: preserve "infinite" indicator + if (timerValue == long.MaxValue) + { + return TimeSpan.MaxValue; + } + + // Convert stopwatch ticks to DateTime/TimeSpan ticks. TimeSpan.TicksPerSecond = 10_000_000. + long timeSpanTicks = timerValue * TimeSpan.TicksPerSecond / Frequency; + return TimeSpan.FromTicks(timeSpanTicks); + } + + /// + /// Converts a TimeSpan tick count (100-nanosecond ticks) to milliseconds as a double. + /// Handles sentinel long.MaxValue as an infinite duration. + /// + internal static double TimeSpanTicksToMilliseconds(long timeSpanTicks) + { + if (timeSpanTicks == long.MaxValue) + { + return double.MaxValue; + } + + return TimeSpan.FromTicks(timeSpanTicks).TotalMilliseconds; + } + + /// + /// Calculates the elapsed number of ticks between two tick count values. + /// + /// The starting tick count value. + /// The ending tick count value. + /// The difference between the ending and starting tick count values, representing the elapsed ticks. + internal static long CalculateTickCountElapsed(long startTick, long endTick) + => endTick - startTick; // keep as long, no truncation +} diff --git a/DuckDB.NET.Data/Connection/FileReference.cs b/DuckDB.NET.Data/Connection/FileReference.cs index 24482986..541e7553 100644 --- a/DuckDB.NET.Data/Connection/FileReference.cs +++ b/DuckDB.NET.Data/Connection/FileReference.cs @@ -1,4 +1,5 @@ -using System.IO; +using DuckDB.NET.Data.Profiling; +using System.IO; namespace DuckDB.NET.Data.Connection; @@ -13,6 +14,17 @@ internal class FileReference(string filename) public long ConnectionCount { get; private set; } //don't need a long, but it is slightly faster on 64 bit systems + /// + /// Gets a value indicating whether profiling is enabled for the current context. + /// Is used to ensure that the DuckDBDatabase is created with the correct profiling mode when a connection is opened on the same database + /// + public bool IsProfilingEnabled { get; internal set; } + + /// + /// Gets the profiling options used to configure performance profiling behavior. + /// + public ProfilingOptions? ProfilingOptions { get; internal set; } + public long Decrement() { return --ConnectionCount; diff --git a/DuckDB.NET.Data/Data.csproj b/DuckDB.NET.Data/Data.csproj index 6a8e00e8..8652de08 100644 --- a/DuckDB.NET.Data/Data.csproj +++ b/DuckDB.NET.Data/Data.csproj @@ -16,6 +16,11 @@ New features: enable + + true + $(NoWarn);1591 + + $(Description) $(NoNativeText) diff --git a/DuckDB.NET.Data/DuckDBConnection.cs b/DuckDB.NET.Data/DuckDBConnection.cs index 54bfbbe6..24760dfb 100644 --- a/DuckDB.NET.Data/DuckDBConnection.cs +++ b/DuckDB.NET.Data/DuckDBConnection.cs @@ -1,4 +1,7 @@ using DuckDB.NET.Data.Connection; +using DuckDB.NET.Data.Profiling; +using DuckDB.NET.Data.Profiling.Statistics; +using DuckDB.NET.Data.Profiling.Statistics.Summary; using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; @@ -12,10 +15,14 @@ public partial class DuckDBConnection : DbConnection private DuckDBConnectionString? parsedConnection; private ConnectionReference? connectionReference; private bool inMemoryDuplication = false; - private static readonly StateChangeEventArgs FromClosedToOpenEventArgs = new(ConnectionState.Closed, ConnectionState.Open); private static readonly StateChangeEventArgs FromOpenToClosedEventArgs = new(ConnectionState.Open, ConnectionState.Closed); + // Statistics support + private ConnectionStatistics? statistics; + private bool profilingEnabled; + private ProfilingOptions? profilingOptions; + #region Protected Properties protected override DbProviderFactory? DbProviderFactory => DuckDBClientFactory.Instance; @@ -72,6 +79,12 @@ public override string DataSource public DuckDBNativeConnection NativeConnection => connectionReference?.NativeConnection ?? throw new InvalidOperationException("The DuckDBConnection must be open to access the native connection."); + /// + /// Gets the underlying native DuckDB database instance associated with the current connection. + /// + public DuckDBDatabase NativeDatabase => connectionReference?.FileReferenceCounter.Database + ?? throw new InvalidOperationException("The DuckDBConnection must be open to access the native database."); + public override string ServerVersion => NativeMethods.Startup.DuckDBLibraryVersion(); public override ConnectionState State => connectionState; @@ -93,7 +106,10 @@ public override void Close() connectionManager.ReturnConnectionReference(connectionReference); } + UpdateStatistics(); + connectionState = ConnectionState.Closed; + OnStateChange(FromOpenToClosedEventArgs); } @@ -109,6 +125,22 @@ public override void Open() : connectionManager.GetConnectionReference(ParsedConnection); connectionState = ConnectionState.Open; + + // If profilingOptions has no explicit value for this connection, inherit the file-backed DB settings. + // Otherwise persist the explicit options to the shared FileReference so other connections see them. + if (!profilingOptions.HasValue) + { + profilingEnabled = connectionReference.FileReferenceCounter.IsProfilingEnabled; + profilingOptions = connectionReference.FileReferenceCounter.ProfilingOptions; + } + else + { + connectionReference.FileReferenceCounter.IsProfilingEnabled = profilingEnabled; + connectionReference.FileReferenceCounter.ProfilingOptions = profilingOptions.Value; + } + + InitProfiling(); + OnStateChange(FromClosedToOpenEventArgs); } @@ -184,7 +216,7 @@ string GetTableName() /// The AppenderMap type defining the mappings /// The table name /// A type-safe mapped appender - public DuckDBMappedAppender CreateAppender(string table) + public DuckDBMappedAppender CreateAppender(string table) where TMap : Mapping.DuckDBAppenderMap, new() { return CreateAppender(null, null, table); @@ -230,6 +262,8 @@ protected override void Dispose(bool disposing) { Close(); } + + statistics?.Dispose(); } base.Dispose(disposing); @@ -258,6 +292,8 @@ public DuckDBConnection Duplicate() parsedConnection = ParsedConnection, inMemoryDuplication = true, connectionReference = connectionReference, + profilingEnabled = profilingEnabled, + profilingOptions = profilingOptions, }; return duplicatedConnection; @@ -277,4 +313,232 @@ public DuckDBQueryProgress GetQueryProgress() EnsureConnectionOpen(); return NativeMethods.Startup.DuckDBQueryProgress(NativeConnection); } + + #region profiling + + public bool IsProfilingEnabled => profilingEnabled; + + /// + /// Retrieves a summary of the collected profiling statistics, including connection time, execution time, and any relevant metrics. + /// If profiling is not enabled, this method will return an empty summary. + /// + /// A containing the collected statistics. + public ProfilingSummary RetrieveStatistics() + { + if (statistics != null) + { + UpdateStatistics(); + return statistics.GetProfilingSummary(); + } + else + { + // Profiling not enabled for this connection + return new ProfilingSummary(); + } + } + + /// + /// Enables profiling for the current connection, optionally using the specified profiling options. + /// + /// If profiling is enabled while the connection is already open, profiling is initialized immediately. + /// Otherwise, profiling will be initialized when the connection is opened. + /// An optional set of profiling options to configure profiling behavior. If null, default options are used. + public void EnableProfiling(ProfilingOptions? options = null) + { + profilingEnabled = true; + this.profilingOptions = options ?? new ProfilingOptions(); // use provided options or default options if null + + if (connectionReference?.FileReferenceCounter is { } fileRefCounter) + { + fileRefCounter.IsProfilingEnabled = true; + fileRefCounter.ProfilingOptions = this.profilingOptions.Value; + } + + if (State == ConnectionState.Open) + { + InitProfiling(); + } + } + + /// + /// Disables profiling for the current session, with an option to reset collected statistics. + /// + /// If profiling is already disabled, calling this method has no effect. Resetting statistics + /// clears all previously collected profiling data, which cannot be recovered. + /// true to reset all collected profiling statistics after disabling profiling; otherwise, false. + public void DisableProfiling(bool resetStatistics = false) + { + // stop + statistics?.StopTimer(); + + if (resetStatistics) + { + ResetStatistics(); + } + + DisableProfiling(); + } + + /// + /// Resets all collected profiling statistics to their initial state. + /// + /// This method has no effect if profiling is not enabled. Use this method to clear accumulated + /// profiling data before starting a new measurement period. + public void ResetStatistics() + { + if (IsProfilingEnabled) + { + statistics?.Reset(); + } + } + + /// + /// Initializes the profiling statistics for the current connection. + /// + /// This method prepares internal data structures to collect and store profiling information + /// related to the connection's activity. It should be called before attempting to access profiling or statistics + /// data for the connection. + private void InitProfiling() + { + EnsureConnectionOpen(); + + if (IsProfilingEnabled) + { + // Dispose any existing statistics instance to ensure fresh state and remove previous mapping + // from the global ConnectionStatistics cache. + try + { + statistics?.Dispose(); + } + finally + { + statistics = new ConnectionStatistics(NativeConnection, profilingEnabled, this.profilingOptions); + } + LoadStatisticsProfile(); + } + } + + /// + /// Modifies the current profiling options used for collecting performance statistics. + /// + /// The new profiling options to apply. Cannot be null. + /// Thrown if profiling is not enabled when attempting to edit profiling options. + public void EditProfilingOptions(ProfilingOptions options) + { + if (!IsProfilingEnabled) + { + throw new InvalidOperationException("Profiling must be enabled to edit profiling options."); + } + + this.profilingOptions = options; + + if (connectionReference?.FileReferenceCounter is { } fileRefCounter) + { + fileRefCounter.ProfilingOptions = options; + } + + statistics?.Reset(); + LoadStatisticsProfile(); + } + + /// + /// Disables query profiling for the current database connection if profiling is enabled. + /// + /// This method has no effect if profiling is already disabled. Disabling profiling may improve + /// performance for subsequent queries. + /// Thrown if an error occurs while disabling profiling on the database connection. + private void DisableProfiling() + { + if (IsProfilingEnabled && State == ConnectionState.Open) + { + var state = NativeMethods.Query.DuckDBQuery(NativeConnection, "CALL disable_profiling();", out var queryResult); + EnsureStateIsSuccess(state, queryResult, "Error disabling profiling."); + + if (this.profilingOptions is { OutputPath: not null }) + { + // If output path is set, we need to disable profiling at the end of each session to ensure the file is properly flushed and closed. + state = NativeMethods.Query.DuckDBQuery(NativeConnection, "SET profiling_output = ''", out var queryResult2); + EnsureStateIsSuccess(state, queryResult2, "Error disabling profiling."); + } + } + + this.profilingOptions = null; + + if (connectionReference?.FileReferenceCounter is { } fileRefCounter) + { + fileRefCounter.IsProfilingEnabled = false; + fileRefCounter.ProfilingOptions = null; + } + + statistics?.DisableQueryExecutionTracing(); + profilingEnabled = false; + } + + /// + /// Configures and enables query profiling for the current database connection based on the specified profiling + /// options. + /// + /// Profiling is only enabled for original in-memory connections; duplicated in-memory + /// connections share the same profiling state and are not reconfigured. This method applies settings such as + /// profiling format, coverage, mode, output path, and enabled metrics according to the current profiling + /// options. + /// Thrown if an error occurs while setting profiling configuration options. + private void LoadStatisticsProfile() + { + statistics?.StartTimer(); + + if (IsProfilingEnabled && profilingOptions is { } options) + { + // Build SQL and emit lightweight diagnostics to help reproduce issues when tests run together + var sql = ProfilingOptionsExtensions.ToDuckDBProfilingOptionString(options); + + var state = NativeMethods.Query.DuckDBQuery(NativeConnection, sql, out var queryResult); + EnsureStateIsSuccess(state, queryResult, "Error configuring profiling settings."); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void EnsureStateIsSuccess(DuckDBState state, DuckDBResult result, string fallbackMessage) + { + try + { + if (!state.IsSuccess()) + { + var errorMessage = NativeMethods.Query.DuckDBResultError(ref result); + var errorType = NativeMethods.Query.DuckDBResultErrorType(ref result); + + if (string.IsNullOrEmpty(errorMessage)) + { + errorMessage = fallbackMessage; + } + + if (errorType == DuckDBErrorType.Interrupt) + { + throw new OperationCanceledException(); + } + + var innerException = UdfExceptionStore.Retrieve(NativeConnection); + throw innerException != null + ? new DuckDBException(errorMessage, innerException) + : new DuckDBException(errorMessage, errorType); + } + } + finally + { + result.Close(); + } + } + + private void UpdateStatistics() + { + if (ConnectionState.Open == State) + { + // update timestamp + statistics?.StopTimer(); + } + + // delegate the rest of the work to the SqlStatistics class + statistics?.UpdateStatistics(); + } + #endregion } diff --git a/DuckDB.NET.Data/Extensions/DuckDBMetricsExtensions.cs b/DuckDB.NET.Data/Extensions/DuckDBMetricsExtensions.cs new file mode 100644 index 00000000..26bbf06f --- /dev/null +++ b/DuckDB.NET.Data/Extensions/DuckDBMetricsExtensions.cs @@ -0,0 +1,92 @@ +using DuckDB.NET.Data.Profiling; +using System.Collections.Frozen; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.Json; + +namespace DuckDB.NET.Data.Extensions +{ + internal static class DuckDBMetricsExtensions + { + // Cache the snake_case conversions of DuckDBProfilingFormat enum values for efficient lookup. + private static readonly FrozenDictionary ProfilingFormatCache = + Enum.GetValues() + .ToFrozenDictionary(f => f, f => JsonNamingPolicy.SnakeCaseLower.ConvertName(f.ToString())); + + // Cache the snake_case conversions of DuckDBMetricType enum values for efficient lookup. + private static readonly FrozenDictionary MetricTypeCache = + Enum.GetValues() + .ToFrozenDictionary(m => m, m => JsonNamingPolicy.SnakeCaseUpper.ConvertName(m.ToString())); + + // Create a reverse lookup cache for string to DuckDBMetricType conversions. + private static readonly FrozenDictionary StringToMetricTypeCache = + MetricTypeCache.ToFrozenDictionary(kv => kv.Value, kv => kv.Key); + + // create a reverse lookup cache for string to DuckDBProfilingFormat conversions. + private static readonly FrozenDictionary StringToProfilingFormatCache = + ProfilingFormatCache.ToFrozenDictionary(kv => kv.Value, kv => kv.Key); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static string ToDuckDBMetricTypeCollectionString(this DuckDBMetricTypeCollection metrics) + { + return $"{{{string.Join(",", metrics.Select(m => $"\"{m.ToDuckDBMetricTypeString()}\": \"TRUE\""))}}}"; + } + + /// + /// Converts the specified DuckDB profiling format to its corresponding string representation. + /// + /// The DuckDB profiling format to convert. + /// A string that represents the specified DuckDB profiling format. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static string ToDuckDBProfilingFormatString(this DuckDBProfilingFormat format) + { + return ProfilingFormatCache[format]; + } + + /// + /// Converts the specified DuckDB metric type to its corresponding string representation. + /// + /// The DuckDB metric type to convert. + /// A string that represents the specified DuckDB metric type. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static string ToDuckDBMetricTypeString(this DuckDBMetricType metricType) + { + if (metricType == DuckDBMetricType.RowsReturned) + { + throw new DuckDBException($"The '{DuckDBMetricType.RowsReturned}' metric is not supported yet.", DuckDBErrorType.InvalidInput); + } + + return MetricTypeCache[metricType]; + } + + /// + /// Attempts to parse the specified string into a corresponding DuckDBMetricType value. + /// + /// The string representation of the DuckDB metric type to parse. Cannot be null. + /// When this method returns, contains the DuckDBMetricType value equivalent to the parsed string, if the conversion + /// succeeded; otherwise, the default value. + /// true if the string was successfully parsed into a DuckDBMetricType value; otherwise, false. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static bool TryParseDuckDBMetricType(string metricTypeString, out DuckDBMetricType metricType) + { + return StringToMetricTypeCache.TryGetValue(metricTypeString, out metricType); + } + + /// + /// Attempts to parse the specified string into a corresponding DuckDB profiling format value. + /// + /// Use this method to safely convert a string to a DuckDB profiling format without + /// throwing an exception if the conversion fails. + /// The string representation of the DuckDB profiling format to parse. This value is compared case-sensitively + /// against known profiling format names. + /// When this method returns, contains the parsed DuckDB profiling format value if the parse operation succeeds; + /// otherwise, contains the default value. + /// true if the string was successfully parsed into a DuckDB profiling format; otherwise, false. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static bool TryParseDuckDBProfilingFormat(string profilingFormatString, out DuckDBProfilingFormat profilingFormat) + { + return StringToProfilingFormatCache.TryGetValue(profilingFormatString, out profilingFormat); + } + + } +} diff --git a/DuckDB.NET.Data/Extensions/ProfilingOptionsExtensions.cs b/DuckDB.NET.Data/Extensions/ProfilingOptionsExtensions.cs new file mode 100644 index 00000000..bba66011 --- /dev/null +++ b/DuckDB.NET.Data/Extensions/ProfilingOptionsExtensions.cs @@ -0,0 +1,38 @@ +using DuckDB.NET.Data.Profiling; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace DuckDB.NET.Data.Extensions +{ + internal static class ProfilingOptionsExtensions + { + public static string ToDuckDBProfilingOptionString(this ProfilingOptions options) + { + var sb = new StringBuilder(); + + sb.AppendLine("CALL enable_profiling("); + sb.AppendLine($" format := '{options.Format.ToDuckDBProfilingFormatString()}',"); + sb.AppendLine($" coverage := '{options.Coverage}',"); + sb.AppendLine($" mode := '{options.Mode}'"); + + if (!string.IsNullOrEmpty(options.OutputPath)) + sb.AppendLine($" ,save_location := '{options.OutputPath.Replace("'", "''")}'"); + + if (options.EnabledMetrics is { Count: > 0 }) + sb.AppendLine($" ,metrics := '{(options.EnabledMetrics).ToDuckDBMetricTypeCollectionString()}'"); + else + sb.AppendLine($" ,metrics := '{DuckDBMetrics.DefaultMetrics.ToDuckDBMetricTypeCollectionString()}'"); + + sb.AppendLine(");"); + + // If no output path is provided, disable profiling output to file + if (string.IsNullOrEmpty(options.OutputPath)) + sb.AppendLine("SET profiling_output = '';"); + + return sb.ToString(); + } + } +} diff --git a/DuckDB.NET.Data/PreparedStatement/ClrToDuckDBConverter.cs b/DuckDB.NET.Data/PreparedStatement/ClrToDuckDBConverter.cs index 435dfc58..44125bf4 100644 --- a/DuckDB.NET.Data/PreparedStatement/ClrToDuckDBConverter.cs +++ b/DuckDB.NET.Data/PreparedStatement/ClrToDuckDBConverter.cs @@ -93,6 +93,7 @@ public static DuckDBValue ToDuckDBValue(this object? item, DuckDBLogicalType log (DuckDBType.Blob, byte[] value) => NativeMethods.Value.DuckDBCreateBlob(value, value.Length), (DuckDBType.List, ICollection value) => CreateCollectionValue(logicalType, value, true, dbType), (DuckDBType.Array, ICollection value) => CreateCollectionValue(logicalType, value, false, dbType), + (DuckDBType.Map, IDictionary value) => CreateMapValue(logicalType, value), _ when ValueCreators.TryGetValue(dbType, out var converter) => converter(item), _ => NativeMethods.Value.DuckDBCreateVarchar(item.ToString()) }; @@ -139,6 +140,29 @@ private static DuckDBValue CreateCollectionValue(DuckDBLogicalType logicalType, : NativeMethods.Value.DuckDBCreateArrayValue(collectionItemType, values, collection.Count); } + private static DuckDBValue CreateMapValue(DuckDBLogicalType logicalType, IDictionary dictionary) + { + //get key and value types from logical type + using var keyType = NativeMethods.LogicalType.DuckDBMapTypeKeyType(logicalType); + using var valueType = NativeMethods.LogicalType.DuckDBMapTypeValueType(logicalType); + + //get duckdb types for key and value types + var keyDuckDBType = NativeMethods.LogicalType.DuckDBGetTypeId(keyType); + var valueDuckDBType = NativeMethods.LogicalType.DuckDBGetTypeId(valueType); + + var keys = new DuckDBValue[dictionary.Count]; + var values = new DuckDBValue[dictionary.Count]; + var index = 0; + foreach (DictionaryEntry entry in dictionary) + { + keys[index] = entry.Key.ToDuckDBValue(keyType, keyDuckDBType, DbType.Object); + values[index] = entry.Value.ToDuckDBValue(valueType, valueDuckDBType, DbType.Object); + index++; + } + + return NativeMethods.Value.DuckDBCreateMapValue(logicalType, keys, values, dictionary.Count); + } + private static DuckDBValue DecimalToDuckDBValue(decimal value) { var mantissa = value.GetMantissa(); diff --git a/DuckDB.NET.Data/PreparedStatement/PreparedStatement.cs b/DuckDB.NET.Data/PreparedStatement/PreparedStatement.cs index d074baab..2733a9ae 100644 --- a/DuckDB.NET.Data/PreparedStatement/PreparedStatement.cs +++ b/DuckDB.NET.Data/PreparedStatement/PreparedStatement.cs @@ -1,11 +1,13 @@ -using System.Linq; using DuckDB.NET.Data.Connection; +using DuckDB.NET.Data.Profiling.Statistics; +using System.Linq; namespace DuckDB.NET.Data.PreparedStatement; internal sealed class PreparedStatement : IDisposable { private readonly DuckDBPreparedStatement statement; + private StatementProfiler? statementProfiler; private PreparedStatement(DuckDBPreparedStatement statement) { @@ -18,67 +20,119 @@ public static IEnumerable PrepareMultiple(DuckDBNativeConnection c using (extractedStatements) { + _ = ConnectionStatistics.TryGetFor(connection, out var stats); + // Initialize the query profiler for the entire batch of statements. The profiler will be responsible for tracking the execution of all statements within this batch. + var queryProfiler = stats?.CreateQueryProfiler(statementCount); + queryProfiler?.StartTimer(); + if (statementCount <= 0) { var error = NativeMethods.ExtractStatements.DuckDBExtractStatementsError(extractedStatements); + + queryProfiler?.SetState(DuckDBState.Error, DuckDBErrorType.Parser, error); + queryProfiler?.StopTimer(); + throw new DuckDBException(error); } - for (int index = 0; index < statementCount; index++) + try { - var status = NativeMethods.ExtractStatements.DuckDBPrepareExtractedStatement(connection, extractedStatements, index, out var statement); - if (status.IsSuccess()) + for (int index = 0; index < statementCount; index++) { - using var preparedStatement = new PreparedStatement(statement); - yield return preparedStatement.Execute(parameters, useStreamingMode, connection); - } - else - { - var errorMessage = NativeMethods.PreparedStatements.DuckDBPrepareError(statement); + var status = NativeMethods.ExtractStatements.DuckDBPrepareExtractedStatement(connection, extractedStatements, index, out var statement); - if (string.IsNullOrEmpty(errorMessage)) + var statementProfiler = queryProfiler?.CreateStatementProfiler(index); + + if (status.IsSuccess()) { - errorMessage = "DuckDBQuery failed"; + using var preparedStatement = new PreparedStatement(statement); + preparedStatement.statementProfiler = statementProfiler; + + var result = preparedStatement.Execute(parameters, useStreamingMode, connection); + + // Stop the query profiler after the last statement has been executed. + // This ensures that the total execution time for the entire batch of statements is accurately captured + // and not after the data retrieval of the last statement. + if (index == statementCount - 1) + { + queryProfiler?.StopTimer(); + } + + yield return result; } + else + { + var errorMessage = NativeMethods.PreparedStatements.DuckDBPrepareError(statement); + + if (string.IsNullOrEmpty(errorMessage)) + { + errorMessage = "DuckDBQuery failed"; + } - throw new DuckDBException(errorMessage, UdfExceptionStore.Retrieve(connection)); + // Initialize the statement profiler for the current statement. This allows for detailed profiling of each individual statement within the batch + statementProfiler?.SetState(status, errorMessage); + + throw new DuckDBException(errorMessage, UdfExceptionStore.Retrieve(connection)); + } } } + finally + { + // Ensure that the query profiler is stopped in case of any exceptions during statement preparation or execution. + queryProfiler?.StopTimer(); + } } } private DuckDBResult Execute(DuckDBParameterCollection parameterCollection, bool useStreamingMode, DuckDBNativeConnection connection) { - BindParameters(statement, parameterCollection); + // Start the statement profiler timer to measure the execution time of this statement. + // The profiler will also capture any relevant metrics or state changes during execution. + var profiler = this.statementProfiler; + profiler?.StartTimer(); - var status = useStreamingMode - ? NativeMethods.PreparedStatements.DuckDBExecutePreparedStreaming(statement, out var queryResult) - : NativeMethods.PreparedStatements.DuckDBExecutePrepared(statement, out queryResult); - - if (!status.IsSuccess()) + try { - var errorMessage = NativeMethods.Query.DuckDBResultError(ref queryResult); - var errorType = NativeMethods.Query.DuckDBResultErrorType(ref queryResult); - queryResult.Close(); - if (string.IsNullOrEmpty(errorMessage)) - { - errorMessage = "DuckDB execution failed"; - } + BindParameters(statement, parameterCollection); + + var status = useStreamingMode + ? NativeMethods.PreparedStatements.DuckDBExecutePreparedStreaming(statement, out var queryResult) + : NativeMethods.PreparedStatements.DuckDBExecutePrepared(statement, out queryResult); - if (errorType == DuckDBErrorType.Interrupt) + if (!status.IsSuccess()) { - throw new OperationCanceledException(); + var errorMessage = NativeMethods.Query.DuckDBResultError(ref queryResult); + var errorType = NativeMethods.Query.DuckDBResultErrorType(ref queryResult); + queryResult.Close(); + + if (string.IsNullOrEmpty(errorMessage)) + { + errorMessage = "DuckDB execution failed"; + } + + profiler?.SetState(status, errorType, errorMessage); + + if (errorType == DuckDBErrorType.Interrupt) + { + throw new OperationCanceledException(); + } + + var innerException = UdfExceptionStore.Retrieve(connection); + throw innerException != null + ? new DuckDBException(errorMessage, innerException) + : new DuckDBException(errorMessage, errorType); } - var innerException = UdfExceptionStore.Retrieve(connection); - throw innerException != null - ? new DuckDBException(errorMessage, innerException) - : new DuckDBException(errorMessage, errorType); - } + profiler?.AcquireMetrics(); - return queryResult; + return queryResult; + } + finally + { + profiler?.StopTimer(); + } } private static void BindParameters(DuckDBPreparedStatement preparedStatement, DuckDBParameterCollection parameterCollection) diff --git a/DuckDB.NET.Data/Profiling/DuckDBMetricTypeCollection.cs b/DuckDB.NET.Data/Profiling/DuckDBMetricTypeCollection.cs new file mode 100644 index 00000000..6a71e8f6 --- /dev/null +++ b/DuckDB.NET.Data/Profiling/DuckDBMetricTypeCollection.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; + +namespace DuckDB.NET.Data.Profiling +{ + public class DuckDBMetricTypeCollection : List + { + public DuckDBMetricTypeCollection() { } + + public DuckDBMetricTypeCollection(IEnumerable collection) : base(collection) { } + } +} diff --git a/DuckDB.NET.Data/Profiling/DuckDBMetrics.cs b/DuckDB.NET.Data/Profiling/DuckDBMetrics.cs new file mode 100644 index 00000000..a9e65ad4 --- /dev/null +++ b/DuckDB.NET.Data/Profiling/DuckDBMetrics.cs @@ -0,0 +1,48 @@ +using System.Linq; + +namespace DuckDB.NET.Data.Profiling +{ + public static class DuckDBMetrics + { + public static readonly DuckDBMetricTypeCollection AllMetrics = new DuckDBMetricTypeCollection(Enum.GetValues().Cast()); + + public static readonly DuckDBMetricTypeCollection DefaultMetrics = new DuckDBMetricTypeCollection( + [ + // Basic query metadata + DuckDBMetricType.QueryName, + DuckDBMetricType.Latency, + DuckDBMetricType.CpuTime, + DuckDBMetricType.BlockedThreadTime, + + // Result and cardinality + DuckDBMetricType.ResultSetSize, + // DuckDBMetricType.RowsReturned, // Not enabled due to known issue + DuckDBMetricType.CumulativeCardinality, + DuckDBMetricType.CumulativeRowsScanned, + DuckDBMetricType.ExtraInfo, + + // Operator-level metrics + DuckDBMetricType.OperatorCardinality, + DuckDBMetricType.OperatorName, + DuckDBMetricType.OperatorRowsScanned, + DuckDBMetricType.OperatorTiming, + DuckDBMetricType.OperatorType, + + // Memory / IO / storage metrics + DuckDBMetricType.TotalBytesRead, + DuckDBMetricType.TotalBytesWritten, + DuckDBMetricType.TotalMemoryAllocated, + DuckDBMetricType.SystemPeakBufferMemory, + DuckDBMetricType.SystemPeakTempDirSize, + + // WAL / attach / checkpoint metrics + DuckDBMetricType.AttachLoadStorageLatency, + DuckDBMetricType.AttachReplayWalLatency, + DuckDBMetricType.CheckpointLatency, + DuckDBMetricType.CommitLocalStorageLatency, + DuckDBMetricType.WaitingToAttachLatency, + DuckDBMetricType.WalReplayEntryCount, + DuckDBMetricType.WriteToWalLatency + ]); + } +} \ No newline at end of file diff --git a/DuckDB.NET.Data/Profiling/DuckDBProfilingInfoWrapper.cs b/DuckDB.NET.Data/Profiling/DuckDBProfilingInfoWrapper.cs new file mode 100644 index 00000000..cacc32b3 --- /dev/null +++ b/DuckDB.NET.Data/Profiling/DuckDBProfilingInfoWrapper.cs @@ -0,0 +1,62 @@ +namespace DuckDB.NET.Native; + +public class DuckDBProfilingInfoWrapper : IDisposable +{ + private DuckDBProfilingInfo handle; + private bool disposed; + + public DuckDBProfilingInfoWrapper(DuckDBProfilingInfo handle) + { + ArgumentNullException.ThrowIfNull(handle); + + this.handle = handle; + } + + public static DuckDBProfilingInfoWrapper? GetProfilingInfo(DuckDBNativeConnection connection) + { + ArgumentNullException.ThrowIfNull(connection); + + var handle = NativeMethods.ProfilingInfo.DuckDBGetProfilingInfo(connection); + if (handle.IsInvalid || handle.IsClosed) + { + return null; + } + return new DuckDBProfilingInfoWrapper(handle); + } + + public DuckDBValue GetValue(string key) + { + ArgumentNullException.ThrowIfNull(key); + + return NativeMethods.ProfilingInfo.DuckDBProfilingInfoGetValue(handle, key); + } + + public DuckDBValue GetMetrics() + { + return NativeMethods.ProfilingInfo.DuckDBProfilingInfoGetMetrics(handle); + } + + public ulong GetChildCount() + { + return NativeMethods.ProfilingInfo.DuckDBProfilingInfoGetChildCount(handle); + } + + public DuckDBProfilingInfoWrapper GetChild(ulong index) + { + var childHandle = NativeMethods.ProfilingInfo.DuckDBProfilingInfoGetChild(handle, index); + if (childHandle.IsInvalid || childHandle.IsClosed) + { + throw new InvalidOperationException($"Failed to get child profiling info at index {index}."); + } + return new DuckDBProfilingInfoWrapper(childHandle); + } + + public void Dispose() + { + if (!disposed) + { + handle?.Dispose(); + disposed = true; + } + } +} \ No newline at end of file diff --git a/DuckDB.NET.Data/Profiling/MetricType.cs b/DuckDB.NET.Data/Profiling/MetricType.cs new file mode 100644 index 00000000..e69de29b diff --git a/DuckDB.NET.Data/Profiling/ProfilingInfo.cs b/DuckDB.NET.Data/Profiling/ProfilingInfo.cs new file mode 100644 index 00000000..2e919912 --- /dev/null +++ b/DuckDB.NET.Data/Profiling/ProfilingInfo.cs @@ -0,0 +1,59 @@ +using DuckDB.NET.Data.Profiling.Statistics; + +internal sealed class ProfilingInfo: IDisposable +{ + private readonly DuckDBNativeConnection connection; + private DuckDBProfilingInfoWrapper? duckDBProfilingInfoWrapper; + private bool isDisposed = false; + + internal ProfilingInfo(DuckDBNativeConnection connection) + { + this.connection = connection; + } + + /// + /// Tries to prepare the profiling info by retrieving it from the connection. Returns true if successful, false otherwise. + /// + /// + internal bool TryPrepare() + { + var profilingInfo = DuckDBProfilingInfoWrapper.GetProfilingInfo(connection); + + if (profilingInfo == null) + { + return false; + } + + duckDBProfilingInfoWrapper = profilingInfo; + return true; + } + + /// + /// Gets the raw metrics as a dictionary of string key-value pairs. Throws an exception if the profiling info is not prepared. + /// + /// + /// + internal Dictionary GetRawMetrics() + { + if (duckDBProfilingInfoWrapper == null) + { + throw new InvalidOperationException("Profiling info is not prepared. Call TryPrepare() first."); + } + + using var metricsValue = duckDBProfilingInfoWrapper.GetMetrics(); + if (metricsValue.IsNull()) + { + return []; + } + return metricsValue.GetMapValue(); + } + + public void Dispose() + { + if (!isDisposed) + { + duckDBProfilingInfoWrapper?.Dispose(); + isDisposed = true; + } + } +} \ No newline at end of file diff --git a/DuckDB.NET.Data/Profiling/ProfilingOptions.cs b/DuckDB.NET.Data/Profiling/ProfilingOptions.cs new file mode 100644 index 00000000..36dbef1b --- /dev/null +++ b/DuckDB.NET.Data/Profiling/ProfilingOptions.cs @@ -0,0 +1,36 @@ +namespace DuckDB.NET.Data.Profiling +{ + public readonly struct ProfilingOptions + { + /// + /// The profiling output format to use when generating profiling results. + /// + public DuckDBProfilingFormat Format { get; init; } + + /// + /// The profiling mode used for DuckDB query execution. + /// + public DuckDBProfilingMode Mode { get; init; } + + /// + /// The profiling coverage settings used for DuckDB query analysis. + /// + public DuckDBProfilingCoverage Coverage { get; init; } + + /// + /// The output directory path where generated files will be placed. + /// + public string OutputPath { get; init; } + + /// + /// The collection of enabled metrics for profiling. + /// + public DuckDBMetricTypeCollection EnabledMetrics { get; init; } + + /// + /// The threshold (in milliseconds) used for metrics acquisition. + /// + /// Execution times below this threshold may not trigger metrics collection. + public int MetricsThreshold { get; init; } + } +} diff --git a/DuckDB.NET.Data/Profiling/Statistics/ConnectionStatistics.cs b/DuckDB.NET.Data/Profiling/Statistics/ConnectionStatistics.cs new file mode 100644 index 00000000..288201dc --- /dev/null +++ b/DuckDB.NET.Data/Profiling/Statistics/ConnectionStatistics.cs @@ -0,0 +1,181 @@ +using DuckDB.NET.Data.Common; +using DuckDB.NET.Data.Profiling.Statistics.Summary; +using System.Collections.Concurrent; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; + +namespace DuckDB.NET.Data.Profiling.Statistics; + +internal sealed class ConnectionStatistics : ExecutionProfiler, IDisposable +{ + + // internal values that are not exposed through properties + private static readonly ConcurrentDictionary ByNativeConnection = new(); + + private bool enableQueryExecutionTracing; + private readonly ConcurrentDictionary queryProfilers = new(); + private bool isDisposed = false; + private int nextQueryId = 0; + + // internal values that are exposed through properties + internal long connectionTime; + + /// + /// Initializes a new instance of the class. + /// + /// The native DuckDB connection. + /// Indicates whether query execution tracing is enabled. + /// The profiling options for the connection. + internal ConnectionStatistics(DuckDBNativeConnection duckDBNativeConnection, bool enableQueryExecutionTracing = false, ProfilingOptions? profilingOptions = default) + : base(duckDBNativeConnection, profilingOptions) + { + this.enableQueryExecutionTracing = enableQueryExecutionTracing; + + // Add or update the mapping for the native connection to this ConnectionStatistics instance + ByNativeConnection.AddOrUpdate(duckDBNativeConnection, this, (_, _) => this); + } + + /// + /// Enables tracing of query execution for diagnostic or debugging purposes. + /// + /// After calling this method, additional diagnostic information about query execution may be collected + /// or logged. This can assist in troubleshooting or performance analysis. Tracing remains enabled until explicitly + /// disabled + internal void EnableQueryExecutionTracing() + { + this.enableQueryExecutionTracing = true; + } + + /// + /// Disables tracing of query execution for the current instance. + /// + /// After calling this method, query execution tracing will no longer be performed until explicitly + /// re-enabled. This may affect the ability to diagnose or audit query behavior. + internal void DisableQueryExecutionTracing() + { + this.enableQueryExecutionTracing = false; + } + + /// + /// Attempts to retrieve a associated with the specified connection. + /// + /// The native DuckDB connection for which to obtain statistics. + /// When this method returns, contains the statistics for the specified connection if found; otherwise, . + /// if statistics are found for the specified connection; otherwise, . + internal static bool TryGetFor(DuckDBNativeConnection nativeConn, out ConnectionStatistics? stats) + { + // Fast path: if no connections have profiling enabled, skip the lookup entirely + if (ByNativeConnection.IsEmpty) + { + stats = null; + return false; + } + return ByNativeConnection.TryGetValue(nativeConn, out stats); + } + + /// + /// Creates a new for the specified query if query execution tracing is enabled. + /// + /// The number of statements in the query to be profiled. Must be non-negative. + /// A new instance of if query execution tracing is enabled; otherwise, . + internal QueryProfiler? CreateQueryProfiler(int statementCount) + { + if (!enableQueryExecutionTracing) + { + return null; + } + + var id = GetNextQueryId(); + + var queryProfiler = new QueryProfiler(id, statementCount, duckDBNativeConnection, profilingOptions); + queryProfilers[id] = queryProfiler; + return queryProfiler; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private int GetNextQueryId() + { + return Interlocked.Increment(ref nextQueryId); + } + + /// + /// Stops all active timers for the connection and its associated query profilers, recording their elapsed times. + /// + public override void StopTimer() + { + foreach (var profiler in queryProfilers.Values) + { + profiler?.StopTimer(); + } + + base.StopTimer(); + } + + internal void UpdateStatistics() + { + // update connection time + if (endTime >= startTime && long.MaxValue > (endTime - startTime).Ticks) + { + connectionTime = (endTime - startTime).Ticks; + } + else + { + connectionTime = long.MaxValue; + } + } + + private IEnumerable ReadSummaries() + { + foreach (var queryProfiler in queryProfilers.Values.OrderBy(qp => qp.StartTime)) + { + yield return queryProfiler.GetSummary(); + } + } + + internal ProfilingSummary GetProfilingSummary() + { + var connectionTimeMs = TimerUtils.TimeSpanTicksToMilliseconds(connectionTime); + + // QueryProfiler.ExecutionTime is already exposed in milliseconds via ExecutionTime property + var executionTimeMs = queryProfilers.Values.Sum(qp => (double)qp.ExecutionTime); + + return new ProfilingSummary( + startTime, + endTime, + connectionTimeMs, + executionTimeMs, + queryProfilers.Values.Count, + [.. ReadSummaries()]); + } + + public override void Reset() + { + executionTime = 0; + startTimestamp = null; + connectionTime = 0; + startTime = default; + endTime = default; + + queryProfilers.Clear(); + + StartTimer(); + } + + // call on connection close/dispose + private void RemoveMapping() + { + ByNativeConnection.TryRemove(duckDBNativeConnection, out _); + } + + public void Dispose() + { + if (!isDisposed) + { + RemoveMapping(); + isDisposed = true; + } + } +} diff --git a/DuckDB.NET.Data/Profiling/Statistics/ExecutionProfiler.cs b/DuckDB.NET.Data/Profiling/Statistics/ExecutionProfiler.cs new file mode 100644 index 00000000..b568e73a --- /dev/null +++ b/DuckDB.NET.Data/Profiling/Statistics/ExecutionProfiler.cs @@ -0,0 +1,111 @@ +using DuckDB.NET.Data.Common; + +namespace DuckDB.NET.Data.Profiling.Statistics; + +internal abstract class ExecutionProfiler(DuckDBNativeConnection duckDBNativeConnection, ProfilingOptions? profilingOptions) : IExecutionProfiler +{ + protected readonly DuckDBNativeConnection duckDBNativeConnection = duckDBNativeConnection; + protected readonly ProfilingOptions? profilingOptions = profilingOptions; + + protected DuckDBState state; + protected string? errorMessage; + + protected long? startTimestamp; + protected long executionTime; + protected DateTimeOffset startTime; + protected DateTimeOffset endTime; + + internal long ExecutionTime => TimerUtils.TimerToMilliseconds(executionTime); + internal DateTimeOffset StartTime => startTime; + internal DateTimeOffset EndTime => endTime; + + /// + /// Starts the timer, initiating the timing operation. + /// + public virtual void StartTimer() + { + if (!startTimestamp.HasValue) + { + startTimestamp = TimerUtils.TimerCurrent(); + startTime = DateTimeOffset.UtcNow; + } + } + + /// + /// Stops the currently running timer, if active. + /// + /// Call this method to halt the timer and record its elapsed time. If the timer is not running, calling + /// this method has no effect. + public virtual void StopTimer() + { + ReleaseAndUpdateExecutionTimer(); + } + + /// + /// Releases the current execution timer and updates the accumulated execution time and end time for the operation. + /// + protected virtual void ReleaseAndUpdateExecutionTimer() + { + if (startTimestamp.HasValue) + { + long elapsed = TimerUtils.CalculateTickCountElapsed(startTimestamp.Value, TimerUtils.TimerCurrent()); + executionTime += elapsed; + + // Convert the total accumulated high-resolution ticks to a TimeSpan and apply to the wall-clock start time. + // Use the cumulative executionTime so that multiple start/stop segments are represented correctly. + var totalSpan = TimerUtils.TimerToTimeSpan(executionTime); + endTime = startTime.Add(totalSpan); + + startTimestamp = null; + } + } + + /// + /// Collects and updates performance or usage metrics for the current instance. + /// + public virtual void AcquireMetrics() { } + + /// + /// Resets the object to its initial state. + /// + public virtual void Reset() { } + + /// + /// Gets the current state of the current operation, indicating success or error + /// + public DuckDBState State => state; + + /// + /// Gets the error message associated with the current operation. + /// + public string ErrorMessage => errorMessage ?? string.Empty; + + /// + /// Sets the current state of the DuckDB connection or operation. + /// + /// The new state to assign to the DuckDB instance. + public void SetState(DuckDBState state) => this.state = state; + + /// + /// Sets the current state and associated error message for the DuckDB instance. + /// + /// The new state to assign to the DuckDB instance. + /// The error message to associate with the specified state. Can be null or empty if no error message is required. + public void SetState(DuckDBState state, string message) + { + this.state = state; + this.errorMessage = message; + } + + /// + /// Sets the current state and error information for the DuckDB connection or operation. + /// + /// The new state to assign to the DuckDB connection or operation. + /// The type of error associated with the state change. Used to categorize the error message. + /// A descriptive message providing details about the error or state change. Cannot be null. + public void SetState(DuckDBState state, DuckDBErrorType errorType, string message) + { + this.state = state; + this.errorMessage = $"{errorType} error: {message}"; + } +} \ No newline at end of file diff --git a/DuckDB.NET.Data/Profiling/Statistics/IExecutionProfiler.cs b/DuckDB.NET.Data/Profiling/Statistics/IExecutionProfiler.cs new file mode 100644 index 00000000..854fca3c --- /dev/null +++ b/DuckDB.NET.Data/Profiling/Statistics/IExecutionProfiler.cs @@ -0,0 +1,14 @@ +namespace DuckDB.NET.Data.Profiling.Statistics +{ + // Single interface — replaces both IExecutionTracerBase and IExecutionProfiler + internal interface IExecutionProfiler + { + void StartTimer(); + void StopTimer(); + void SetState(DuckDBState state); + void SetState(DuckDBState state, string message); + void SetState(DuckDBState state, DuckDBErrorType errorType, string message); + void AcquireMetrics(); + DuckDBState State { get; } + } +} \ No newline at end of file diff --git a/DuckDB.NET.Data/Profiling/Statistics/ProfilingInfoMetrics.cs b/DuckDB.NET.Data/Profiling/Statistics/ProfilingInfoMetrics.cs new file mode 100644 index 00000000..645cb9aa --- /dev/null +++ b/DuckDB.NET.Data/Profiling/Statistics/ProfilingInfoMetrics.cs @@ -0,0 +1,22 @@ +namespace DuckDB.NET.Data.Profiling.Statistics; + + +public sealed class ProfilingInfoMetrics : Dictionary +{ + internal static ProfilingInfoMetrics FromRawMetrics(Dictionary dict) + { + ArgumentNullException.ThrowIfNull(dict); + + ProfilingInfoMetrics result = []; + + foreach (var kvp in dict) + { + if (DuckDBMetricsExtensions.TryParseDuckDBMetricType(kvp.Key, out var metricType)) + { + result.Add(metricType, kvp.Value); + } + } + + return result; + } +} diff --git a/DuckDB.NET.Data/Profiling/Statistics/QueryProfiler.cs b/DuckDB.NET.Data/Profiling/Statistics/QueryProfiler.cs new file mode 100644 index 00000000..89ee2507 --- /dev/null +++ b/DuckDB.NET.Data/Profiling/Statistics/QueryProfiler.cs @@ -0,0 +1,69 @@ +using DuckDB.NET.Data.Common; +using DuckDB.NET.Data.Profiling.Statistics.Summary; +using System.Linq; + +namespace DuckDB.NET.Data.Profiling.Statistics; + +internal sealed class QueryProfiler(int queryIdentifier, int statementCount, DuckDBNativeConnection connection, ProfilingOptions? profilingOptions) +: ExecutionProfiler(connection, profilingOptions) +{ + private readonly StatementProfiler[] statementProfilers = new StatementProfiler[statementCount]; + + + internal int StatementCount => statementCount; + + internal int QueryIdentifier => queryIdentifier; + + internal StatementProfiler CreateStatementProfiler(int index) + { + var profiler = new StatementProfiler(index, duckDBNativeConnection, profilingOptions); + statementProfilers[index] = profiler; + return profiler; + } + + /// + /// Creates a summary of the profiling query, including execution times, statement information, and state. + /// + /// A object containing details about the query's execution, statements, state, and + /// any error message. + internal ProfilingQuerySummary GetSummary() + { + var childWithError = statementProfilers.Where(sp => sp?.State == DuckDBState.Error).FirstOrDefault(); + + state = state == DuckDBState.Error ? state : (childWithError?.State ?? DuckDBState.Success); + errorMessage = !string.IsNullOrEmpty(ErrorMessage) ? ErrorMessage : (childWithError?.ErrorMessage ?? string.Empty); + + return new ProfilingQuerySummary( + startTime, + endTime, + TimerUtils.TimerToMilliseconds(executionTime), + statementCount, + [.. statementProfilers.Select(sp => sp?.GetSummary() ?? default)], + state, + errorMessage + ); + } + + /// + /// Stops all the statement profilers associated with this query profiler. + /// + public override void StopTimer() + { + + foreach (var profiler in statementProfilers) + { + profiler?.StopTimer(); + } + + base.StopTimer(); + } + + /// + public override void Reset() + { + executionTime = 0; + startTimestamp = null; + startTime = default; + endTime = default; + } +} diff --git a/DuckDB.NET.Data/Profiling/Statistics/StatementProfiler.cs b/DuckDB.NET.Data/Profiling/Statistics/StatementProfiler.cs new file mode 100644 index 00000000..a30be188 --- /dev/null +++ b/DuckDB.NET.Data/Profiling/Statistics/StatementProfiler.cs @@ -0,0 +1,68 @@ +using DuckDB.NET.Data.Common; +using DuckDB.NET.Data.Profiling.Statistics.Summary; + +namespace DuckDB.NET.Data.Profiling.Statistics; + +internal sealed class StatementProfiler(int queryIndex, DuckDBNativeConnection connection, ProfilingOptions? profilingOptions) +: ExecutionProfiler(connection, profilingOptions) +{ + private Dictionary rawMetrics = []; + + /// + /// Gets the profiling metrics associated with the current operation. + /// + internal ProfilingInfoMetrics Info => ProfilingInfoMetrics.FromRawMetrics(rawMetrics); + + /// + /// Creates a summary of the profiling statement, including execution timing, order, metrics, state, and any associated + /// message. + /// + /// A instance containing the collected profiling data for the statement. + internal ProfilingStatementSummary GetSummary() + { + return new ProfilingStatementSummary( + StartTime: startTime, + EndTime: endTime, + ExecutionTimeMilliseconds: TimerUtils.TimerToMilliseconds(executionTime), + Order: queryIndex, + Metrics: Info, + State: state, + Message: ErrorMessage); + } + + /// + public override void AcquireMetrics() + { + // If a metrics threshold is set, check if the elapsed execution time meets the threshold before acquiring metrics. + var threshold = profilingOptions?.MetricsThreshold ?? 0; + if (threshold > 0) + { + // defensive: if timer wasn't started, skip acquiring metrics based on threshold + if (!startTimestamp.HasValue) + { + return; + } + + long elapsed = TimerUtils.CalculateTickCountElapsed(startTimestamp.Value, TimerUtils.TimerCurrent()); + + if (TimerUtils.TimerToMilliseconds(elapsed) < threshold) + return; + } + + using var profile = new ProfilingInfo(duckDBNativeConnection); + + if (profile.TryPrepare()) + { + rawMetrics = profile.GetRawMetrics(); + } + } + + /// + public override void Reset() + { + executionTime = 0; + startTimestamp = null; + startTime = default; + endTime = default; + } +} diff --git a/DuckDB.NET.Data/Profiling/Statistics/Summary/ProfilingQuerySummary.cs b/DuckDB.NET.Data/Profiling/Statistics/Summary/ProfilingQuerySummary.cs new file mode 100644 index 00000000..ad52c64d --- /dev/null +++ b/DuckDB.NET.Data/Profiling/Statistics/Summary/ProfilingQuerySummary.cs @@ -0,0 +1,25 @@ +namespace DuckDB.NET.Data.Profiling.Statistics.Summary; + +public readonly record struct ProfilingQuerySummary( + DateTimeOffset StartTime, + DateTimeOffset EndTime, + double ExecutionTimeMilliseconds, + int StatementCount, + ProfilingStatementSummary[] StatementSummaries, + DuckDBState State, + string Message) +{ + public IDictionary ToDictionary() + { + return new Dictionary(7) + { + ["StartTime"] = StartTime, + ["EndTime"] = EndTime, + ["ExecutionTime"] = ExecutionTimeMilliseconds, + ["StatementCount"] = StatementCount, + ["StatementSummaries"] = StatementSummaries, + ["State"] = State, + ["Message"] = Message + }; + } +} diff --git a/DuckDB.NET.Data/Profiling/Statistics/Summary/ProfilingStatementSummary.cs b/DuckDB.NET.Data/Profiling/Statistics/Summary/ProfilingStatementSummary.cs new file mode 100644 index 00000000..d2f8c98b --- /dev/null +++ b/DuckDB.NET.Data/Profiling/Statistics/Summary/ProfilingStatementSummary.cs @@ -0,0 +1,25 @@ +namespace DuckDB.NET.Data.Profiling.Statistics.Summary; + +public readonly record struct ProfilingStatementSummary( + DateTimeOffset StartTime, + DateTimeOffset EndTime, + double ExecutionTimeMilliseconds, + int Order, + ProfilingInfoMetrics Metrics, + DuckDBState State, + string Message) +{ + public IDictionary ToDictionary() + { + return new Dictionary(7) + { + ["StartTime"] = StartTime, + ["EndTime"] = EndTime, + ["ExecutionTime"] = ExecutionTimeMilliseconds, + ["Order"] = Order, + ["Metrics"] = Metrics, + ["State"] = State, + ["Message"] = Message + }; + } +} diff --git a/DuckDB.NET.Data/Profiling/Statistics/Summary/ProfilingSummary.cs b/DuckDB.NET.Data/Profiling/Statistics/Summary/ProfilingSummary.cs new file mode 100644 index 00000000..17b81721 --- /dev/null +++ b/DuckDB.NET.Data/Profiling/Statistics/Summary/ProfilingSummary.cs @@ -0,0 +1,23 @@ +namespace DuckDB.NET.Data.Profiling.Statistics.Summary; + +public readonly record struct ProfilingSummary( + DateTimeOffset StartTime, + DateTimeOffset EndTime, + double ConnectionTimeMilliseconds, + double ExecutionTimeMilliseconds, + int QueryCount, + ProfilingQuerySummary[]? QuerySummaryList) +{ + public IDictionary ToDictionary() + { + return new Dictionary(6) + { + ["StartTime"] = StartTime, + ["EndTime"] = EndTime, + ["ConnectionTime"] = ConnectionTimeMilliseconds, + ["ExecutionTime"] = ExecutionTimeMilliseconds, + ["QueryCount"] = QueryCount, + ["QuerySummaryList"] = QuerySummaryList ?? Array.Empty() + }; + } +} diff --git a/DuckDB.NET.Samples/NativeDebugResolver.cs b/DuckDB.NET.Samples/NativeDebugResolver.cs new file mode 100644 index 00000000..0428e72e --- /dev/null +++ b/DuckDB.NET.Samples/NativeDebugResolver.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Reflection; +using System.Runtime.InteropServices; + +internal static class NativeDebugResolver +{ + private static IntPtr _duckdbHandle; + private static bool _initialized; + + public static void Initialize(string DuckDBDebugdllPath) + { + if (_initialized) return; + _initialized = true; + + string repo = DuckDBDebugdllPath; + string duckdbDir = Path.Combine(repo, "build", "debug", "src", "Debug"); + + string duckdbPath = Path.Combine(duckdbDir, "duckdb.dll"); + + if (!File.Exists(duckdbPath)) throw new FileNotFoundException("duckdb.dll not found", duckdbPath); + + // Optional but useful: preload debug runtime deps near duckdb.dll + //PreloadIfExists(Path.Combine(duckdbDir, "zlibd1.dll")); + //PreloadIfExists(Path.Combine(duckdbDir, "libcrypto-3-x64.dll")); + //PreloadIfExists(Path.Combine(duckdbDir, "libssl-3-x64.dll")); + //PreloadIfExists(Path.Combine(duckdbDir, "libcurl-d.dll")); + + _duckdbHandle = NativeLibrary.Load(duckdbPath); + + // Install resolver for the assembly that contains your DllImport declarations. + // If your DllImports are in another assembly, pass that assembly instead. + NativeLibrary.SetDllImportResolver( + typeof(NativeDebugResolver).Assembly, + Resolve); + } + + private static IntPtr Resolve(string libraryName, Assembly assembly, DllImportSearchPath? searchPath) + { + if (IsDuckDbName(libraryName)) + { + return _duckdbHandle; + } + + return IntPtr.Zero; + } + + private static bool IsDuckDbName(string name) + { + return name.Equals("duckdb", StringComparison.OrdinalIgnoreCase) + || name.Equals("duckdb.dll", StringComparison.OrdinalIgnoreCase); + } + + private static void PreloadIfExists(string path) + { + if (File.Exists(path)) + { + NativeLibrary.Load(path); + } + } + +} \ No newline at end of file diff --git a/DuckDB.NET.Samples/Program.cs b/DuckDB.NET.Samples/Program.cs index f575c49c..f9555336 100644 --- a/DuckDB.NET.Samples/Program.cs +++ b/DuckDB.NET.Samples/Program.cs @@ -1,12 +1,19 @@ using Dapper; using DuckDB.NET.Data; +using DuckDB.NET.Data.Profiling; +using DuckDB.NET.Data.Profiling.Statistics; +using DuckDB.NET.Data.Profiling.Statistics.Summary; using DuckDB.NET.Native; using DuckDB.NET.Test.Helpers; using System; +using System.Collections; +using System.Collections.Generic; +using System.Data; using System.Data.Common; using System.Diagnostics; using System.IO; using System.Linq; +using System.Reflection.Metadata; using static DuckDB.NET.Native.NativeMethods; namespace DuckDB.NET.Samples @@ -20,6 +27,10 @@ static void Main(string[] args) Console.Error.WriteLine("native assembly not found"); return; } + //NativeDebugResolver.Initialize(); + + PrintVersion(); + DapperSample(); @@ -28,6 +39,22 @@ static void Main(string[] args) LowLevelBindingsSample(); BulkDataLoad(); + + ParametersBinding(); + + //Profiling(); + } + + private static void PrintVersion() + { + using var con = new DuckDBConnection("data source=:memory:"); + con.Open(); + + using var cmd = con.CreateCommand(); + cmd.CommandText = "PRAGMA Version"; + + var version = cmd.ExecuteScalar(); + Console.WriteLine("DuckDB version: {0}", version); } private static void DapperSample() @@ -137,7 +164,7 @@ private static void LowLevelBindingsSample() } } } - + private static void BulkDataLoad() { Stopwatch stopwatch = Stopwatch.StartNew(); @@ -167,8 +194,8 @@ private static void BulkDataLoad() var row = appender.CreateRow(); row .AppendValue(i) - .AppendValue(Convert.ToSingle(i+2)) - .AppendValue(Convert.ToDouble(i+4)) + .AppendValue(Convert.ToSingle(i + 2)) + .AppendValue(Convert.ToDouble(i + 4)) .AppendValue($"varchar {i.ToString()}") .EndRow(); } @@ -185,7 +212,132 @@ private static void BulkDataLoad() Console.WriteLine($"ElapsedMilliseconds {stopwatch.ElapsedMilliseconds}"); Console.WriteLine($"connection state before {connection.State}"); Console.WriteLine($"connection state after {connection.State}"); + + } + + private static void ParametersBinding() + { + + using var duckDBConnection = new DuckDBConnection("Data Source=:memory:"); + duckDBConnection.Open(); + + var testDicto = new Dictionary + { + ["foo"] = "42", + ["bar"] = "test" + }; + var testList = new List { "foo", "bar" }; + var testDictoList = new Dictionary> + { + ["foo"] = testList + }; + + using var command = duckDBConnection.CreateCommand(); + command.CommandText = "CREATE TABLE TestTable (foo MAP(string, string), bar MAP(string, string[]), baz string[])"; + command.ExecuteNonQuery(); + + command.CommandText = "INSERT INTO TestTable (foo, bar, baz) VALUES ($testDicto, $testDictoList, $testList)"; + command.Parameters.Add(new DuckDBParameter(testDicto)); + command.Parameters.Add(new DuckDBParameter(testDictoList)); + command.Parameters.Add(new DuckDBParameter(testList)); + command.ExecuteNonQuery(); + + command.CommandText = "SELECT foo, bar, baz FROM TestTable"; + using var reader = command.ExecuteReader(); + PrintQueryResults(reader); + } + + private static void Profiling() + { + + using var con = new DuckDBConnection("data source=:memory:"); + var options = new ProfilingOptions + { + Format = DuckDBProfilingFormat.NoOutput, + Mode = DuckDBProfilingMode.Detailed, + Coverage = DuckDBProfilingCoverage.Select, + EnabledMetrics = + [ + DuckDBMetricType.QueryName, + DuckDBMetricType.Latency, + DuckDBMetricType.CpuTime, + DuckDBMetricType.ResultSetSize, + DuckDBMetricType.CumulativeRowsScanned, + DuckDBMetricType.TotalBytesRead, + DuckDBMetricType.TotalBytesWritten, + DuckDBMetricType.TotalMemoryAllocated, + DuckDBMetricType.SystemPeakBufferMemory, + DuckDBMetricType.SystemPeakTempDirSize, + DuckDBMetricType.WriteToWalLatency, + ], + MetricsThreshold = 100 + }; + + con.Open(); + + var id = Guid.NewGuid().ToString("N"); + + using var dBCommand = con.CreateCommand(); + + LoadTpch(con); + + con.EnableProfiling(options); + + using var cmd = con.CreateCommand(); + cmd.CommandText = "SELECT a:1;"; + using var reader = cmd.ExecuteReader(); + var metrics = con.RetrieveStatistics(); + + cmd.CommandText = $"PRAGMA tpch(1);"; + cmd.ExecuteNonQuery(); + + metrics = con.RetrieveStatistics(); + + Console.WriteLine($"QuerySummaryList: {metrics.QuerySummaryList?.Length}"); + + cmd.CommandText = $"PRAGMA tpch(2); CREATE TABLE test (id int);"; + cmd.ExecuteNonQuery(); + + metrics = con.RetrieveStatistics(); + Console.WriteLine($"QuerySummaryList: {metrics.QuerySummaryList?.Length}"); + + PrintMetrics(metrics); + } + + private static void LoadTpch(DuckDBConnection connection, int scaleFactor = 1) + { + using var cmd = connection.CreateCommand(); + cmd.CommandText = "INSTALL tpch;"; + cmd.ExecuteNonQuery(); + cmd.CommandText = "LOAD tpch;"; + cmd.ExecuteNonQuery(); + // Generate TPCH sf=1 dataset into the current database + cmd.CommandText = $"CALL dbgen(sf={scaleFactor});"; + cmd.ExecuteNonQuery(); + } + + private static void PrintMetrics(ProfilingSummary profilingSummary) + { + profilingSummary.ToDictionary().ToList().ForEach(kv => Console.WriteLine($"{kv.Key}: {(kv.Value is ProfilingQuerySummary[] ? PrintQueryInfo((ProfilingQuerySummary[])kv.Value) : kv.Value)}")); + } + private static string PrintQueryInfo(ProfilingQuerySummary[] profilingSummary) + { + return string.Join("", profilingSummary.Select((q, i) => $"\nQuery {i}: " + (PrintQueryInfo(q)))); + } + private static string PrintQueryInfo(ProfilingQuerySummary profilingSummary) + { + return string.Join("", profilingSummary.ToDictionary().ToList().Select(kv => $"\n\t{kv.Key}: {(kv.Value is ProfilingInfoMetrics[] ? PrintStatementMetrics((ProfilingInfoMetrics[])kv.Value) : kv.Value)}")); + } + + private static string PrintStatementMetrics(ProfilingInfoMetrics[] profilingSummaries) + { + return string.Join("", profilingSummaries.Select((s, i) => $"\n\tStatement {i}: " + PrintStatementMetrics(s))); + } + + private static string PrintStatementMetrics(ProfilingInfoMetrics profilingSummary) + { + return string.Join("", profilingSummary.ToDictionary().ToList().Select(kv => $"\n\t\t{kv.Key}: {kv.Value}")); } private static void PrintQueryResults(DbDataReader queryResult) @@ -197,7 +349,7 @@ private static void PrintQueryResults(DbDataReader queryResult) } Console.WriteLine(); - + while (queryResult.Read()) { for (int ordinal = 0; ordinal < queryResult.FieldCount; ordinal++) @@ -208,7 +360,9 @@ private static void PrintQueryResults(DbDataReader queryResult) continue; } var val = queryResult.GetValue(ordinal); - Console.Write(val); + + Console.Write(FormatValue(val)); + Console.Write(" "); } @@ -240,6 +394,38 @@ private static void PrintQueryResults(DuckDBResult queryResult) Console.WriteLine(); } } + + private static string FormatValue(object? value) + { + if (value is null) + { + return "NULL"; + } + + if (value is string s) + { + return s; + } + + if (value is IDictionary dictionary) + { + var parts = new List(); + foreach (DictionaryEntry entry in dictionary) + { + parts.Add($"{FormatValue(entry.Key)}:{FormatValue(entry.Value)}"); + } + + return "{" + string.Join(", ", parts) + "}"; + } + + if (value is IEnumerable enumerable) + { + var parts = enumerable.Cast().Select(FormatValue); + return "[" + string.Join("|", parts) + "]"; + } + + return value.ToString() ?? string.Empty; + } } class FooBar diff --git a/DuckDB.NET.Test/Parameters/MapParameterTests.cs b/DuckDB.NET.Test/Parameters/MapParameterTests.cs new file mode 100644 index 00000000..aafe74a1 --- /dev/null +++ b/DuckDB.NET.Test/Parameters/MapParameterTests.cs @@ -0,0 +1,205 @@ +using System.Collections; + +namespace DuckDB.NET.Test.Parameters; + +public class MapParameterTests(DuckDBDatabaseFixture db) : DuckDBTestBase(db) +{ + public sealed record MapTypeCase( + string Name, + string DuckDbType, + Type ClrType, + Func Generator, + Func Normalize); + + private static readonly MapTypeCase[] ScalarTypeCases = + [ + new("bool", "bool", typeof(bool), faker => faker.Random.Bool(), value => value), + new("sbyte", "tinyint", typeof(sbyte), faker => faker.Random.SByte(), value => value), + new("short", "SmallInt", typeof(short), faker => faker.Random.Short(), value => value), + new("int", "int", typeof(int), faker => faker.Random.Int(), value => value), + new("long", "BigInt", typeof(long), faker => faker.Random.Long(), value => value), + new("hugeint", "HugeInt", typeof(BigInteger), faker => BigInteger.Subtract(DuckDBHugeInt.HugeIntMaxValue, faker.Random.Int(min: 0)), value => value), + new("byte", "UTinyInt", typeof(byte), faker => faker.Random.Byte(), value => value), + new("ushort", "USmallInt", typeof(ushort), faker => faker.Random.UShort(), value => value), + new("uint", "UInteger", typeof(uint), faker => faker.Random.UInt(), value => value), + new("ulong", "UBigInt", typeof(ulong), faker => faker.Random.ULong(), value => value), + new("float", "Float", typeof(float), faker => faker.Random.Float(), value => value), + new("double", "Double", typeof(double), faker => faker.Random.Double(), value => value), + new("decimal", "Decimal(38, 28)", typeof(decimal), faker => faker.Random.Decimal(), value => value), + new("guid", "UUID", typeof(Guid), faker => faker.Random.Uuid(), value => value), + new("datetime", "Date", typeof(DateTime), faker => faker.Date.Past().Date, NormalizeDateValue), + new("datetimeoffset", "TimeTZ", typeof(DateTimeOffset), faker => GetRandomTimeTz(faker), value => value), + new("string", "String", typeof(string), faker => faker.Random.Utf16String(), value => value), + new("interval", "Interval", typeof(TimeSpan), faker => + { + var timespan = faker.Date.Timespan(); + return TimeSpan.FromTicks(timespan.Ticks - timespan.Ticks % 10); + }, value => value), + new("duckdbdateonly", "Date", typeof(DuckDBDateOnly), faker => (DuckDBDateOnly)faker.Date.Past().Date, NormalizeDateValue), + new("duckdbtimeonly", "Time", typeof(DuckDBTimeOnly), faker => (DuckDBTimeOnly)faker.Date.Past(), NormalizeTimeValue), + new("dateonly", "Date", typeof(DateOnly), faker => DateOnly.FromDateTime(faker.Date.Past().Date), NormalizeDateValue), + new("timeonly", "Time", typeof(TimeOnly), faker => + { + var dateTime = faker.Date.Past(); + return new TimeOnly(dateTime.TimeOfDay.Ticks - dateTime.TimeOfDay.Ticks % 10); + }, NormalizeTimeValue) + ]; + + public static IEnumerable ScalarKeyValueCombinations => + from keyCase in ScalarTypeCases + from valueCase in ScalarTypeCases + select new object[] { keyCase, valueCase }; + + [Theory] + [MemberData(nameof(ScalarKeyValueCombinations))] + public void CanBindMapForEveryScalarKeyAndValueCombination(MapTypeCase keyCase, MapTypeCase valueCase) + { + var map = CreateMap(keyCase, valueCase); + + Action act = () => InsertSelectMap(keyCase, valueCase, map); + act.Should().NotThrow($"key={keyCase.Name}, value={valueCase.Name}"); + } + + [Fact] + public void CanBindMapWithNestedListValues() + { + var value = new Dictionary> + { + ["one"] = new List { 1, 2, 0 }, + ["two"] = new List { 3, 0, 5 } + }; + + InsertSelectMap("String", "INTEGER[]", value); + } + + [Fact] + public void CanBindMapWithNestedMapValues() + { + var value = new Dictionary> + { + [1] = new Dictionary + { + ["a"] = new TimeOnly(11, 22, 33), + ["b"] = new TimeOnly(9, 5, 2) + }, + [2] = new Dictionary + { + ["x"] = new TimeOnly(6, 30, 45) + } + }; + + InsertSelectMap("INTEGER", "MAP(String, Time)", value); + } + + private void InsertSelectMap(string keyDuckDbType, string valueDuckDbType, IDictionary map) + { + Command.Parameters.Clear(); + Command.CommandText = $"CREATE OR REPLACE TABLE ParameterMapTest (a MAP({keyDuckDbType}, {valueDuckDbType}));"; + Command.ExecuteNonQuery(); + + Command.CommandText = "INSERT INTO ParameterMapTest (a) VALUES ($map);"; + Command.Parameters.Add(new DuckDBParameter(map)); + Command.ExecuteNonQuery(); + + Command.Parameters.Clear(); + Command.CommandText = "SELECT a FROM ParameterMapTest;"; + + using var reader = Command.ExecuteReader(); + reader.Read().Should().BeTrue(); + + var value = reader.GetValue(0); + value.Should().BeEquivalentTo(map); + } + + private void InsertSelectMap(MapTypeCase keyCase, MapTypeCase valueCase, IDictionary map) + { + Command.Parameters.Clear(); + Command.CommandText = $"CREATE OR REPLACE TABLE ParameterMapTest (a MAP({keyCase.DuckDbType}, {valueCase.DuckDbType}));"; + Command.ExecuteNonQuery(); + + Command.CommandText = "INSERT INTO ParameterMapTest (a) VALUES ($map);"; + Command.Parameters.Add(new DuckDBParameter(map)); + Command.ExecuteNonQuery(); + + Command.Parameters.Clear(); + Command.CommandText = "SELECT a FROM ParameterMapTest;"; + + using var reader = Command.ExecuteReader(); + reader.Read().Should().BeTrue(); + + var actual = (IDictionary)reader.GetValue(0); + + var expectedEntries = map.Keys.Cast() + .Select(key => new + { + Key = keyCase.Normalize(key), + Value = valueCase.Normalize(map[key]!) + }); + + var actualEntries = actual.Keys.Cast() + .Select(key => new + { + Key = keyCase.Normalize(key), + Value = valueCase.Normalize(actual[key]!) + }); + + actualEntries.Should().BeEquivalentTo(expectedEntries); + } + + private IDictionary CreateMap(MapTypeCase keyCase, MapTypeCase valueCase) + { + var dictionaryType = typeof(Dictionary<,>).MakeGenericType(keyCase.ClrType, valueCase.ClrType); + var map = (IDictionary)Activator.CreateInstance(dictionaryType)!; + + object? key = null; + var attempts = 0; + + while (key is null && attempts < 10) + { + key = keyCase.Generator(Faker); + attempts++; + } + + key.Should().NotBeNull(); + var value = valueCase.Generator(Faker); + map.Add(key!, value); + + return map; + } + + private static DateTimeOffset GetRandomTimeTz(Faker faker) + { + var dateTime = faker.Date.Between(DateTime.Now.AddYears(-100), DateTime.Now.AddYears(100)); + + if (dateTime.Hour < 1) + { + dateTime = dateTime.AddHours(1); + } + + dateTime = dateTime.AddTicks(-dateTime.Ticks % 10); + + return new DateTimeOffset(DateTime.SpecifyKind(dateTime, DateTimeKind.Unspecified), TimeSpan.FromHours(1)); + } + + private static object NormalizeDateValue(object value) + { + return value switch + { + DateTime dateTime => DateOnly.FromDateTime(dateTime), + DuckDBDateOnly duckDBDateOnly => (DateOnly)duckDBDateOnly, + DateOnly dateOnly => dateOnly, + _ => value + }; + } + + private static object NormalizeTimeValue(object value) + { + return value switch + { + DuckDBTimeOnly duckDBTimeOnly => (TimeOnly)duckDBTimeOnly, + TimeOnly timeOnly => timeOnly, + DateTime dateTime => TimeOnly.FromDateTime(dateTime), + _ => value + }; + } +} \ No newline at end of file diff --git a/DuckDB.NET.Test/Profiling/ProfilingTests.cs b/DuckDB.NET.Test/Profiling/ProfilingTests.cs new file mode 100644 index 00000000..a747ab27 --- /dev/null +++ b/DuckDB.NET.Test/Profiling/ProfilingTests.cs @@ -0,0 +1,1275 @@ +using DuckDB.NET.Data.Common; +using DuckDB.NET.Data.Extensions; +using DuckDB.NET.Data.Profiling; +using DuckDB.NET.Test.Helpers; +using System.Text.Json; +using System.Threading; + +namespace DuckDB.NET.Test.Profiling; + +public class ProfilingTests(DuckDBDatabaseFixture db) : DuckDBTestBase(db) +{ + + [Fact] + public void ProfilingSummariesContainCorrectStateAndMessageOnSuccess() + { + var options = new ProfilingOptions + { + Coverage = DuckDBProfilingCoverage.All, + EnabledMetrics = new DuckDBMetricTypeCollection(), + Format = DuckDBProfilingFormat.Json, + Mode = DuckDBProfilingMode.Standard + }; + + Connection.EnableProfiling(options); + + try + { + Connection.ResetStatistics(); + + Command.CommandText = "SELECT 1;"; + using (var r = Command.ExecuteReader()) { } + + var summary = Connection.RetrieveStatistics(); + summary.QuerySummaryList.Length.Should().BeGreaterThan(0); + + var last = summary.QuerySummaryList.Last(); + // Expect overall query to be successful + last.State.Should().Be(DuckDBState.Success); + last.Message.Should().BeNullOrEmpty(); + + // Expect constituent statements to be successful as well + if (last.StatementSummaries.Length > 0) + { + var stmt = last.StatementSummaries.Last(); + stmt.State.Should().Be(DuckDBState.Success); + stmt.Message.Should().BeNullOrEmpty(); + } + } + finally + { + Connection.DisableProfiling(); + } + } + + [Fact] + public void DisableProfiling_WithResetTrue_ClearsStatistics() + { + var options = new ProfilingOptions + { + Coverage = DuckDBProfilingCoverage.All, + EnabledMetrics = new DuckDBMetricTypeCollection { DuckDBMetricType.QueryName }, + Format = DuckDBProfilingFormat.Json, + Mode = DuckDBProfilingMode.Standard + }; + + Connection.EnableProfiling(options); + + try + { + // run a first batch to collect stats + Command.CommandText = "SELECT 1;"; + using (var r = Command.ExecuteReader()) { } + + var before = Connection.RetrieveStatistics(); + before.QueryCount.Should().BeGreaterThan(0, "Expected some profiling data before disabling with reset"); + + // disable and reset statistics + Connection.DisableProfiling(true); + + var after = Connection.RetrieveStatistics(); + after.QueryCount.Should().Be(0, "Expected QueryCount to be zero after DisableProfiling(true)"); + } + finally + { + try { Connection.DisableProfiling(true); } catch { } + } + } + + [Fact] + public void DisableProfiling_StopsWritingFiles() + { + // create a temp directory to be used as profiling output + var tempLocation = Path.Combine(Path.GetTempPath(), "duckdb_profile_output_" + Guid.NewGuid().ToString("N"), "profile.json"); + var tempDir = Path.GetDirectoryName(tempLocation)!; + Directory.CreateDirectory(tempDir); + + try + { + var options = new ProfilingOptions + { + Coverage = DuckDBProfilingCoverage.All, + EnabledMetrics = new DuckDBMetricTypeCollection { DuckDBMetricType.QueryName }, + Format = DuckDBProfilingFormat.Json, + Mode = DuckDBProfilingMode.Standard, + OutputPath = tempLocation + }; + + Connection.EnableProfiling(options); + + try + { + Connection.ResetStatistics(); + + Command.CommandText = "SELECT 1;"; + using (var r = Command.ExecuteReader()) { } + + // give DuckDB a small amount of time to flush files + var jsonFiles = Array.Empty(); + for (int i = 0; i < 10; i++) + { + jsonFiles = [.. Directory.EnumerateFiles(tempDir, "*.json", SearchOption.AllDirectories)]; + if (jsonFiles.Length > 0) break; + Thread.Sleep(200); + } + + jsonFiles.Length.Should().BeGreaterThan(0, "Expected at least one JSON file in the profiling output path after enabling with OutputPath"); + + var beforeCount = jsonFiles.Length; + + // Disable profiling + Connection.DisableProfiling(); + + Connection.ResetStatistics(); + + // run another query; no new files should be produced + Command.CommandText = "SELECT 2;"; + using (var r = Command.ExecuteReader()) { } + + // wait a short while and re-check files + var jsonFilesAfter = Array.Empty(); + for (int i = 0; i < 10; i++) + { + jsonFilesAfter = [.. Directory.EnumerateFiles(tempDir, "*.json", SearchOption.AllDirectories)]; + if (jsonFilesAfter.Length != beforeCount) break; + Thread.Sleep(200); + } + + // After disabling profiling, directory should not gain additional JSON files + jsonFilesAfter.Length.Should().Be(beforeCount, "No additional profiling JSON files should be created after disabling profiling"); + } + finally + { + try { Connection.DisableProfiling(); } catch { } + } + } + finally + { + try { Directory.Delete(tempDir, true); } catch { } + } + } + + [Fact] + public void EnableProfiling_UsesOutputPathConfiguration() + { + // create a temp directory to be used as profiling output + var tempLocation = Path.Combine(Path.GetTempPath(), "duckdb_profile_output_" + Guid.NewGuid().ToString("N"), "profile.json"); + var tempDir = Path.GetDirectoryName(tempLocation)!; + Directory.CreateDirectory(Path.GetDirectoryName(tempLocation)!); + + var options = new ProfilingOptions + { + Coverage = DuckDBProfilingCoverage.All, + EnabledMetrics = new DuckDBMetricTypeCollection { DuckDBMetricType.QueryName }, + Format = DuckDBProfilingFormat.Json, + Mode = DuckDBProfilingMode.Standard + }; + + try + { + Connection.EnableProfiling(options); + + Connection.ResetStatistics(); + + Command.CommandText = "SELECT 1;"; + using (var r = Command.ExecuteReader()) { } + + // If DuckDB writes files to the provided OutputPath, the directory should remain accessible + // We can't reliably assert that DuckDB created files in all environments, but we verify the option was accepted + var summary = Connection.RetrieveStatistics(); + summary.QuerySummaryList.Length.Should().BeGreaterThan(0); + } + finally + { + Connection.DisableProfiling(); + try { Directory.Delete(tempDir, true); } catch { } + } + } + + [Fact] + public void EnableProfiling_WritesJsonToOutputPath_WithQueryNameProperty() + { + // create a temp directory to be used as profiling output + var tempLocation = Path.Combine(Path.GetTempPath(), "duckdb_profile_output_" + Guid.NewGuid().ToString("N"), "profile.json"); + var tempDir = Path.GetDirectoryName(tempLocation)!; + Directory.CreateDirectory(tempDir); + + var options = new ProfilingOptions + { + Coverage = DuckDBProfilingCoverage.All, + EnabledMetrics = new DuckDBMetricTypeCollection { DuckDBMetricType.QueryName }, + Format = DuckDBProfilingFormat.Json, + Mode = DuckDBProfilingMode.Standard, + OutputPath = tempLocation + }; + + try + { + + Connection.EnableProfiling(options); + Connection.ResetStatistics(); + + Command.CommandText = "SELECT 1;"; + using (var r = Command.ExecuteReader()) { } + + // give DuckDB a small amount of time to flush files + var jsonFiles = Array.Empty(); + for (int i = 0; i < 10; i++) + { + jsonFiles = Directory.EnumerateFiles(tempDir, "*.json", SearchOption.AllDirectories).ToArray(); + if (jsonFiles.Length > 0) break; + Thread.Sleep(200); + } + + jsonFiles.Length.Should().BeGreaterThan(0, "Expected at least one JSON file in the profiling output path"); + + // parse the first JSON file and assert it contains a property named "QueryName" somewhere + var json = File.ReadAllText(jsonFiles[0]); + using var doc = JsonDocument.Parse(json); + + bool found = false; + void Search(JsonElement el) + { + if (found) return; + if (el.ValueKind == JsonValueKind.Object) + { + foreach (var prop in el.EnumerateObject()) + { + if (string.Equals(prop.Name, DuckDBMetricsExtensions.ToDuckDBMetricTypeString(DuckDBMetricType.QueryName), StringComparison.OrdinalIgnoreCase)) + { + found = true; + return; + } + Search(prop.Value); + if (found) return; + } + } + else if (el.ValueKind == JsonValueKind.Array) + { + foreach (var item in el.EnumerateArray()) + { + Search(item); + if (found) return; + } + } + } + + Search(doc.RootElement); + found.Should().BeTrue("Expected the profiling JSON to contain a 'QueryName' property when QueryName metric is enabled"); + } + finally + { + Connection.DisableProfiling(); + try { Directory.Delete(tempDir, true); } catch { } + } + } + + [Fact] + public void EditingProfilingOptions_RemovesOutputPath_WhenCleared() + { + var tempLocation = Path.Combine(Path.GetTempPath(), "duckdb_profile_output_" + Guid.NewGuid().ToString("N"), "profile.json"); + var tempDir = Path.GetDirectoryName(tempLocation)!; + Directory.CreateDirectory(tempDir); + + try + { + var options = new ProfilingOptions + { + Coverage = DuckDBProfilingCoverage.All, + EnabledMetrics = new DuckDBMetricTypeCollection { DuckDBMetricType.QueryName }, + Format = DuckDBProfilingFormat.Json, + Mode = DuckDBProfilingMode.Standard, + OutputPath = tempLocation + }; + + Connection.EnableProfiling(options); + + try + { + Connection.ResetStatistics(); + + Command.CommandText = "SELECT 1;"; + using (var r = Command.ExecuteReader()) { } + + var jsonFiles = Array.Empty(); + for (int i = 0; i < 10; i++) + { + jsonFiles = Directory.EnumerateFiles(tempDir, "*.json", SearchOption.AllDirectories).ToArray(); + if (jsonFiles.Length > 0) break; + Thread.Sleep(200); + } + + jsonFiles.Length.Should().BeGreaterThan(0, "Expected at least one JSON file in the profiling output path after enabling with OutputPath"); + var beforeCount = jsonFiles.Length; + + var newOptions = new ProfilingOptions + { + Coverage = DuckDBProfilingCoverage.All, + EnabledMetrics = new DuckDBMetricTypeCollection { DuckDBMetricType.QueryName }, + Format = DuckDBProfilingFormat.Json, + Mode = DuckDBProfilingMode.Standard + }; + + Connection.EditProfilingOptions(newOptions); + Connection.ResetStatistics(); + + Command.CommandText = "SELECT 2;"; + using (var r = Command.ExecuteReader()) { } + + var jsonFilesAfter = Array.Empty(); + for (int i = 0; i < 10; i++) + { + jsonFilesAfter = Directory.EnumerateFiles(tempDir, "*.json", SearchOption.AllDirectories).ToArray(); + if (jsonFilesAfter.Length != beforeCount) break; + Thread.Sleep(200); + } + + jsonFilesAfter.Length.Should().Be(beforeCount, "No additional profiling JSON files should be created after clearing OutputPath"); + } + finally + { + Connection.DisableProfiling(); + } + } + finally + { + try { Directory.Delete(tempDir, true); } catch { } + } + } + + [Fact] + public void ProfilingSummary_ConversionsAreConsistent() + { + var options = new ProfilingOptions + { + Coverage = DuckDBProfilingCoverage.All, + EnabledMetrics = new DuckDBMetricTypeCollection { DuckDBMetricType.QueryName }, + Format = DuckDBProfilingFormat.Json, + Mode = DuckDBProfilingMode.Standard + }; + + Connection.EnableProfiling(options); + + try + { + Connection.ResetStatistics(); + + // run two queries with measurable time + Command.CommandText = "SELECT SUM(i) FROM range(200000) AS t(i);"; + using (var r = Command.ExecuteReader()) { } + + Command.CommandText = "SELECT 1;"; + using (var r = Command.ExecuteReader()) { } + + var summary = Connection.RetrieveStatistics(); + summary.QuerySummaryList.Length.Should().BeGreaterThanOrEqualTo(1); + + // connection-level: wall-clock vs stored connection time (milliseconds) + var wallConnMs = (summary.EndTime - summary.StartTime).TotalMilliseconds; + Math.Abs(wallConnMs - summary.ConnectionTimeMilliseconds).Should().BeLessThan(250, "Connection time conversion should match wall-clock duration"); + + // execution time (total) should equal summed query execution times + var sumQueryExecMs = summary.QuerySummaryList.Sum(q => q.ExecutionTimeMilliseconds); + Math.Abs(sumQueryExecMs - summary.ExecutionTimeMilliseconds).Should().BeLessThan(200, "Total execution time should match sum of query execution times"); + + // per-query: check wall-clock vs measured execution time and statement sum + foreach (var q in summary.QuerySummaryList) + { + var wallMs = (q.EndTime - q.StartTime).TotalMilliseconds; + Math.Abs(wallMs - q.ExecutionTimeMilliseconds).Should().BeLessThan(250, "Query execution conversion should match wall-clock duration"); + + var sumStmtMs = q.StatementSummaries.Sum(s => s.ExecutionTimeMilliseconds); + Math.Abs(sumStmtMs - q.ExecutionTimeMilliseconds).Should().BeLessThan(200, "Query execution time should match sum of statement execution times"); + } + } + finally + { + Connection.DisableProfiling(); + } + } + + [Fact] + public void TimerUtils_ConversionsAreConsistent() + { + // measure a short delay using Stopwatch ticks and verify conversions + long start = System.Diagnostics.Stopwatch.GetTimestamp(); + Thread.Sleep(120); + long end = System.Diagnostics.Stopwatch.GetTimestamp(); + + long elapsed = end - start; + + var msFromTimer = TimerUtils.TimerToMilliseconds(elapsed); + var span = TimerUtils.TimerToTimeSpan(elapsed); + + // both conversions should be approximately the same + Math.Abs(((long)span.TotalMilliseconds) - msFromTimer).Should().BeLessOrEqualTo(2); + + // and should be close to the real sleep duration + msFromTimer.Should().BeGreaterOrEqualTo(100); + } + + [Fact] + public void Profiler_StartStop_ProducesConsistentTiming() + { + var options = new ProfilingOptions { Coverage = DuckDBProfilingCoverage.All }; + Connection.EnableProfiling(options); + + try + { + Connection.ResetStatistics(); + + // run a query that takes measurable time and is visible through public RetrieveStatistics API + Command.CommandText = "SELECT SUM(i) FROM range(200000) AS t(i);"; + using (var r = Command.ExecuteReader()) { } + + var summary = Connection.RetrieveStatistics(); + summary.QuerySummaryList.Length.Should().BeGreaterThan(0); + + var last = summary.QuerySummaryList.Last(); + + // execution time should be non-zero and reasonable + last.ExecutionTimeMilliseconds.Should().BeGreaterThan(0); + + var wallMs = (last.EndTime - last.StartTime).TotalMilliseconds; + + // the wall-clock duration and the measured execution time should be reasonably close + Math.Abs(wallMs - last.ExecutionTimeMilliseconds).Should().BeLessThan(200, "Timer conversion should be consistent with wall-clock duration"); + } + finally + { + Connection.DisableProfiling(); + } + } + + [Fact] + public void QueryAndStatementStatusAndTimingWhenPrepareFails() + { + var options = new ProfilingOptions + { + Coverage = DuckDBProfilingCoverage.All, + EnabledMetrics = new DuckDBMetricTypeCollection(), + Format = DuckDBProfilingFormat.Json, + Mode = DuckDBProfilingMode.Standard + }; + + Connection.EnableProfiling(options); + + try + { + Connection.ResetStatistics(); + + var id = Guid.NewGuid().ToString("N"); + var tbl = $"pfp_{id}"; + + // batch where one statement will fail at prepare time (syntax error) + Command.CommandText = $"CREATE TABLE {tbl}(i INTEGER); BAD SYNTAX HERE; SELECT 1;"; + + try + { + using (var r = Command.ExecuteReader()) { do { } while (r.NextResult()); } + } + catch (Exception) + { + // swallow - prepare/execute is expected to throw + } + + var summary = Connection.RetrieveStatistics(); + summary.QuerySummaryList.Length.Should().BeGreaterThan(0); + + var last = summary.QuerySummaryList.Last(); + + // Query-level state should reflect the failure + last.State.Should().Be(DuckDBState.Error); + last.Message.Should().NotBeNullOrWhiteSpace(); + + // Timing at query-level should be well-formed + last.ExecutionTimeMilliseconds.Should().BeGreaterOrEqualTo(0); + (last.StartTime <= last.EndTime).Should().BeTrue("StartTime should be less than or equal to EndTime"); + + last.StatementSummaries.Should().NotBeNull(); + last.StatementSummaries.Length.Should().Be(0, "No statements should be recorded when prepare fails"); + + } + finally + { + Connection.DisableProfiling(); + } + } + + [Fact] + public void QueryAndStatementStatusAndTimingWhenStatementFails() + { + var options = new ProfilingOptions + { + Coverage = DuckDBProfilingCoverage.All, + EnabledMetrics = new DuckDBMetricTypeCollection(), + Format = DuckDBProfilingFormat.Json, + Mode = DuckDBProfilingMode.Standard + }; + + Connection.EnableProfiling(options); + + try + { + Connection.ResetStatistics(); + + var id = Guid.NewGuid().ToString("N"); + var tbl = $"pf_{id}"; + + // batch with multiple statements where the last one will fail + Command.CommandText = $"CREATE TABLE {tbl}(i INTEGER); INSERT INTO {tbl} VALUES (1); SELECT i FROM {tbl}; SELECT * FROM __this_table_does_not_exist__;"; + + try + { + using (var r = Command.ExecuteReader()) { do { } while (r.NextResult()); } + } + catch (Exception) + { + // swallow - we expect an error for the failing statement + } + + var summary = Connection.RetrieveStatistics(); + summary.QuerySummaryList.Length.Should().BeGreaterThan(0); + + var last = summary.QuerySummaryList.Last(); + + // Query-level state should reflect the failure + last.State.Should().Be(DuckDBState.Error); + last.Message.Should().NotBeNullOrWhiteSpace(); + + // Timing at query-level should be well-formed + last.ExecutionTimeMilliseconds.Should().BeGreaterOrEqualTo(0); + (last.StartTime <= last.EndTime).Should().BeTrue("StartTime should be less than or equal to EndTime"); + + // Ensure at least one statement reports error and at least one reports success + last.StatementSummaries.Should().NotBeNull(); + last.StatementSummaries.Length.Should().BeGreaterThan(0); + + var anyError = last.StatementSummaries.Any(i => i.State == DuckDBState.Error && !string.IsNullOrWhiteSpace(i.Message)); + anyError.Should().BeTrue("At least one statement should report an error state and message when one statement in a query fails"); + + var anySuccess = last.StatementSummaries.Any(i => i.State == DuckDBState.Success); + anySuccess.Should().BeTrue("At least one statement prior to the failing statement should have succeeded"); + + // Check statement timing sanity + foreach (var stmt in last.StatementSummaries) + { + stmt.ExecutionTimeMilliseconds.Should().BeGreaterOrEqualTo(0); + (stmt.StartTime <= stmt.EndTime).Should().BeTrue("Statement StartTime should be less than or equal to EndTime"); + } + } + finally + { + Connection.DisableProfiling(); + } + } + + [Fact] + public void MetricsCollectedWhenOverMetricsThreshold() + { + var options = new ProfilingOptions + { + Coverage = DuckDBProfilingCoverage.All, + EnabledMetrics = new DuckDBMetricTypeCollection { DuckDBMetricType.QueryName, DuckDBMetricType.CpuTime }, + Format = DuckDBProfilingFormat.Json, + Mode = DuckDBProfilingMode.Standard, + MetricsThreshold = 1 // very small threshold so a heavy query will exceed it + }; + + Connection.EnableProfiling(options); + + try + { + Connection.ResetStatistics(); + + // run a heavier query to exceed the threshold + // use DuckDB's range table function with a single argument (0..n-1) + Command.CommandText = "SELECT SUM(i) FROM range(1000000) AS t(i);"; + using (var r = Command.ExecuteReader()) { } + + var summary = Connection.RetrieveStatistics(); + summary.QuerySummaryList.Length.Should().BeGreaterThan(0); + + var last = summary.QuerySummaryList.Last(); + last.StatementSummaries.Should().NotBeNull(); + + // When execution is over the metrics threshold, at least one statement should contain enabled metrics + var hasMetrics = last.StatementSummaries.Any(i => i.Metrics.ContainsKey(DuckDBMetricType.QueryName) || i.Metrics.ContainsKey(DuckDBMetricType.CpuTime)); + hasMetrics.Should().BeTrue("Metrics should be collected when execution time exceeds MetricsThresholdMS"); + } + finally + { + Connection.DisableProfiling(); + } + } + + [Fact] + public void MetricsNotCollectedWhenUnderMetricsThreshold() + { + var options = new ProfilingOptions + { + Coverage = DuckDBProfilingCoverage.All, + EnabledMetrics = new DuckDBMetricTypeCollection { DuckDBMetricType.QueryName }, + Format = DuckDBProfilingFormat.Json, + Mode = DuckDBProfilingMode.Standard, + MetricsThreshold = 10000 // high threshold to prevent metrics collection for a short query + }; + + Connection.EnableProfiling(options); + + try + { + Connection.ResetStatistics(); + + Command.CommandText = "SELECT 1;"; + using (var r = Command.ExecuteReader()) { } + + var summary = Connection.RetrieveStatistics(); + summary.QuerySummaryList.Length.Should().BeGreaterThan(0); + + var last = summary.QuerySummaryList.Last(); + last.StatementSummaries.Should().NotBeNull(); + + // When execution is under the metrics threshold, no metrics should be extracted + last.StatementSummaries.All(i => i.Metrics.Count == 0).Should().BeTrue("Metrics should not be collected when execution time is below the configured MetricsThresholdMS"); + } + finally + { + Connection.DisableProfiling(); + } + } + + [Fact] + public void ProfilingSummariesContainCorrectStateAndMessageOnError() + { + var options = new ProfilingOptions + { + Coverage = DuckDBProfilingCoverage.All, + EnabledMetrics = new DuckDBMetricTypeCollection(), + Format = DuckDBProfilingFormat.Json, + Mode = DuckDBProfilingMode.Standard + }; + + Connection.EnableProfiling(options); + + try + { + Connection.ResetStatistics(); + + // Execute a statement that will error + Command.CommandText = "SELECT * FROM __this_table_does_not_exist__"; + try + { + using (var r = Command.ExecuteReader()) { } + } + catch (Exception) + { + // swallow - we expect an error to be thrown by the command execution + } + + var summary = Connection.RetrieveStatistics(); + summary.QuerySummaryList.Length.Should().BeGreaterThan(0); + + var last = summary.QuerySummaryList.Last(); + // Overall query should be marked as error + last.State.Should().Be(DuckDBState.Error); + last.Message.Should().NotBeNullOrWhiteSpace(); + + // Ensure at least one statement reports error state/message + var anyError = last.StatementSummaries.Any(i => i.State == DuckDBState.Error && !string.IsNullOrWhiteSpace(i.Message)); + anyError.Should().BeTrue("At least one statement should report an error state and message"); + } + finally + { + Connection.DisableProfiling(); + } + } + + [Fact] + public void MetricsDictionaryContainsEnabledMetrics() + { + // enable profiling with specific metrics enabled + var options = new ProfilingOptions + { + Coverage = DuckDBProfilingCoverage.All, + EnabledMetrics = new DuckDBMetricTypeCollection { DuckDBMetricType.QueryName, DuckDBMetricType.CpuTime }, + Format = DuckDBProfilingFormat.Json, + Mode = DuckDBProfilingMode.Standard + }; + + Connection.EnableProfiling(options); + + try + { + var before = Connection.RetrieveStatistics(); + var beforeCount = before.QuerySummaryList.Length; + + // execute a small batch that contains different statement types + Command.CommandText = "CREATE TABLE profiling_test(a INTEGER); INSERT INTO profiling_test VALUES (1); SELECT a FROM profiling_test;"; + using var reader = Command.ExecuteReader(); + + // advance through all result sets to ensure statements are executed + do { } while (reader.NextResult()); + + var summary = Connection.RetrieveStatistics(); + // If no new summaries were produced, profiling might not be available in this environment. + var newSummaries = summary.QuerySummaryList.Skip(beforeCount).ToArray(); + newSummaries.Length.Should().BeGreaterThan(0, "Expected new query summaries after executing batch"); + var last = newSummaries.Last().StatementSummaries; + + // check that enabled metrics appear in at least one statement's metrics + var containsQueryName = last.Any(i => i.Metrics.ContainsKey(DuckDBMetricType.QueryName)); + var containsCpuTime = last.Any(i => i.Metrics.ContainsKey(DuckDBMetricType.CpuTime)); + + containsQueryName.Should().BeTrue(); + containsCpuTime.Should().BeTrue(); + } + finally + { + Connection.DisableProfiling(); + } + } + + [Fact] + public void EnableProfilingWithoutOptionsUsesDefaults() + { + // enable profiling with explicit empty options - should use defaults + var defaultMetrics = DuckDBMetrics.DefaultMetrics.ToList(); + _ = defaultMetrics.Remove(DuckDBMetricType.QueryName); + var options = new ProfilingOptions { Coverage = DuckDBProfilingCoverage.All, EnabledMetrics = new DuckDBMetricTypeCollection(defaultMetrics) }; + + var con = new DuckDBConnection("data source=:memory:"); + con.Open(); + + con.EnableProfiling(options); + + using var cmd = con.CreateCommand(); + + try + { + // ensure clean start + con.ResetStatistics(); + + // run a simple query to produce profiling data + cmd.CommandText = "SELECT 1;"; + using (var r = cmd.ExecuteReader()) { } + + // profiling should be enabled + con.IsProfilingEnabled.Should().BeTrue(); + + var summary = con.RetrieveStatistics(); + summary.QueryCount.Should().BeGreaterThan(0); + + + // enable profiling without passing any options - should reset to defaults if already enabled + con.EnableProfiling(); + + con.ResetStatistics(); + + cmd.CommandText = "SET user = 'test';"; + using (var r = cmd.ExecuteReader()) { } + + // profiling should be enabled + con.IsProfilingEnabled.Should().BeTrue(); + + summary = con.RetrieveStatistics(); + summary.QueryCount.Should().BeGreaterThan(0); + + // with default options, there should be no metrics collected for Set statements + summary.QuerySummaryList.Last().StatementSummaries.Length.Should().Be(1); + summary.QuerySummaryList.Last().StatementSummaries[0].Metrics.Count.Should().Be(0); + } + finally + { + con.DisableProfiling(true); + } + } + + [Fact] + public async Task FileBackedConnectionSharesProfilingStateWhenEnabledOnFirst() + { + // create a physical file-backed database + using var dbInfo = DisposableFile.GenerateInTemp("db"); + + // open first connection and enable profiling + await using (var conn1 = new DuckDBConnection(dbInfo.ConnectionString)) + { + conn1.Open(); + + var options = new ProfilingOptions + { + Coverage = DuckDBProfilingCoverage.All, + EnabledMetrics = new DuckDBMetricTypeCollection { DuckDBMetricType.QueryName }, + Format = DuckDBProfilingFormat.Json, + Mode = DuckDBProfilingMode.Standard + }; + + conn1.EnableProfiling(options); + + // run a query to ensure profiling collects something + using var cmd1 = conn1.CreateCommand(); + cmd1.CommandText = "SELECT 1;"; + using (var r = cmd1.ExecuteReader()) { } + + // open a second connection to the same file WITHOUT enabling profiling explicitly + await using (var conn2 = new DuckDBConnection(dbInfo.ConnectionString)) + { + conn2.Open(); + + // conn2 should have profiling enabled because it shares the file-backed DB state + conn2.IsProfilingEnabled.Should().BeTrue(); + + // run a query on conn2 and ensure it has its own statistics recorded + using var cmd2 = conn2.CreateCommand(); + cmd2.CommandText = "SELECT 2;"; + using (var r = cmd2.ExecuteReader()) { } + + var s2 = conn2.RetrieveStatistics(); + s2.QueryCount.Should().BeGreaterThan(0); + } + + conn1.DisableProfiling(true); + } + } + + [Fact] + public void InMemoryDuplicateHasIndependentStatistics() + { + var options = new ProfilingOptions + { + Coverage = DuckDBProfilingCoverage.All, + EnabledMetrics = new DuckDBMetricTypeCollection { DuckDBMetricType.QueryName }, + Format = DuckDBProfilingFormat.Json, + Mode = DuckDBProfilingMode.Standard + }; + + Connection.EnableProfiling(options); + + try + { + // ensure clean start + Connection.ResetStatistics(); + + // run a query on the parent + Command.CommandText = "SELECT 1;"; + using (var r = Command.ExecuteReader()) { } + + var parentSummary = Connection.RetrieveStatistics(); + parentSummary.QueryCount.Should().BeGreaterThanOrEqualTo(1); + var parentCount = parentSummary.QueryCount; + + // create a duplicate connection and run a separate query + using var dup = Connection.Duplicate(); + dup.Open(); + using var dupCmd = dup.CreateCommand(); + dupCmd.CommandText = "SELECT 2;"; + using (var r = dupCmd.ExecuteReader()) { } + + var dupSummary = dup.RetrieveStatistics(); + // the duplicate should have its own stats with only the query it ran + dupSummary.QueryCount.Should().Be(1); + + // parent stats should not include duplicate's query + var parentAfter = Connection.RetrieveStatistics(); + parentAfter.QueryCount.Should().Be(parentCount); + } + finally + { + Connection.DisableProfiling(true); + } + } + + [Fact] + public async Task FileBackedDuplicateReturnsSameMetricsAsOriginal() + { + using var dbInfo = DisposableFile.GenerateInTemp("db"); + + var options = new ProfilingOptions + { + Coverage = DuckDBProfilingCoverage.All, + EnabledMetrics = new DuckDBMetricTypeCollection { DuckDBMetricType.QueryName, DuckDBMetricType.CpuTime, DuckDBMetricType.Latency }, + Format = DuckDBProfilingFormat.Json, + Mode = DuckDBProfilingMode.Standard + }; + + await using (var conn1 = new DuckDBConnection(dbInfo.ConnectionString)) + { + conn1.Open(); + conn1.EnableProfiling(options); + + using (var c = conn1.CreateCommand()) + { + c.CommandText = "SELECT 1;"; + using var r = c.ExecuteReader(); + } + + await using (var conn2 = new DuckDBConnection(dbInfo.ConnectionString)) + { + conn2.Open(); + // conn2 should have profiling enabled implicitly for file-backed DB when conn1 enabled it + conn2.IsProfilingEnabled.Should().BeTrue(); + + using (var c2 = conn2.CreateCommand()) + { + c2.CommandText = "SELECT 2;"; + using var r2 = c2.ExecuteReader(); + } + + var s1 = conn1.RetrieveStatistics(); + var s2 = conn2.RetrieveStatistics(); + + s1.QuerySummaryList.Length.Should().BeGreaterThan(0, "Expected profiling summaries for original connection"); + s2.QuerySummaryList.Length.Should().BeGreaterThan(0, "Expected profiling summaries for duplicate connection"); + + var last1 = s1.QuerySummaryList.Last(); + var last2 = s2.QuerySummaryList.Last(); + + var keys1 = new HashSet(last1.StatementSummaries.SelectMany(i => i.Metrics.Keys)); + var keys2 = new HashSet(last2.StatementSummaries.SelectMany(i => i.Metrics.Keys)); + + // strict check: both sets of metric keys must match + keys1.Should().BeEquivalentTo(keys2); + } + + conn1.DisableProfiling(true); + } + } + + [Fact] + public void InMemoryDuplicateReturnsSameMetricsAsOriginal() + { + var options = new ProfilingOptions + { + Coverage = DuckDBProfilingCoverage.All, + EnabledMetrics = new DuckDBMetricTypeCollection { DuckDBMetricType.QueryName, DuckDBMetricType.CpuTime, DuckDBMetricType.Latency }, + Format = DuckDBProfilingFormat.Json, + Mode = DuckDBProfilingMode.Standard + }; + + Connection.EnableProfiling(options); + + try + { + Connection.ResetStatistics(); + + // run a query on the original + Command.CommandText = "SELECT 10;"; + using (var r = Command.ExecuteReader()) { } + + // duplicate in-memory and open + using var dup = Connection.Duplicate(); + dup.Open(); + + // run a query on duplicate + using var cdup = dup.CreateCommand(); + cdup.CommandText = "SELECT 20;"; + using (var r = cdup.ExecuteReader()) { } + + var sOriginal = Connection.RetrieveStatistics(); + var sDup = dup.RetrieveStatistics(); + + sOriginal.QuerySummaryList.Length.Should().BeGreaterThan(0, "Expected profiling summaries for original connection"); + sDup.QuerySummaryList.Length.Should().BeGreaterThan(0, "Expected profiling summaries for duplicate connection"); + + var lastOriginal = sOriginal.QuerySummaryList.Last(); + var lastDup = sDup.QuerySummaryList.Last(); + + var keysOrig = new HashSet(lastOriginal.StatementSummaries.SelectMany(i => i.Metrics.Keys)); + var keysDup = new HashSet(lastDup.StatementSummaries.SelectMany(i => i.Metrics.Keys)); + + // strict check: both sets of metric keys must match + keysOrig.Should().BeEquivalentTo(keysDup); + } + finally + { + Connection.DisableProfiling(true); + } + } + + [Fact] + public void MultipleEnabledMetricsArePresentInMetricsDictionary() + { + var metricsToEnable = new[] + { + DuckDBMetricType.QueryName, + DuckDBMetricType.CpuTime, + DuckDBMetricType.Latency, + DuckDBMetricType.TotalBytesRead + }; + + var options = new ProfilingOptions + { + Coverage = DuckDBProfilingCoverage.All, + EnabledMetrics = new DuckDBMetricTypeCollection(metricsToEnable), + Format = DuckDBProfilingFormat.Json, + Mode = DuckDBProfilingMode.Standard + }; + + Connection.EnableProfiling(options); + + try + { + var before = Connection.RetrieveStatistics(); + var beforeCount = before.QuerySummaryList.Length; + + Command.CommandText = "CREATE TABLE profiling_metrics(a INTEGER); INSERT INTO profiling_metrics VALUES (1); SELECT a FROM profiling_metrics;"; + using var reader = Command.ExecuteReader(); + do { } while (reader.NextResult()); + + var summary = Connection.RetrieveStatistics(); + var newSummaries = summary.QuerySummaryList.Skip(beforeCount).ToArray(); + newSummaries.Length.Should().BeGreaterThan(0, "Expected new query summaries after executing batch"); + var last = newSummaries.Last(); + last.StatementSummaries.Should().NotBeNull(); + last.StatementSummaries.Length.Should().BeGreaterThan(0, "Expected some profiling info when metrics are enabled"); + + // ensure at least one statement collected all requested metrics + bool found = last.StatementSummaries.Any(summary => metricsToEnable.All(m => summary.Metrics.ContainsKey(m))); + + found.Should().BeTrue("At least one statement should include all the enabled metrics (QueryName, CpuTime, Latency, TotalBytesRead)"); + } + finally + { + Connection.DisableProfiling(); + } + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + public void SelectCoverageRespectsSelectPosition(int selectPosition) + { + var id = Guid.NewGuid().ToString("N"); + + var options = new ProfilingOptions + { + Coverage = DuckDBProfilingCoverage.Select, + EnabledMetrics = new DuckDBMetricTypeCollection { DuckDBMetricType.QueryName }, + Format = DuckDBProfilingFormat.Json, + Mode = DuckDBProfilingMode.Standard + }; + + Connection.EnableProfiling(options); + + try + { + var before = Connection.RetrieveStatistics(); + var beforeCount = before.QuerySummaryList.Length; + + var val = 200 + selectPosition; + string a = $"t_{id}_a"; + string b = $"t_{id}_b"; + + string cmd; + if (selectPosition == 0) + { + cmd = $"SELECT {val}; CREATE TABLE {a}(i INTEGER); CREATE TABLE {b}(i INTEGER);"; + } + else if (selectPosition == 1) + { + cmd = $"CREATE TABLE {a}(i INTEGER); SELECT {val}; CREATE TABLE {b}(i INTEGER);"; + } + else + { + cmd = $"CREATE TABLE {a}(i INTEGER); CREATE TABLE {b}(i INTEGER); SELECT {val};"; + } + + Command.CommandText = cmd; + using var reader = Command.ExecuteReader(); + do { } while (reader.NextResult()); + + var summary = Connection.RetrieveStatistics(); + var newSummaries = summary.QuerySummaryList.Skip(beforeCount).ToArray(); + newSummaries.Length.Should().BeGreaterThan(0, "Expected new query summaries after executing batch"); + var last = newSummaries.Last(); + last.StatementCount.Should().Be(3); + + last.StatementSummaries.Should().NotBeNull(); + + if (last.StatementSummaries.Length >= last.StatementCount) + { + for (int i = 0; i < last.StatementCount; i++) + { + var info = i < last.StatementSummaries.Length ? last.StatementSummaries[i].Metrics : null; + if (i == selectPosition) + { + info.Should().NotBeNull(); + info.Count.Should().BeGreaterThan(0); + } + else + { + info.Should().NotBeNull(); + info.Count.Should().Be(0); + } + } + } + else + { + // If Metrics length doesn't map 1:1 to statements, at least ensure exactly one statement collected metrics + var nonEmpty = last.StatementSummaries.Count(i => i.Metrics.Count > 0); + nonEmpty.Should().Be(1, "Expected exactly one statement to have metrics when coverage=Select"); + } + } + finally + { + Connection.DisableProfiling(); + } + } + + [Fact] + public void DisableStopsCollectingNewQueries() + { + var options = new ProfilingOptions + { + Coverage = DuckDBProfilingCoverage.All, + EnabledMetrics = new DuckDBMetricTypeCollection { DuckDBMetricType.QueryName }, + Format = DuckDBProfilingFormat.Json, + Mode = DuckDBProfilingMode.Standard + }; + + Connection.EnableProfiling(options); + + try + { + // run first batch + Command.CommandText = "SELECT 1;"; + using (var r = Command.ExecuteReader()) { } + + var before = Connection.RetrieveStatistics(); + var beforeCount = before.QueryCount; + + // disable and run another batch + Connection.DisableProfiling(); + Command.CommandText = "SELECT 2;"; + using (var r = Command.ExecuteReader()) { } + + var after = Connection.RetrieveStatistics(); + // after disabling, QueryCount should not have increased + after.QueryCount.Should().Be(beforeCount); + } + finally + { + Connection.DisableProfiling(); + } + } + + [Fact] + public void ResetCleanCollectedStatistics() + { + var options = new ProfilingOptions + { + Coverage = DuckDBProfilingCoverage.All, + EnabledMetrics = new DuckDBMetricTypeCollection { DuckDBMetricType.QueryName }, + Format = DuckDBProfilingFormat.Json, + Mode = DuckDBProfilingMode.Standard + }; + + Connection.EnableProfiling(options); + + try + { + // run first batch + Command.CommandText = "SELECT 1;"; + using (var r = Command.ExecuteReader()) { } + + // disable and run another batch + Connection.ResetStatistics(); + var after = Connection.RetrieveStatistics(); + // after resetting, QueryCount should be zero + after.QueryCount.Should().Be(0); + } + finally + { + Connection.DisableProfiling(); + } + } + + [Fact] + public void EnablingWithRowsReturnedMetricThrows() + { + var options = new ProfilingOptions + { + Coverage = DuckDBProfilingCoverage.All, + EnabledMetrics = new DuckDBMetricTypeCollection { DuckDBMetricType.RowsReturned }, + Format = DuckDBProfilingFormat.Json, + Mode = DuckDBProfilingMode.Standard + }; + + Action act = () => Connection.EnableProfiling(options); + act.Should().Throw().Where(e => e.Message.Contains("RowsReturned") || (e.InnerException != null && e.InnerException.Message.Contains("RowsReturned"))); + } + + [Fact] + public void CoverageSelectOnlyProfilesOnlySelectStatements() + { + // enable profiling for SELECT coverage only + var options = new ProfilingOptions + { + Coverage = DuckDBProfilingCoverage.Select, + EnabledMetrics = new DuckDBMetricTypeCollection { DuckDBMetricType.QueryName }, + Format = DuckDBProfilingFormat.Json, + Mode = DuckDBProfilingMode.Standard + }; + + Connection.EnableProfiling(options); + + try + { + var before = Connection.RetrieveStatistics(); + var beforeCount = before.QuerySummaryList.Length; + + // execute a batch with CREATE, INSERT and SELECT (three statements) + Command.CommandText = "CREATE TABLE profiling_cov(a INTEGER); INSERT INTO profiling_cov VALUES (1); SELECT a FROM profiling_cov;"; + using var reader = Command.ExecuteReader(); + + // advance through all result sets + do { } while (reader.NextResult()); + + var summary = Connection.RetrieveStatistics(); + var newSummaries = summary.QuerySummaryList.Skip(beforeCount).ToArray(); + newSummaries.Length.Should().BeGreaterThan(0, "Expected new query summaries after executing batch"); + var last = newSummaries.Last(); + + last.StatementCount.Should().BeGreaterOrEqualTo(3); + + // For SELECT coverage, expect only the SELECT (last) statement to have metrics collected + if (last.StatementSummaries.Length >= last.StatementCount) + { + for (int i = 0; i < last.StatementCount; i++) + { + var info = i < last.StatementSummaries.Length ? last.StatementSummaries[i].Metrics : null; + if (i == last.StatementCount - 1) + { + info.Should().NotBeNull(); + info.Count.Should().BeGreaterThan(0); + } + else + { + info.Should().NotBeNull(); + info.Count.Should().Be(0); + } + } + } + else + { + var nonEmpty = last.StatementSummaries.Count(i => i.Metrics.Count > 0); + nonEmpty.Should().Be(1, "Expected exactly one statement to have metrics when coverage=Select"); + } + } + finally + { + Connection.DisableProfiling(); + } + } +} diff --git a/DuckDB.NET.sln b/DuckDB.NET.sln index 76619fcc..e7df9322 100644 --- a/DuckDB.NET.sln +++ b/DuckDB.NET.sln @@ -20,6 +20,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Test", "DuckDB.NET.Test\Tes EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Irion.DuckDB.NET.Test", "Irion.DuckDB.NET.Test\Irion.DuckDB.NET.Test.csproj", "{6D707558-05DF-4DA6-9203-F79BA634D4D2}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Irion.DuckDB.NET.Benchmark", "Irion.DuckDB.NET.Benchmark\Irion.DuckDB.NET.Benchmark.csproj", "{7A7A7A7A-7777-7777-7777-777777777777}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -46,6 +48,10 @@ Global {6D707558-05DF-4DA6-9203-F79BA634D4D2}.Debug|Any CPU.Build.0 = Debug|Any CPU {6D707558-05DF-4DA6-9203-F79BA634D4D2}.Release|Any CPU.ActiveCfg = Release|Any CPU {6D707558-05DF-4DA6-9203-F79BA634D4D2}.Release|Any CPU.Build.0 = Release|Any CPU + {7A7A7A7A-7777-7777-7777-777777777777}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7A7A7A7A-7777-7777-7777-777777777777}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7A7A7A7A-7777-7777-7777-777777777777}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7A7A7A7A-7777-7777-7777-777777777777}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/DuckDB.NET.slnx b/DuckDB.NET.slnx index e7a87142..5eb9fbe9 100644 --- a/DuckDB.NET.slnx +++ b/DuckDB.NET.slnx @@ -3,5 +3,6 @@ + diff --git a/Irion.DuckDB.NET.Benchmark/Config/CustomConfig.cs b/Irion.DuckDB.NET.Benchmark/Config/CustomConfig.cs new file mode 100644 index 00000000..a85f215f --- /dev/null +++ b/Irion.DuckDB.NET.Benchmark/Config/CustomConfig.cs @@ -0,0 +1,28 @@ +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Diagnosers; +using BenchmarkDotNet.Engines; +using BenchmarkDotNet.Environments; +using BenchmarkDotNet.Jobs; + +namespace Irion.DuckDB.NET.Benchmark.Config +{ + internal class CustomConfig : ManualConfig + { + public CustomConfig() + { + // bring in default loggers/exporters/columns so output appears + foreach (var logger in DefaultConfig.Instance.GetLoggers()) AddLogger(logger); + foreach (var exporter in DefaultConfig.Instance.GetExporters()) AddExporter(exporter); + foreach (var column in DefaultConfig.Instance.GetColumnProviders()) AddColumnProvider(column); + foreach (var diagnoser in DefaultConfig.Instance.GetDiagnosers()) AddDiagnoser(diagnoser); + + var job = Job.Default.WithId("MyBaseline") + .WithIterationCount(50) + .WithRuntime(CoreRuntime.Core10_0) + .WithStrategy(RunStrategy.Monitoring); + + AddJob(job); + AddDiagnoser(MemoryDiagnoser.Default); + } + } +} diff --git a/Irion.DuckDB.NET.Benchmark/Irion.DuckDB.NET.Benchmark.csproj b/Irion.DuckDB.NET.Benchmark/Irion.DuckDB.NET.Benchmark.csproj new file mode 100644 index 00000000..a56352d7 --- /dev/null +++ b/Irion.DuckDB.NET.Benchmark/Irion.DuckDB.NET.Benchmark.csproj @@ -0,0 +1,44 @@ + + + + net10.0;net8.0 + Exe + Irion.DuckDB.NET.Benchmark + Irion.DuckDB.NET.Benchmark + Irion.DuckDB.NET.Benchmark + + + + Full + + + + + + + + + + + + + + + + + + + + + + + + + + false + PreserveNewest + \runtimes + runtimes\%(RecursiveDir)\%(FileName)%(Extension) + + + diff --git a/Irion.DuckDB.NET.Benchmark/Program.cs b/Irion.DuckDB.NET.Benchmark/Program.cs new file mode 100644 index 00000000..8b3d2a10 --- /dev/null +++ b/Irion.DuckDB.NET.Benchmark/Program.cs @@ -0,0 +1,34 @@ +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Running; +using Irion.DuckDB.NET.Benchmark.Config; +using System; +using System.Diagnostics; +using System.IO; + +namespace Irion.DuckDB.NET.Benchmark +{ + internal class Program + { + static void Main(string[] args) + { + ManualConfig config; + + if (!Debugger.IsAttached) + { + config = new CustomConfig(); + } + else + { + config = ManualConfig.Create(new DebugInProcessConfig()); + } + + //clean 1.5.2 benchmark + //var type = typeof(TpchBenchmarks_Baseline); + var type = typeof(TpchBenchmarks); + + config.ArtifactsPath = Path.Combine("BenchmarkDotNet.Artifacts", $"{type.Name}_{DateTime.Now:yyyyMMdd-HHmmss}"); + + BenchmarkRunner.Run(type, config, args); + } + } +} diff --git a/Irion.DuckDB.NET.Benchmark/TpchBenchmarks.cs b/Irion.DuckDB.NET.Benchmark/TpchBenchmarks.cs new file mode 100644 index 00000000..f4615e6e --- /dev/null +++ b/Irion.DuckDB.NET.Benchmark/TpchBenchmarks.cs @@ -0,0 +1,274 @@ +using BenchmarkDotNet.Attributes; +using DuckDB.NET.Data; +using DuckDB.NET.Data.Profiling; +using DuckDB.NET.Native; +using DuckDB.NET.Test.Helpers; +using Irion.DuckDB.NET.Benchmark.Config; +using Perfolizer.Mathematics.OutlierDetection; +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace Irion.DuckDB.NET.Benchmark +{ + //[CPUUsageDiagnoser] + [Outliers(OutlierMode.DontRemove)] + [Config(typeof(CustomConfig))] + public class TpchBenchmarks + { + private DuckDBConnection? mainConnection; + private DuckDBCommand? mainCommand; + // Per-iteration connection/command used for extension loading & secret creation + private DuckDBConnection? iterationConnection; + private DuckDBCommand? iterationCommand; + private string? attachedDbPath; + + + //[ParamsAllValues] + [Params(true)] + public bool MetricsEnabled { get; set; } = false; + + //[ParamsAllValues] + [Params(DuckDBProfilingMode.Standard)] + public DuckDBProfilingMode ProfilingMode { get; set; } + + [GlobalSetup] + public void Setup() + { + if (!NativeLibraryHelper.TryLoad()) + { + Console.Error.WriteLine("native assembly not found"); + return; + } + + // Initialize an in-memory DuckDB connection and attempt to install/load the tpch extension. + mainConnection = new DuckDBConnection("DataSource=:memory:"); + mainConnection.Open(); + mainCommand = mainConnection.CreateCommand(); + try + { + mainCommand.CommandText = "INSTALL tpch;"; + mainCommand.ExecuteNonQuery(); + mainCommand.CommandText = "LOAD tpch;"; + mainCommand.ExecuteNonQuery(); + // Generate TPCH sf=1 dataset into the current database + mainCommand.CommandText = "CALL dbgen(sf=1);"; + mainCommand.ExecuteNonQuery(); + } + catch (Exception) + { + // If the extension isn't available, fall back to a simple generated dataset that approximates scale. + mainCommand.CommandText = "CREATE TABLE IF NOT EXISTS lineitem (l_orderkey INTEGER, l_partkey INTEGER, l_quantity INTEGER);"; + mainCommand.ExecuteNonQuery(); + // Insert a moderate number of rows to simulate work (1k) + mainCommand.CommandText = "BEGIN TRANSACTION;"; + mainCommand.ExecuteNonQuery(); + var rnd = new Random(42); + for (int i = 0; i < 1000; i++) + { + mainCommand.CommandText = $"INSERT INTO lineitem VALUES ({rnd.Next(1, 100000)}, {rnd.Next(1, 100000)}, {rnd.Next(1, 100)});"; + mainCommand.ExecuteNonQuery(); + } + + mainCommand.CommandText = "COMMIT;"; + mainCommand.ExecuteNonQuery(); + } + + EnableProfiling(mainConnection); + } + + private void EnableProfiling(DuckDBConnection connection) + { + if (MetricsEnabled) + { + var options = new ProfilingOptions + { + Format = DuckDBProfilingFormat.NoOutput, + Mode = ProfilingMode, //DuckDBProfilingMode.Detailed, + Coverage = DuckDBProfilingCoverage.All, + EnabledMetrics = [] + }; + connection.EnableProfiling(options); + } + } + + private void ReadMetrics(DuckDBConnection connection) + { + if (MetricsEnabled) + { + var metrics = connection.RetrieveStatistics(); + } + } + + [GlobalCleanup] + public void Cleanup() + { + try + { + if (!string.IsNullOrEmpty(attachedDbPath) && File.Exists(attachedDbPath)) + { + File.Delete(attachedDbPath); + } + } + catch + { + } + + mainCommand?.Dispose(); + mainConnection?.Dispose(); + } + + [IterationSetup] + public void IterationSetup() + { + // Each iteration needs a fresh connection where the extension is installed and the secret created. + iterationConnection = new DuckDBConnection("DataSource=:memory:"); + try + { + iterationConnection.Open(); + iterationCommand = iterationConnection.CreateCommand(); + + // Install and load httpfs, then create a secret using the CREATE SECRET syntax. + iterationCommand.CommandText = "INSTALL httpfs;"; + iterationCommand.ExecuteNonQuery(); + iterationCommand.CommandText = "LOAD httpfs;"; + iterationCommand.ExecuteNonQuery(); + + EnableProfiling(iterationConnection); + } + catch + { + // Extension or CREATE SECRET not available on this environment; clean up the iteration resources + // and leave iterationCommand null so the benchmark method can detect the unsupported case. + iterationCommand?.Dispose(); + iterationConnection?.Dispose(); + iterationCommand = null; + iterationConnection = null; + } + } + + [IterationCleanup] + public void IterationCleanup() + { + iterationCommand?.Dispose(); + iterationConnection?.Dispose(); + iterationCommand = null; + iterationConnection = null; + } + + public static IEnumerable Queries => Enumerable.Range(1, 22); + + [Benchmark] + [ArgumentsSource(nameof(Queries))] + public void RunTpchQuery(int query) + { + mainCommand!.CommandText = $"PRAGMA tpch({query});"; + mainCommand.ExecuteNonQuery(); + + ReadMetrics(mainConnection); + } + + [Benchmark] + public void SimpleAggregation() + { + mainCommand!.CommandText = "SELECT l_partkey, SUM(l_quantity) as s FROM lineitem GROUP BY l_partkey LIMIT 100;"; + using var reader = mainCommand.ExecuteReader(); + while (reader.Read()) + { /* iterate */ + } + + ReadMetrics(mainConnection); + + } + + [Benchmark] + public void AttachDatabaseAndQuery() + { + // create a temporary on-disk database and attach it + attachedDbPath = Path.Combine(Path.GetTempPath(), $"duckdb_bench_{Guid.NewGuid():N}.db"); + mainCommand!.CommandText = $"ATTACH DATABASE '{attachedDbPath}' AS attached_db;"; + mainCommand.ExecuteNonQuery(); + + ReadMetrics(mainConnection); + + // run a simple query against the attached database (it will be empty) + mainCommand.CommandText = "SELECT COUNT(*) FROM attached_db.sqlite_master WHERE 1=0;"; + try + { + mainCommand.ExecuteScalar(); + + ReadMetrics(mainConnection); + + } + catch + { /* ignore — attached DB may not have that table */ + } + + mainCommand.CommandText = "DETACH DATABASE attached_db;"; + mainCommand.ExecuteNonQuery(); + + ReadMetrics(mainConnection); + + // cleanup file + try + { + File.Delete(attachedDbPath); + } + catch + { + } + + attachedDbPath = null; + } + + [Benchmark] + public void LoadExtension() + { + // Each iteration needs a fresh connection where the extension is installed and the secret created. + using var connection = new DuckDBConnection("DataSource=:memory:"); + + connection.Open(); + using var command = connection.CreateCommand(); + + // Install and load httpfs, then create a secret using the CREATE SECRET syntax. + command.CommandText = "INSTALL httpfs;"; + command.ExecuteNonQuery(); + + ReadMetrics(connection); + + command.CommandText = "LOAD httpfs;"; + command.ExecuteNonQuery(); + + ReadMetrics(connection); + + } + + [Benchmark] + public void CreateSecret() + { + // The extension must have been installed and the secret created during IterationSetup. The + // benchmark itself only verifies the secret/extension is usable, keeping the measured work focused. + if (iterationCommand is null) + { + // Extension or CREATE SECRET not supported in this iteration; skip measurable work. + return; + } + + // Use CREATE SECRET syntax to register fake credentials for the extension. + // If the running DuckDB build does not support CREATE SECRET, an exception will be thrown and + // we will treat this iteration as not supporting the extension. + iterationCommand.CommandText = "CREATE SECRET s3_credentials (type s3, KEY_ID 'AKIAFAKE', SECRET 'fakeSecret');"; + iterationCommand.ExecuteNonQuery(); + + ReadMetrics(iterationConnection); + + iterationCommand.CommandText = "SELECT 1;"; + _ = iterationCommand.ExecuteScalar(); + + ReadMetrics(iterationConnection); + + } + } +} \ No newline at end of file diff --git a/Irion.DuckDB.NET.Benchmark/TpchBenchmarks_Baseline.cs b/Irion.DuckDB.NET.Benchmark/TpchBenchmarks_Baseline.cs new file mode 100644 index 00000000..e684c8b5 --- /dev/null +++ b/Irion.DuckDB.NET.Benchmark/TpchBenchmarks_Baseline.cs @@ -0,0 +1,205 @@ +using BenchmarkDotNet.Attributes; +using DuckDB.NET.Data; +using DuckDB.NET.Test.Helpers; +using Irion.DuckDB.NET.Benchmark.Config; +using Perfolizer.Mathematics.OutlierDetection; +using System; +using System.IO; + +namespace Irion.DuckDB.NET.Benchmark +{ + //[CPUUsageDiagnoser] + [Outliers(OutlierMode.DontRemove)] + [Config(typeof(CustomConfig))] + public class TpchBenchmarks_Baseline + { + private DuckDBConnection? connection; + private DuckDBCommand? command; + // Per-iteration connection/command used for extension loading & secret creation + private DuckDBConnection? iterationConnection; + private DuckDBCommand? iterationCommand; + private string? attachedDbPath; + [GlobalSetup] + public void Setup() + { + if (!NativeLibraryHelper.TryLoad()) + { + Console.Error.WriteLine("native assembly not found"); + return; + } + + // Initialize an in-memory DuckDB connection and attempt to install/load the tpch extension. + connection = new DuckDBConnection("DataSource=:memory:"); + connection.Open(); + command = connection.CreateCommand(); + try + { + command.CommandText = "INSTALL tpch;"; + command.ExecuteNonQuery(); + command.CommandText = "LOAD tpch;"; + command.ExecuteNonQuery(); + // Generate TPCH sf=1 dataset into the current database + command.CommandText = "CALL dbgen(sf=1);"; + command.ExecuteNonQuery(); + } + catch (Exception) + { + // If the extension isn't available, fall back to a simple generated dataset that approximates scale. + command.CommandText = "CREATE TABLE IF NOT EXISTS lineitem (l_orderkey INTEGER, l_partkey INTEGER, l_quantity INTEGER);"; + command.ExecuteNonQuery(); + // Insert a moderate number of rows to simulate work (1k) + command.CommandText = "BEGIN TRANSACTION;"; + command.ExecuteNonQuery(); + var rnd = new Random(42); + for (int i = 0; i < 1000; i++) + { + command.CommandText = $"INSERT INTO lineitem VALUES ({rnd.Next(1, 100000)}, {rnd.Next(1, 100000)}, {rnd.Next(1, 100)});"; + command.ExecuteNonQuery(); + } + + command.CommandText = "COMMIT;"; + command.ExecuteNonQuery(); + } + } + + [GlobalCleanup] + public void Cleanup() + { + try + { + if (!string.IsNullOrEmpty(attachedDbPath) && File.Exists(attachedDbPath)) + { + File.Delete(attachedDbPath); + } + } + catch + { + } + + command?.Dispose(); + connection?.Dispose(); + } + + [IterationSetup] + public void IterationSetup() + { + // Each iteration needs a fresh connection where the extension is installed and the secret created. + iterationConnection = new DuckDBConnection("DataSource=:memory:"); + try + { + iterationConnection.Open(); + iterationCommand = iterationConnection.CreateCommand(); + + // Install and load httpfs, then create a secret using the CREATE SECRET syntax. + iterationCommand.CommandText = "INSTALL httpfs;"; + iterationCommand.ExecuteNonQuery(); + iterationCommand.CommandText = "LOAD httpfs;"; + iterationCommand.ExecuteNonQuery(); + } + catch + { + // Extension or CREATE SECRET not available on this environment; clean up the iteration resources + // and leave iterationCommand null so the benchmark method can detect the unsupported case. + iterationCommand?.Dispose(); + iterationConnection?.Dispose(); + iterationCommand = null; + iterationConnection = null; + } + } + + [IterationCleanup] + public void IterationCleanup() + { + iterationCommand?.Dispose(); + iterationConnection?.Dispose(); + iterationCommand = null; + iterationConnection = null; + } + + [Benchmark] + public long CountLineitem() + { + command!.CommandText = "SELECT COUNT(*) FROM lineitem;"; + var result = command.ExecuteScalar(); + return Convert.ToInt64(result ?? 0); + } + + [Benchmark] + public void SimpleAggregation() + { + command!.CommandText = "SELECT l_partkey, SUM(l_quantity) as s FROM lineitem GROUP BY l_partkey LIMIT 100;"; + using var reader = command.ExecuteReader(); + while (reader.Read()) + { /* iterate */ + } + } + + [Benchmark] + public void AttachDatabaseAndQuery() + { + // create a temporary on-disk database and attach it + attachedDbPath = Path.Combine(Path.GetTempPath(), $"duckdb_bench_{Guid.NewGuid():N}.db"); + command!.CommandText = $"ATTACH DATABASE '{attachedDbPath}' AS attached_db;"; + command.ExecuteNonQuery(); + // run a simple query against the attached database (it will be empty) + command.CommandText = "SELECT COUNT(*) FROM attached_db.sqlite_master WHERE 1=0;"; + try + { + command.ExecuteScalar(); + } + catch + { /* ignore — attached DB may not have that table */ + } + + command.CommandText = "DETACH DATABASE attached_db;"; + command.ExecuteNonQuery(); + // cleanup file + try + { + File.Delete(attachedDbPath); + } + catch + { + } + + attachedDbPath = null; + } + + [Benchmark] + public void LoadExtension() + { + // Each iteration needs a fresh connection where the extension is installed and the secret created. + using var connection = new DuckDBConnection("DataSource=:memory:"); + + connection.Open(); + using var command = connection.CreateCommand(); + + // Install and load httpfs, then create a secret using the CREATE SECRET syntax. + command.CommandText = "INSTALL httpfs;"; + command.ExecuteNonQuery(); + command.CommandText = "LOAD httpfs;"; + command.ExecuteNonQuery(); + } + + [Benchmark] + public void CreateSecret() + { + // The extension must have been installed and the secret created during IterationSetup. The + // benchmark itself only verifies the secret/extension is usable, keeping the measured work focused. + if (iterationCommand is null) + { + // Extension or CREATE SECRET not supported in this iteration; skip measurable work. + return; + } + + // Use CREATE SECRET syntax to register fake credentials for the extension. + // If the running DuckDB build does not support CREATE SECRET, an exception will be thrown and + // we will treat this iteration as not supporting the extension. + iterationCommand.CommandText = "CREATE SECRET s3_credentials (type s3, KEY_ID 'AKIAFAKE', SECRET 'fakeSecret');"; + iterationCommand.ExecuteNonQuery(); + + iterationCommand.CommandText = "SELECT 1;"; + _ = iterationCommand.ExecuteScalar(); + } + } +} \ No newline at end of file diff --git a/README.md b/README.md index 8237a7c6..3f91aef2 100644 --- a/README.md +++ b/README.md @@ -100,16 +100,17 @@ Current version: Set explicit version: ```powershell -.\scripts\irion-package.ps1 -Command set -Version 1.4.4.2 +.\scripts\irion-package.ps1 -Command set -Version 1.5.2.2 ``` Bump version in `build/irion.version`: ```powershell -.\scripts\irion-package.ps1 -Command bump -Part revision # 1.4.4.1 -> 1.4.4.2 -.\scripts\irion-package.ps1 -Command bump -Part build # 1.4.4.1 -> 1.4.5.0 -.\scripts\irion-package.ps1 -Command bump -Part minor # 1.4.4.1 -> 1.5.0.0 -.\scripts\irion-package.ps1 -Command bump -Part major # 1.4.4.1 -> 2.0.0.0 +.\scripts\irion-package.ps1 -Command bump -Part prerelease # 1.4.4.0-alpha.1 -> 1.4.4.0-alpha.2 +# .\scripts\irion-package.ps1 -Command bump -Part revision # 1.4.4.1 -> 1.4.4.2 +# .\scripts\irion-package.ps1 -Command bump -Part build # 1.4.4.1 -> 1.4.5.0 +# .\scripts\irion-package.ps1 -Command bump -Part minor # 1.4.4.1 -> 1.5.0.0 +# .\scripts\irion-package.ps1 -Command bump -Part major # 1.4.4.1 -> 2.0.0.0 ``` Check latest published versions on the configured feed: @@ -127,7 +128,7 @@ You can override package IDs if needed: #### 3. Build (Full) ```powershell -.\scripts\irion-package.ps1 -Command build +.\scripts\irion-package.ps1 -Command build -p "NativeDownloadRetries=30","NativeDownloadRetryDelayMilliseconds=15000" ``` The build command runs a full `dotnet clean` for both Irion projects before building. @@ -151,6 +152,13 @@ Generated packages: - `DuckDB.NET.Bindings/bin/Release/Irion.DuckDB.NET.Bindings.Full..nupkg` - `DuckDB.NET.Data/bin/Release/Irion.DuckDB.NET.Data.Full..nupkg` +if `-PackageReleaseNotes` or `-PackageReleaseNotesFile` are provided, the content is included in the generated `.nupkg` metadata and visible on NuGet.org. + +```powershell +.\scripts\irion-package.ps1 -Command pack -PackageReleaseNotes "New feature short description" -PackageReleaseNotesFile "RELEASE-NOTE.md" +``` + + #### 5. Push packages to feed ```powershell diff --git a/RELEASE-NOTE.md b/RELEASE-NOTE.md new file mode 100644 index 00000000..5e9f8db6 --- /dev/null +++ b/RELEASE-NOTE.md @@ -0,0 +1,74 @@ +New functionality + +- ProfilingInfo: collect detailed per-query execution profiles and metrics (timing, operator breakdown, runtime statistics). + +How to enable + +1. Enable profiling on an open `DuckDBConnection` with `ProfilingOptions`: + +```csharp +connection.EnableProfiling(new ProfilingOptions { + Coverage = DuckDBProfilingCoverage.All, + Format = DuckDBProfilingFormat.Json, + Mode = DuckDBProfilingMode.Standard, + //OutputPath = "profiling-output.json", // optional path for generated files + // EnabledMetrics = new DuckDBMetricTypeCollection(...), // optional: pick specific metrics + MetricsThreshold = 5 // optional: ignore measurements under X ms +}); +``` + +2. Run queries as usual using `CreateCommand()` / `ExecuteReader()` / `ExecuteNonQuery()`. + +Retrieve summaries + +- Call `connection.RetrieveStatistics()` to obtain the profiling summary. The returned object includes: + - `QueryCount` and `QuerySummaryList` — overall counts and per-query summaries + - Per-query timings, operator breakdowns, and collected metrics + +Example (read summary): + +```csharp +var summary = connection.RetrieveStatistics(); +Console.WriteLine($"Collected {summary.QueryCount} queries"); +foreach (var q in summary.QuerySummaryList ?? Enumerable.Empty()) +{ + var firstStatement = q.StatementSummaries != null && q.StatementSummaries.Length > 0 + ? q.StatementSummaries[0].Metrics[MetricsType.QueryName] + : "(no statement)"; + + Console.WriteLine($"Query: {firstStatement} — Duration: {q.ExecutionTimeMilliseconds} ms"); +} +``` + +Disable and reset + +- To stop profiling and clear collected data: + +```csharp +connection.DisableProfiling(reset: true); +``` + +Notes + +- `MetricsThreshold` helps filter noise by ignoring very short measurements. +- `QuerySummaryList` may be null when no queries were captured; use `?? Enumerable.Empty()` when enumerating. + +ProfilingOptions fields + +- `Coverage` (enum: `DuckDBProfilingCoverage`): controls which queries/operators are profiled. Valid values: + - `Select` — profile SELECT queries (default for fine-grained sampling of reads). + - `All` — profile all query kinds and collect operator-level details. + +- `Format` (enum: `DuckDBProfilingFormat`): output format for summaries. Valid values: + - `QueryTree` — tree-style query plan output (human-readable; use this for a "Text" style summary). + - `Json` — structured JSON suitable for storage and programmatic analysis. + - `QueryTreeOptimizer` — query-tree output focused on optimizer phases. + - `NoOutput` — disable textual/report output while still collecting metrics. + +- `Mode` (enum: `DuckDBProfilingMode`): optional mode selector controlling standard vs detailed captures: + - `Standard`, `Detailed`, `All` — choose the level of internal detail captured. + + +- `MetricsThreshold` (int): optional threshold in milliseconds; measurements shorter than this are ignored to reduce noise. +- `OutputPath` (string): optional file path for query plan output. +- `EnabledMetrics` (DuckDBMetricTypeCollection): optional collection of metric types to collect; omit to use the library defaults. diff --git a/build/irion.version b/build/irion.version index 3d19aff8..45cbcf54 100644 --- a/build/irion.version +++ b/build/irion.version @@ -1 +1 @@ -1.5.2.1 \ No newline at end of file +1.5.2.2 \ No newline at end of file diff --git a/nuget.config b/nuget.config index 7505d5b5..5f388a93 100644 --- a/nuget.config +++ b/nuget.config @@ -2,6 +2,6 @@ - + \ No newline at end of file diff --git a/scripts/irion-package.ps1 b/scripts/irion-package.ps1 index b3b0b46b..a96615e9 100644 --- a/scripts/irion-package.ps1 +++ b/scripts/irion-package.ps1 @@ -5,9 +5,15 @@ param( [string]$Version, - [ValidateSet("major", "minor", "build", "revision")] + [ValidateSet("major", "minor", "build", "revision", "prerelease")] [string]$Part = "revision", + [Alias('p')] + [string[]]$MsBuildProperty, + + [string]$PackageReleaseNotes, + [string]$PackageReleaseNotesFile, + [string]$VersionFile = "build/irion.version", [string]$Configuration = "Release", [string]$NuGetSource = "Repository", @@ -55,13 +61,47 @@ function Parse-VersionString { $trimmed = $VersionString.Trim() $parsed = $null - if (-not [Version]::TryParse($trimmed, [ref]$parsed)) { - throw "Invalid version '$VersionString'. Expected format like '1.4.4.1'." + # Allow SemVer-style prerelease or build metadata (e.g. 1.5.2-alpha.1 or 1.5.2+meta) + # by extracting the numeric core before any '-' (prerelease) or '+' (build metadata). + $numericPart = ($trimmed -split '[-+]')[0] + + if (-not [Version]::TryParse($numericPart, [ref]$parsed)) { + throw "Invalid version '$VersionString'. Expected numeric format like '1.4.4.1' or semver with prerelease like '1.4.4-alpha.1'." } return (Normalize-Version -ParsedVersion $parsed) } +function Split-SemVersion { + param([Parameter(Mandatory = $true)][string]$VersionString) + + $raw = $VersionString.Trim() + $sepIndex = -1 + for ($i = 0; $i -lt $raw.Length; $i++) { + if ($raw[$i] -eq '-' -or $raw[$i] -eq '+') { $sepIndex = $i; break } + } + + if ($sepIndex -ge 0) { + $numericPart = $raw.Substring(0, $sepIndex) + $suffix = $raw.Substring($sepIndex) # includes leading '-' or '+' + } + else { + $numericPart = $raw + $suffix = '' + } + + $parsed = $null + if (-not [Version]::TryParse($numericPart, [ref]$parsed)) { + throw "Invalid version '$VersionString'. Expected numeric format like '1.4.4.1' or semver with prerelease like '1.4.4-alpha.1'." + } + + return [PSCustomObject]@{ + Raw = $raw + Numeric = (Normalize-Version -ParsedVersion $parsed) + Suffix = $suffix + } +} + function Get-VersionFilePath { return (Resolve-RepoPath -Path $VersionFile) } @@ -69,13 +109,19 @@ function Get-VersionFilePath { function Get-NuGetPackageVersion { param([Parameter(Mandatory = $true)][string]$VersionString) - $version = Parse-VersionString -VersionString $VersionString + $parts = Split-SemVersion -VersionString $VersionString + $version = $parts.Numeric + $suffix = $parts.Suffix + + # NuGet supports prerelease suffixes (with '-') but not build metadata ('+' section), + # so drop '+'-prefixed suffixes and keep '-'-prefixed prerelease identifiers. + if ($suffix.StartsWith('+')) { $suffix = '' } if ($version.Revision -eq 0) { - return $version.ToString(3) + return ($version.ToString(3) + $suffix) } - return $version.ToString(4) + return ($version.ToString(4) + $suffix) } function Get-CurrentVersion { @@ -86,13 +132,16 @@ function Get-CurrentVersion { } $rawValue = Get-Content -Path $versionFilePath -Raw - return (Parse-VersionString -VersionString $rawValue).ToString(4) + return $rawValue.Trim() } function Save-Version { param([Parameter(Mandatory = $true)][string]$VersionToSave) - $normalizedVersion = (Parse-VersionString -VersionString $VersionToSave).ToString(4) + $trimmed = $VersionToSave.Trim() + # Validate semver but preserve the original string (including prerelease) in file + [void](Split-SemVersion -VersionString $trimmed) + $versionFilePath = Get-VersionFilePath $parentDir = Split-Path -Parent $versionFilePath @@ -100,8 +149,8 @@ function Save-Version { New-Item -Path $parentDir -ItemType Directory -Force | Out-Null } - Set-Content -Path $versionFilePath -Value $normalizedVersion -NoNewline - return $normalizedVersion + Set-Content -Path $versionFilePath -Value $trimmed -NoNewline + return $trimmed } function Get-BumpedVersion { @@ -110,13 +159,42 @@ function Get-BumpedVersion { [Parameter(Mandatory = $true)][string]$BumpPart ) - $version = Parse-VersionString -VersionString $CurrentVersion + $parts = Split-SemVersion -VersionString $CurrentVersion + $version = $parts.Numeric + $suffix = $parts.Suffix switch ($BumpPart) { "major" { return [Version]::new($version.Major + 1, 0, 0, 0).ToString(4) } "minor" { return [Version]::new($version.Major, $version.Minor + 1, 0, 0).ToString(4) } "build" { return [Version]::new($version.Major, $version.Minor, $version.Build + 1, 0).ToString(4) } "revision" { return [Version]::new($version.Major, $version.Minor, $version.Build, $version.Revision + 1).ToString(4) } + "prerelease" { + if ([string]::IsNullOrEmpty($suffix) -or $suffix.StartsWith('+')) { + throw "No prerelease suffix to bump for version '$CurrentVersion'." + } + + # strip leading '-' + $s = $suffix.Substring(1) + $lastDot = $s.LastIndexOf('.') + if ($lastDot -lt 0) { + # no numeric tail, append .1 + $newSuffix = "$s.1" + } + else { + $label = $s.Substring(0, $lastDot) + $tail = $s.Substring($lastDot + 1) + $tailNum = 0 + if ([int]::TryParse($tail, [ref]$tailNum)) { + $newSuffix = "$label.$($tailNum + 1)" + } + else { + # tail is not numeric; append .1 + $newSuffix = "$s.1" + } + } + + return ($version.ToString(4) + '-' + $newSuffix) + } default { throw "Unsupported bump part '$BumpPart'." } } } @@ -235,83 +313,175 @@ function Set-PackageVersionEnvironment { Write-Host "DUCKDB_VERSION_BUILD=$env:DUCKDB_VERSION_BUILD" } +function Get-MsBuildPropertyArguments { + if ($null -eq $MsBuildProperty -or $MsBuildProperty.Count -eq 0) { + return @() + } + + $propertyArgs = New-Object System.Collections.Generic.List[string] + foreach ($property in $MsBuildProperty) { + $trimmed = $property.Trim() + if ([string]::IsNullOrWhiteSpace($trimmed)) { + continue + } + + if ($trimmed.StartsWith("/p:") -or $trimmed.StartsWith("-p:")) { + [void]$propertyArgs.Add($trimmed) + } + else { + [void]$propertyArgs.Add("/p:$trimmed") + } + } + + return @($propertyArgs) +} + function Invoke-Clean { param([Parameter(Mandatory = $true)][string]$PackageVersion) $projects = Get-ProjectPaths + $msBuildPropertyArgs = Get-MsBuildPropertyArguments Set-PackageVersionEnvironment -PackageVersion $PackageVersion - Invoke-DotNet -Arguments @( + $bindingsArgs = @( "clean", $projects.Bindings, "-c", $Configuration, "/p:BuildType=Full" ) + if (-not [string]::IsNullOrWhiteSpace($PackageReleaseNotes)) { + $bindingsArgs += "/p:PackageReleaseNotes=$PackageReleaseNotes" + } + if (-not [string]::IsNullOrWhiteSpace($PackageReleaseNotesFile)) { + $resolved = Resolve-RepoPath -Path $PackageReleaseNotesFile + if (-not (Test-Path $resolved)) { throw "PackageReleaseNotesFile not found: $resolved" } + $leaf = Split-Path -Leaf $resolved + $bindingsArgs += "/p:IncludeReleaseNotesFromRepoRoot=true" + $bindingsArgs += "/p:PackageReadmeFile=$leaf" + } + + $bindingsArgs += $msBuildPropertyArgs + Invoke-DotNet -Arguments $bindingsArgs - Invoke-DotNet -Arguments @( + $dataArgs = @( "clean", $projects.Data, "-c", $Configuration, "/p:BuildType=Full" ) + if (-not [string]::IsNullOrWhiteSpace($PackageReleaseNotes)) { + $dataArgs += "/p:PackageReleaseNotes=$PackageReleaseNotes" + } + if (-not [string]::IsNullOrWhiteSpace($PackageReleaseNotesFile)) { + $resolved = Resolve-RepoPath -Path $PackageReleaseNotesFile + if (-not (Test-Path $resolved)) { throw "PackageReleaseNotesFile not found: $resolved" } + $leaf = Split-Path -Leaf $resolved + $dataArgs += "/p:IncludeReleaseNotesFromRepoRoot=true" + $dataArgs += "/p:PackageReadmeFile=$leaf" + } + + $dataArgs += $msBuildPropertyArgs + Invoke-DotNet -Arguments $dataArgs } function Invoke-Build { param([Parameter(Mandatory = $true)][string]$PackageVersion) $projects = Get-ProjectPaths + $msBuildPropertyArgs = Get-MsBuildPropertyArguments + $versionParts = Split-SemVersion -VersionString $PackageVersion + $assemblyVersion = $versionParts.Numeric.ToString(4) $nuGetPackageVersion = Get-NuGetPackageVersion -VersionString $PackageVersion Set-PackageVersionEnvironment -PackageVersion $PackageVersion + Write-Host "AssemblyVersion=$assemblyVersion" Write-Host "NuGetPackageVersion=$nuGetPackageVersion" - Invoke-DotNet -Arguments @( + $bindingsArgs = @( "build", $projects.Bindings, "-c", $Configuration, "/p:BuildType=Full", - "/p:Version=$PackageVersion", - "/p:FileVersion=$PackageVersion", + "/p:Version=$assemblyVersion", + "/p:FileVersion=$assemblyVersion", + "/p:InformationalVersion=$PackageVersion", "/p:PackageVersion=$nuGetPackageVersion" ) + $bindingsArgs += $msBuildPropertyArgs + Invoke-DotNet -Arguments $bindingsArgs - Invoke-DotNet -Arguments @( + $dataArgs = @( "build", $projects.Data, "-c", $Configuration, "/p:BuildType=Full", - "/p:Version=$PackageVersion", - "/p:FileVersion=$PackageVersion", + "/p:Version=$assemblyVersion", + "/p:FileVersion=$assemblyVersion", + "/p:InformationalVersion=$PackageVersion", "/p:PackageVersion=$nuGetPackageVersion" ) + $dataArgs += $msBuildPropertyArgs + Invoke-DotNet -Arguments $dataArgs } function Invoke-Pack { param([Parameter(Mandatory = $true)][string]$PackageVersion) $projects = Get-ProjectPaths + $msBuildPropertyArgs = Get-MsBuildPropertyArguments + $versionParts = Split-SemVersion -VersionString $PackageVersion + $assemblyVersion = $versionParts.Numeric.ToString(4) $nuGetPackageVersion = Get-NuGetPackageVersion -VersionString $PackageVersion Set-PackageVersionEnvironment -PackageVersion $PackageVersion + Write-Host "AssemblyVersion=$assemblyVersion" Write-Host "NuGetPackageVersion=$nuGetPackageVersion" - Invoke-DotNet -Arguments @( + $bindingsArgs = @( "pack", $projects.Bindings, "-c", $Configuration, "/p:BuildType=Full", - "/p:Version=$PackageVersion", - "/p:FileVersion=$PackageVersion", + "/p:Version=$assemblyVersion", + "/p:FileVersion=$assemblyVersion", + "/p:InformationalVersion=$PackageVersion", "/p:PackageVersion=$nuGetPackageVersion" ) + if (-not [string]::IsNullOrWhiteSpace($PackageReleaseNotes)) { + $bindingsArgs += "/p:PackageReleaseNotes=$PackageReleaseNotes" + } + if (-not [string]::IsNullOrWhiteSpace($PackageReleaseNotesFile)) { + $resolved = Resolve-RepoPath -Path $PackageReleaseNotesFile + if (-not (Test-Path $resolved)) { throw "PackageReleaseNotesFile not found: $resolved" } + $repoReleaseNote = Join-Path $repoRoot 'RELEASE-NOTE.md' + if ($resolved -ne $repoReleaseNote) { throw "PackageReleaseNotesFile must be RELEASE-NOTE.md at the repository root for props-based inclusion. Move it to repo root or omit this parameter." } + $bindingsArgs += "/p:IncludeReleaseNotesFromRepoRoot=true" + $bindingsArgs += "/p:PackageReadmeFile=RELEASE-NOTE.md" + } + $bindingsArgs += $msBuildPropertyArgs + Invoke-DotNet -Arguments $bindingsArgs - Invoke-DotNet -Arguments @( + $dataArgs = @( "pack", $projects.Data, "-c", $Configuration, "/p:BuildType=Full", - "/p:Version=$PackageVersion", - "/p:FileVersion=$PackageVersion", + "/p:Version=$assemblyVersion", + "/p:FileVersion=$assemblyVersion", + "/p:InformationalVersion=$PackageVersion", "/p:PackageVersion=$nuGetPackageVersion" ) + if (-not [string]::IsNullOrWhiteSpace($PackageReleaseNotes)) { + $dataArgs += "/p:PackageReleaseNotes=$PackageReleaseNotes" + } + if (-not [string]::IsNullOrWhiteSpace($PackageReleaseNotesFile)) { + $resolved = Resolve-RepoPath -Path $PackageReleaseNotesFile + if (-not (Test-Path $resolved)) { throw "PackageReleaseNotesFile not found: $resolved" } + $repoReleaseNote = Join-Path $repoRoot 'RELEASE-NOTE.md' + if ($resolved -ne $repoReleaseNote) { throw "PackageReleaseNotesFile must be RELEASE-NOTE.md at the repository root for props-based inclusion. Move it to repo root or omit this parameter." } + $dataArgs += "/p:IncludeReleaseNotesFromRepoRoot=true" + $dataArgs += "/p:PackageReadmeFile=RELEASE-NOTE.md" + } + $dataArgs += $msBuildPropertyArgs + Invoke-DotNet -Arguments $dataArgs } function Invoke-Push { @@ -342,7 +512,7 @@ function Invoke-Push { function Get-RemoteVersions { param([Parameter(Mandatory = $true)][string]$PackageId) - $args = @( + $propertyArgs = @( "package", "search", $PackageId, @@ -353,18 +523,18 @@ function Get-RemoteVersions { ) if ($IncludePrerelease) { - $args += "--prerelease" + $propertyArgs += "--prerelease" } if ($Interactive) { - $args += "--interactive" + $propertyArgs += "--interactive" } if (-not [string]::IsNullOrWhiteSpace($ConfigFile)) { - $args += @("--configfile", (Resolve-RepoPath -Path $ConfigFile)) + $propertyArgs += @("--configfile", (Resolve-RepoPath -Path $ConfigFile)) } - $output = Invoke-DotNetCapture -Arguments $args + $output = Invoke-DotNetCapture -Arguments $propertyArgs $outputText = ($output -join [Environment]::NewLine).Trim() $jsonStart = $outputText.IndexOf("{") if ($jsonStart -lt 0) {