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
129 changes: 1 addition & 128 deletions src/AppHost/AppHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,6 @@
//Solution Name : MyBlog
//Project Name : AppHost
//=======================================================
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;

using MongoDB.Bson;
using MongoDB.Driver;

var builder = DistributedApplication.CreateBuilder(args);

Expand All @@ -19,129 +14,7 @@
var mongoDb = mongo.AddDatabase("myblog");
var redis = builder.AddRedis("redis");

// AC2 (#249): Semaphore prevents overlapping clear runs. A second concurrent invocation
// returns immediately with operator feedback instead of racing against the first.
var clearMutex = new SemaphoreSlim(1, 1);

// Expose the destructive clear-data action only during local runs (IsRunMode = false when publishing).
if (builder.ExecutionContext.IsRunMode)
{
mongo.WithCommand(
"clear-myblog-data",
"⚠️ Clear MyBlog Data",
executeCommand: async context =>
{
// AC2: Non-blocking acquire — return immediately if another clear is already in flight.
if (!await clearMutex.WaitAsync(0))
{
context.Logger.LogWarning(
"Clear MyBlog data skipped on {ResourceName} — a clear operation is already in progress.",
context.ResourceName);

return new ExecuteCommandResult
{
Success = false,
Message = "A clear operation is already in progress. Wait for the current run to finish, then try again."
};
}

try
{
context.Logger.LogWarning(
"Clear MyBlog data invoked on {ResourceName} — enumerating collections in 'myblog'.",
context.ResourceName);

var connectionString = await mongo.Resource.ConnectionStringExpression.GetValueAsync(context.CancellationToken);
if (connectionString is null)
{
context.Logger.LogError("Could not resolve MongoDB connection string for resource {ResourceName}.", context.ResourceName);
return new ExecuteCommandResult
{
Success = false,
Message = "Could not resolve MongoDB connection string. Is the MongoDB resource running?"
};
}

var client = new MongoClient(connectionString);
var database = client.GetDatabase("myblog");

var namesCursor = await database.ListCollectionNamesAsync(cancellationToken: context.CancellationToken);
var collectionNames = await namesCursor.ToListAsync(context.CancellationToken);

var results = new List<(string Name, long Deleted)>();
var warnings = new List<string>();

foreach (var name in collectionNames)
{
// Skip MongoDB internal system collections (e.g. system.views, system.users).
if (name.StartsWith("system.", StringComparison.OrdinalIgnoreCase))
continue;

try
{
// AC3 (#249): Best-effort per collection — errors are caught, logged as warnings,
// and the loop continues so remaining collections are still processed.
var collection = database.GetCollection<BsonDocument>(name);
var deleteResult = await collection.DeleteManyAsync(
FilterDefinition<BsonDocument>.Empty,
context.CancellationToken);

results.Add((name, deleteResult.DeletedCount));

context.Logger.LogInformation(
"Collection '{Collection}': {Count} document(s) deleted.",
name, deleteResult.DeletedCount);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
var warning = $"{name}: {ex.Message}";
warnings.Add(warning);
context.Logger.LogWarning(
ex,
"Collection '{Collection}' could not be cleared — skipping and continuing.",
name);
}
}

var totalDeleted = results.Sum(static r => r.Deleted);
var perCollection = results.Count == 0
? "no non-system collections found"
: string.Join("; ", results.Select(static r => $"{r.Name}: {r.Deleted}"));

context.Logger.LogWarning(
"Clear MyBlog data complete: {Total} document(s) removed across {Count} collection(s). Warnings: {WarnCount}.",
totalDeleted, results.Count, warnings.Count);

var message = $"{results.Count} collection(s) cleared — {totalDeleted} total document(s) deleted. ({perCollection})";
if (warnings.Count > 0)
message += $" ⚠️ {warnings.Count} collection(s) had errors: {string.Join("; ", warnings)}";

return new ExecuteCommandResult
{
Success = true,
Message = message
};
}
finally
{
clearMutex.Release();
}
},
new CommandOptions
{
Description = "Permanently deletes all data from the myblog database. Local development only.",
ConfirmationMessage = "This will permanently delete ALL data from the myblog database and cannot be undone. Confirm?",
IsHighlighted = true,
IconName = "DatabaseWarning",
// AC1 (#249): Gates only on the MongoDB resource's own health — intentionally does NOT
// check dependent resources (Web, etc.). Clearing is valid while the app is live against
// local Mongo; the Web app running is not a reason to disable the command.
UpdateState = ctx =>
ctx.ResourceSnapshot.HealthStatus == HealthStatus.Healthy
? ResourceCommandState.Enabled
: ResourceCommandState.Disabled
});
}
mongo.WithMongoDbDevCommands("myblog");

builder.AddProject<Projects.Web>("web")
.WithReference(mongoDb)
Expand Down
154 changes: 154 additions & 0 deletions src/AppHost/MongoDbResourceBuilderExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
//=======================================================
//Copyright (c) 2026. All rights reserved.
//File Name : MongoDbResourceBuilderExtensions.cs
//Company : mpaulosky
//Author : Matthew Paulosky
//Solution Name : MyBlog
//Project Name : AppHost
//=======================================================

