Skip to content

Version Patch System

Chronicle includes a version-based patch system to handle breaking changes during upgrades. Patches are automatically discovered and applied during server startup, ensuring the system state is migrated correctly between versions.

The patch system:

  • Automatically discovers patches using reflection (IInstancesOf<ICanApplyPatch>)
  • Applies patches in order based on semantic version
  • Tracks applied patches to prevent re-execution
  • Supports rollback through Down() methods for safe downgrades
  • Runs at startup before other Chronicle services initialize

Patches are tied to semantic versions (e.g., 1.5.0, 2.0.0-beta.1). The system compares the current system version with patch versions to determine which patches to apply.

  1. Discovery: On startup, all ICanApplyPatch implementations are discovered
  2. Filtering: Only patches with versions newer than the current system version are selected
  3. Ordering: Selected patches are sorted in ascending version order
  4. Application: Each patch’s Up() method is called sequentially
  5. Tracking: Successfully applied patches are recorded in storage
  6. Version Update: System version is updated to the latest applied patch version

Patches are tracked in MongoDB:

  • Collection: patches (in system database)
  • Version: Stored separately as the current system version
  • State: PatchManager grain maintains state of all applied patches

Create a patch by implementing ICanApplyPatch:

public class PatchesBasicPatch(IStorage storage, ILogger<PatchesBasicPatch> logger) : ICanApplyPatch
{
public SemanticVersion Version => new(1, 5, 0);
public async Task Up()
{
logger.LogInformation("Applying PatchesBasicPatch");
var eventStore = storage.GetEventStore(EventStoreName.System);
var reactors = await eventStore.Reactors.GetAll();
logger.LogInformation("Found {Count} reactors", reactors.Count());
}
public Task Down()
{
logger.LogInformation("Rolling back PatchesBasicPatch");
return Task.CompletedTask;
}
}
  • Folder structure: Organize patches by version in Source/Kernel/Core/Patches/{version}/
  • File naming: Use descriptive names (e.g., RenameReactors.cs, MigrateEventSchema.cs)
  • Class naming: Match the file name (the Name property is auto-derived from the type name)

Patches can inject any services registered in the DI container:

public class PatchesComplexPatch(
IStorage storage,
IEventTypes eventTypes,
ILogger<PatchesComplexPatch> logger) : ICanApplyPatch
{
public SemanticVersion Version => new(2, 0, 0);
public async Task Up()
{
if (await storage.HasEventStore(EventStoreName.System))
{
await eventTypes.DiscoverAndRegister(EventStoreName.System);
logger.LogInformation("Discovered and registered event types");
}
}
public Task Down() => Task.CompletedTask;
}

Use proper semantic logging with LoggerMessage attributes:

internal static partial class PatchesLoggingPatchLogMessages
{
[LoggerMessage(LogLevel.Information, "Starting PatchesLoggingPatch migration")]
internal static partial void StartingMigration(this ILogger<PatchesLoggingPatch> logger);
[LoggerMessage(LogLevel.Information, "Migrated {Count} items")]
internal static partial void MigratedItems(this ILogger<PatchesLoggingPatch> logger, int count);
}
public class PatchesLoggingPatch(IStorage storage, ILogger<PatchesLoggingPatch> logger) : ICanApplyPatch
{
public SemanticVersion Version => new(1, 6, 0);
public async Task Up()
{
logger.StartingMigration();
var count = (await storage.GetEventStores()).Count();
logger.MigratedItems(count);
}
public Task Down() => Task.CompletedTask;
}

Always implement Down() to support rollback scenarios:

public class PatchesRollbackPatch(ILogger<PatchesRollbackPatch> logger) : ICanApplyPatch
{
public SemanticVersion Version => new(1, 7, 0);
public Task Up() => Task.CompletedTask;
public Task Down()
{
// Reverse the changes made in Up()
// This allows safe rollback if needed
logger.LogInformation("Rolling back PatchesRollbackPatch");
return Task.CompletedTask;
}
}

Patches should be idempotent where possible. The system prevents re-execution, but defensive coding helps:

public class PatchesIdempotentPatch(IStorage storage, ILogger<PatchesIdempotentPatch> logger) : ICanApplyPatch
{
public SemanticVersion Version => new(1, 8, 0);
public async Task Up()
{
var eventStore = storage.GetEventStore(EventStoreName.System);
var reactors = await eventStore.Reactors.GetAll();
// Filter to only process items that need migration
var reactorsToMigrate = reactors
.Where(r => r.Identifier.Value.Contains("OldPattern"))
.ToList();
if (reactorsToMigrate.Count == 0)
{
logger.LogInformation("Nothing to migrate");
return;
}
// Proceed with migration
}
public Task Down() => Task.CompletedTask;
}

Write comprehensive specs for your patches:

