diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/deploy.sh b/samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/deploy.sh index b977363..da9454c 100755 --- a/samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/deploy.sh +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/deploy.sh @@ -101,6 +101,71 @@ else exit 1 fi +# Make sure the CosmosDB account runs the MongoDB server version of MONGODB_API_VERSION. The Azure CLI sends the +# --server-version of az cosmosdb create and az cosmosdb update as a ServerVersion property, which the service +# ignores, so the account keeps the default server version, 3.6, which the MongoDB driver of the web app refuses. +# The script sets the serverVersion property itself, with the API version the Azure CLI uses, and waits until the +# account reports the new server version, which takes about a minute. The MongoDB endpoint can go on serving the old +# version for a while after that: the web app starts without the database and connects once the endpoint switched. +echo "Getting [$COSMOSDB_ACCOUNT_NAME] CosmosDB account MongoDB server version in the [$RESOURCE_GROUP_NAME] resource group..." +SERVER_VERSION=$(az cosmosdb show \ + --name $COSMOSDB_ACCOUNT_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --query "apiProperties.serverVersion" \ + --output tsv \ + --only-show-errors) + +if [ -n "$SERVER_VERSION" ]; then + echo "MongoDB server version retrieved successfully: $SERVER_VERSION" +else + echo "Failed to retrieve MongoDB server version." + exit 1 +fi + +if [[ "$SERVER_VERSION" != "$MONGODB_API_VERSION" ]]; then + echo "Upgrading [$COSMOSDB_ACCOUNT_NAME] CosmosDB account to MongoDB server version [$MONGODB_API_VERSION]..." + az rest \ + --method patch \ + --url "${COSMOSDB_ACCOUNT_ID}?api-version=2026-03-15" \ + --body "{\"properties\": {\"apiProperties\": {\"serverVersion\": \"$MONGODB_API_VERSION\"}}}" \ + --only-show-errors 1>/dev/null + + if [ $? -ne 0 ]; then + echo "Failed to upgrade [$COSMOSDB_ACCOUNT_NAME] CosmosDB account to MongoDB server version [$MONGODB_API_VERSION]" + exit 1 + fi + + for ATTEMPT in $(seq 1 60); do + SERVER_VERSION=$(az cosmosdb show \ + --name $COSMOSDB_ACCOUNT_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --query "apiProperties.serverVersion" \ + --output tsv \ + --only-show-errors) + PROVISIONING_STATE=$(az cosmosdb show \ + --name $COSMOSDB_ACCOUNT_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --query "provisioningState" \ + --output tsv \ + --only-show-errors) + + if [[ "$SERVER_VERSION" == "$MONGODB_API_VERSION" && "$PROVISIONING_STATE" == "Succeeded" ]]; then + break + fi + echo "Waiting for the upgrade of [$COSMOSDB_ACCOUNT_NAME] CosmosDB account (attempt $ATTEMPT of 60)..." + sleep 10 + done + + if [[ "$SERVER_VERSION" == "$MONGODB_API_VERSION" && "$PROVISIONING_STATE" == "Succeeded" ]]; then + echo "[$COSMOSDB_ACCOUNT_NAME] CosmosDB account successfully upgraded to MongoDB server version [$MONGODB_API_VERSION]" + else + echo "Failed to upgrade [$COSMOSDB_ACCOUNT_NAME] CosmosDB account: it still runs MongoDB server version [$SERVER_VERSION]" + exit 1 + fi +else + echo "[$COSMOSDB_ACCOUNT_NAME] CosmosDB account already runs MongoDB server version [$MONGODB_API_VERSION]" +fi + # Retrieve document endpoint echo "Getting [$COSMOSDB_ACCOUNT_NAME] CosmosDB account document endpoint in the [$RESOURCE_GROUP_NAME] resource group..." DOCUMENT_ENDPOINT=$(az cosmosdb show \ diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Pages/Delete.cshtml.cs b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Pages/Delete.cshtml.cs index 84277d4..a3e0c33 100644 --- a/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Pages/Delete.cshtml.cs +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Pages/Delete.cshtml.cs @@ -13,9 +13,17 @@ public async Task OnPostAsync(string id, CancellationToken cancel { if (!string.IsNullOrWhiteSpace(id)) { - await store.DeleteAsync(id, cancellationToken); - logger.LogInformation("Activity deleted: {Id}", id); - TempData["Flash"] = "Activity deleted."; + try + { + await store.DeleteAsync(id, cancellationToken); + logger.LogInformation("Activity deleted: {Id}", id); + TempData["Flash"] = "Activity deleted."; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogError(ex, "Could not delete the activity"); + TempData["Flash"] = IndexModel.DatabaseUnavailable; + } } return RedirectToPage("/Index"); diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Pages/Index.cshtml.cs b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Pages/Index.cshtml.cs index 904f8c5..8d622b5 100644 --- a/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Pages/Index.cshtml.cs +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Pages/Index.cshtml.cs @@ -7,6 +7,9 @@ namespace VacationPlanner.Pages; public class IndexModel(IActivityStore store, ILogger logger) : PageModel { + /// Shown instead of an error page while the database is not reachable; the next request tries again. + internal const string DatabaseUnavailable = "The database is not available. Try again in a minute."; + public IReadOnlyList Activities { get; private set; } = []; /// Flash messages set by the previous request (the equivalent of Flask's flash()). @@ -20,7 +23,15 @@ public class IndexModel(IActivityStore store, ILogger logger) : Page public async Task OnGetAsync(CancellationToken cancellationToken) { - Activities = await store.ListAsync(cancellationToken); + try + { + Activities = await store.ListAsync(cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogError(ex, "Could not read the activities"); + TempData["Flash"] = DatabaseUnavailable; + } } public async Task OnPostAsync(CancellationToken cancellationToken) @@ -29,17 +40,25 @@ public async Task OnPostAsync(CancellationToken cancellationToken var id = RowId?.Trim(); if (!string.IsNullOrEmpty(text)) { - if (!string.IsNullOrEmpty(id)) + try { - await store.UpdateAsync(id, text, cancellationToken); - logger.LogInformation("Activity updated: {Id}", id); - TempData["Flash"] = "Activity updated!"; + if (!string.IsNullOrEmpty(id)) + { + await store.UpdateAsync(id, text, cancellationToken); + logger.LogInformation("Activity updated: {Id}", id); + TempData["Flash"] = "Activity updated!"; + } + else + { + await store.AddAsync(text, cancellationToken); + logger.LogInformation("Activity added: {Activity}", text); + TempData["Flash"] = "Activity added!"; + } } - else + catch (Exception ex) when (ex is not OperationCanceledException) { - await store.AddAsync(text, cancellationToken); - logger.LogInformation("Activity added: {Activity}", text); - TempData["Flash"] = "Activity added!"; + logger.LogError(ex, "Could not save the activity"); + TempData["Flash"] = DatabaseUnavailable; } } diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Services/StoreInitializer.cs b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Services/StoreInitializer.cs index c74eb50..fe97370 100644 --- a/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Services/StoreInitializer.cs +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Services/StoreInitializer.cs @@ -1,33 +1,40 @@ namespace VacationPlanner.Services; /// -/// Runs at startup with a bounded retry, so the app fails fast -/// (and the container exits) when the backing service never becomes reachable. +/// Runs in the background and retries it until it succeeds, so the app +/// starts even when the database is not reachable yet, for example while Azure Cosmos DB for MongoDB upgrades the +/// server version of the account. Until then, the pages show an error and /health answers 503. /// public sealed class StoreInitializer( IActivityStore store, ILogger logger, - int attempts = 1, - TimeSpan delay = default) : IHostedService + TimeSpan delay = default) : BackgroundService { - public async Task StartAsync(CancellationToken cancellationToken) + private readonly TimeSpan _delay = delay > TimeSpan.Zero ? delay : TimeSpan.FromSeconds(15); + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - for (var attempt = 1; ; attempt++) + try { - try - { - await store.InitializeAsync(cancellationToken); - logger.LogInformation("Activity store initialized after {Attempts} attempt(s).", attempt); - return; - } - catch (Exception ex) when (attempt < attempts && !cancellationToken.IsCancellationRequested) + for (var attempt = 1; !stoppingToken.IsCancellationRequested; attempt++) { - logger.LogWarning(ex, "Activity store not ready (attempt {Attempt}/{Attempts}); retrying in {Delay}s.", - attempt, attempts, delay.TotalSeconds); - await Task.Delay(delay, cancellationToken); + try + { + await store.InitializeAsync(stoppingToken); + logger.LogInformation("Activity store initialized after {Attempts} attempt(s).", attempt); + return; + } + catch (Exception ex) when (!stoppingToken.IsCancellationRequested) + { + logger.LogWarning(ex, "Activity store not ready (attempt {Attempt}); retrying in {Delay}s.", + attempt, _delay.TotalSeconds); + await Task.Delay(_delay, stoppingToken); + } } } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + // The app is stopping before the store was initialized: nothing left to do. + } } - - public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; } diff --git a/samples/web-app-cosmosdb-mongodb-api/python/scripts/deploy.sh b/samples/web-app-cosmosdb-mongodb-api/python/scripts/deploy.sh index e7ceb62..f57f77f 100755 --- a/samples/web-app-cosmosdb-mongodb-api/python/scripts/deploy.sh +++ b/samples/web-app-cosmosdb-mongodb-api/python/scripts/deploy.sh @@ -101,6 +101,71 @@ else exit 1 fi +# Make sure the CosmosDB account runs the MongoDB server version of MONGODB_API_VERSION. The Azure CLI sends the +# --server-version of az cosmosdb create and az cosmosdb update as a ServerVersion property, which the service +# ignores, so the account keeps the default server version, 3.6, which the MongoDB driver of the web app refuses. +# The script sets the serverVersion property itself, with the API version the Azure CLI uses, and waits until the +# account reports the new server version, which takes about a minute. The MongoDB endpoint can go on serving the old +# version for a while after that: the web app starts without the database and connects once the endpoint switched. +echo "Getting [$COSMOSDB_ACCOUNT_NAME] CosmosDB account MongoDB server version in the [$RESOURCE_GROUP_NAME] resource group..." +SERVER_VERSION=$(az cosmosdb show \ + --name $COSMOSDB_ACCOUNT_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --query "apiProperties.serverVersion" \ + --output tsv \ + --only-show-errors) + +if [ -n "$SERVER_VERSION" ]; then + echo "MongoDB server version retrieved successfully: $SERVER_VERSION" +else + echo "Failed to retrieve MongoDB server version." + exit 1 +fi + +if [[ "$SERVER_VERSION" != "$MONGODB_API_VERSION" ]]; then + echo "Upgrading [$COSMOSDB_ACCOUNT_NAME] CosmosDB account to MongoDB server version [$MONGODB_API_VERSION]..." + az rest \ + --method patch \ + --url "${COSMOSDB_ACCOUNT_ID}?api-version=2026-03-15" \ + --body "{\"properties\": {\"apiProperties\": {\"serverVersion\": \"$MONGODB_API_VERSION\"}}}" \ + --only-show-errors 1>/dev/null + + if [ $? -ne 0 ]; then + echo "Failed to upgrade [$COSMOSDB_ACCOUNT_NAME] CosmosDB account to MongoDB server version [$MONGODB_API_VERSION]" + exit 1 + fi + + for ATTEMPT in $(seq 1 60); do + SERVER_VERSION=$(az cosmosdb show \ + --name $COSMOSDB_ACCOUNT_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --query "apiProperties.serverVersion" \ + --output tsv \ + --only-show-errors) + PROVISIONING_STATE=$(az cosmosdb show \ + --name $COSMOSDB_ACCOUNT_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --query "provisioningState" \ + --output tsv \ + --only-show-errors) + + if [[ "$SERVER_VERSION" == "$MONGODB_API_VERSION" && "$PROVISIONING_STATE" == "Succeeded" ]]; then + break + fi + echo "Waiting for the upgrade of [$COSMOSDB_ACCOUNT_NAME] CosmosDB account (attempt $ATTEMPT of 60)..." + sleep 10 + done + + if [[ "$SERVER_VERSION" == "$MONGODB_API_VERSION" && "$PROVISIONING_STATE" == "Succeeded" ]]; then + echo "[$COSMOSDB_ACCOUNT_NAME] CosmosDB account successfully upgraded to MongoDB server version [$MONGODB_API_VERSION]" + else + echo "Failed to upgrade [$COSMOSDB_ACCOUNT_NAME] CosmosDB account: it still runs MongoDB server version [$SERVER_VERSION]" + exit 1 + fi +else + echo "[$COSMOSDB_ACCOUNT_NAME] CosmosDB account already runs MongoDB server version [$MONGODB_API_VERSION]" +fi + # Retrieve document endpoint echo "Getting [$COSMOSDB_ACCOUNT_NAME] CosmosDB account document endpoint in the [$RESOURCE_GROUP_NAME] resource group..." DOCUMENT_ENDPOINT=$(az cosmosdb show \ diff --git a/samples/web-app-cosmosdb-mongodb-api/python/src/app.py b/samples/web-app-cosmosdb-mongodb-api/python/src/app.py index aab8e20..ace8a1f 100644 --- a/samples/web-app-cosmosdb-mongodb-api/python/src/app.py +++ b/samples/web-app-cosmosdb-mongodb-api/python/src/app.py @@ -2,8 +2,10 @@ import os import datetime import logging +import threading from typing import List, Tuple from flask import Flask, flash, render_template, request, redirect, url_for +from pymongo.errors import PyMongoError from mongodb import MongoDbClient import hashlib @@ -28,12 +30,29 @@ logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) -# Global variables for MongoDB configuration -mongodb_client: MongoDbClient +# The MongoDB client is created by the first request that needs it, not when the module is loaded, so the web app +# starts even when the database is not reachable yet, for example while Azure Cosmos DB for MongoDB upgrades the +# server version of the account. A request that finds the database unavailable shows an error, and the next request +# tries again. +mongodb_client: MongoDbClient | None = None +mongodb_client_lock = threading.Lock() +DATABASE_UNAVAILABLE = "The database is not available. Try again in a minute." debug: bool = os.environ.get("FLASK_DEBUG", "false").lower() == "true" activities: List[Tuple[int, str]] = [] + +def get_mongodb_client() -> MongoDbClient | None: + """Return the MongoDB client, creating it on first use, or None while the database is not available.""" + global mongodb_client + with mongodb_client_lock: + if mongodb_client is None: + try: + mongodb_client = MongoDbClient.from_env() + except PyMongoError as e: + logger.error("The database is not available: %s", e) + return mongodb_client + def create_document(activity: str | None = None) -> dict: """Create a document with activity and timestamp.""" @@ -55,13 +74,19 @@ def create_document(activity: str | None = None) -> dict: def read_documents(username: str | None = None) -> List[dict]: """Read all documents from the MongoDB collection.""" documents = [] + activities.clear() + client = get_mongodb_client() + if client is None: + flash(DATABASE_UNAVAILABLE) + return documents try: - if mongodb_client: - activities.clear() - query = {'username': username} if username else {} - for document in mongodb_client.read_documents(query=query): - documents.append(document) - activities.append((document["_id"], document["activity"])) + query = {'username': username} if username else {} + for document in client.read_documents(query=query): + documents.append(document) + activities.append((document["_id"], document["activity"])) + except PyMongoError as e: + logger.error("Error reading documents: %s", e) + flash(DATABASE_UNAVAILABLE) except (ConnectionError, ValueError, KeyError) as e: logger.error("Error reading documents: %s", e) return documents @@ -71,7 +96,10 @@ def index(): """Handle the main page for viewing and adding activities.""" if request.method == 'POST': activity = request.form.get('activity') - if activity: + client = get_mongodb_client() if activity else None + if activity and client is None: + flash(DATABASE_UNAVAILABLE) + elif activity: try: row_id = request.form.get('row_id') if row_id: @@ -79,20 +107,23 @@ def index(): if not row_id.strip(): raise ValueError("Row ID cannot be None or empty") - updated_activity = mongodb_client.update_document_by_id(row_id, {"activity": activity}) + updated_activity = client.update_document_by_id(row_id, {"activity": activity}) if updated_activity: logger.info(f"Activity updated: {row_id}") flash("Activity updated!") else: # Create a document with the activity provided document: dict = create_document(activity) - mongodb_client.insert_document(document) + client.insert_document(document) # Append the activity to the activities list activities.append((document["_id"], activity)) logger.info(f"Activity added: {activity}") flash("Activity added!") + except PyMongoError as e: + logger.error("Error saving the activity: %s", e) + flash(DATABASE_UNAVAILABLE) except (ConnectionError, ValueError) as e: logger.error("Error creating document: %s", e) @@ -112,19 +143,28 @@ def favicon(): def delete(activity_id: int): """Handle deletion of an activity by its index.""" if 0 <= activity_id < len(activities): - # Delete the document from MongoDB - mongodb_client.delete_document_by_id(activities[activity_id][0]) - flash("Activity deleted.") + client = get_mongodb_client() + if client is None: + flash(DATABASE_UNAVAILABLE) + else: + try: + # Delete the document from MongoDB + client.delete_document_by_id(activities[activity_id][0]) + flash("Activity deleted.") + except PyMongoError as e: + logger.error("Error deleting the activity: %s", e) + flash(DATABASE_UNAVAILABLE) return redirect(url_for('index')) # Read debug environment variable debug = os.environ.get("DEBUG", "false").lower() == "true" -# Initialize the application and MongoDB client when the module is loaded. -# This ensures that the setup runs regardless of how the app is started -# (e.g., via 'flask run' or directly). -mongodb_client = MongoDbClient.from_env() +# Validate the configuration when the module is loaded, so that a misconfigured deployment fails at startup, +# regardless of how the app is started (e.g., via 'flask run' or directly). The database is reached later, by the +# first request that needs it (see get_mongodb_client). +if not (os.environ.get("COSMOSDB_CONNECTION_STRING") or os.environ.get("MONGODB_CONNECTION_STRING")): + raise ValueError("Missing required environment variable: COSMOSDB_CONNECTION_STRING or MONGODB_CONNECTION_STRING") # Get username from form or environment variable username = os.environ.get("LOGIN_NAME", "paolo") @@ -133,10 +173,6 @@ def delete(activity_id: int): if not username or not username.strip(): raise ValueError("Username cannot be None or empty") -if mongodb_client: - # Read documents from MongoDB to populate the activities list - read_documents(username) - # Run the Flask application if __name__ == '__main__': app.run(debug=debug) diff --git a/samples/web-app-cosmosdb-mongodb-api/python/src/mongodb.py b/samples/web-app-cosmosdb-mongodb-api/python/src/mongodb.py index 8919bf6..beb5264 100644 --- a/samples/web-app-cosmosdb-mongodb-api/python/src/mongodb.py +++ b/samples/web-app-cosmosdb-mongodb-api/python/src/mongodb.py @@ -78,11 +78,16 @@ def __init__(self, # Connect with connection string self._connect_with_connection_string(connection_string) - if database_name: - self.create_database_if_not_exists(database_name) - if collection_name: - self.create_collection_if_not_exists( - database_name, collection_name) + # Close the connection when the database is not usable, so that a caller that retries does not leak clients + try: + if database_name: + self.create_database_if_not_exists(database_name) + if collection_name: + self.create_collection_if_not_exists( + database_name, collection_name) + except Exception: + self.close() + raise def _connect_with_connection_string(self, connection_string: str): """Connect to MongoDB using connection string."""