Skip to content

Commit 3ff6912

Browse files
ozakboyclaude
andcommitted
新增 Flush/Shutdown 生命週期 API、日誌檔改共用讀寫 升版 3.3.0
寫入是非同步的,在此之前想確定「剛寫的那筆落地了沒」只能睡一下再賭。 新增 LOG.Flush / FlushAsync / Shutdown / ShutdownAsync / IsShutdown, 全部是新增,既有簽章與預設值都沒動。 Flush 是精確屏障而不是換個寫法的 sleep:呼叫端執行緒一起幫忙排空, 等的是「未完成筆數」歸零而非「佇列空了」—— 佇列空只代表沒東西在等, dispatcher 可能剛取走一筆還沒寫進檔案,只看佇列的 Flush 會提早一筆回來。 排空後強制 fsync,因為宿主會呼叫它的理由就是接下來可能被砍。 Shutdown 排空後停掉 dispatcher、定期 flush 與過期清理計時器並關檔; 之後的寫入一律靜默丟棄不擲例外(背景服務型套件不能在自己收尾後把宿主弄掛), 冪等,且與既有的 ProcessExit 收尾不論誰先誰後都安全。dispatcher 的 semaphore 與 CTS 改為每次 Initialize 重建、ProcessExit handler 每行程只掛一次, 收尾後才能真的重啟;Configure 因此在 Shutdown 之後開放再次呼叫 (管線還活著時仍不可重入),重啟時配置回到預設值避免殘留成隱藏狀態。 日誌檔的共用模式由 FileShare.Read 放寬為 ReadWrite。舊設定只收「以唯讀方式 開檔」的讀取者,而 tail / 編輯器 / 監看工具普遍以 FileAccess.ReadWrite 開檔, 一律吃到共用違規 —— 實務上就是跑著的時候看不到日誌。 順手修掉 csproj 釘死的 DocumentationFile:五個 TFM 搶寫專案目錄下同一個 file.xml,打包出來又是 lib/<tfm>/file.xml 這個 IntelliSense 不認的名字 (它只找 <AssemblyName>.xml),整套中英雙語註解在消費者端一直是看不到的。 只留 GenerateDocumentationFile 後改為 lib/<tfm>/OzaLog.xml,repo 裡那份 過時的 file.xml 一併移除。 新增 LifecycleTests 與 FileShareTests,組件層級關閉跨類別平行測試 (收尾是行程級全域狀態,不關會把其他測試類別寫到一半掐斷),73 → 81 個測試全綠; 五個 TFM 建置零警告,net8/9/10 維持零 NuGet 相依。雙語 changelog、README、 api / async-pipeline / configuration / getting-started 文件同步。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent afe615c commit 3ff6912

26 files changed

Lines changed: 1159 additions & 1515 deletions

OzaLog/OzaLog.Test/Program.cs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
var quoteFormat = args.Length >= 2 ? ParseQuoteFormat(args[1]) : QuoteOutputFormat.Json;
3333
var asyncLogging = args.Length < 3 || ParseWriteMode(args[2]);
3434

35-
Header("OzaLog v3.2.0 console smoke test");
35+
Header("OzaLog v3.3.0 console smoke test");
3636
Console.WriteLine($" PID: {Environment.ProcessId}");
3737
Console.WriteLine($" BaseDir: {AppContext.BaseDirectory}");
3838
Console.WriteLine($" Runtime: {System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription}");
@@ -476,8 +476,14 @@
476476
Console.WriteLine(" ✓ 預期:超出 queue 部分被 drop oldest,callback 應該收到通知");
477477

478478
// ─── 等 dispatcher 處理完 ────────────────────────────────────────────────
479-
Header("等待 dispatcher flush 最後 batch");
480-
Thread.Sleep(2500);
479+
// v3.3.0:改用 LOG.Flush() 取代「睡 2.5 秒賭它寫完了」。
480+
// Flush 回來就代表兩條 pipeline 的佇列都排空且檔案已落盤,下面的統計才是確定的。
481+
Header("LOG.Flush() — 等兩條 pipeline 排空並落盤");
482+
var flushSw = Stopwatch.StartNew();
483+
var flushed = LOG.Flush();
484+
flushSw.Stop();
485+
Console.WriteLine($" 回傳值: {flushed}(false 代表逾時或已 Shutdown)");
486+
Console.WriteLine($" 耗時: {flushSw.ElapsedMilliseconds:N0} ms");
481487

