Skip to content

Commit 3fc28f6

Browse files
fix: interpret policy InitialStart values as UTC regardless of machine timezone
1 parent 988cdcd commit 3fc28f6

20 files changed

Lines changed: 1979 additions & 20 deletions

‎src/Eftdb.Design/Features/CompressionPolicy/CompressionPolicyScaffoldingExtractor.cs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration;
12
using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding;
23
using System.Data.Common;
34
using System.Text.Json;
@@ -56,7 +57,7 @@ FROM _timescaledb_catalog.hypertable
5657
string schema = reader.GetString(0);
5758
string name = reader.GetString(1);
5859
string? configJson = reader.IsDBNull(2) ? null : reader.GetString(2);
59-
DateTime? initialStart = reader.IsDBNull(3) ? null : reader.GetDateTime(3);
60+
DateTime? initialStart = reader.IsDBNull(3) ? null : ConventionValidationHelper.NormalizeInitialStartToUtc(reader.GetDateTime(3));
6061
string? scheduleInterval = reader.IsDBNull(4) ? null : IntervalParsingHelper.NormalizeInterval(reader.GetString(4));
6162
string? timezone = reader.IsDBNull(5) ? null : reader.GetString(5);
6263

‎src/Eftdb.Design/Features/ContinuousAggregatePolicy/ContinuousAggregatePolicyScaffoldingExtractor.cs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration;
12
using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding;
23
using System.Data.Common;
34
using System.Text.Json;
@@ -47,7 +48,7 @@ INNER JOIN _timescaledb_catalog.continuous_agg ca
4748
string viewName = reader.GetString(1);
4849
string? configJson = reader.IsDBNull(2) ? null : reader.GetString(2);
4950
string? scheduleInterval = reader.IsDBNull(3) ? null : IntervalParsingHelper.NormalizeInterval(reader.GetString(3));
50-
DateTime? initialStart = reader.IsDBNull(4) ? null : reader.GetDateTime(4);
51+
DateTime? initialStart = reader.IsDBNull(4) ? null : ConventionValidationHelper.NormalizeInitialStartToUtc(reader.GetDateTime(4));
5152

5253
// Parse the JSONB config to extract policy parameters
5354
string? startOffset = null;

‎src/Eftdb.Design/Features/ReorderPolicy/ReorderPolicyScaffoldingExtractor.cs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration;
12
using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding;
23
using System.Data.Common;
34

@@ -42,7 +43,7 @@ FROM timescaledb_information.jobs AS j
4243
string schema = reader.GetString(0);
4344
string name = reader.GetString(1);
4445
string indexName = reader.GetString(2);
45-
DateTime? initialStart = reader.IsDBNull(3) ? null : reader.GetDateTime(3);
46+
DateTime? initialStart = reader.IsDBNull(3) ? null : ConventionValidationHelper.NormalizeInitialStartToUtc(reader.GetDateTime(3));
4647
string? scheduleInterval = reader.IsDBNull(4) ? null : IntervalParsingHelper.NormalizeInterval(reader.GetString(4));
4748
string? maxRuntime = reader.IsDBNull(5) ? null : IntervalParsingHelper.NormalizeInterval(reader.GetString(5));
4849
int? maxRetries = reader.IsDBNull(6) ? null : reader.GetInt32(6);

‎src/Eftdb.Design/Features/RetentionPolicy/RetentionPolicyScaffoldingExtractor.cs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration;
12
using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding;
23
using System.Data.Common;
34
using System.Text.Json;
@@ -45,7 +46,7 @@ FROM timescaledb_information.jobs AS j
4546
string schema = reader.GetString(0);
4647
string name = reader.GetString(1);
4748
string? configJson = reader.IsDBNull(2) ? null : reader.GetString(2);
48-
DateTime? initialStart = reader.IsDBNull(3) ? null : reader.GetDateTime(3);
49+
DateTime? initialStart = reader.IsDBNull(3) ? null : ConventionValidationHelper.NormalizeInitialStartToUtc(reader.GetDateTime(3));
4950
string? scheduleInterval = reader.IsDBNull(4) ? null : IntervalParsingHelper.NormalizeInterval(reader.GetString(4));
5051
string? maxRuntime = reader.IsDBNull(5) ? null : IntervalParsingHelper.NormalizeInterval(reader.GetString(5));
5152
int? maxRetries = reader.IsDBNull(6) ? null : reader.GetInt32(6);

