Skip to content

Commit 774a6de

Browse files
Merge pull request #244 from Chris-Wolfgang/feature/report-timing
feat: timing + throughput metrics on Report (#144, #91)
2 parents 995f497 + 1d1f5e5 commit 774a6de

8 files changed

Lines changed: 645 additions & 2 deletions

File tree

src/Wolfgang.Etl.Abstractions/ExtractorBase.cs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System;
22
using System.Collections.Generic;
3+
using System.Diagnostics;
34
using System.Diagnostics.CodeAnalysis;
45
using System.Runtime.CompilerServices;
56
using System.Threading;
@@ -22,6 +23,43 @@ public abstract class ExtractorBase<TSource, TProgress>
2223
{
2324
private int _currentItemCount;
2425
private int _currentSkippedItemCount;
26+
private long _startTimestamp;
27+
private DateTimeOffset _startedAtUtc;
28+
29+
30+
31+
/// <summary>
32+
/// The UTC time at which the first item was processed (extracted or skipped), or
33+
/// <c>null</c> if extraction has not produced any items yet. Captured automatically
34+
/// the first time <see cref="IncrementCurrentItemCount"/> or
35+
/// <see cref="IncrementCurrentSkippedItemCount"/> is called, so derived classes can
36+
/// surface it on their progress report (see <see cref="Report.StartedAt"/>).
37+
/// </summary>
38+
protected DateTimeOffset? StartedAt =>
39+
Volatile.Read(ref _startTimestamp) == 0 ? null : _startedAtUtc;
40+
41+
42+
43+
/// <summary>
44+
/// The monotonic wall-clock time elapsed since the first item was processed, or
45+
/// <see cref="TimeSpan.Zero"/> if extraction has not produced any items yet.
46+
/// Read this when building a progress report (see <see cref="Report.Elapsed"/>) to
47+
/// snapshot how long extraction has been running.
48+
/// </summary>
49+
protected TimeSpan Elapsed
50+
{
51+
get
52+
{
53+
var start = Volatile.Read(ref _startTimestamp);
54+
if (start == 0)
55+
{
56+
return TimeSpan.Zero;
57+
}
58+
59+
var ticks = Stopwatch.GetTimestamp() - start;
60+
return TimeSpan.FromSeconds(ticks / (double)Stopwatch.Frequency);
61+
}
62+
}
2563

2664

2765

@@ -304,6 +342,7 @@ private void ReportProgress(object? state)
304342
Justification = "Interlocked.Increment return value intentionally discarded; only the side-effect matters.")]
305343
protected void IncrementCurrentItemCount()
306344
{
345+
EnsureStarted();
307346
_ = Interlocked.Increment(ref _currentItemCount);
308347
}
309348

@@ -320,6 +359,27 @@ protected void IncrementCurrentItemCount()
320359
Justification = "Interlocked.Increment return value intentionally discarded; only the side-effect matters.")]
321360
protected void IncrementCurrentSkippedItemCount()
322361
{
362+
EnsureStarted();
323363
_ = Interlocked.Increment(ref _currentSkippedItemCount);
324364
}
365+
366+
367+
368+
// Captures the start timestamp (monotonic) and wall-clock StartedAt the first
369+
// time any item is processed. Idempotent and thread-safe: the first caller to
370+
// win the CompareExchange records the start; later calls are a cheap volatile read.
371+
private void EnsureStarted()
372+
{
373+
if (Volatile.Read(ref _startTimestamp) != 0)
374+
{
375+
return;
376+
}
377+
378+
var now = DateTimeOffset.UtcNow;
379+
var timestamp = Stopwatch.GetTimestamp();
380+
if (Interlocked.CompareExchange(ref _startTimestamp, timestamp, 0) == 0)
381+
{
382+
_startedAtUtc = now;
383+
}
384+
}
325385
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
#if !NET5_0_OR_GREATER
2+
3+
using System.ComponentModel;
4+
using System.Diagnostics.CodeAnalysis;
5+
6+
namespace System.Runtime.CompilerServices;
7+
8+
/// <summary>
9+
/// Polyfill for the compiler-required <c>IsExternalInit</c> marker type, which enables
10+
/// <see langword="init"/>-only property setters on target frameworks (.NET Framework,
11+
/// .NET Standard 2.0) whose reference assemblies do not ship it. Built-in on .NET 5.0+.
12+
/// </summary>
13+
[EditorBrowsable(EditorBrowsableState.Never)]
14+
[ExcludeFromCodeCoverage]
15+
internal static class IsExternalInit
16+
{
17+
}
18+
19+
#endif