using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;

using MongoDB.Bson;
using MongoDB.Driver;

namespace Aspire.Hosting;

internal static class MongoDbResourceBuilderExtensions
{
// Shared semaphore — one per process; all commands share the same mutex.
private static readonly SemaphoreSlim _clearMutex = new(1, 1);

public static IResourceBuilder<MongoDBServerResource> WithMongoDbDevCommands(
this IResourceBuilder<MongoDBServerResource> builder,
string databaseName)
{
if (!builder.ApplicationBuilder.ExecutionContext.IsRunMode)
return builder;

builder.WithClearDatabaseCommand(databaseName);
return builder;
Comment on lines +20 to +31
}

private static void WithClearDatabaseCommand(
this IResourceBuilder<MongoDBServerResource> builder,
string databaseName)
{
builder.WithCommand(
"clear-myblog-data",
"⚠️ Clear MyBlog Data",
executeCommand: async context =>
{
// AC2: Non-blocking acquire — return immediately if another clear is already in flight.
Comment on lines +39 to +43
if (!await _clearMutex.WaitAsync(0))
{
context.Logger.LogWarning(
"Clear MyBlog data skipped on {ResourceName} — a clear operation is already in progress.",
context.ResourceName);

return new ExecuteCommandResult
{
Success = false,
Message = "A clear operation is already in progress. Wait for the current run to finish, then try again."
};
}

try
{
context.Logger.LogWarning(
"Clear MyBlog data invoked on {ResourceName} — enumerating collections in '{Database}'.",
context.ResourceName, databaseName);

var connectionString = await builder.Resource.ConnectionStringExpression.GetValueAsync(context.CancellationToken);
if (connectionString is null)
{
context.Logger.LogError("Could not resolve MongoDB connection string for resource {ResourceName}.", context.ResourceName);
return new ExecuteCommandResult
{
Success = false,
Message = "Could not resolve MongoDB connection string. Is the MongoDB resource running?"
};
}

var client = new MongoClient(connectionString);
var database = client.GetDatabase(databaseName);

var namesCursor = await database.ListCollectionNamesAsync(cancellationToken: context.CancellationToken);
var collectionNames = await namesCursor.ToListAsync(context.CancellationToken);

var results = new List<(string Name, long Deleted)>();
var warnings = new List<string>();

foreach (var name in collectionNames)
{
// Skip MongoDB internal system collections (e.g. system.views, system.users).
if (name.StartsWith("system.", StringComparison.OrdinalIgnoreCase))
continue;

try
{
// AC3 (#249): Best-effort per collection — errors are caught, logged as warnings,
// and the loop continues so remaining collections are still processed.
var collection = database.GetCollection<BsonDocument>(name);
var deleteResult = await collection.DeleteManyAsync(
FilterDefinition<BsonDocument>.Empty,
context.CancellationToken);

results.Add((name, deleteResult.DeletedCount));

context.Logger.LogInformation(
"Collection '{Collection}': {Count} document(s) deleted.",
name, deleteResult.DeletedCount);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
var warning = $"{name}: {ex.Message}";
warnings.Add(warning);
context.Logger.LogWarning(
ex,
"Collection '{Collection}' could not be cleared — skipping and continuing.",
name);
}
}

var totalDeleted = results.Sum(static r => r.Deleted);
var perCollection = results.Count == 0
? "no non-system collections found"
: string.Join("; ", results.Select(static r => $"{r.Name}: {r.Deleted}"));

context.Logger.LogWarning(
"Clear MyBlog data complete: {Total} document(s) removed across {Count} collection(s). Warnings: {WarnCount}.",
totalDeleted, results.Count, warnings.Count);

var message = $"{results.Count} collection(s) cleared — {totalDeleted} total document(s) deleted. ({perCollection})";
if (warnings.Count > 0)
message += $" ⚠️ {warnings.Count} collection(s) had errors: {string.Join("; ", warnings)}";

return new ExecuteCommandResult
{
Success = true,
Message = message
};
}
finally
{
_clearMutex.Release();
}
},
new CommandOptions
{
Description = "Permanently deletes all data from the myblog database. Local development only.",
ConfirmationMessage = "This will permanently delete ALL data from the myblog database and cannot be undone. Confirm?",
IsHighlighted = true,
IconName = "DatabaseWarning",
// AC1 (#249): Gates only on the MongoDB resource's own health — intentionally does NOT
// check dependent resources (Web, etc.). Clearing is valid while the app is live against
// local Mongo; the Web app running is not a reason to disable the command.
UpdateState = ctx =>
ctx.ResourceSnapshot.HealthStatus == HealthStatus.Healthy
? ResourceCommandState.Enabled
: ResourceCommandState.Disabled
});
}
}
Loading