‎src/Eftdb/Configuration/CompressionPolicy/CompressionPolicyStringBuilder.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ internal CompressionPolicyStringBuilder(EntityTypeBuilder<TEntity> builder)
2626
/// <returns>The builder for method chaining.</returns>
2727
public CompressionPolicyStringBuilder<TEntity> WithInitialStart(DateTime initialStart)
2828
{
29-
_builder.HasAnnotation(CompressionPolicyAnnotations.InitialStart, initialStart);
29+
PolicyJobBuilderCore.WithInitialStart(_builder, CompressionPolicyAnnotations.InitialStart, initialStart);
3030
return this;
3131
}
3232
}

‎src/Eftdb/Configuration/CompressionPolicy/CompressionPolicyTypeBuilder.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ public static EntityTypeBuilder<TEntity> WithCompressionPolicy<TEntity>(
4848
WriteCompressionPolicy(entityTypeBuilder, after, createdBefore, scheduleInterval, timezone, ifNotExists);
4949

5050
if (initialStart.HasValue)
51-
entityTypeBuilder.HasAnnotation(CompressionPolicyAnnotations.InitialStart, initialStart.Value);
51+
PolicyJobBuilderCore.WithInitialStart(entityTypeBuilder, CompressionPolicyAnnotations.InitialStart, initialStart.Value);
5252

5353
return entityTypeBuilder;
5454
}

‎src/Eftdb/Configuration/ConventionValidationHelper.cs‎

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
using System.Globalization;
2+
13
namespace CmdScale.EntityFrameworkCore.TimescaleDB.Configuration
24
{
35
/// <summary>
@@ -54,27 +56,75 @@ internal static void ValidateExclusiveFields(
5456
}
5557

5658
/// <summary>
57-
/// Parses a <see cref="DateTime"/> from an attribute string value, throwing when
58-
/// the value is present but cannot be parsed.
59+
/// Parses a policy <c>InitialStart</c> <see cref="DateTime"/> from an attribute string value,
60+
/// throwing when the value is present but cannot be parsed. The result is always
61+
/// <see cref="DateTimeKind.Utc"/>.
5962
/// </summary>
63+
/// <remarks>
64+
/// Parsing uses <see cref="CultureInfo.InvariantCulture"/> with
65+
/// <see cref="DateTimeStyles.AssumeUniversal"/> | <see cref="DateTimeStyles.AdjustToUniversal"/>
66+
/// so the produced instant does not depend on the machine's local time zone. Strings carrying an
67+
/// explicit designator ("Z" or an offset) convert to UTC correctly; strings without a designator
68+
/// are interpreted as already being UTC. Treating unsuffixed values as UTC is the only
69+
/// machine-independent interpretation: the alternative (local time) would render a different
70+
/// literal into migrations on every machine and produce phantom alter-policy operations.
71+
/// </remarks>
6072
/// <param name="rawValue">The raw attribute string to parse.</param>
6173
/// <param name="entityName">The CLR type name of the entity, for use in exception messages.</param>
6274
/// <param name="attributeName">The attribute name shown in the exception message prefix.</param>
63-
/// <returns>The parsed <see cref="DateTime"/>, or <see langword="null"/> when <paramref name="rawValue"/> is null or whitespace.</returns>
75+
/// <returns>The parsed UTC <see cref="DateTime"/>, or <see langword="null"/> when <paramref name="rawValue"/> is null or whitespace.</returns>
6476
internal static DateTime? ParseInitialStart(string? rawValue, string? entityName, string attributeName)
6577
{
6678
if (string.IsNullOrWhiteSpace(rawValue))
6779
{
6880
return null;
6981
}
7082

71-
if (DateTime.TryParse(rawValue, out DateTime parsed))
83+
if (DateTime.TryParse(
84+
rawValue,
85+
CultureInfo.InvariantCulture,
86+
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
87+
out DateTime parsed))
7288
{
7389
return parsed;
7490
}
7591

7692
throw new InvalidOperationException(
7793
$"{attributeName} on '{entityName}': InitialStart '{rawValue}' is not a valid DateTime format. Use an ISO 8601 string.");
7894
}
95+
96+
/// <summary>
97+
/// Normalizes a policy <c>InitialStart</c> value to a machine-independent UTC instant so that
98+
/// values written by the fluent API, parsed from attributes, and read back from snapshots all
99+
/// compare on the same footing.
100+
/// </summary>
101+
/// <remarks>
102+
/// Kind handling:
103+
/// <list type="bullet">
104+
/// <item><see cref="DateTimeKind.Utc"/>: returned unchanged.</item>
105+
/// <item><see cref="DateTimeKind.Local"/>: converted via <see cref="DateTime.ToUniversalTime"/>.</item>
106+
/// <item><see cref="DateTimeKind.Unspecified"/>: reinterpreted as UTC via
107+
/// <see cref="DateTime.SpecifyKind"/> (NOT <see cref="DateTime.ToUniversalTime"/>, which would
108+
/// treat it as local and reintroduce a machine dependency). This matches the attribute path's
109+
/// "unsuffixed = UTC" rule.</item>
110+
/// </list>
111+
/// </remarks>
112+
/// <param name="value">The value to normalize.</param>
113+
/// <returns>The equivalent UTC <see cref="DateTime"/>.</returns>
114+
internal static DateTime NormalizeInitialStartToUtc(DateTime value) => value.Kind switch
115+
{
116+
DateTimeKind.Utc => value,
117+
DateTimeKind.Local => value.ToUniversalTime(),
118+
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc),
119+
};
120+
121+
/// <summary>
122+
/// Nullable overload of <see cref="NormalizeInitialStartToUtc(DateTime)"/>; returns
123+
/// <see langword="null"/> unchanged.
124+
/// </summary>
125+
/// <param name="value">The value to normalize, or <see langword="null"/>.</param>
126+
/// <returns>The equivalent UTC <see cref="DateTime"/>, or <see langword="null"/>.</returns>
127+
internal static DateTime? NormalizeInitialStartToUtc(DateTime? value)
128+
=> value.HasValue ? NormalizeInitialStartToUtc(value.Value) : null;
79129
}
80130
}

