From e84c272fb65d1e25de2190c75cb1ce2b47911374 Mon Sep 17 00:00:00 2001 From: "J. Ritchie Carroll" Date: Thu, 18 Jun 2026 16:45:01 -0500 Subject: [PATCH 1/4] Initial implementation of typed `HistorianValue` --- src/SnapDB/BitConvert.cs | 42 ++++++++++++++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/src/SnapDB/BitConvert.cs b/src/SnapDB/BitConvert.cs index 22061ef2..ba8dfb25 100644 --- a/src/SnapDB/BitConvert.cs +++ b/src/SnapDB/BitConvert.cs @@ -24,8 +24,6 @@ // //****************************************************************************************************** -using System.Diagnostics; - namespace SnapDB; /// @@ -47,7 +45,7 @@ public static class BitConvert /// This method performs an unsafe conversion by treating the input float as an uint and /// then casting it to ulong. /// - /// + /// public static unsafe ulong ToUInt64(float value) { return *(uint*)&value; @@ -72,6 +70,38 @@ public static unsafe float ToSingle(ulong value) return *(float*)&tmpValue; } + /// + /// Converts a double-precision floating-point number to an unsigned 64-bit integer representation. + /// + /// The double-precision floating-point number to convert. + /// + /// An unsigned 64-bit integer representation of the input double-precision floating-point number. + /// + /// + /// This method performs an unsafe conversion by casting double to ulong. + /// + /// + public static unsafe ulong ToUInt64(double value) + { + return *(ulong*)&value; + } + + /// + /// Converts an unsigned 64-bit integer to a double-precision floating-point number. + /// + /// The unsigned 64-bit integer to convert. + /// + /// A double-precision floating-point number representing the input unsigned 64-bit integer. + /// + /// + /// This method performs an unsafe conversion by casting the input ulong as a double. + /// + /// + public static unsafe double ToDouble(ulong value) + { + return *(double*)&value; + } + /// /// Converts a Guid value into two unsigned 64-bit integers. /// @@ -86,7 +116,7 @@ public static unsafe (ulong value1, ulong value2) ToUInt64Pair(Guid value) ulong value1; ulong value2; - + fixed (byte* ptr = bytes) { ulong* lptr = (ulong*)ptr; @@ -94,7 +124,7 @@ public static unsafe (ulong value1, ulong value2) ToUInt64Pair(Guid value) lptr++; value2 = *lptr; } - + return (value1, value2); } @@ -120,6 +150,6 @@ public static unsafe Guid ToGuid(ulong value1, ulong value2) return new Guid(bytes); } - + #endregion } \ No newline at end of file From a3a7d52017d0103b903b2cafa3bffc04f3a9bc54 Mon Sep 17 00:00:00 2001 From: "J. Ritchie Carroll" Date: Tue, 23 Jun 2026 15:32:41 -0500 Subject: [PATCH 2/4] Removed unused using directives --- src/SnapDB/Snap/Services/ArchiveDetails.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/SnapDB/Snap/Services/ArchiveDetails.cs b/src/SnapDB/Snap/Services/ArchiveDetails.cs index 34344689..2ec64727 100644 --- a/src/SnapDB/Snap/Services/ArchiveDetails.cs +++ b/src/SnapDB/Snap/Services/ArchiveDetails.cs @@ -23,8 +23,6 @@ // Converted code to .NET core. // //****************************************************************************************************** -using System.Linq; - namespace SnapDB.Snap.Services; From b5c00513db3a2f8ce7f9ed7e75b4c694ddb185b8 Mon Sep 17 00:00:00 2001 From: "J. Ritchie Carroll" Date: Tue, 23 Jun 2026 15:37:38 -0500 Subject: [PATCH 3/4] Initial external event-details implementation --- .../Services/ArchiveDirectoryFillMethod.cs | 43 ++++++ .../AdvancedServerDatabaseConfig.cs | 13 +- .../Services/Writer/ArchiveInitializer.cs | 43 ++---- .../Writer/ArchiveInitializerSettings.cs | 24 +++- .../Snap/Services/Writer/ArchivePathHelper.cs | 130 ++++++++++++++++++ .../Writer/SimplifiedArchiveInitializer.cs | 35 +---- .../SimplifiedArchiveInitializerSettings.cs | 24 +++- 7 files changed, 245 insertions(+), 67 deletions(-) create mode 100644 src/SnapDB/Snap/Services/ArchiveDirectoryFillMethod.cs create mode 100644 src/SnapDB/Snap/Services/Writer/ArchivePathHelper.cs diff --git a/src/SnapDB/Snap/Services/ArchiveDirectoryFillMethod.cs b/src/SnapDB/Snap/Services/ArchiveDirectoryFillMethod.cs new file mode 100644 index 00000000..3e813bcf --- /dev/null +++ b/src/SnapDB/Snap/Services/ArchiveDirectoryFillMethod.cs @@ -0,0 +1,43 @@ +//****************************************************************************************************** +// ArchiveDirectoryFillMethod.cs - Gbtc +// +// Copyright © 2026, Grid Protection Alliance. All Rights Reserved. +// +// Licensed to the Grid Protection Alliance (GPA) under one or more contributor license agreements. See +// the NOTICE file distributed with this work for additional information regarding copyright ownership. +// The GPA licenses this file to you under the MIT License (MIT), the "License"; you may +// not use this file except in compliance with the License. You may obtain a copy of the License at: +// +// http://opensource.org/licenses/MIT +// +// Unless agreed to in writing, the subject software distributed under the License is distributed on an +// "AS-IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. Refer to the +// License for the specific language governing permissions and limitations. +// +// Code Modification History: +// ---------------------------------------------------------------------------------------------------- +// 06/23/2026 - J. Ritchie Carroll +// Generated original version of source code. +// +//****************************************************************************************************** + +namespace SnapDB.Snap.Services; + +/// +/// Specifies how a write directory is selected from the set of configured archive directories when more than one +/// is available. +/// +public enum ArchiveDirectoryFillMethod +{ + /// + /// Fill each configured write path, in order, advancing to the next only when the current path's drive lacks + /// sufficient free space. This is the default and matches the historical archive write behavior. + /// + Sequential, + + /// + /// Distribute new files across the configured write paths in round-robin order, skipping any path whose drive + /// lacks sufficient free space. + /// + RoundRobin +} diff --git a/src/SnapDB/Snap/Services/Configuration/AdvancedServerDatabaseConfig.cs b/src/SnapDB/Snap/Services/Configuration/AdvancedServerDatabaseConfig.cs index 6c152a32..548ef712 100644 --- a/src/SnapDB/Snap/Services/Configuration/AdvancedServerDatabaseConfig.cs +++ b/src/SnapDB/Snap/Services/Configuration/AdvancedServerDatabaseConfig.cs @@ -79,6 +79,7 @@ public AdvancedServerDatabaseConfig(string databaseName, string mainPath, bool s DesiredRemainingSpace = 5L * SI2.Giga; StagingCount = 3; DirectoryMethod = ArchiveDirectoryMethod.TopDirectoryOnly; + FillMethod = ArchiveDirectoryFillMethod.Sequential; DiskFlushInterval = 10000; CacheFlushInterval = 100; } @@ -120,6 +121,12 @@ public AdvancedServerDatabaseConfig(string databaseName, string mainPath, bool s /// public ArchiveDirectoryMethod DirectoryMethod { get; set; } + /// + /// Gets or sets how a write path is selected from the final write paths when more than one is configured. + /// Defaults to . + /// + public ArchiveDirectoryFillMethod FillMethod { get; set; } + /// /// The number of milliseconds before data is automatically flushed to the disk. /// @@ -234,8 +241,12 @@ private void ToWriteProcessorSettings(WriteProcessorSettings settings) if (remainingStages > 0) rollover.ArchiveSettings.ConfigureOnDisk([m_mainPath], SI2.Giga, ArchiveDirectoryMethod.TopDirectoryOnly, ArchiveEncodingMethod, "stage" + stage, intermediateFilePendingExtension, intermediateFileFinalExtension, FileFlags.GetStage(stage), FileFlags.IntermediateFile); else - // Final staging file + { + // Final staging file - this is the only stage that writes across the configured final paths, so the + // fill method (sequential vs round-robin) applies here rollover.ArchiveSettings.ConfigureOnDisk(finalPaths, DesiredRemainingSpace, DirectoryMethod, ArchiveEncodingMethod, DatabaseName.ToNonNullNorEmptyString("stage" + stage).RemoveInvalidFileNameCharacters(), finalFilePendingExtension, finalFileFinalExtension, FileFlags.GetStage(stage)); + rollover.ArchiveSettings.FillMethod = FillMethod; + } rollover.ArchiveSettings.Metadata = Metadata; diff --git a/src/SnapDB/Snap/Services/Writer/ArchiveInitializer.cs b/src/SnapDB/Snap/Services/Writer/ArchiveInitializer.cs index 8d33fe6d..230c1d34 100644 --- a/src/SnapDB/Snap/Services/Writer/ArchiveInitializer.cs +++ b/src/SnapDB/Snap/Services/Writer/ArchiveInitializer.cs @@ -24,7 +24,6 @@ // //****************************************************************************************************** -using Gemstone.IO; using SnapDB.Snap.Storage; using SnapDB.Snap.Types; using SnapDB.Threading; @@ -41,6 +40,7 @@ namespace SnapDB.Snap.Services.Writer; #region [ Members ] private readonly ReaderWriterLockEasy m_lock; + private int m_writePathSeed; #endregion @@ -147,6 +147,10 @@ public SortedTreeTable CreateArchiveFile(TKey startKey, TKey endKe } } + /// + /// Selects a write path with enough free space, distributing selections across the configured write paths in + /// round-robin order. See . + /// /// /// Creates a new random file in one of the provided folders in a round robin fashion. /// @@ -183,43 +187,14 @@ private string CreateArchiveName(string path, TKey startKey, TKey endKey) private string GetPath(string rootPath, DateTime time) { - switch (Settings.DirectoryMethod) - { - case ArchiveDirectoryMethod.TopDirectoryOnly: - break; - case ArchiveDirectoryMethod.Year: - rootPath = Path.Combine(rootPath, time.Year.ToString()); - break; - case ArchiveDirectoryMethod.YearMonth: - rootPath = Path.Combine(rootPath, time.Year + time.Month.ToString("00")); - break; - case ArchiveDirectoryMethod.YearThenMonth: - rootPath = Path.Combine(rootPath, time.Year.ToString() + '\\' + time.Month.ToString("00")); - break; - } - - if (!Directory.Exists(rootPath)) - Directory.CreateDirectory(rootPath); - - return rootPath; + return ArchivePathHelper.GetPath(rootPath, time, Settings.DirectoryMethod); } + // Selects a write path with enough free space, distributing selections across the configured write paths in + // round-robin order via a per-instance interlocked seed. private string GetPathWithEnoughSpace(long estimatedSize) { - if (estimatedSize < 0) - return Settings.WritePath.FirstOrDefault() ?? throw new InvalidOperationException("No write path defined"); - - long remainingSpace = Settings.DesiredRemainingSpace; - - foreach (string path in Settings.WritePath) - { - FilePath.GetAvailableFreeSpace(path, out long freeSpace, out _); - - if (freeSpace - estimatedSize > remainingSpace) - return path; - } - - throw new InvalidOperationException("Out of free space"); + return ArchivePathHelper.GetPathWithEnoughSpace(Settings.WritePath, estimatedSize, Settings.DesiredRemainingSpace, Settings.FillMethod, Interlocked.Increment(ref m_writePathSeed) - 1); } #endregion diff --git a/src/SnapDB/Snap/Services/Writer/ArchiveInitializerSettings.cs b/src/SnapDB/Snap/Services/Writer/ArchiveInitializerSettings.cs index 87d6d4e2..1f828e83 100644 --- a/src/SnapDB/Snap/Services/Writer/ArchiveInitializerSettings.cs +++ b/src/SnapDB/Snap/Services/Writer/ArchiveInitializerSettings.cs @@ -40,6 +40,7 @@ public class ArchiveInitializerSettings : SettingsBase + /// Gets or sets the method used to select a write path when more than one is configured. Defaults to + /// . + /// + public ArchiveDirectoryFillMethod FillMethod + { + get => m_fillMethod; + set + { + TestForEditable(); + m_fillMethod = value; + } + } + /// /// The encoding method that will be used to write files. /// @@ -226,8 +241,9 @@ public void ConfigureOnDisk(IEnumerable paths, long desiredRemainingSpac /// The stream where the configuration data will be saved. public override void Save(Stream stream) { - stream.Write((byte)1); + stream.Write((byte)2); stream.Write((int)m_directoryMethod); + stream.Write((int)m_fillMethod); stream.Write(m_isMemoryArchive); stream.Write(m_prefix); stream.Write(m_fileExtension); @@ -256,7 +272,12 @@ public override void Load(Stream stream) switch (version) { case 1: + case 2: m_directoryMethod = (ArchiveDirectoryMethod)stream.ReadInt32(); + + // Fill method was introduced in version 2; version 1 streams default to Sequential + m_fillMethod = version >= 2 ? (ArchiveDirectoryFillMethod)stream.ReadInt32() : ArchiveDirectoryFillMethod.Sequential; + m_isMemoryArchive = stream.ReadBoolean(); m_prefix = stream.ReadString(); m_fileExtension = stream.ReadString(); @@ -311,6 +332,7 @@ public override void Validate() private void Initialize() { m_directoryMethod = ArchiveDirectoryMethod.TopDirectoryOnly; + m_fillMethod = ArchiveDirectoryFillMethod.Sequential; m_isMemoryArchive = false; m_prefix = string.Empty; m_fileExtension = ".d2i"; diff --git a/src/SnapDB/Snap/Services/Writer/ArchivePathHelper.cs b/src/SnapDB/Snap/Services/Writer/ArchivePathHelper.cs new file mode 100644 index 00000000..3956a629 --- /dev/null +++ b/src/SnapDB/Snap/Services/Writer/ArchivePathHelper.cs @@ -0,0 +1,130 @@ +//****************************************************************************************************** +// ArchivePathHelper.cs - Gbtc +// +// Copyright © 2026, Grid Protection Alliance. All Rights Reserved. +// +// Licensed to the Grid Protection Alliance (GPA) under one or more contributor license agreements. See +// the NOTICE file distributed with this work for additional information regarding copyright ownership. +// The GPA licenses this file to you under the MIT License (MIT), the "License"; you may +// not use this file except in compliance with the License. You may obtain a copy of the License at: +// +// http://opensource.org/licenses/MIT +// +// Unless agreed to in writing, the subject software distributed under the License is distributed on an +// "AS-IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. Refer to the +// License for the specific language governing permissions and limitations. +// +// Code Modification History: +// ---------------------------------------------------------------------------------------------------- +// 06/23/2026 - J. Ritchie Carroll +// Generated original version of source code. +// +//****************************************************************************************************** + +using Gemstone.IO; + +namespace SnapDB.Snap.Services.Writer; + +/// +/// Provides shared helpers for resolving archive file directories and selecting a write directory from a set of +/// candidate paths based on available free space. +/// +/// +/// These helpers centralize the directory layout and disk-space-aware path selection rules used by the archive +/// writers (e.g., and +/// ) so that any other component needing to place files +/// alongside the historian archive (.d2) files can follow identical rules. +/// +public static class ArchivePathHelper +{ + /// + /// Gets the directory, relative to an archive root, in which files for the specified + /// are placed according to the given . + /// + /// Timestamp used to derive the dated sub-directory. + /// Directory structure to follow. + /// + /// The relative directory, or an empty string for . This + /// method performs no I/O. + /// + public static string GetRelativeDirectory(DateTime time, ArchiveDirectoryMethod directoryMethod) + { + return directoryMethod switch + { + ArchiveDirectoryMethod.Year => time.Year.ToString(), + ArchiveDirectoryMethod.YearMonth => time.Year + time.Month.ToString("00"), + ArchiveDirectoryMethod.YearThenMonth => Path.Combine(time.Year.ToString(), time.Month.ToString("00")), + _ => string.Empty // TopDirectoryOnly + }; + } + + /// + /// Resolves the full directory for the specified and + /// according to the given , creating the directory if it does not exist. + /// + /// Root archive directory. + /// Timestamp used to derive the dated sub-directory. + /// Directory structure to follow. + /// The full (and existing) directory path. + public static string GetPath(string rootPath, DateTime time, ArchiveDirectoryMethod directoryMethod) + { + string relativeDirectory = GetRelativeDirectory(time, directoryMethod); + string path = relativeDirectory.Length == 0 ? rootPath : Path.Combine(rootPath, relativeDirectory); + + if (!Directory.Exists(path)) + Directory.CreateDirectory(path); + + return path; + } + + /// + /// Selects a write directory from that has enough free space to store a file of the + /// estimated size while still leaving the desired remaining space, distributing selections across the + /// candidate paths in round-robin order. + /// + /// Candidate write directories, in configured order. Must contain at least one entry. + /// + /// Estimated size, in bytes, of the file to be written. A negative value skips the free-space check. + /// + /// Minimum free space, in bytes, to leave on the target drive. + /// + /// Determines how the starting candidate is chosen: always + /// starts at the first path (filling paths in order), while + /// rotates the starting candidate using . + /// + /// + /// A value used to rotate the starting candidate when is + /// ; ignored otherwise. Callers typically pass a per-instance + /// counter (e.g., an interlocked increment) so that each call advances the round-robin position. + /// + /// The selected write directory. + /// + /// is null or empty, or no path has sufficient free space. + /// + public static string GetPathWithEnoughSpace(IList paths, long estimatedSize, long desiredRemainingSpace, ArchiveDirectoryFillMethod fillMethod, int roundRobinSeed) + { + if (paths is null || paths.Count == 0) + throw new InvalidOperationException("No write path defined"); + + int count = paths.Count; + + // Sequential fill always starts at the first path; round-robin rotates the start using the seed + int start = fillMethod == ArchiveDirectoryFillMethod.RoundRobin ? ((roundRobinSeed % count) + count) % count : 0; + + // No size estimate: return the selected path without a free-space check + if (estimatedSize < 0) + return paths[start]; + + // Scan all candidate paths once, beginning at the round-robin position and wrapping around, returning the + // first whose drive has enough free space to store the estimated file while leaving the desired remaining space + for (int offset = 0; offset < count; offset++) + { + string path = paths[(start + offset) % count]; + + if (FilePath.GetAvailableFreeSpace(path, out long freeSpace, out _) && freeSpace - estimatedSize > desiredRemainingSpace) + return path; + } + + throw new InvalidOperationException("Out of free space"); + } +} diff --git a/src/SnapDB/Snap/Services/Writer/SimplifiedArchiveInitializer.cs b/src/SnapDB/Snap/Services/Writer/SimplifiedArchiveInitializer.cs index 2bfb1bce..b9460b49 100644 --- a/src/SnapDB/Snap/Services/Writer/SimplifiedArchiveInitializer.cs +++ b/src/SnapDB/Snap/Services/Writer/SimplifiedArchiveInitializer.cs @@ -24,7 +24,6 @@ // //****************************************************************************************************** -using Gemstone.IO; using SnapDB.Snap.Storage; using SnapDB.Snap.Types; using SnapDB.Threading; @@ -41,6 +40,7 @@ namespace SnapDB.Snap.Services.Writer; #region [ Members ] private readonly ReaderWriterLockEasy m_lock; + private int m_writePathSeed; #endregion @@ -148,39 +148,14 @@ private string CreateArchiveName(string path, TKey startKey, TKey endKey) private string GetPath(string rootPath, DateTime time) { - switch (Settings.DirectoryMethod) - { - case ArchiveDirectoryMethod.TopDirectoryOnly: - break; - case ArchiveDirectoryMethod.Year: - rootPath = Path.Combine(rootPath, time.Year.ToString()); - break; - case ArchiveDirectoryMethod.YearMonth: - rootPath = Path.Combine(rootPath, time.Year + time.Month.ToString("00")); - break; - case ArchiveDirectoryMethod.YearThenMonth: - rootPath = Path.Combine(rootPath, time.Year.ToString() + '\\' + time.Month.ToString("00")); - break; - } - - if (!Directory.Exists(rootPath)) - Directory.CreateDirectory(rootPath); - return rootPath; + return ArchivePathHelper.GetPath(rootPath, time, Settings.DirectoryMethod); } + // Selects a write path with enough free space using the configured fill method, distributing selections in + // round-robin order (when enabled) via a per-instance interlocked seed. private string GetPathWithEnoughSpace(long estimatedSize) { - if (estimatedSize < 0) - return Settings.WritePath.First(); - long remainingSpace = Settings.DesiredRemainingSpace; - foreach (string path in Settings.WritePath) - { - FilePath.GetAvailableFreeSpace(path, out long freeSpace, out _); - if (freeSpace - estimatedSize > remainingSpace) - return path; - } - - throw new Exception("Out of free space"); + return ArchivePathHelper.GetPathWithEnoughSpace(Settings.WritePath, estimatedSize, Settings.DesiredRemainingSpace, Settings.FillMethod, Interlocked.Increment(ref m_writePathSeed) - 1); } #endregion diff --git a/src/SnapDB/Snap/Services/Writer/SimplifiedArchiveInitializerSettings.cs b/src/SnapDB/Snap/Services/Writer/SimplifiedArchiveInitializerSettings.cs index ba46bb84..fdd93af9 100644 --- a/src/SnapDB/Snap/Services/Writer/SimplifiedArchiveInitializerSettings.cs +++ b/src/SnapDB/Snap/Services/Writer/SimplifiedArchiveInitializerSettings.cs @@ -40,6 +40,7 @@ public class SimplifiedArchiveInitializerSettings : SettingsBase + /// Gets or sets the method used to select a write path when more than one is configured. Defaults to + /// . + /// + public ArchiveDirectoryFillMethod FillMethod + { + get => m_fillMethod; + set + { + TestForEditable(); + m_fillMethod = value; + } + } + /// /// The encoding method that will be used to write files. /// @@ -212,8 +227,9 @@ public void ConfigureOnDisk(IEnumerable paths, long desiredRemainingSpac /// The stream to which the configuration will be saved. public override void Save(Stream stream) { - stream.Write((byte)1); + stream.Write((byte)2); stream.Write((int)m_directoryMethod); + stream.Write((int)m_fillMethod); stream.Write(m_prefix); stream.Write(m_pendingExtension); stream.Write(m_finalExtension); @@ -242,7 +258,12 @@ public override void Load(Stream stream) switch (version) { case 1: + case 2: m_directoryMethod = (ArchiveDirectoryMethod)stream.ReadInt32(); + + // Fill method was introduced in version 2; version 1 streams default to Sequential + m_fillMethod = version >= 2 ? (ArchiveDirectoryFillMethod)stream.ReadInt32() : ArchiveDirectoryFillMethod.Sequential; + m_prefix = stream.ReadString(); m_pendingExtension = stream.ReadString(); m_finalExtension = stream.ReadString(); @@ -285,6 +306,7 @@ public override void Validate() private void Initialize() { m_directoryMethod = ArchiveDirectoryMethod.TopDirectoryOnly; + m_fillMethod = ArchiveDirectoryFillMethod.Sequential; m_prefix = string.Empty; m_pendingExtension = ".~d2i"; m_finalExtension = ".d2i"; From 904c242f3c96e0a36bb5c5b9eed93770fb2aefa4 Mon Sep 17 00:00:00 2001 From: "J. Ritchie Carroll" Date: Tue, 23 Jun 2026 17:23:33 -0500 Subject: [PATCH 4/4] Added socket layer API handling for event details --- .../Net/CustomCommandNotSupportedException.cs | 50 +++++++++++++++++++ src/SnapDB/Snap/Services/Net/ServerCommand.cs | 28 ++++++++++- .../Snap/Services/Net/SnapStreamingClient.cs | 48 ++++++++++++++++++ .../Snap/Services/Net/SnapStreamingServer.cs | 31 ++++++++++++ src/SnapDB/Snap/Services/SnapClient.cs | 20 ++++++++ src/SnapDB/Snap/Services/SnapServer.cs | 48 ++++++++++++++++++ src/SnapDB/Snap/Services/SnapServerClient.cs | 19 +++++++ 7 files changed, 242 insertions(+), 2 deletions(-) create mode 100644 src/SnapDB/Snap/Services/Net/CustomCommandNotSupportedException.cs diff --git a/src/SnapDB/Snap/Services/Net/CustomCommandNotSupportedException.cs b/src/SnapDB/Snap/Services/Net/CustomCommandNotSupportedException.cs new file mode 100644 index 00000000..de23f92c --- /dev/null +++ b/src/SnapDB/Snap/Services/Net/CustomCommandNotSupportedException.cs @@ -0,0 +1,50 @@ +//****************************************************************************************************** +// CustomCommandNotSupportedException.cs - Gbtc +// +// Copyright © 2026, Grid Protection Alliance. All Rights Reserved. +// +// Licensed to the Grid Protection Alliance (GPA) under one or more contributor license agreements. See +// the NOTICE file distributed with this work for additional information regarding copyright ownership. +// The GPA licenses this file to you under the MIT License (MIT), the "License"; you may +// not use this file except in compliance with the License. You may obtain a copy of the License at: +// +// http://opensource.org/licenses/MIT +// +// Unless agreed to in writing, the subject software distributed under the License is distributed on an +// "AS-IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. Refer to the +// License for the specific language governing permissions and limitations. +// +// Code Modification History: +// ---------------------------------------------------------------------------------------------------- +// 06/23/2026 - J. Ritchie Carroll +// Generated original version of source code. +// +//****************************************************************************************************** + +namespace SnapDB.Snap.Services.Net; + +/// +/// Exception thrown when a client invokes a custom command that the server does not support, either because no +/// handler is registered for the command name or because the server is an older version that does not recognize +/// the custom-command protocol. +/// +/// +/// Callers can catch this exception to degrade gracefully when connecting to servers that lack support for a +/// given custom command. +/// +public class CustomCommandNotSupportedException : Exception +{ + /// + /// Gets the name of the custom command that was not supported, when known. + /// + public string? CommandName { get; } + + /// + /// Creates a new . + /// + /// The name of the unsupported custom command, when known. + public CustomCommandNotSupportedException(string? commandName) : base($"Custom command '{commandName}' is not supported by the server.") + { + CommandName = commandName; + } +} diff --git a/src/SnapDB/Snap/Services/Net/ServerCommand.cs b/src/SnapDB/Snap/Services/Net/ServerCommand.cs index 06b44aad..771db2a7 100644 --- a/src/SnapDB/Snap/Services/Net/ServerCommand.cs +++ b/src/SnapDB/Snap/Services/Net/ServerCommand.cs @@ -66,7 +66,13 @@ public enum ServerCommand : byte /// /// - GetAllDatabases = 7 + GetAllDatabases = 7, + + /// + /// Invokes a named, server-registered custom command with an opaque request payload, returning an opaque + /// response payload. Provides a generic extension point without coupling the protocol to specific features. + /// + RunCustomCommand = 8 } /// @@ -184,7 +190,25 @@ public enum ServerResponse : byte /// /// - AuthenticationFailed + AuthenticationFailed, + + /// + /// Returned by when a registered handler produced a result. The + /// length-prefixed response payload follows. + /// + CustomCommandResult = 26, + + /// + /// Returned by when no handler is registered for the requested + /// command name. The connection remains usable. + /// + CustomCommandNotSupported = 27, + + /// + /// Returned by when a registered handler threw an exception. The + /// error message string follows. The connection remains usable. + /// + CustomCommandError = 28 } #endregion \ No newline at end of file diff --git a/src/SnapDB/Snap/Services/Net/SnapStreamingClient.cs b/src/SnapDB/Snap/Services/Net/SnapStreamingClient.cs index df378140..ea236775 100644 --- a/src/SnapDB/Snap/Services/Net/SnapStreamingClient.cs +++ b/src/SnapDB/Snap/Services/Net/SnapStreamingClient.cs @@ -175,6 +175,54 @@ public override bool Contains(string databaseName) return m_databaseInfos.ContainsKey(databaseName.ToUpper()); } + /// + /// Invokes a named, server-registered custom command at the connection root level. + /// + /// The case-insensitive name of the custom command to invoke. + /// The opaque request payload; may be empty. + /// The opaque response payload produced by the server-side handler. + /// + /// The server is an older version that does not recognize the custom-command protocol, or no handler is + /// registered for . + /// + public override byte[] RunCustomCommand(string command, byte[] request) + { + if (m_sortedTreeEngine is not null) + throw new InvalidOperationException("Cannot run a custom command while connected to a database; use a dedicated connection for custom commands."); + + request ??= []; + + m_stream.Write((byte)ServerCommand.RunCustomCommand); + m_stream.Write(command); + m_stream.Write(request.Length); + + if (request.Length > 0) + m_stream.Write(request, 0, request.Length); + + m_stream.Flush(); + + ServerResponse response = (ServerResponse)m_stream.ReadUInt8(); + + switch (response) + { + case ServerResponse.UnhandledException: + throw new Exception($"Server UnhandledException: \n{m_stream.ReadString()}"); + case ServerResponse.CustomCommandResult: + int length = m_stream.ReadInt32(); + return length > 0 ? m_stream.ReadBytes(length) : []; + case ServerResponse.CustomCommandNotSupported: + throw new CustomCommandNotSupportedException(m_stream.ReadString()); + case ServerResponse.CustomCommandError: + throw new Exception($"Server custom command error: {m_stream.ReadString()}"); + case ServerResponse.UnknownCommand: + // Older server that does not recognize the custom-command protocol; it echoes the command byte and drops the connection + m_stream.ReadUInt8(); + throw new CustomCommandNotSupportedException(command); + default: + throw new Exception($"Unknown server response: {response}"); + } + } + /// /// Creates a /// diff --git a/src/SnapDB/Snap/Services/Net/SnapStreamingServer.cs b/src/SnapDB/Snap/Services/Net/SnapStreamingServer.cs index 65cb02fd..607941cd 100644 --- a/src/SnapDB/Snap/Services/Net/SnapStreamingServer.cs +++ b/src/SnapDB/Snap/Services/Net/SnapStreamingServer.cs @@ -282,6 +282,37 @@ private void ProcessRootLevelCommands() if (!success) return; + break; + case ServerCommand.RunCustomCommand: + string customCommand = m_stream.ReadString(); + int requestLength = m_stream.ReadInt32(); + byte[] request = requestLength > 0 ? m_stream.ReadBytes(requestLength) : []; + + if (!m_server.TryGetCustomCommand(customCommand, out Func? handler) || handler is null) + { + m_stream.Write((byte)ServerResponse.CustomCommandNotSupported); + m_stream.Write(customCommand); + m_stream.Flush(); + break; + } + + try + { + byte[] response = handler(request) ?? []; + m_stream.Write((byte)ServerResponse.CustomCommandResult); + m_stream.Write(response.Length); + + if (response.Length > 0) + m_stream.Write(response, 0, response.Length); + } + catch (Exception ex) + { + Log.Publish(MessageLevel.Warning, "Custom Command Error", $"Custom command '{customCommand}' handler threw an exception.", null, ex); + m_stream.Write((byte)ServerResponse.CustomCommandError); + m_stream.Write(ex.Message); + } + + m_stream.Flush(); break; case ServerCommand.Disconnect: m_stream.Write((byte)ServerResponse.GoodBye); diff --git a/src/SnapDB/Snap/Services/SnapClient.cs b/src/SnapDB/Snap/Services/SnapClient.cs index 3241c503..915ac018 100644 --- a/src/SnapDB/Snap/Services/SnapClient.cs +++ b/src/SnapDB/Snap/Services/SnapClient.cs @@ -97,6 +97,26 @@ protected SnapClient() : base(MessageClass.Framework) /// public abstract bool Contains(string databaseName); + /// + /// Invokes a named, server-registered custom command, passing an opaque request payload and returning an + /// opaque response payload. + /// + /// The case-insensitive name of the custom command to invoke. + /// The opaque request payload; may be empty. + /// The opaque response payload produced by the server-side handler. + /// + /// The server does not support the custom-command protocol, or no handler is registered for + /// . + /// + /// + /// Custom commands operate at the connection root level. When using a network client, do not invoke a custom + /// command while connected to a database; use a dedicated connection for custom commands. + /// + public virtual byte[] RunCustomCommand(string command, byte[] request) + { + throw new NotSupportedException("This client does not support custom commands."); + } + #endregion #region [ Static ] diff --git a/src/SnapDB/Snap/Services/SnapServer.cs b/src/SnapDB/Snap/Services/SnapServer.cs index e62cdf2c..d563c5ac 100644 --- a/src/SnapDB/Snap/Services/SnapServer.cs +++ b/src/SnapDB/Snap/Services/SnapServer.cs @@ -24,6 +24,7 @@ // //****************************************************************************************************** +using System.Collections.Concurrent; using System.Net; using System.Text; using Gemstone.Diagnostics; @@ -59,6 +60,12 @@ public partial class SnapServer : DisposableLoggingClassBase /// private readonly Dictionary m_sockets; + /// + /// Registered custom command handlers, keyed by case-insensitive command name. Each handler maps an opaque + /// request payload to an opaque response payload. + /// + private readonly ConcurrentDictionary> m_customCommands = new(StringComparer.OrdinalIgnoreCase); + private readonly Lock m_syncRoot = new(); private bool m_disposed; @@ -111,6 +118,47 @@ public SnapServer(IToServerSettings settings) : this() #region [ Methods ] + /// + /// Registers a custom command handler that can be invoked by clients via + /// . Registering the same command name again replaces the prior handler. + /// + /// The case-insensitive command name. + /// + /// Handler mapping an opaque request payload to an opaque response payload. The handler runs on the connection's + /// thread; it should be thread-safe and reasonably fast. + /// + public void RegisterCustomCommand(string command, Func handler) + { + if (string.IsNullOrEmpty(command)) + throw new ArgumentNullException(nameof(command)); + + m_customCommands[command] = handler ?? throw new ArgumentNullException(nameof(handler)); + } + + /// + /// Removes a previously registered custom command handler. + /// + /// The case-insensitive command name. + /// true if a handler was removed; otherwise, false. + public bool UnregisterCustomCommand(string command) + { + return command is not null && m_customCommands.TryRemove(command, out _); + } + + /// + /// Attempts to get the registered handler for the specified custom command name. + /// + internal bool TryGetCustomCommand(string command, out Func? handler) + { + if (command is null) + { + handler = null; + return false; + } + + return m_customCommands.TryGetValue(command, out handler); + } + /// /// Releases the unmanaged resources used by the object and optionally releases the managed resources. /// diff --git a/src/SnapDB/Snap/Services/SnapServerClient.cs b/src/SnapDB/Snap/Services/SnapServerClient.cs index 71341fd1..48069974 100644 --- a/src/SnapDB/Snap/Services/SnapServerClient.cs +++ b/src/SnapDB/Snap/Services/SnapServerClient.cs @@ -24,6 +24,8 @@ // //****************************************************************************************************** +using SnapDB.Snap.Services.Net; + namespace SnapDB.Snap.Services; public partial class SnapServer @@ -144,6 +146,23 @@ public override List GetDatabaseInfo() return m_server.GetDatabaseInfo(); } + /// + /// Invokes a named, server-registered custom command directly against the in-process server. + /// + /// The case-insensitive name of the custom command to invoke. + /// The opaque request payload; may be empty. + /// The opaque response payload produced by the server-side handler. + /// No handler is registered for . + public override byte[] RunCustomCommand(string command, byte[] request) + { + ObjectDisposedException.ThrowIf(m_disposed, this); + + if (!m_server.TryGetCustomCommand(command, out Func? handler) || handler is null) + throw new CustomCommandNotSupportedException(command); + + return handler(request ?? []) ?? []; + } + /// /// Unregisters a client database. ///