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
2 changes: 1 addition & 1 deletion .claude/reference/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ Drops negative (before EF table drops, reverse dependency order); adds/alters po
| -40 | DropContinuousAggregate |
| -20 | DropReorderPolicy |
| 0 | standard EF operations |
| 10 / 15 | CreateHypertable / AlterHypertable |
| 10 / 15 / 16 | CreateHypertable / AlterHypertable / RemoveHypertable |
| 20 | Add/AlterReorderPolicy |
| 30 / 40 | Create/AlterContinuousAggregate |
| 45 | AddContinuousAggregatePolicy |
Expand Down
4 changes: 3 additions & 1 deletion docs/05-apache-edition.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ Provider warnings are dispatched through EF Core's diagnostics pipeline, so they
| --- | --- | --- |
| `CommunityFeatureSkipped` | 63000 | A Community-only feature (compression, policy, or continuous aggregate) was skipped at migration SQL generation because `UseApacheEdition()` is set. |
| `TimeBucketColumnUnmapped` | 63001 | A continuous aggregate exposes a `time_bucket` column that no property maps to. Raised at model validation. |
| `UnsupportedHypertableChangeSkipped` | 63002 | A model change to an existing hypertable (time-column re-designation, dimension removal, or removal of the hypertable designation) was skipped because TimescaleDB cannot apply it. Raised at migration SQL generation, in every edition. See [Hypertables — Unsupported Model Changes](fluent-api/hypertable.md#unsupported-model-changes). |

```csharp
using CmdScale.EntityFrameworkCore.TimescaleDB.Diagnostics;
Expand All @@ -54,5 +55,6 @@ optionsBuilder.UseNpgsql(connectionString)
.UseTimescaleDb()
.ConfigureWarnings(w => w
.Ignore(TimescaleDbEventId.CommunityFeatureSkipped)
.Throw(TimescaleDbEventId.TimeBucketColumnUnmapped));
.Throw(TimescaleDbEventId.TimeBucketColumnUnmapped)
.Throw(TimescaleDbEventId.UnsupportedHypertableChangeSkipped));
```
4 changes: 4 additions & 0 deletions docs/data-annotations/hypertable.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ public class DeviceReading

To add partitioning with dimensions, refere to the [Fluent API](../fluent-api/hypertable#advanced-partitioning-with-dimensions).

## Unsupported Model Changes

Changing the time column, removing a dimension, or removing the `[Hypertable]` attribute from an existing hypertable cannot be applied by TimescaleDB. Such a change is skipped, leaving a `-- WARNING: Hypertable '<table>': ...` comment in the migration SQL and raising `TimescaleDbEventId.UnsupportedHypertableChangeSkipped` (event id 63002) through the diagnostics pipeline. The behavior is identical to the Fluent API - see [Fluent API — Unsupported Model Changes](../fluent-api/hypertable.md#unsupported-model-changes).

## Compression

Time-series data can be compressed to reduce the amount of storage required, and increase the speed of some queries. This is a cornerstone feature of TimescaleDB. When new data is added to your database, it is in the form of uncompressed rows. TimescaleDB uses a built-in job scheduler to convert this data to the form of compressed columns. This occurs across chunks of TimescaleDB hypertables.
Expand Down
15 changes: 15 additions & 0 deletions docs/fluent-api/hypertable.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,21 @@ public class WeatherDataConfiguration : IEntityTypeConfiguration<WeatherData>
}
```

## Unsupported Model Changes

Three model changes cannot be applied to an existing hypertable because TimescaleDB has no operation for them. The migration is still generated, but the affected change is **skipped**: no SQL is emitted for it, the database keeps its current shape, and the model and database intentionally diverge until the change is resolved manually (typically by recreating the table). Each skipped change surfaces in two places:

- A `-- WARNING: Hypertable '<table>': ...` comment in the generated migration SQL (visible in `dotnet ef migrations script` output).
- A warning through EF Core's diagnostics pipeline as `TimescaleDbEventId.UnsupportedHypertableChangeSkipped` (event id 63002, `Migrations` category), reaching `ILogger`, `LogTo(...)`, and `DiagnosticSource`.

The three changes are:

- **Re-designating the time column** of an existing hypertable — TimescaleDB cannot repartition onto a different time column. Every *other* change carried by the same migration (chunk interval, compression, dimensions) still emits SQL normally. A pure column *rename* is not affected: it is applied as an ordinary rename and produces no warning.
- **Removing a dimension** — TimescaleDB provides no `remove_dimension`. The dimension stays in the database.
- **Removing the hypertable designation** while keeping the entity — TimescaleDB cannot convert a hypertable back to a plain table. This is distinct from deleting the entity entirely, which remains a normal EF `DropTable`. The scaffolded migration contains a `RemoveHypertable(...)` call annotated with a comment stating that it only emits the warning and leaves the database unchanged.

> :warning: **Note:** Because the warning honors `ConfigureWarnings`, it can be silenced with `w.Ignore(TimescaleDbEventId.UnsupportedHypertableChangeSkipped)` or promoted to a hard failure with `w.Throw(...)` — the latter is useful in CI to reject a migration that would silently drift from the model. See [Diagnostics event IDs](../05-apache-edition.md#diagnostics-event-ids).

## Compression

Time-series data can be compressed to reduce the amount of storage required, and increase the speed of some queries. This is a cornerstone feature of TimescaleDB. When new data is added to your database, it is in the form of uncompressed rows. TimescaleDB uses a built-in job scheduler to convert this data to the form of compressed columns. This occurs across chunks of TimescaleDB hypertables.
Expand Down
19 changes: 19 additions & 0 deletions src/Eftdb.Design/Features/Hypertable/HypertableCSharpGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Design.Features.Hypertable
/// </summary>
internal class HypertableCSharpGenerator(ICSharpHelper code)
{
internal const string RemoveHypertableWarningComment =
"// TimescaleDB cannot convert a hypertable back into a plain table; this call only emits a warning and leaves the database unchanged.";

private readonly ICSharpHelper code = code;

public void Generate(CreateHypertableOperation operation, IndentedStringBuilder builder)
Expand Down Expand Up @@ -60,6 +63,9 @@ public void Generate(AlterHypertableOperation operation, IndentedStringBuilder b
if (!string.IsNullOrEmpty(operation.Schema))
call.Arg("schema", code.Literal(operation.Schema));

if (!string.IsNullOrEmpty(operation.TimeColumnName))
call.Arg("timeColumnName", code.Literal(operation.TimeColumnName));

if (!string.IsNullOrEmpty(operation.ChunkTimeInterval))
call.Arg("chunkTimeInterval", code.Literal(operation.ChunkTimeInterval));

Expand All @@ -85,6 +91,9 @@ public void Generate(AlterHypertableOperation operation, IndentedStringBuilder b
call.Arg("compressChunkTimeInterval", code.Literal(operation.CompressChunkTimeInterval));

// Old* values — emitted for Down() reversibility, only when non-default.
if (!string.IsNullOrEmpty(operation.OldTimeColumnName))
call.Arg("oldTimeColumnName", code.Literal(operation.OldTimeColumnName));

if (!string.IsNullOrEmpty(operation.OldChunkTimeInterval))
call.Arg("oldChunkTimeInterval", code.Literal(operation.OldChunkTimeInterval));

Expand All @@ -110,6 +119,16 @@ public void Generate(AlterHypertableOperation operation, IndentedStringBuilder b
call.Arg("oldCompressChunkTimeInterval", code.Literal(operation.OldCompressChunkTimeInterval));
}

public void Generate(RemoveHypertableOperation operation, IndentedStringBuilder builder)
{
using MigrationCallWriter call = new(builder, "RemoveHypertable");

call.Arg("tableName", code.Literal(operation.TableName));

if (!string.IsNullOrEmpty(operation.Schema))
call.Arg("schema", code.Literal(operation.Schema));
}

// Writes the dimension list directly into the builder so each entry is on its own
// line at the correct indent level.
private void AppendDimensionList(IndentedStringBuilder builder, IReadOnlyList<Dimension> dimensions)
Expand Down
31 changes: 31 additions & 0 deletions src/Eftdb.Design/TimescaleCSharpMigrationOperationGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,34 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Design
{
public class TimescaleCSharpMigrationOperationGenerator(CSharpMigrationOperationGeneratorDependencies dependencies) : CSharpMigrationOperationGenerator(dependencies)
{
public override void Generate(string builderName, IReadOnlyList<MigrationOperation> operations, IndentedStringBuilder builder)
{
ArgumentNullException.ThrowIfNull(operations);
ArgumentNullException.ThrowIfNull(builder);

bool first = true;
foreach (MigrationOperation operation in operations)
{
if (first)
{
first = false;
}
else
{
builder
.AppendLine()
.AppendLine();
}

if (operation is RemoveHypertableOperation)
{
builder.AppendLine(HypertableCSharpGenerator.RemoveHypertableWarningComment);
}

base.Generate(builderName, [operation], builder);
}
}

protected override void Generate(MigrationOperation operation, IndentedStringBuilder builder)
{
ArgumentNullException.ThrowIfNull(operation);
Expand All @@ -26,6 +54,9 @@ protected override void Generate(MigrationOperation operation, IndentedStringBui
case AlterHypertableOperation alter:
new HypertableCSharpGenerator(Dependencies.CSharpHelper).Generate(alter, builder);
return;
case RemoveHypertableOperation remove:
new HypertableCSharpGenerator(Dependencies.CSharpHelper).Generate(remove, builder);
return;

case AddReorderPolicyOperation addReorder:
new ReorderPolicyCSharpGenerator(Dependencies.CSharpHelper).Generate(addReorder, builder);
Expand Down
10 changes: 10 additions & 0 deletions src/Eftdb/Diagnostics/TimescaleDbEventId.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ private enum Id
{
CommunityFeatureSkipped = Base,
TimeBucketColumnUnmapped,
UnsupportedHypertableChangeSkipped,
}

/// <summary>
Expand All @@ -42,6 +43,15 @@ private enum Id
/// </summary>
public static readonly EventId TimeBucketColumnUnmapped = MakeValidationId(Id.TimeBucketColumnUnmapped);

/// <summary>
/// A hypertable model change was skipped because TimescaleDB cannot apply it to an existing hypertable
/// (e.g. changing the time column, removing a space/range dimension, or reverting a hypertable back to a
/// plain table). The provider emits a warning comment into the generated migration SQL and raises this
/// event once per skipped change, in the <see cref="DbLoggerCategory.Migrations"/> category, instead of
/// producing SQL that would fail.
/// </summary>
public static readonly EventId UnsupportedHypertableChangeSkipped = MakeMigrationsId(Id.UnsupportedHypertableChangeSkipped);

private static readonly string MigrationsPrefix = DbLoggerCategory.Migrations.Name + ".";
private static readonly string ValidationPrefix = DbLoggerCategory.Model.Validation.Name + ".";

Expand Down
51 changes: 51 additions & 0 deletions src/Eftdb/Diagnostics/TimescaleDbLoggerExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,34 @@ public static void TimeBucketColumnUnmapped(
}
}

/// <summary>
/// Warns that a hypertable model change cannot be applied to an existing hypertable and was skipped, so a
/// warning comment stands in for the SQL that would otherwise fail.
/// </summary>
/// <param name="diagnostics">The migrations diagnostics logger.</param>
/// <param name="tableName">The affected hypertable's name.</param>
/// <param name="changeDescription">The human-readable description of the skipped change.</param>
public static void UnsupportedHypertableChangeSkipped(
this IDiagnosticsLogger<DbLoggerCategory.Migrations> diagnostics,
string tableName,
string changeDescription)
{
EventDefinition<string, string> definition = LogUnsupportedHypertableChangeSkipped(diagnostics);

if (diagnostics.ShouldLog(definition))
{
definition.Log(diagnostics, tableName, changeDescription);
}

if (diagnostics.NeedsEventData(definition, out bool diagnosticSourceEnabled, out bool simpleLogEnabled))
{
EventData eventData = new(
definition,
(d, _) => ((EventDefinition<string, string>)d).GenerateMessage(tableName, changeDescription));
diagnostics.DispatchEventData(definition, eventData, diagnosticSourceEnabled, simpleLogEnabled);
}
}

private static EventDefinition<string> LogCommunityFeatureSkipped(IDiagnosticsLogger diagnostics)
{
TimescaleDbLoggingDefinitions definitions = GetDefinitions(diagnostics);
Expand Down Expand Up @@ -113,6 +141,29 @@ private static EventDefinition<string, string, string> LogTimeBucketColumnUnmapp
return (EventDefinition<string, string, string>)definition;
}

private static EventDefinition<string, string> LogUnsupportedHypertableChangeSkipped(IDiagnosticsLogger diagnostics)
{
TimescaleDbLoggingDefinitions definitions = GetDefinitions(diagnostics);
EventDefinitionBase? definition = definitions.LogUnsupportedHypertableChangeSkipped;
if (definition == null)
{
EventDefinition<string, string> created = new(
diagnostics.Options,
TimescaleDbEventId.UnsupportedHypertableChangeSkipped,
LogLevel.Warning,
"TimescaleDbEventId.UnsupportedHypertableChangeSkipped",
static level => LoggerMessage.Define<string, string>(
level,
TimescaleDbEventId.UnsupportedHypertableChangeSkipped,
"The change to hypertable '{Table}' was skipped because TimescaleDB cannot apply it to an " +
"existing hypertable: {ChangeDescription} The migration emits a warning comment instead of SQL."));

definition = Interlocked.CompareExchange(ref definitions.LogUnsupportedHypertableChangeSkipped, created, null) ?? created;
}

return (EventDefinition<string, string>)definition;
}

// Fail loud rather than fall back: EF and Npgsql hard-cast the provider's LoggingDefinitions with no
// graceful path, so a non-TimescaleDB instance here means the Replace registration was lost — a wiring
// bug that must surface, not a runtime condition to tolerate.
Expand Down
5 changes: 5 additions & 0 deletions src/Eftdb/Diagnostics/TimescaleDbLoggingDefinitions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ public class TimescaleDbLoggingDefinitions : NpgsqlLoggingDefinitions
/// Cached definition for <see cref="TimescaleDbEventId.TimeBucketColumnUnmapped"/>.
/// </summary>
public EventDefinitionBase? LogTimeBucketColumnUnmapped;

/// <summary>
/// Cached definition for <see cref="TimescaleDbEventId.UnsupportedHypertableChangeSkipped"/>.
/// </summary>
public EventDefinitionBase? LogUnsupportedHypertableChangeSkipped;
}
#pragma warning restore EF1001
}
Loading
Loading