55f46e7c92
Well-localised perf fixes across 8 modules.
Lock decoupling / SQL streaming:
- AuditLog-005: SqliteAuditWriter gains dedicated read-only _readConnection
(+ _readLock) backed by WAL journal mode. GetBacklogStatsAsync,
ReadPendingAsync, ReadPendingSinceAsync, ReadForwardedAsync no longer
contend with the hot-path INSERT lock — backlog probes on a 30s timer
can't stall the writer under multi-hundred-K Pending backlog.
- SEL-022: dropped Cache=Shared from SiteEventLogger's default connection
string (single-connection logger; mode was dormant config).
Memory / streaming:
- CLI-019: bundle export streams base64 in 1 MB-aligned chunks via
Convert.TryFromBase64Chars straight into the FileStream — no more
full-bundle byte[] allocation.
- CentralUI-031: TransportImport now stages the upload to a per-session
temp file under Path.GetTempPath() (replaces in-memory byte[] field);
page implements IDisposable to delete the temp file on reset / new
upload / dispose. Per-circuit working set drops from ~100 MB to ~80 KB.
N+1 hoisting:
- Transport-008: added ITemplateEngineRepository.GetTemplatesWithChildrenAsync
bulk method; BundleImporter.PreviewAsync calls it once instead of per-
template-name. Single query with .Include(...).AsSplitQuery().
- DM-023: BuildDeployArtifactsCommandAsync's per-site loop now references
a pre-fetched GlobalArtifactSnapshot (shared scripts, external systems,
DB connections, notification lists, SMTP) instead of re-querying per site.
- MgmtSvc-023: HandleQueryDeployments unfiltered branch uses one
GetAllInstancesAsync bulk load + Dictionary<int,int?> lookup (was a
GetInstanceByIdAsync per record).
Small allocations / per-tick rebuilds:
- InboundAPI-019: AuditWriteMiddleware gates EnableBuffering() on
RequestHasBody() so GET/HEAD/DELETE/TRACE/OPTIONS and Content-Length:0
requests skip the FileBufferingReadStream allocation.
- NotifOutbox-006: ResolveAdapters dictionary now cached on
_adaptersCache (built lazily on first sweep) + actor-lifetime
_adaptersScope; ResolveAdapters no longer rebuilds per dispatch tick.
Verify-only:
- Comm-017: Confirmed _inProgressDeployments was deleted by Comm-016 in
commit ac96b83 — marked Resolved with that attribution. No code change.
Doc-correction:
- NS-022: Updated MailKitSmtpClientWrapper XML doc to spell out single-
connection / per-delivery-factory contract (option (b) — transient
client per Send — rejected because it re-handshakes TLS per email).
10+ new regression tests across 8 test projects. Build clean; affected
suites all green. README regenerated: 54 open (was 65).
95 lines
3.2 KiB
C#
95 lines
3.2 KiB
C#
using ScadaLink.CLI.Commands;
|
|
|
|
namespace ScadaLink.CLI.Tests.Commands;
|
|
|
|
/// <summary>
|
|
/// CLI-019 regression tests for <see cref="BundleCommands.StreamBase64ToFile"/>.
|
|
/// The pre-fix code did <c>Convert.FromBase64String(...) → File.WriteAllBytes(...)</c>,
|
|
/// doubling the bundle's bytes onto the LOH and writing synchronously. The new
|
|
/// streaming helper decodes the base64 string in fixed-size chunks straight into
|
|
/// a <see cref="FileStream"/>, so peak working set is bounded by the chunk size
|
|
/// regardless of how large the bundle is.
|
|
/// </summary>
|
|
public class BundleCommandsStreamingTests : IDisposable
|
|
{
|
|
private readonly string _tempPath;
|
|
|
|
public BundleCommandsStreamingTests()
|
|
{
|
|
_tempPath = Path.Combine(Path.GetTempPath(), $"bundle-stream-test-{Guid.NewGuid():N}.bin");
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (File.Exists(_tempPath))
|
|
{
|
|
File.Delete(_tempPath);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void StreamBase64ToFile_SmallPayload_RoundTrips()
|
|
{
|
|
var bytes = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
|
|
var base64 = Convert.ToBase64String(bytes);
|
|
|
|
var written = BundleCommands.StreamBase64ToFile(base64, _tempPath);
|
|
|
|
Assert.Equal(bytes.Length, written);
|
|
var roundTripped = File.ReadAllBytes(_tempPath);
|
|
Assert.Equal(bytes, roundTripped);
|
|
}
|
|
|
|
[Fact]
|
|
public void StreamBase64ToFile_PayloadCrossesChunkBoundary_RoundTrips()
|
|
{
|
|
// Build a payload several chunks wide so the slicing loop runs more than
|
|
// once, with enough trailing bytes that the final slice is short and
|
|
// exercises the padding/short-final-chunk path.
|
|
var size = (BundleCommands.Base64StreamChunkChars / 4 * 3) * 3 + 17;
|
|
var bytes = new byte[size];
|
|
for (var i = 0; i < size; i++) bytes[i] = (byte)(i & 0xFF);
|
|
|
|
var base64 = Convert.ToBase64String(bytes);
|
|
|
|
var written = BundleCommands.StreamBase64ToFile(base64, _tempPath);
|
|
|
|
Assert.Equal(size, written);
|
|
var roundTripped = File.ReadAllBytes(_tempPath);
|
|
Assert.Equal(bytes, roundTripped);
|
|
}
|
|
|
|
[Fact]
|
|
public void StreamBase64ToFile_EmptyString_WritesEmptyFile()
|
|
{
|
|
var written = BundleCommands.StreamBase64ToFile(string.Empty, _tempPath);
|
|
|
|
Assert.Equal(0, written);
|
|
Assert.True(File.Exists(_tempPath));
|
|
Assert.Empty(File.ReadAllBytes(_tempPath));
|
|
}
|
|
|
|
[Fact]
|
|
public void StreamBase64ToFile_InvalidBase64_ThrowsFormatException()
|
|
{
|
|
// '*' is not a valid base64 character, so TryFromBase64Chars returns
|
|
// false and the helper throws — the pre-fix code threw FormatException
|
|
// from Convert.FromBase64String, so the contract is preserved.
|
|
var invalid = "this is not valid base64 !!!*";
|
|
|
|
Assert.Throws<FormatException>(() => BundleCommands.StreamBase64ToFile(invalid, _tempPath));
|
|
}
|
|
|
|
[Fact]
|
|
public void StreamBase64ToFile_NullBase64_Throws()
|
|
{
|
|
Assert.Throws<ArgumentNullException>(() => BundleCommands.StreamBase64ToFile(null!, _tempPath));
|
|
}
|
|
|
|
[Fact]
|
|
public void StreamBase64ToFile_EmptyOutputPath_Throws()
|
|
{
|
|
Assert.Throws<ArgumentException>(() => BundleCommands.StreamBase64ToFile("AAAA", string.Empty));
|
|
}
|
|
}
|