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
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;
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/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.
///
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";