482488
// ─── 終端報告 ────────────────────────────────────────────────────────────
483489
Header("最終統計");
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
using Xunit;
2+
3+
// v3.3.0:關閉跨測試類別的平行執行。
4+
// LifecycleTests 會呼叫 LOG.Shutdown(),那是行程級的全域狀態——
5+
// 與其他測試類別同時跑的話,別人的日誌會在寫到一半時被收尾掉,失敗還無法重現。
6+
[assembly: CollectionBehavior(DisableTestParallelization = true)]
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
using System;
2+
using System.IO;
3+
using Xunit;
4+
5+
namespace OzaLog.Tests
6+
{
7+
/// <summary>
8+
/// 防回歸(v3.3.0):執行期的日誌檔必須能被外部工具開起來看。
9+
/// </summary>
10+
/// <remarks>
11+
/// v3.2.0 以前寫入端以 <c>FileShare.Read</c> 開檔,只允許「自身共用模式含 Write 的唯讀開啟」;
12+
/// tail / 編輯器 / 監看工具普遍以 <c>FileAccess.ReadWrite</c> 開檔,一律吃到共用違規,
13+
/// 實務上等同「跑著的時候看不到日誌」。v3.3.0 放寬為 <c>FileShare.ReadWrite</c>。
14+
/// </remarks>
15+
public class FileShareTests
16+
{
17+
[Fact]
18+
public void LogFileIsReadableWhileTheWriterStillHoldsIt()
19+
{
20+
var name = "Share" + Guid.NewGuid().ToString("N").Substring(0, 8);
21+
var marker = "file share probe " + name;
22+
23+
LOG.CustomName_Log(name, marker);
24+
Assert.True(LOG.Flush(), "Flush 應在佇列排空後回 true");
25+
26+
var file = LogFileProbe.FindLogFile(name);
27+
Assert.NotNull(file);
28+
29+
// 1) 唯讀開啟(tail 類工具的最低需求)
30+
using (var fs = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
31+
using (var reader = new StreamReader(fs))
32+
{
33+
Assert.Contains(marker, reader.ReadToEnd(), StringComparison.Ordinal);
34+
}
35+
36+
// 2) 以 ReadWrite 存取開啟 —— 這一項在寫入端為 FileShare.Read 時會直接 IOException:
37+
// 要求的存取含 Write,而既有寫入 handle 的共用模式只允許 Read。
38+
using (var fs = new FileStream(file, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite))
39+
{
40+
Assert.True(fs.Length > 0);
41+
}
42+
43+
// 讀完之後寫入端仍應能繼續寫(確認前面的開啟沒有把檔案卡住)
44+
LOG.CustomName_Log(name, marker + " after external read");
45+
Assert.True(LOG.Flush());
46+
47+
var content = LogFileProbe.ReadAllTextShared(file);
48+
Assert.Contains("after external read", content, StringComparison.Ordinal);
49+
}
50+
}
51+
}
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
using System;
2+
using System.Threading.Tasks;
3+
using OzaLog.Core;
4+
using Xunit;
5+
6+
namespace OzaLog.Tests
7+
{
8+
/// <summary>
9+
/// v3.3.0 新增的宿主端生命週期 API:<c>LOG.Flush</c> / <c>LOG.FlushAsync</c> /
10+
/// <c>LOG.Shutdown</c> / <c>LOG.ShutdownAsync</c> / <c>LOG.IsShutdown</c>。
11+
/// </summary>
12+
/// <remarks>
13+
/// 這個類別會操作行程級的全域狀態(收尾整條管線),因此:
14+
/// • 組件層級已關閉平行測試(見 AssemblyInfo.cs)
15+
/// • 每個會收尾的測試都在 finally 裡用 Configure 把系統復原,讓後續測試不受影響
16+
/// </remarks>
17+
public class LifecycleTests
18+
{
19+
private const int EntryCount = 1000;
20+
21+
/// <summary>收尾後把系統復原成可寫入狀態(Shutdown 之後才允許再次 Configure)。</summary>
22+
private static void RestoreLogging()
23+
{
24+
if (LOG.IsShutdown)
25+
LOG.Configure(_ => { });
26+
}
27+
28+
[Fact]
29+
public void FlushReturnsOnlyAfterEveryQueuedEntryHasLanded()
30+
{
31+
var name = "Flush" + Guid.NewGuid().ToString("N").Substring(0, 8);
32+
33+
for (var i = 0; i < EntryCount; i++)
34+
LOG.CustomName_Log(name, "flush regression " + i.ToString());
35+
36+
Assert.True(LOG.Flush(), "Flush 應在佇列排空後回 true");
37+
38+
var file = LogFileProbe.FindLogFile(name);
39+
Assert.NotNull(file);
40+
41+
var lines = LogFileProbe.ReadAllTextShared(file)
42+
.Split(new[] { Environment.NewLine }, StringSplitOptions.RemoveEmptyEntries);
43+
44+
// Flush 回來的當下就必須是 1000 行:不補 sleep、不重讀
45+
Assert.Equal(EntryCount, lines.Length);
46+
}
47+
48+
[Fact]
49+
public async Task FlushAsyncReturnsOnlyAfterEveryQueuedEntryHasLanded()
50+
{
51+
var name = "FlushAsync" + Guid.NewGuid().ToString("N").Substring(0, 8);
52+
53+
for (var i = 0; i < EntryCount; i++)
54+
LOG.CustomName_Log(name, "flush async regression " + i.ToString());
55+
56+
Assert.True(await LOG.FlushAsync());
57+
58+
var file = LogFileProbe.FindLogFile(name);
59+
Assert.NotNull(file);
60+
61+
var lines = LogFileProbe.ReadAllTextShared(file)
62+
.Split(new[] { Environment.NewLine }, StringSplitOptions.RemoveEmptyEntries);
63+
64+
Assert.Equal(EntryCount, lines.Length);
65+
}
66+
67+
[Fact]
68+
public void WritesAfterShutdownAreSilentlyDiscarded()
69+
{
70+
var marker = "after_shutdown_" + Guid.NewGuid().ToString("N").Substring(0, 8);
71+
72+
try
73+
{
74+
Assert.True(LOG.Shutdown());
75+
Assert.True(LOG.IsShutdown);
76+
77+
// 收尾之後的寫入不可擲例外(背景服務型套件不能把宿主弄掛)
78+
var ex = Record.Exception(() =>
79+
{
80+
LOG.Trace_Log(marker);
81+
LOG.Info_Log(marker);
82+
LOG.Warn_Log(marker);
83+
LOG.Error_Log(marker);
84+
LOG.Fatal_Log(marker);
85+
LOG.CustomName_Log("AfterShutdown", marker);
86+
LOG.Info_Log(marker, args: null, writeTxt: true, immediateFlush: true);
87+
LOG.Error_Log("with object", new InvalidOperationException(marker));
88+
});
89+
Assert.Null(ex);
90+
91+
// 靜默丟棄:內容不會出現在任何日誌檔
92+
Assert.Equal(0, LogFileProbe.CountOccurrences(marker));
93+
94+
// 已收尾時 Flush 明確回 false(而不是假裝成功)
95+
Assert.False(LOG.Flush());
96+
}
97+
finally
98+
{
99+
RestoreLogging();
100+
}
101+
}
102+
103+
[Fact]
104+
public void ShutdownIsIdempotentAlongsideTheProcessExitCleanup()
105+
{
106+
try
107+
{
108+
LOG.Info_Log("idempotent shutdown probe");
109+
110+
Assert.True(LOG.Shutdown()); // 第一次真的收尾
111+
Assert.False(LOG.Shutdown()); // 第二次冪等,回 false 不是錯誤
112+
113+
// 模擬 ProcessExit / UnhandledException 已註冊的收尾在 Shutdown 之後才被觸發
114+
var ex = Record.Exception(() =>
115+
{
116+
AsyncLogHandler.ShutdownGracefully();
117+
QuoteLogHandler.ShutdownGracefully();
118+
LOG.Shutdown();
119+
});
120+
121+
Assert.Null(ex);
122+
Assert.True(LOG.IsShutdown);
123+
}
124+
finally
125+
{
126+
RestoreLogging();
127+
}
128+
}
129+
130+
[Fact]
131+
public async Task ShutdownAsyncIsIdempotent()
132+
{
133+
try
134+
{
135+
LOG.Info_Log("idempotent async shutdown probe");
136+
137+
Assert.True(await LOG.ShutdownAsync());
138+
Assert.False(await LOG.ShutdownAsync());
139+
Assert.True(LOG.IsShutdown);
140+
}
141+
finally
142+
{
143+
RestoreLogging();
144+
}
145+
}
146+
147+
[Fact]
148+
public void ConfigureStaysNonReentrantUntilShutdown()
149+
{
150+
try
151+
{
152+
// 先收尾把狀態歸零,這樣不論前面哪個測試先跑,下一行的 Configure 一定是「第一次」
153+
LOG.Shutdown();
154+
LOG.Configure(_ => { });
155+
156+
// 管線還活著時,Configure 仍然不可重入(v3.0 起的 by design 行為不變)
157+
Assert.Throws<InvalidOperationException>(() => LOG.Configure(_ => { }));
158+
159+
Assert.True(LOG.Shutdown());
160+
161+
// 收尾之後才允許再次 Configure
162+
var ex = Record.Exception(() => LOG.Configure(_ => { }));
163+
Assert.Null(ex);
164+
Assert.False(LOG.IsShutdown);
165+
}
166+
finally
167+
{
168+
RestoreLogging();
169+
}
170+
}
171+
172+
[Fact]
173+
public void LoggingResumesAfterConfigureFollowingShutdown()
174+
{
175+
var marker = "resumed_" + Guid.NewGuid().ToString("N").Substring(0, 8);
176+
177+
try
178+
{
179+
LOG.Shutdown();
180+
LOG.Configure(_ => { });
181+
182+
LOG.Info_Log("resume probe " + marker);
183+
Assert.True(LOG.Flush());
184+
185+
Assert.Equal(1, LogFileProbe.CountOccurrences(marker));
186+
}
187+
finally
188+
{
189+
RestoreLogging();
190+
}
191+
}
192+
}
193+
}

OzaLog/OzaLog.Tests/LogFileProbe.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,16 @@ public static int CountOccurrences(string marker)
4040
return total;
4141
}
4242

