-
-
Notifications
You must be signed in to change notification settings - Fork 21
Add fingerprint-based module caching #3576
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
thomhurst
wants to merge
23
commits into
main
Choose a base branch
from
issue-3536-module-cache
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
23 commits
Select commit
Hold shift + click to select a range
7d3955a
feat(caching): add incremental module cache
thomhurst 28d7d3c
fix(caching): address review and CI
thomhurst 6bab581
fix(caching): harden cache correctness
thomhurst 0bfaca6
fix(caching): preserve artifact semantics
thomhurst c639f55
fix(cache): honor skips and empty directories
thomhurst d8348d5
fix(cache): preserve hook and directory modes
thomhurst 777ef8b
fix(cache): preserve symbolic links
thomhurst 5014a7d
fix(cache): harden cache result handling
thomhurst 67fbc5c
refactor(cache): share artifact path traversal
thomhurst 41c9099
test(cache): wire condition handler
thomhurst 059f40e
fix(cache): harden artifact path handling
thomhurst d18bdb1
refactor(cache): reduce pipeline complexity
thomhurst 01b330b
fix(cache): restore artifact links safely
thomhurst c2b00a0
fix(cache): harden artifact restoration
thomhurst 287143b
fix(cache): secure artifact restoration
thomhurst d534181
fix(cache): harden artifact restoration
thomhurst 2920afa
fix(cache): restore sparse artifacts
thomhurst 4c5205d
fix(cache): restore desktop artifact parity
thomhurst e543fe2
fix(cache): honor filesystem input semantics
thomhurst 86c9b79
fix(cache): trust linked working directory
thomhurst c712866
fix(redis): publish cache expiry atomically
thomhurst ff367ff
fix(cache): preserve filesystem restore semantics
thomhurst 80ca8be
fix(cache): harden artifact path handling
thomhurst File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| --- | ||
| title: Cache Module Results | ||
| --- | ||
|
|
||
| # Cache Module Results | ||
|
|
||
| Fingerprint-based module caching skips work when its declared inputs have not changed. A cache entry contains the typed module result and files declared with `ProducesArtifact`, so a hit can restore outputs before dependent modules start. | ||
|
|
||
| ## Enable a local cache | ||
|
|
||
| Register the filesystem backend once: | ||
|
|
||
| ```csharp | ||
| using ModularPipelines.Caching; | ||
| using ModularPipelines.Extensions; | ||
|
|
||
| builder.AddModuleCache<FileSystemModuleCache>(); | ||
| ``` | ||
|
|
||
| Then declare every file input that can affect a module: | ||
|
|
||
| ```csharp | ||
| [CacheInputs("src/**/*.cs", "*.csproj")] | ||
| [ProducesArtifact("application", "artifacts/publish/**/*")] | ||
| public sealed class BuildModule : Module<BuildOutput> | ||
| { | ||
| protected override Task<BuildOutput?> ExecuteAsync( | ||
| IModuleContext context, | ||
| CancellationToken cancellationToken) | ||
| { | ||
| // Build the application. | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| Patterns are relative to `ModuleCacheOptions.WorkingDirectory`. They support `*`, `?`, and recursive `**` wildcards. | ||
|
|
||
| The fingerprint contains: | ||
|
|
||
| - the module type and assembly version; | ||
| - the content and relative path of every declared input; | ||
| - explicit key parts and declared environment variable values; | ||
| - normalized results from all direct, selector-based, and dynamic dependencies. | ||
|
|
||
| A hit has `Status.UsedHistory`. Cache lookup or storage failures are logged and the module executes normally. | ||
|
|
||
| ## Include configuration | ||
|
|
||
| File contents do not represent every input. Add configuration or tool versions explicitly: | ||
|
|
||
| ```csharp | ||
| protected override ModuleConfiguration Configure() => | ||
| ModuleConfiguration.Create() | ||
| .WithCacheKeyPart($"configuration={configurationName}") | ||
| .WithCacheKeyPart($"sdk={sdkVersion}") | ||
| .WithCacheEnvironmentVariable("TARGET_RUNTIME") | ||
| .Build(); | ||
| ``` | ||
|
|
||
| Changing any key part or declared environment value invalidates the entry. | ||
|
|
||
| ## Configure limits and locations | ||
|
|
||
| ```csharp | ||
| builder.AddModuleCache<FileSystemModuleCache>(options => | ||
| { | ||
| options.WorkingDirectory = repositoryRoot; | ||
| options.CacheDirectory = Path.Combine(repositoryRoot, ".cache", "modules"); | ||
| options.MaximumInputFiles = 50_000; | ||
| options.MaximumHashConcurrency = 8; | ||
| }); | ||
| ``` | ||
|
|
||
| The file limit prevents unexpectedly broad globs. File hashes use a persistent modification-time and size index; changed files are hashed concurrently. | ||
|
|
||
| ## Share entries through S3 or Redis | ||
|
|
||
| The S3 and Redis packages provide cross-run cache backends. Their cache namespaces do not use distributed pipeline run identifiers, so concurrent runs can safely share entries. | ||
|
|
||
| ```csharp | ||
| builder.AddS3ModuleCache(options => | ||
| { | ||
| options.BucketName = "pipeline-artifacts"; | ||
| options.Region = "eu-west-2"; | ||
| }); | ||
| ``` | ||
|
|
||
| Or use Redis: | ||
|
|
||
| ```csharp | ||
| builder.AddRedisModuleCache( | ||
| redis => redis.ConnectionString = "localhost:6379", | ||
| cacheEntries => cacheEntries.TimeToLiveSeconds = 86_400); | ||
| ``` | ||
|
|
||
| ## Correctness rules | ||
|
|
||
| - Declare all file inputs. An undeclared input cannot invalidate a cache entry. | ||
| - Add key parts for arguments, configuration objects, external service versions, and other non-file inputs. | ||
| - Return dependency values that change whenever relevant upstream state changes. Only direct dependency results are fingerprinted, so constant or `Unit` results do not propagate transitive invalidation. | ||
| - Declare environment variables individually; the framework does not fingerprint the entire process environment. | ||
| - Input files are content-hashed on every fingerprint calculation. File size and timestamps are not treated as proof that content is unchanged. | ||
| - Use `ProducesArtifact` for files a cache hit must recreate. | ||
| - Do not cache modules whose result cannot be serialized to JSON. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
26 changes: 26 additions & 0 deletions
26
src/ModularPipelines.Distributed.Artifacts.S3/Artifacts/S3ClientFactory.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| using Amazon; | ||
| using Amazon.S3; | ||
| using ModularPipelines.Distributed.Artifacts.S3.Configuration; | ||
|
|
||
| namespace ModularPipelines.Distributed.Artifacts.S3.Artifacts; | ||
|
|
||
| internal static class S3ClientFactory | ||
| { | ||
| public static IAmazonS3 Create(S3ArtifactOptions options) | ||
| { | ||
| var config = new AmazonS3Config | ||
| { | ||
| RegionEndpoint = RegionEndpoint.GetBySystemName(options.Region), | ||
| ForcePathStyle = options.ForcePathStyle, | ||
| }; | ||
|
|
||
| if (!string.IsNullOrEmpty(options.ServiceUrl)) | ||
| { | ||
| config.ServiceURL = options.ServiceUrl; | ||
| } | ||
|
|
||
| return !string.IsNullOrEmpty(options.AccessKey) && !string.IsNullOrEmpty(options.SecretKey) | ||
| ? new AmazonS3Client(options.AccessKey, options.SecretKey, config) | ||
| : new AmazonS3Client(config); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
114 changes: 114 additions & 0 deletions
114
src/ModularPipelines.Distributed.Artifacts.S3/Caching/S3ModuleCache.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,114 @@ | ||
| using System.Net; | ||
| using Amazon.S3; | ||
| using Amazon.S3.Model; | ||
| using ModularPipelines.Caching; | ||
| using ModularPipelines.Distributed.Artifacts.S3.Artifacts; | ||
| using ModularPipelines.Distributed.Artifacts.S3.Configuration; | ||
|
|
||
| namespace ModularPipelines.Distributed.Artifacts.S3.Caching; | ||
|
|
||
| /// <summary> | ||
| /// Stores shareable module cache entries in S3 or an S3-compatible service. | ||
| /// Cache keys are independent of distributed pipeline run identifiers. | ||
| /// </summary> | ||
| public sealed class S3ModuleCache : IModuleCacheStore, IDisposable | ||
| { | ||
| private readonly S3ArtifactOptions _options; | ||
| private readonly Lazy<IAmazonS3> _client; | ||
|
|
||
| /// <summary> | ||
| /// Initializes a new instance of the <see cref="S3ModuleCache"/> class. | ||
| /// </summary> | ||
| public S3ModuleCache(S3ArtifactOptions options) | ||
| { | ||
| ValidateOptions(options); | ||
| _options = options; | ||
| _client = new Lazy<IAmazonS3>(() => S3ClientFactory.Create(options)); | ||
| } | ||
|
|
||
| internal S3ModuleCache(S3ArtifactOptions options, IAmazonS3 client) | ||
| { | ||
| ValidateOptions(options); | ||
| ArgumentNullException.ThrowIfNull(client); | ||
| _options = options; | ||
| _client = new Lazy<IAmazonS3>(() => client); | ||
| } | ||
|
|
||
| /// <inheritdoc /> | ||
| public async Task<Stream?> OpenReadAsync(string fingerprint, CancellationToken cancellationToken) | ||
| { | ||
| ModuleCacheFingerprint.Validate(fingerprint); | ||
|
|
||
| GetObjectResponse response; | ||
| try | ||
| { | ||
| response = await _client.Value | ||
| .GetObjectAsync(_options.BucketName, BuildObjectKey(fingerprint), cancellationToken) | ||
| .ConfigureAwait(false); | ||
| } | ||
| catch (AmazonS3Exception exception) when (exception.StatusCode == HttpStatusCode.NotFound) | ||
| { | ||
| return null; | ||
| } | ||
|
|
||
| using (response) | ||
| { | ||
| var temporary = Path.GetTempFileName(); | ||
| var stream = new FileStream( | ||
| temporary, | ||
| FileMode.Create, | ||
| FileAccess.ReadWrite, | ||
| FileShare.None, | ||
| 64 * 1024, | ||
| FileOptions.Asynchronous | FileOptions.DeleteOnClose); | ||
| try | ||
| { | ||
| await response.ResponseStream.CopyToAsync(stream, cancellationToken).ConfigureAwait(false); | ||
| stream.Position = 0; | ||
| return stream; | ||
| } | ||
| catch | ||
| { | ||
| await stream.DisposeAsync().ConfigureAwait(false); | ||
| throw; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// <inheritdoc /> | ||
| public async Task WriteAsync(string fingerprint, Stream content, CancellationToken cancellationToken) | ||
| { | ||
| ModuleCacheFingerprint.Validate(fingerprint); | ||
| ArgumentNullException.ThrowIfNull(content); | ||
|
|
||
| var request = new PutObjectRequest | ||
| { | ||
| BucketName = _options.BucketName, | ||
| Key = BuildObjectKey(fingerprint), | ||
| InputStream = content, | ||
| ContentType = "application/zip", | ||
| DisablePayloadSigning = true, | ||
| }; | ||
|
|
||
| await _client.Value.PutObjectAsync(request, cancellationToken).ConfigureAwait(false); | ||
| } | ||
|
|
||
| /// <inheritdoc /> | ||
| public void Dispose() | ||
| { | ||
| if (_client.IsValueCreated) | ||
| { | ||
| _client.Value.Dispose(); | ||
| } | ||
| } | ||
|
|
||
| private string BuildObjectKey(string fingerprint) => | ||
| $"{_options.KeyPrefix.TrimEnd('/')}/module-cache/v1/{fingerprint.ToLowerInvariant()}.zip"; | ||
|
|
||
| private static void ValidateOptions(S3ArtifactOptions options) | ||
| { | ||
| ArgumentNullException.ThrowIfNull(options); | ||
| ArgumentException.ThrowIfNullOrWhiteSpace(options.BucketName); | ||
| ArgumentException.ThrowIfNullOrWhiteSpace(options.KeyPrefix); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.