using Cratis.Specifications;
using NSubstitute;
using Xunit;
public class when_rolling_back_the_patch : Specification
{
readonly ILogger<PatchesRollbackPatch> _logger = Substitute.For<ILogger<PatchesRollbackPatch>>();
PatchesRollbackPatch _patch = default!;
void Establish() => _patch = new PatchesRollbackPatch(_logger);
Task Because() => _patch.Down();
[Fact] void should_complete_without_throwing() => true.ShouldBeTrue();
}
  1. Server Initialization: Chronicle server starts
  2. Patch Discovery: PatchManager grain discovers all ICanApplyPatch implementations
  3. Version Check: Current system version is retrieved from storage
  4. Patch Selection: Patches newer than current version are selected
  5. Sequential Application: Patches are applied in ascending version order
  6. Version Update: System version is updated to latest applied patch
  7. Normal Startup: Chronicle continues with normal initialization

If a patch fails:

  • The exception is logged and re-thrown
  • Startup is halted to prevent running with inconsistent state
  • Manual intervention is required to fix the issue
  • The patch can be fixed and server restarted

Patches are applied based on version comparison:

  • Current: 1.0.0, Patch: 1.5.0 → Patch IS applied
  • Current: 2.0.0, Patch: 1.5.0 → Patch is NOT applied
  • Current: 1.5.0, Patch: 1.5.0 → Patch is NOT applied (equal versions)
  • Current: null, Patch: 1.0.0 → Patch IS applied (treats null as 0.0.0)

The RenameReactors patch (version 15.3.0) demonstrates a real-world migration:

internal static partial class RenameReactorsLogMessages
{
[LoggerMessage(LogLevel.Information, "Starting patch to rename reactors")]
internal static partial void StartingPatch(this ILogger<RenameReactors> logger);
[LoggerMessage(LogLevel.Information, "Found {Count} reactors to rename")]
internal static partial void FoundReactorsToRename(this ILogger<RenameReactors> logger, int count);
[LoggerMessage(LogLevel.Information, "Renaming reactor from {CurrentId} to {NewId}")]
internal static partial void RenamingReactor(this ILogger<RenameReactors> logger, string currentId, string newId);
[LoggerMessage(LogLevel.Information, "Patch completed")]
internal static partial void PatchCompleted(this ILogger<RenameReactors> logger);
[LoggerMessage(LogLevel.Information, "Starting rollback")]
internal static partial void StartingRollback(this ILogger<RenameReactors> logger);
[LoggerMessage(LogLevel.Information, "Rollback completed")]
internal static partial void RollbackCompleted(this ILogger<RenameReactors> logger);
}
public class RenameReactors(IStorage storage, ILogger<RenameReactors> logger) : ICanApplyPatch
{
public SemanticVersion Version => new(15, 3, 0);
public async Task Up()
{
logger.StartingPatch();
var systemEventStore = storage.GetEventStore(EventStoreName.System);
var reactors = await systemEventStore.Reactors.GetAll();
var reactorsToRename = reactors
.Where(r => r.Identifier.Value.Contains("Grains", StringComparison.OrdinalIgnoreCase))
.ToList();
logger.FoundReactorsToRename(reactorsToRename.Count);
foreach (var reactor in reactorsToRename)
{
var currentId = reactor.Identifier;
var newIdValue = currentId.Value.Replace("Grains", string.Empty, StringComparison.OrdinalIgnoreCase);
logger.RenamingReactor(currentId.Value, newIdValue);
await systemEventStore.Reactors.Rename(currentId, newIdValue);
}
logger.PatchCompleted();
}
public Task Down()
{
logger.StartingRollback();
logger.RollbackCompleted();
return Task.CompletedTask;
}
}
  1. Check version: Ensure patch version is greater than current system version
  2. Check registration: Verify patch implements ICanApplyPatch and is in correct namespace
  3. Check logs: Look for patch discovery and application logs at startup
  1. Review exception: Check startup logs for detailed error information
  2. Test locally: Run patch specs to verify logic
  3. Check storage: Ensure storage connections are working
  4. Verify state: Check if system is in expected state before migration

If you need to re-run a patch (e.g., during development):

  1. Remove patch record from patches collection in MongoDB
  2. Optionally reset system version if needed
  3. Restart server

Warning: Only do this in development environments. Production systems should use new patch versions.

When upgrading Chronicle from versions without the patch system:

  1. System version defaults to 0.0.0 (or SemanticVersion.NotSet)
  2. All patches will be discovered and applied in order
  3. After successful application, system version is set to latest patch version
  4. Future upgrades will only apply newer patches

The patch system provides a robust, automated way to handle breaking changes during Chronicle upgrades. By following the conventions and best practices outlined here, you can write safe, testable patches that keep Chronicle systems properly migrated across versions.