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
31 changes: 29 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,8 +194,7 @@ If you prefer SQL Server's built-in Change Tracking over custom triggers:

```csharp
var config = new SqlServerCTSyncConfigurationBuilder(connectionString)
.ChangeRetentionDays(7) // how long change history is kept
.AutoCleanup(true)
.ChangeRetention(days: 7, autoCleanup: true) // how long change history is kept
.Table("Users")
.Table("Posts")
.Build();
Expand All @@ -204,6 +203,34 @@ var provider = new SqlServerCTProvider(config);
await provider.ApplyProvisionAsync();
```

The retention can also be expressed in hours or minutes:

```csharp
.ChangeRetention(90, ChangeRetentionUnit.Minutes)
```

`ApplyProvisionAsync` reconciles the setting: when the retention is configured explicitly it is applied
even to a database that already has change tracking enabled. Leave `ChangeRetention` unset to keep
whatever the database is already configured with.

Retention matters. A client that stays offline longer than the window can no longer resume an
incremental sync: both `GetChangesAsync` and `ApplyChangesAsync` then fail with
`SyncAnchorTooOldException`, which is permanent for that client. Catch it to trigger a
reinitialization from a fresh snapshot rather than retrying:

```csharp
try
{
await agent.SynchronizeAsync();
}
catch (SyncAnchorTooOldException ex)
{
// ex.TableName / ex.RequestedVersion / ex.MinValidVersion
// Retrying can never succeed - drop the local copy and take a fresh initial snapshot.
await ReinitializeClientAsync();
}
```

### Managing Change Tracking