43+
/// <summary>
44+
/// 找出某個 name 對應的日誌檔({name}_Log.*),找不到回傳 null
45+
/// </summary>
46+
public static string FindLogFile(string name)
47+
{
48+
if (!Directory.Exists(LogRoot)) return null;
49+
var files = Directory.GetFiles(LogRoot, name + "_Log.*", SearchOption.AllDirectories);
50+
return files.Length > 0 ? files[0] : null;
51+
}
52+
4353
/// <summary>
4454
/// 以共用模式讀取檔案;讀不到時回傳 null(不讓 I/O 例外中斷測試掃描)
4555
/// </summary>

OzaLog/OzaLog/Configurations/LogConfiguration.cs

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@ public static class LogConfiguration
1414
/// 儲存當前的日誌配置選項
1515
/// Stores the current logging configuration options
1616
/// </summary>
17-
private static readonly LogOptions _currentOptions = new LogOptions();
17+
/// <remarks>
18+
/// v3.3.0 起不再是 readonly:<c>LOG.Shutdown()</c> 之後允許再次 <c>Configure</c>,
19+
/// 屆時整組選項換成全新的預設值,避免上一輪的設定殘留成看不見的隱藏狀態。
20+
/// </remarks>
21+
private static LogOptions _currentOptions = new LogOptions();
1822
/// <summary>
1923
/// 標記日誌系統是否已經初始化
2024
/// Flag indicating whether the logging system has been initialized
@@ -586,13 +590,33 @@ public static void Initialize(Action<LogOptions> configure)
586590
configure?.Invoke(_currentOptions);
587591
_isInitialized = true;
588592

