Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions Darling/Darling.Tests/PgLogRotationEvidence.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
using PerformanceMonitor.Collectors;

namespace Darling.Tests;

/// <summary>
/// Renders the evidence behind a failed log-rotation count check, so a failure names what was read instead of only
/// "expected 1, actual 2": every row of the wait's backend (with its hash, time and text), which of them the test's
/// identity matched, the resume state going in and coming out, and the target's log directory. The caller builds it
/// only when a check fails.
/// </summary>
internal static class PgLogRotationEvidence
{
private const int TextLimit = 200;

internal static string Describe(
string what,
IEnumerable<PgLogEvent> rows,
int waitPid,
DateTime waitFloorUtc,
Func<PgLogEvent, bool> isTheWait,
IReadOnlyDictionary<string, string>? carriedState,
IReadOnlyDictionary<string, string>? newState,
string logDirListing)
{
var all = rows.ToList();
var sb = new StringBuilder();
sb.Append("--- ").Append(what).AppendLine(" ---");
sb.Append("wait: pid=").Append(waitPid.ToString(CultureInfo.InvariantCulture))
.Append(" floor=").Append(waitFloorUtc.ToString("O", CultureInfo.InvariantCulture)).AppendLine();
sb.Append("rows read: ").Append(all.Count.ToString(CultureInfo.InvariantCulture))
.Append(", matching the wait: ").Append(all.Count(isTheWait).ToString(CultureInfo.InvariantCulture))
.Append(", distinct hashes among all rows: ").Append(all.Select(r => r.RawLineHash).Distinct().Count().ToString(CultureInfo.InvariantCulture)).AppendLine();

sb.AppendLine("rows matching the wait's identity, or carrying its pid (M = matched):");
var shown = 0;
foreach (var row in all.Where(r => isTheWait(r) || r.Pid == waitPid))
{
shown++;
sb.Append(isTheWait(row) ? " M " : " ")
.Append("hash=").Append(row.RawLineHash)
.Append(" at=").Append(row.OccurredAtUtc.ToString("O", CultureInfo.InvariantCulture))
.Append(" pid=").Append(row.Pid.ToString(CultureInfo.InvariantCulture))
.Append(" severity=").Append(row.Severity)
.Append(" family=").Append(row.Family)
.Append(" message=").Append(Clip(row.Message))
.Append(" detail=").Append(Clip(row.Detail))
.Append(" context=").Append(Clip(row.Context))
.AppendLine();
}

if (shown == 0)
{
sb.AppendLine(" (none)");
}

var repeated = all.GroupBy(r => r.RawLineHash).Where(g => g.Count() > 1).ToList();
sb.Append("hashes that occur more than once: ").Append(repeated.Count.ToString(CultureInfo.InvariantCulture)).AppendLine();
foreach (var group in repeated)
{
sb.Append(" ").Append(group.Key).Append(" x").Append(group.Count().ToString(CultureInfo.InvariantCulture))
.Append(" message=").Append(Clip(group.First().Message)).AppendLine();
}

sb.Append("carried state: ").AppendLine(State(carriedState));
sb.Append("resulting state: ").AppendLine(State(newState));
sb.AppendLine("log directory (name, size, modification):");
sb.AppendLine(string.IsNullOrEmpty(logDirListing) ? " (not read)" : logDirListing);
return sb.ToString();
}

private static string State(IReadOnlyDictionary<string, string>? state) =>
state is null || state.Count == 0
? "(none)"
: string.Join("; ", state.OrderBy(p => p.Key, StringComparer.Ordinal).Select(p => p.Key + "=" + p.Value));

private static string Clip(string? text)
{
if (text is null)
{
return "<null>";
}

var flat = text.Replace("\r", "\\r", StringComparison.Ordinal).Replace("\n", "\\n", StringComparison.Ordinal);
return flat.Length <= TextLimit ? "\"" + flat + "\"" : "\"" + flat[..TextLimit] + "\"...(" + flat.Length.ToString(CultureInfo.InvariantCulture) + " chars)";
}
}
64 changes: 64 additions & 0 deletions Darling/Darling.Tests/PgLogRotationEvidenceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
using System;
using System.Collections.Generic;
using PerformanceMonitor.Collectors;
using Xunit;

namespace Darling.Tests;

/// <summary>
/// The rotation live tests' failure description has to name each hash, pid, time and message it was given; a
/// description that dropped them would turn a duplicate back into "expected 1, actual 2".
/// </summary>
public sealed class PgLogRotationEvidenceTests
{
private static PgLogEvent Row(string hash, int pid, DateTime at, string message) => new(
OccurredAtUtc: at, Family: PgLogFamilies.Error, Severity: "LOG", SqlState: null, DatabaseName: null, UserName: null,
ApplicationName: null, Pid: pid, Message: message, Detail: "Process holding the lock: 99.", Context: "while updating tuple",
StatementFingerprint: null, RawLineHash: hash, Metrics: default);

[Fact]
public void Describe_NamesEveryHashPidTimeAndMessageOfTheMatchedRows()
{
var floor = new DateTime(2026, 9, 30, 12, 0, 0, DateTimeKind.Utc);
var rows = new List<PgLogEvent>
{
Row("hash-aaa", 4242, floor.AddSeconds(1), "process 4242 still waiting for ShareLock after 100.123 ms"),
Row("hash-bbb", 4242, floor.AddSeconds(2), "process 4242 still waiting for ShareLock after 900.456 ms"),
Row("hash-ccc", 7, floor.AddSeconds(3), "unrelated entry"),
Row("hash-aaa", 4242, floor.AddSeconds(1), "process 4242 still waiting for ShareLock after 100.123 ms"),
};

var text = PgLogRotationEvidence.Describe(
"after rotation", rows, 4242, floor, r => r.Pid == 4242 && r.OccurredAtUtc >= floor,
new Dictionary<string, string> { ["log_tail_json"] = "100|postgresql-a.json" },
new Dictionary<string, string> { ["log_tail_json"] = "200|postgresql-b.json" },
" postgresql-a.json 100 2026-09-30 12:00:00+00");

foreach (var expected in new[]
{
"pid=4242", floor.ToString("O", System.Globalization.CultureInfo.InvariantCulture), "hash=hash-aaa", "hash=hash-bbb",
"after 100.123 ms", "after 900.456 ms", "severity=LOG", "family=" + PgLogFamilies.Error, "detail=\"Process holding the lock: 99.\"",
"context=\"while updating tuple\"", "hash-aaa x2", "log_tail_json=100|postgresql-a.json", "log_tail_json=200|postgresql-b.json",
"postgresql-a.json 100",
})
{
Assert.Contains(expected, text, StringComparison.Ordinal);
}

Assert.DoesNotContain("hash=hash-ccc", text, StringComparison.Ordinal);
Assert.Contains("matching the wait: 3", text, StringComparison.Ordinal);
}

[Fact]
public void Describe_ClipsLongTextAndToleratesMissingParts()
{
var floor = DateTime.UtcNow;
var text = PgLogRotationEvidence.Describe(
"x", [Row("h", 1, floor, new string('m', 500))], 1, floor, _ => true, null, null, string.Empty);

Assert.Contains("...(500 chars)", text, StringComparison.Ordinal);
Assert.DoesNotContain(new string('m', 250), text, StringComparison.Ordinal);
Assert.Contains("carried state: (none)", text, StringComparison.Ordinal);
Assert.Contains("(not read)", text, StringComparison.Ordinal);
}
}
Loading
Loading