src/Wolfgang.Etl.Abstractions/LoaderBase.cs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System;
22
using System.Collections.Generic;
3+
using System.Diagnostics;
34
using System.Diagnostics.CodeAnalysis;
45
using System.Threading;
56
using System.Threading.Tasks;
@@ -22,6 +23,43 @@ public abstract class LoaderBase<TDestination, TProgress>
2223
{
2324
private int _currentItemCount;
2425
private int _currentSkippedItemCount;
26+
private long _startTimestamp;
27+
private DateTimeOffset _startedAtUtc;
28+
29+
30+
31+
/// <summary>
32+
/// The UTC time at which the first item was processed (loaded or skipped), or
33+
/// <c>null</c> if loading has not produced any items yet. Captured automatically
34+
/// the first time <see cref="IncrementCurrentItemCount"/> or
35+
/// <see cref="IncrementCurrentSkippedItemCount"/> is called, so derived classes can
36+
/// surface it on their progress report (see <see cref="Report.StartedAt"/>).
37+
/// </summary>
38+
protected DateTimeOffset? StartedAt =>
39+
Volatile.Read(ref _startTimestamp) == 0 ? null : _startedAtUtc;
40+
41+
42+
43+
/// <summary>
44+
/// The monotonic wall-clock time elapsed since the first item was processed, or
45+
/// <see cref="TimeSpan.Zero"/> if loading has not produced any items yet.
46+
/// Read this when building a progress report (see <see cref="Report.Elapsed"/>) to
47+
/// snapshot how long loading has been running.
48+
/// </summary>
49+
protected TimeSpan Elapsed
50+
{
51+
get
52+
{
53+
var start = Volatile.Read(ref _startTimestamp);
54+
if (start == 0)
55+
{
56+
return TimeSpan.Zero;
57+
}
58+
59+
var ticks = Stopwatch.GetTimestamp() - start;
60+
return TimeSpan.FromSeconds(ticks / (double)Stopwatch.Frequency);
61+
}
62+
}
2563

2664

2765

@@ -307,6 +345,7 @@ private void ReportProgress(object? state)
307345
Justification = "Interlocked.Increment return value intentionally discarded; only the side-effect matters.")]
308346
protected void IncrementCurrentItemCount()
309347
{
348+
EnsureStarted();
310349
_ = Interlocked.Increment(ref _currentItemCount);
311350
}
312351

@@ -323,6 +362,27 @@ protected void IncrementCurrentItemCount()
323362
Justification = "Interlocked.Increment return value intentionally discarded; only the side-effect matters.")]
324363
protected void IncrementCurrentSkippedItemCount()
325364
{
365+
EnsureStarted();
326366
_ = Interlocked.Increment(ref _currentSkippedItemCount);
327367
}
368+
369+
370+
371+
// Captures the start timestamp (monotonic) and wall-clock StartedAt the first
372+
// time any item is processed. Idempotent and thread-safe: the first caller to
373+
// win the CompareExchange records the start; later calls are a cheap volatile read.
374+
private void EnsureStarted()
375+
{
376+
if (Volatile.Read(ref _startTimestamp) != 0)
377+
{
378+
return;
379+
}
380+
381+
var now = DateTimeOffset.UtcNow;
382+
var timestamp = Stopwatch.GetTimestamp();
383+
if (Interlocked.CompareExchange(ref _startTimestamp, timestamp, 0) == 0)
384+
{
385+
_startedAtUtc = now;
386+
}
387+
}
328388
}