593+
// v3.3.0:Shutdown 之後再次 Configure 視為「重新開張」,解除靜默丟棄狀態,
594+
// 下一筆日誌會重新啟動背景管線。首次 Configure 時本呼叫沒有副作用。
595+
Core.LogLifecycle.Resume();
596+
589597
// v3.0:啟用全域意外攔截(如果使用者明確 opt-in)
590598
if (_currentOptions.EnableGlobalExceptionCapture)
591599
{
592600
Core.GlobalExceptionCapture.Enable();
593601
}
594602
}
595603

604+
/// <summary>
605+
/// v3.3.0:<c>LOG.Shutdown()</c> 收尾後把配置還原成未初始化狀態,
606+
/// 讓宿主(與測試)可以再次呼叫 <c>Configure</c>。
607+
/// Resets the configuration to its uninitialized state after LOG.Shutdown(),
608+
/// so Configure can be called again.
609+
/// </summary>
610+
/// <remarks>
611+
/// <c>Configure</c> 的不可重入是 by design(避免執行中途被改設定),
612+
/// 但那個限制的前提是「管線還活著」;收尾之後限制就沒有意義了。
613+
/// </remarks>
614+
internal static void ResetForRestart()
615+
{
616+
_currentOptions = new LogOptions();
617+
_isInitialized = false;
618+
}
619+
596620
/// <summary>
597621
/// 取得當前配置
598622
/// </summary>

0 commit comments

Comments
 (0)