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
378 changes: 244 additions & 134 deletions src/AppHost/MongoDbResourceBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,138 +17,248 @@ 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;
}

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.
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
});
}
// Shared semaphore — one per process; all commands share the same mutex.
private static readonly SemaphoreSlim _clearMutex = new(1, 1);

Comment on lines +20 to +22
public static IResourceBuilder<MongoDBServerResource> WithMongoDbDevCommands(
this IResourceBuilder<MongoDBServerResource> builder,
string databaseName)
{
if (!builder.ApplicationBuilder.ExecutionContext.IsRunMode)
return builder;

builder.WithClearDatabaseCommand(databaseName);
builder.WithSeedDataCommand(databaseName);
return builder;
}

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.
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
});
}

private static void WithSeedDataCommand(
this IResourceBuilder<MongoDBServerResource> builder,
string databaseName)
{
builder.WithCommand(
"seed-myblog-data",
"🌱 Seed MyBlog Data",
executeCommand: async context =>
{
if (!await _clearMutex.WaitAsync(0))
{
context.Logger.LogWarning(
"Seed MyBlog data skipped on {ResourceName} — a database operation is already in progress.",
context.ResourceName);

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

try
{
context.Logger.LogInformation(
"Seed MyBlog data invoked on {ResourceName} — inserting seed data into '{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 collection = database.GetCollection<BsonDocument>("blogposts");

var now = DateTime.UtcNow;
var seedDocuments = new BsonDocument[]
{
new()
{
["_id"] = new BsonBinaryData(Guid.NewGuid(), GuidRepresentation.Standard),
["Title"] = "Welcome to MyBlog",
["Content"] = "This is the first post on MyBlog. Welcome!",
["Author"] = "Matthew Paulosky",
["CreatedAt"] = now,
["UpdatedAt"] = now,
["IsPublished"] = true,
["Version"] = 1,
},
new()
{
["_id"] = new BsonBinaryData(Guid.NewGuid(), GuidRepresentation.Standard),
["Title"] = "Getting Started with .NET Aspire",
["Content"] = "Learn how to build cloud-native apps with .NET Aspire.",
["Author"] = "Matthew Paulosky",
["CreatedAt"] = now,
["UpdatedAt"] = now,
["IsPublished"] = true,
["Version"] = 1,
},
new()
{
["_id"] = new BsonBinaryData(Guid.NewGuid(), GuidRepresentation.Standard),
["Title"] = "Draft: MongoDB Performance Tips",
["Content"] = "Work in progress — tips for optimising MongoDB queries.",
["Author"] = "Matthew Paulosky",
["CreatedAt"] = now,
["UpdatedAt"] = now,
["IsPublished"] = false,
["Version"] = 1,
},
};

await collection.InsertManyAsync(seedDocuments, cancellationToken: context.CancellationToken);

context.Logger.LogInformation(
"Seed MyBlog data complete: {Count} blog post(s) inserted.",
seedDocuments.Length);

return new ExecuteCommandResult
{
Success = true,
Message = $"blogposts: {seedDocuments.Length} inserted (2 published, 1 draft)"
};
}
finally
{
_clearMutex.Release();
}
},
new CommandOptions
{
Description = "Inserts seed blog posts into the myblog database. Local development only.",
IconName = "DatabaseArrowUp",
UpdateState = ctx =>
ctx.ResourceSnapshot.HealthStatus == HealthStatus.Healthy
? ResourceCommandState.Enabled
: ResourceCommandState.Disabled
});
Comment on lines +254 to +262
}
}
Loading
Loading