-
Notifications
You must be signed in to change notification settings - Fork 44
Expand file tree
/
Copy pathAwsS3SyncApplyStrategy.cs
More file actions
310 lines (265 loc) · 13.1 KB
/
Copy pathAwsS3SyncApplyStrategy.cs
File metadata and controls
310 lines (265 loc) · 13.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
// Licensed to Elasticsearch B.V under one or more agreements.
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information
using System.Diagnostics;
using System.Diagnostics.Metrics;
using System.Threading;
using Amazon.S3;
using Amazon.S3.Model;
using Amazon.S3.Transfer;
using Elastic.Documentation.Diagnostics;
using Elastic.Documentation.Integrations.S3;
using Elastic.Documentation.ServiceDefaults.Telemetry;
using Microsoft.Extensions.Logging;
namespace Elastic.Documentation.Deploying.Synchronization;
public partial class AwsS3SyncApplyStrategy(
ILoggerFactory logFactory,
IAmazonS3 s3Client,
ITransferUtility transferUtility,
string bucketName,
IDocsSyncContext context,
IDiagnosticsCollector collector
) : IDocsSyncApplyStrategy
{
private static readonly ActivitySource ApplyStrategyActivitySource = new(TelemetryConstants.AssemblerSyncInstrumentationName);
// Meter for OpenTelemetry metrics
private static readonly Meter SyncMeter = new(TelemetryConstants.AssemblerSyncInstrumentationName);
private const int DefaultUploadConcurrency = 32;
private const int MaxUploadConcurrency = 128;
private const int DefaultDeleteConcurrency = 4;
private const int MaxDeleteConcurrency = 16;
private const string UploadConcurrencyEnvironmentVariable = "DOCS_SYNC_UPLOAD_CONCURRENCY";
private const string DeleteConcurrencyEnvironmentVariable = "DOCS_SYNC_DELETE_CONCURRENCY";
// Deployment-level metrics (histograms for distribution analysis, counters for totals)
// Note: Histograms require delta temporality to work with Elasticsearch
// See Extensions.cs where MetricTemporalityPreference.Delta is configured
private static readonly Histogram<double> FilesPerDeploymentHistogram = SyncMeter.CreateHistogram<double>(
"docs.deployment.files.count",
"files",
"Number of files per deployment operation (added + updated + deleted + skipped)");
private static readonly Counter<double> FilesTotalCounter = SyncMeter.CreateCounter<double>(
"docs.deployment.files.total",
"files",
"Total number of files in deployment (added + updated + deleted + skipped)");
private static readonly Counter<double> FilesAddedCounter = SyncMeter.CreateCounter<double>(
"docs.sync.files.added.total",
"files",
"Total number of files added to S3");
private static readonly Counter<double> FilesUpdatedCounter = SyncMeter.CreateCounter<double>(
"docs.sync.files.updated.total",
"files",
"Total number of files updated in S3");
private static readonly Counter<double> FilesDeletedCounter = SyncMeter.CreateCounter<double>(
"docs.sync.files.deleted.total",
"files",
"Total number of files deleted from S3");
private static readonly Counter<double> FilesSkippedCounter = SyncMeter.CreateCounter<double>(
"docs.sync.files.skipped.total",
"files",
"Total number of files skipped (unchanged)");
private static readonly Histogram<double> FileSizeHistogram = SyncMeter.CreateHistogram<double>(
"docs.sync.file.size",
"By",
"Distribution of file sizes synced to S3");
private static readonly Counter<double> FilesByExtensionCounter = SyncMeter.CreateCounter<double>(
"docs.sync.files.by_extension",
"files",
"File operations grouped by extension");
private static readonly Histogram<double> SyncDurationHistogram = SyncMeter.CreateHistogram<double>(
"docs.sync.duration",
"s",
"Duration of sync operations");
private readonly ILogger<AwsS3SyncApplyStrategy> _logger = logFactory.CreateLogger<AwsS3SyncApplyStrategy>();
private void DisplayUploadProgress(object? sender, UploadProgressArgs args) => LogUploadProgress(_logger, args);
[LoggerMessage(
EventId = 4,
Level = LogLevel.Debug,
Message = "{Args}")]
private static partial void LogUploadProgress(ILogger logger, UploadProgressArgs args);
[LoggerMessage(
EventId = 3,
Level = LogLevel.Information,
Message = "File operation: {Operation} | Path: {FilePath} | Size: {FileSize} bytes")]
private static partial void LogFileOperation(ILogger logger, string operation, string filePath, long fileSize);
public async Task<bool> Apply(SyncPlan plan, Cancel ctx = default)
{
var sw = Stopwatch.StartNew();
var errorsBeforeApply = collector.Errors;
using var applyActivity = ApplyStrategyActivitySource.StartActivity("sync apply", ActivityKind.Client);
if (Environment.GetEnvironmentVariable("GITHUB_ACTIONS") == "true")
{
_ = applyActivity?.SetTag("cicd.pipeline.name", Environment.GetEnvironmentVariable("GITHUB_WORKFLOW") ?? "unknown");
_ = applyActivity?.SetTag("cicd.pipeline.run.id", Environment.GetEnvironmentVariable("GITHUB_RUN_ID") ?? "unknown");
_ = applyActivity?.SetTag("cicd.pipeline.run.attempt", Environment.GetEnvironmentVariable("GITHUB_RUN_ATTEMPT") ?? "unknown");
}
var addCount = plan.AddRequests.Count;
var updateCount = plan.UpdateRequests.Count;
var deleteCount = plan.DeleteRequests.Count;
var skipCount = plan.SkipRequests.Count;
var totalFiles = addCount + updateCount + deleteCount + skipCount;
// Add aggregate metrics to span
_ = applyActivity?.SetTag("docs.sync.files.added", addCount);
_ = applyActivity?.SetTag("docs.sync.files.updated", updateCount);
_ = applyActivity?.SetTag("docs.sync.files.deleted", deleteCount);
_ = applyActivity?.SetTag("docs.sync.files.skipped", skipCount);
_ = applyActivity?.SetTag("docs.sync.files.total", totalFiles);
// Record deployment-level metrics (always emit, even if 0)
// Histogram for distribution analysis (p50, p95, p99)
FilesPerDeploymentHistogram.Record(totalFiles);
// Record per-operation histograms (for distribution analysis by operation type)
FilesPerDeploymentHistogram.Record(addCount, [new("operation", "add")]);
FilesPerDeploymentHistogram.Record(updateCount, [new("operation", "update")]);
FilesPerDeploymentHistogram.Record(deleteCount, [new("operation", "delete")]);
FilesPerDeploymentHistogram.Record(skipCount, [new("operation", "skip")]);
// Counter for simple totals and rates
FilesTotalCounter.Add(totalFiles);
// Record counter versions for easy dashboard queries (always emit, even if 0)
FilesAddedCounter.Add(addCount);
FilesUpdatedCounter.Add(updateCount);
FilesDeletedCounter.Add(deleteCount);
FilesSkippedCounter.Add(skipCount);
_logger.LogInformation(
"Deployment sync: {TotalFiles} files ({AddCount} added, {UpdateCount} updated, {DeleteCount} deleted, {SkipCount} skipped) in {Environment}",
totalFiles, addCount, updateCount, deleteCount, skipCount, context.EnvironmentName);
await Upload(plan, ctx);
await Delete(plan, ctx);
// Record sync duration (both histogram for distribution and counter for total)
SyncDurationHistogram.Record(sw.Elapsed.TotalSeconds);
return collector.Errors == errorsBeforeApply;
}
private async Task Upload(SyncPlan plan, Cancel ctx)
{
var sw = Stopwatch.StartNew();
var uploadedCount = 0;
var uploadRequests = plan.AddRequests.Cast<UploadRequest>().Concat(plan.UpdateRequests).ToList();
var uploadConcurrency = GetConcurrency(UploadConcurrencyEnvironmentVariable, DefaultUploadConcurrency, MaxUploadConcurrency);
// Always create activity span (even if 0 files) for consistent tracing
using var uploadActivity = ApplyStrategyActivitySource.StartActivity("upload files", ActivityKind.Client);
_ = uploadActivity?.SetTag("docs.sync.upload.count", uploadRequests.Count);
_ = uploadActivity?.SetTag("docs.sync.upload.concurrency", uploadConcurrency);
if (uploadRequests.Count > 0)
{
var addCount = plan.AddRequests.Count;
var updateCount = plan.UpdateRequests.Count;
_logger.LogInformation("Starting to process {AddCount} new files and {UpdateCount} updated files", addCount, updateCount);
var addPaths = plan.AddRequests.Select(r => r.LocalPath).ToHashSet();
_logger.LogInformation("Uploading {Count} files to S3 bucket {BucketName} with concurrency {Concurrency}", uploadRequests.Count, bucketName, uploadConcurrency);
await Parallel.ForEachAsync(uploadRequests, new ParallelOptions
{
CancellationToken = ctx,
MaxDegreeOfParallelism = uploadConcurrency
}, async (upload, token) =>
{
var operation = addPaths.Contains(upload.LocalPath) ? "add" : "update";
var fileSize = context.WriteFileSystem.FileInfo.New(upload.LocalPath).Length;
var extension = Path.GetExtension(upload.DestinationPath).ToLowerInvariant();
FileSizeHistogram.Record(fileSize);
if (!string.IsNullOrEmpty(extension))
FilesByExtensionCounter.Add(1, new("operation", operation), new("extension", extension));
LogFileOperation(_logger, operation, upload.DestinationPath, fileSize);
var request = new TransferUtilityUploadRequest
{
BucketName = bucketName,
FilePath = upload.LocalPath,
Key = upload.DestinationPath,
PartSize = S3EtagCalculator.PartSize
};
request.UploadProgressEvent += DisplayUploadProgress;
try
{
await transferUtility.UploadAsync(request, token);
_ = Interlocked.Increment(ref uploadedCount);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Failed to upload {LocalPath} to s3://{BucketName}/{DestinationPath}", upload.LocalPath, bucketName, upload.DestinationPath);
collector.EmitError(upload.LocalPath, $"Failed to upload to s3://{bucketName}/{upload.DestinationPath}", ex);
}
});
_logger.LogInformation("Finished uploading {UploadedCount}/{Count} files ({AddCount} added, {UpdateCount} updated) in {ElapsedMs}ms",
uploadedCount, uploadRequests.Count, addCount, updateCount, sw.ElapsedMilliseconds);
}
_ = uploadActivity?.SetTag("docs.sync.upload.duration_ms", sw.Elapsed.TotalMilliseconds);
}
private async Task Delete(SyncPlan plan, Cancel ctx)
{
var sw = Stopwatch.StartNew();
var deleteCount = 0;
var deleteRequests = plan.DeleteRequests;
var deleteConcurrency = GetConcurrency(DeleteConcurrencyEnvironmentVariable, DefaultDeleteConcurrency, MaxDeleteConcurrency);
// Always create activity span (even if 0 files) for consistent tracing
using var deleteActivity = ApplyStrategyActivitySource.StartActivity("delete files", ActivityKind.Client);
_ = deleteActivity?.SetTag("docs.sync.delete.count", deleteRequests.Count);
_ = deleteActivity?.SetTag("docs.sync.delete.concurrency", deleteConcurrency);
if (deleteRequests.Count > 0)
{
_logger.LogInformation("Starting to delete {Count} files from S3 bucket {BucketName} with concurrency {Concurrency}", deleteRequests.Count, bucketName, deleteConcurrency);
// Emit file-level metrics (low cardinality) and logs for each file
foreach (var delete in deleteRequests)
{
var extension = Path.GetExtension(delete.DestinationPath).ToLowerInvariant();
// Record by extension (low cardinality)
if (!string.IsNullOrEmpty(extension))
{
FilesByExtensionCounter.Add(1,
new("operation", "delete"),
new("extension", extension));
}
// Log individual file operations for detailed analysis
LogFileOperation(_logger, "delete", delete.DestinationPath, 0);
}
// Process deletes in batches of 1000 (AWS S3 limit)
await Parallel.ForEachAsync(deleteRequests.Chunk(1000), new ParallelOptions
{
CancellationToken = ctx,
MaxDegreeOfParallelism = deleteConcurrency
}, async (batch, token) =>
{
var deleteObjectsRequest = new DeleteObjectsRequest
{
BucketName = bucketName,
Objects = batch.Select(d => new KeyVersion
{
Key = d.DestinationPath
}).ToList()
};
try
{
var response = await s3Client.DeleteObjectsAsync(deleteObjectsRequest, token);
if (response.HttpStatusCode != System.Net.HttpStatusCode.OK)
{
_logger.LogError("Delete batch failed with status code {StatusCode}", response.HttpStatusCode);
if (response.DeleteErrors is null or { Count: 0 })
collector.EmitGlobalError($"Delete batch failed with status code {response.HttpStatusCode}");
foreach (var error in response.DeleteErrors ?? Enumerable.Empty<DeleteError>())
{
_logger.LogError("Failed to delete {Key}: {Message}", error.Key, error.Message);
collector.EmitError(error.Key, $"Failed to delete: {error.Message}");
}
}
else
{
var currentCount = Interlocked.Add(ref deleteCount, batch.Length);
_logger.LogInformation("Deleted {BatchCount} files ({CurrentCount}/{TotalCount})",
batch.Length, currentCount, deleteRequests.Count);
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Failed to delete batch from S3 bucket {BucketName}", bucketName);
foreach (var delete in batch)
collector.EmitError(delete.DestinationPath, "Failed to delete from S3", ex);
}
});
_logger.LogInformation("Finished deleting {DeletedCount}/{Count} files in {ElapsedMs}ms", deleteCount, deleteRequests.Count, sw.ElapsedMilliseconds);
}
_ = deleteActivity?.SetTag("docs.sync.delete.duration_ms", sw.Elapsed.TotalMilliseconds);
}
private static int GetConcurrency(string environmentVariable, int defaultValue, int maxValue)
{
var configured = Environment.GetEnvironmentVariable(environmentVariable);
return int.TryParse(configured, out var parsed) && parsed > 0
? Math.Min(parsed, maxValue)
: defaultValue;
}
}