src/Wolfgang.Etl.Abstractions/PublicAPI.Shipped.txt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
Wolfgang.Etl.Abstractions.ExtractorBase<TSource, TProgress>
33
Wolfgang.Etl.Abstractions.ExtractorBase<TSource, TProgress>.CurrentItemCount.get -> int
44
Wolfgang.Etl.Abstractions.ExtractorBase<TSource, TProgress>.CurrentSkippedItemCount.get -> int
5+
Wolfgang.Etl.Abstractions.ExtractorBase<TSource, TProgress>.Elapsed.get -> System.TimeSpan
56
Wolfgang.Etl.Abstractions.ExtractorBase<TSource, TProgress>.ExtractorBase() -> void
67
Wolfgang.Etl.Abstractions.ExtractorBase<TSource, TProgress>.IncrementCurrentItemCount() -> void
78
Wolfgang.Etl.Abstractions.ExtractorBase<TSource, TProgress>.IncrementCurrentSkippedItemCount() -> void
@@ -11,6 +12,7 @@ Wolfgang.Etl.Abstractions.ExtractorBase<TSource, TProgress>.ReportingInterval.ge
1112
Wolfgang.Etl.Abstractions.ExtractorBase<TSource, TProgress>.ReportingInterval.set -> void
1213
Wolfgang.Etl.Abstractions.ExtractorBase<TSource, TProgress>.SkipItemCount.get -> int
1314
Wolfgang.Etl.Abstractions.ExtractorBase<TSource, TProgress>.SkipItemCount.set -> void
15+
Wolfgang.Etl.Abstractions.ExtractorBase<TSource, TProgress>.StartedAt.get -> System.DateTimeOffset?
1416
Wolfgang.Etl.Abstractions.IExtractAsync<TSource>
1517
Wolfgang.Etl.Abstractions.IExtractAsync<TSource>.ExtractAsync() -> System.Collections.Generic.IAsyncEnumerable<TSource>!
1618
Wolfgang.Etl.Abstractions.IExtractStage<TSource>
@@ -71,6 +73,7 @@ Wolfgang.Etl.Abstractions.ITransformWithProgressAsync<TSource, TDestination, TPr
7173
Wolfgang.Etl.Abstractions.LoaderBase<TDestination, TProgress>
7274
Wolfgang.Etl.Abstractions.LoaderBase<TDestination, TProgress>.CurrentItemCount.get -> int
7375
Wolfgang.Etl.Abstractions.LoaderBase<TDestination, TProgress>.CurrentSkippedItemCount.get -> int
76+
Wolfgang.Etl.Abstractions.LoaderBase<TDestination, TProgress>.Elapsed.get -> System.TimeSpan
7477
Wolfgang.Etl.Abstractions.LoaderBase<TDestination, TProgress>.IncrementCurrentItemCount() -> void
7578
Wolfgang.Etl.Abstractions.LoaderBase<TDestination, TProgress>.IncrementCurrentSkippedItemCount() -> void
7679
Wolfgang.Etl.Abstractions.LoaderBase<TDestination, TProgress>.LoaderBase() -> void
@@ -80,13 +83,24 @@ Wolfgang.Etl.Abstractions.LoaderBase<TDestination, TProgress>.ReportingInterval.
8083
Wolfgang.Etl.Abstractions.LoaderBase<TDestination, TProgress>.ReportingInterval.set -> void
8184
Wolfgang.Etl.Abstractions.LoaderBase<TDestination, TProgress>.SkipItemCount.get -> int
8285
Wolfgang.Etl.Abstractions.LoaderBase<TDestination, TProgress>.SkipItemCount.set -> void
86+
Wolfgang.Etl.Abstractions.LoaderBase<TDestination, TProgress>.StartedAt.get -> System.DateTimeOffset?
8387
Wolfgang.Etl.Abstractions.Pipeline
8488
Wolfgang.Etl.Abstractions.Report
8589
Wolfgang.Etl.Abstractions.Report.CurrentItemCount.get -> int
90+
Wolfgang.Etl.Abstractions.Report.Elapsed.get -> System.TimeSpan
91+
Wolfgang.Etl.Abstractions.Report.Elapsed.init -> void
92+
Wolfgang.Etl.Abstractions.Report.EstimatedRemaining.get -> System.TimeSpan?
93+
Wolfgang.Etl.Abstractions.Report.ItemsPerSecond.get -> double
94+
Wolfgang.Etl.Abstractions.Report.PercentComplete.get -> double?
8695
Wolfgang.Etl.Abstractions.Report.Report(int currentItemCount) -> void
96+
Wolfgang.Etl.Abstractions.Report.StartedAt.get -> System.DateTimeOffset?
97+
Wolfgang.Etl.Abstractions.Report.StartedAt.init -> void
98+
Wolfgang.Etl.Abstractions.Report.TotalItemCount.get -> int?
99+
Wolfgang.Etl.Abstractions.Report.TotalItemCount.init -> void
87100
Wolfgang.Etl.Abstractions.TransformerBase<TSource, TDestination, TProgress>
88101
Wolfgang.Etl.Abstractions.TransformerBase<TSource, TDestination, TProgress>.CurrentItemCount.get -> int
89102
Wolfgang.Etl.Abstractions.TransformerBase<TSource, TDestination, TProgress>.CurrentSkippedItemCount.get -> int
103+
Wolfgang.Etl.Abstractions.TransformerBase<TSource, TDestination, TProgress>.Elapsed.get -> System.TimeSpan
90104
Wolfgang.Etl.Abstractions.TransformerBase<TSource, TDestination, TProgress>.IncrementCurrentItemCount() -> void
91105
Wolfgang.Etl.Abstractions.TransformerBase<TSource, TDestination, TProgress>.IncrementCurrentSkippedItemCount() -> void
92106
Wolfgang.Etl.Abstractions.TransformerBase<TSource, TDestination, TProgress>.MaximumItemCount.get -> int
@@ -95,6 +109,7 @@ Wolfgang.Etl.Abstractions.TransformerBase<TSource, TDestination, TProgress>.Repo
95109
Wolfgang.Etl.Abstractions.TransformerBase<TSource, TDestination, TProgress>.ReportingInterval.set -> void
96110
Wolfgang.Etl.Abstractions.TransformerBase<TSource, TDestination, TProgress>.SkipItemCount.get -> int
97111
Wolfgang.Etl.Abstractions.TransformerBase<TSource, TDestination, TProgress>.SkipItemCount.set -> void
112+
Wolfgang.Etl.Abstractions.TransformerBase<TSource, TDestination, TProgress>.StartedAt.get -> System.DateTimeOffset?
98113
Wolfgang.Etl.Abstractions.TransformerBase<TSource, TDestination, TProgress>.TransformerBase() -> void
99114
abstract Wolfgang.Etl.Abstractions.ExtractorBase<TSource, TProgress>.CreateProgressReport() -> TProgress
100115
abstract Wolfgang.Etl.Abstractions.ExtractorBase<TSource, TProgress>.ExtractWorkerAsync(System.Threading.CancellationToken token) -> System.Collections.Generic.IAsyncEnumerable<TSource>!