‎src/Eftdb/Configuration/PolicyJobBuilderCore.cs‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,11 @@ internal readonly struct PolicyJobAnnotationKeys
4040
internal static class PolicyJobBuilderCore
4141
{
4242
/// <summary>
43-
/// Writes the initial-start annotation.
43+
/// Writes the initial-start annotation, normalizing the value to a machine-independent
44+
/// UTC instant (see <see cref="ConventionValidationHelper.NormalizeInitialStartToUtc(DateTime)"/>).
4445
/// </summary>
4546
public static void WithInitialStart(EntityTypeBuilder builder, string annotationKey, DateTime initialStart)
46-
=> builder.HasAnnotation(annotationKey, initialStart);
47+
=> builder.HasAnnotation(annotationKey, ConventionValidationHelper.NormalizeInitialStartToUtc(initialStart));
4748

4849
/// <summary>
4950
/// Writes the if-not-exists annotation.

‎src/Eftdb/Configuration/ReorderPolicy/ReorderPolicyStringBuilder.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ internal ReorderPolicyStringBuilder(EntityTypeBuilder<TEntity> builder)
2424
/// <returns>The builder for method chaining.</returns>
2525
public ReorderPolicyStringBuilder<TEntity> WithInitialStart(DateTime initialStart)
2626
{
27-
_builder.HasAnnotation(ReorderPolicyAnnotations.InitialStart, initialStart);
27+
PolicyJobBuilderCore.WithInitialStart(_builder, ReorderPolicyAnnotations.InitialStart, initialStart);
2828
return this;
2929
}
3030
}

‎src/Eftdb/Configuration/ReorderPolicy/ReorderPolicyTypeBuilder.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ public static EntityTypeBuilder<TEntity> WithReorderPolicy<TEntity>(
4747
entityTypeBuilder.HasAnnotation(ReorderPolicyAnnotations.IndexName, indexName);
4848

4949
if (initialStart.HasValue)
50-
entityTypeBuilder.HasAnnotation(ReorderPolicyAnnotations.InitialStart, initialStart);
50+
PolicyJobBuilderCore.WithInitialStart(entityTypeBuilder, ReorderPolicyAnnotations.InitialStart, initialStart.Value);
5151

5252
if (!string.IsNullOrWhiteSpace(scheduleInterval))
5353
entityTypeBuilder.HasAnnotation(ReorderPolicyAnnotations.ScheduleInterval, scheduleInterval);

0 commit comments

Comments
 (0)