```csharp
Expand Down
5 changes: 4 additions & 1 deletion src/CoreSync.SqlServer/SqlSyncProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -343,8 +343,11 @@ private async Task<SyncAnchor> GetLastRemoteAnchorForStoreAsync(Guid otherStoreI

var version = await cmd.ExecuteScalarAsync(cancellationToken);

// Report a missing anchor row as the null sentinel, matching
// GetLastLocalAnchorForStoreAsync and the other providers. Version 0 is not a sentinel: it
// is a real-looking version that callers cannot tell apart from "synchronized up to 0".
if (version == null || version == DBNull.Value)
return new SyncAnchor(otherStoreId, 0);
return SyncAnchor.Null;

return new SyncAnchor(otherStoreId, (long)version);
}
Expand Down
28 changes: 28 additions & 0 deletions src/CoreSync.SqlServerCT/ChangeRetentionUnit.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
namespace CoreSync.SqlServerCT
{
/// <summary>
/// The time unit a SQL Server Change Tracking retention period is expressed in.
/// </summary>
/// <remarks>
/// The numeric values match the <c>retention_period_units</c> column of
/// <c>sys.change_tracking_databases</c>, so a value read from that catalog view can be cast
/// directly to this enum.
/// </remarks>
public enum ChangeRetentionUnit
{
/// <summary>
/// The retention period is expressed in minutes (<c>CHANGE_RETENTION = n MINUTES</c>).
/// </summary>
Minutes = 1,

/// <summary>
/// The retention period is expressed in hours (<c>CHANGE_RETENTION = n HOURS</c>).
/// </summary>
Hours = 2,

/// <summary>
/// The retention period is expressed in days (<c>CHANGE_RETENTION = n DAYS</c>).
/// </summary>
Days = 3
}
}
23 changes: 23 additions & 0 deletions src/CoreSync.SqlServerCT/ChangeTrackingDatabaseOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
namespace CoreSync.SqlServerCT
{
/// <summary>
/// The database-level change tracking settings as reported by <c>sys.change_tracking_databases</c>.
/// </summary>
internal sealed class ChangeTrackingDatabaseOptions
{
public ChangeTrackingDatabaseOptions(int retentionPeriod, ChangeRetentionUnit retentionPeriodUnit, bool autoCleanup)
{
RetentionPeriod = retentionPeriod;
RetentionPeriodUnit = retentionPeriodUnit;
AutoCleanup = autoCleanup;
}

public int RetentionPeriod { get; }

public ChangeRetentionUnit RetentionPeriodUnit { get; }

public bool AutoCleanup { get; }

public override string ToString() => $"CHANGE_RETENTION = {RetentionPeriod} {RetentionPeriodUnit}, AUTO_CLEANUP = {(AutoCleanup ? "ON" : "OFF")}";
}
}
58 changes: 56 additions & 2 deletions src/CoreSync.SqlServerCT/SqlConnectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,67 @@ public static async Task<bool> GetIsChangeTrackingEnabledAsync(this SqlConnectio
return ((int)await cmd.ExecuteScalarAsync(cancellationToken)) == 1;
}

public static async Task EnableChangeTrackingAsync(this SqlConnection connection, int days = 7, bool autoCleanup = true, CancellationToken cancellationToken = default)
/// <summary>
/// Reads the change tracking retention settings currently in effect for the connected database,
/// or <c>null</c> when change tracking is not enabled on it.
/// </summary>
public static async Task<ChangeTrackingDatabaseOptions?> GetChangeTrackingOptionsAsync(this SqlConnection connection, CancellationToken cancellationToken)
{
var cmdText = $@"ALTER DATABASE CURRENT SET CHANGE_TRACKING = ON (CHANGE_RETENTION = {days} DAYS, AUTO_CLEANUP = {(autoCleanup ? "ON" : "OFF")})";
var cmdText = @"SELECT retention_period, retention_period_units, is_auto_cleanup_on
FROM sys.change_tracking_databases
WHERE database_id = DB_ID()";
using var cmd = new SqlCommand(cmdText, connection);
using var reader = await cmd.ExecuteReaderAsync(cancellationToken);

if (!await reader.ReadAsync(cancellationToken))
return null;

// sys.change_tracking_databases reports retention_period_units as tinyint and
// is_auto_cleanup_on as tinyint too, so read them through Convert rather than the typed
// getters.
return new ChangeTrackingDatabaseOptions(
Convert.ToInt32(reader.GetValue(0)),
(ChangeRetentionUnit)Convert.ToInt32(reader.GetValue(1)),
Convert.ToBoolean(reader.GetValue(2)));
}

/// <summary>
/// Turns change tracking on for the connected database. Only valid when change tracking is off:
/// the <c>= ON (...)</c> form is rejected once it is already enabled - use
/// <see cref="AlterChangeTrackingRetentionAsync"/> to change the settings of a database that
/// already has it on.
/// </summary>
public static async Task EnableChangeTrackingAsync(this SqlConnection connection, int retentionPeriod = 7, ChangeRetentionUnit retentionPeriodUnit = ChangeRetentionUnit.Days, bool autoCleanup = true, CancellationToken cancellationToken = default)
{
var cmdText = $@"ALTER DATABASE CURRENT SET CHANGE_TRACKING = ON ({FormatChangeTrackingOptions(retentionPeriod, retentionPeriodUnit, autoCleanup)})";
using var cmd = new SqlCommand(cmdText, connection);
await cmd.ExecuteNonQueryAsync(cancellationToken);
}

/// <summary>
/// Changes the retention settings of a database that already has change tracking enabled.
/// </summary>
public static async Task AlterChangeTrackingRetentionAsync(this SqlConnection connection, int retentionPeriod, ChangeRetentionUnit retentionPeriodUnit, bool autoCleanup, CancellationToken cancellationToken)
{
var cmdText = $@"ALTER DATABASE CURRENT SET CHANGE_TRACKING ({FormatChangeTrackingOptions(retentionPeriod, retentionPeriodUnit, autoCleanup)})";
using var cmd = new SqlCommand(cmdText, connection);
await cmd.ExecuteNonQueryAsync(cancellationToken);
}

private static string FormatChangeTrackingOptions(int retentionPeriod, ChangeRetentionUnit retentionPeriodUnit, bool autoCleanup)
=> $"CHANGE_RETENTION = {retentionPeriod} {ToSqlKeyword(retentionPeriodUnit)}, AUTO_CLEANUP = {(autoCleanup ? "ON" : "OFF")}";

private static string ToSqlKeyword(ChangeRetentionUnit unit)
{
switch (unit)
{
case ChangeRetentionUnit.Minutes: return "MINUTES";
case ChangeRetentionUnit.Hours: return "HOURS";
case ChangeRetentionUnit.Days: return "DAYS";
default: throw new ArgumentOutOfRangeException(nameof(unit), unit, "Unsupported change retention unit");
}
}

public static async Task DisableChangeTrackingAsync(this SqlConnection connection, CancellationToken cancellationToken)
{
var cmdText = @"ALTER DATABASE CURRENT SET CHANGE_TRACKING = OFF";
Expand Down
Loading
Loading