src/Wolfgang.Etl.Abstractions/Report.cs

Lines changed: 117 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,23 @@
33
namespace Wolfgang.Etl.Abstractions;
44

55
/// <summary>
6-
/// Provides a report of the current item count in an ETL process.
6+
/// Provides a point-in-time snapshot of the progress of an ETL process — how many
7+
/// items have been processed, how long it has been running, and (when the total is
8+
/// known) how far along it is and how much longer it is expected to take.
79
/// </summary>
810
/// <remarks>
11+
/// <para>
912
/// This class can be used as a base class for other progress reports and expanded
10-
/// with additional information such as total count, count remaining, etc.
13+
/// with additional information specific to a particular extractor, transformer, or loader.
14+
/// </para>
15+
/// <para>
16+
/// All values are <em>snapshot</em> values captured at the moment the report is
17+
/// constructed. <see cref="Elapsed"/> does not advance after construction, mirroring
18+
/// <see cref="CurrentItemCount"/>. The throughput and completion estimates
19+
/// (<see cref="ItemsPerSecond"/>, <see cref="PercentComplete"/>,
20+
/// <see cref="EstimatedRemaining"/>) are derived from those snapshot values, so a
21+
/// given report is internally consistent.
22+
/// </para>
1123
/// </remarks>
1224
public record Report
1325
{
@@ -32,4 +44,107 @@ public Report(int currentItemCount)
3244
/// The number of items that have been processed so far in the ETL process.
3345
/// </summary>
3446
public int CurrentItemCount { get; }
47+
48+
49+
50+
/// <summary>
51+
/// The wall-clock time (UTC) at which processing started, or <c>null</c> if it
52+
/// has not started yet (no items processed) or the producer does not track it.
53+
/// </summary>
54+
public DateTimeOffset? StartedAt { get; init; }
55+
56+
57+
58+
/// <summary>
59+
/// The wall-clock time that had elapsed since processing started, captured at the
60+
/// moment this report was constructed. <see cref="TimeSpan.Zero"/> when timing is
61+
/// not tracked or processing has not started.
62+
/// </summary>
63+
public TimeSpan Elapsed { get; init; }
64+
65+
66+
67+
/// <summary>
68+
/// The total number of items expected to be processed, when known (for example a
69+
/// file line count or a SQL <c>COUNT(*)</c>). <c>null</c> for unknown-size or
70+
/// infinite sources. Must be greater than or equal to 0 when set.
71+
/// </summary>
72+
/// <exception cref="ArgumentOutOfRangeException">The specified value is less than 0.</exception>
73+
public int? TotalItemCount
74+
{
75+
get;
76+
init
77+
{
78+
if (value is < 0)
79+
{
80+
throw new ArgumentOutOfRangeException(nameof(value), "Total item count cannot be less than 0.");
81+
}
82+
83+
field = value;
84+
}
85+
}
86+
87+
88+
89+
/// <summary>
90+
/// The processing throughput, in items per second, derived from
91+
/// <see cref="CurrentItemCount"/> and <see cref="Elapsed"/>. Returns 0 until at
92+
/// least some time has elapsed.
93+
/// </summary>
94+
public double ItemsPerSecond =>
95+
Elapsed.TotalSeconds > 0
96+
? CurrentItemCount / Elapsed.TotalSeconds
97+
: 0d;
98+
99+
100+
101+
/// <summary>
102+
/// The fraction of work completed, as a percentage in the range [0, 100], when
103+
/// <see cref="TotalItemCount"/> is known; otherwise <c>null</c>. Clamped to 100
104+
/// if <see cref="CurrentItemCount"/> exceeds <see cref="TotalItemCount"/>.
105+
/// </summary>
106+
public double? PercentComplete
107+
{
108+
get
109+
{
110+
if (TotalItemCount is not { } total)
111+
{
112+
return null;
113+
}
114+
115+
return total == 0
116+
? 100d
117+
: Math.Min(100d, 100d * CurrentItemCount / total);
118+
}
119+
}
120+
121+
122+
123+
/// <summary>
124+
/// The estimated time remaining until completion, when both
125+
/// <see cref="TotalItemCount"/> is known and throughput can be measured;
126+
/// otherwise <c>null</c>. Returns <see cref="TimeSpan.Zero"/> once the current
127+
/// count has reached the total.
128+
/// </summary>
129+
public TimeSpan? EstimatedRemaining
130+
{
131+
get
132+
{
133+
if (TotalItemCount is not { } total)
134+
{
135+
return null;
136+
}
137+
138+
var remaining = total - CurrentItemCount;
139+
if (remaining <= 0)
140+
{
141+
return TimeSpan.Zero;
142+
}
143+
144+
var rate = ItemsPerSecond;
145+
return rate > 0
146+
? TimeSpan.FromSeconds(remaining / rate)
147+
: (TimeSpan?)null;
148+
}
149+
}
35150
}

0 commit comments

Comments
 (0)