Repository navigation
Deploys that explain themselves: one list of startup problems #386
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
Merged
hamzahalq
merged 10 commits into
releases/r10.0-develop
from
hamza/fix/self-safe-deploy
Oct 19, 2026
Merged
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
de2a6f4
feat: list every startup problem in one log entry
hamzahalq c099deb
feat: refuse to start when the encryption key can't read stored crede…
hamzahalq 5da9ba9
feat: refuse to start when the bus environment name changes
hamzahalq 726a7a0
feat: read queues left by versions before work groups without a setting
hamzahalq a0b2b56
feat: warn when a stored setting overrides configuration
hamzahalq 8da76ab
feat: check storage can be written and read back at startup
hamzahalq 6ce6f00
docs: upgrading no longer needs downtime
hamzahalq 5799705
fix: refuse to start when RabbitMQ can't tell the old environment name
hamzahalq 8f26ec0
fix: check every stored credential at startup, not the newest 100
hamzahalq 3b78c74
chore: reword a placeholder address and a docs sentence
hamzahalq 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
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
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,70 @@ | ||
| using System; | ||
| using System.Threading.Tasks; | ||
| using RabbitMQ.Client; | ||
| using RabbitMQ.Client.Exceptions; | ||
| using SW.Bus; | ||
|
|
||
| namespace SW.Bitween.Services; | ||
|
|
||
| /// <summary> | ||
| /// Bitween's own queues on RabbitMQ, looked at before the bus starts: whether one exists, what is in | ||
| /// it, and removing one nothing uses any more. The bus itself only ever declares, which would create | ||
| /// the very queue being asked about. | ||
| /// </summary> | ||
| public sealed class BrokerQueues : IAsyncDisposable | ||
| { | ||
| private readonly IConnection _connection; | ||
|
|
||
| private BrokerQueues(IConnection connection) => _connection = connection; | ||
|
|
||
| public static async Task<BrokerQueues> ConnectAsync(string connectionString) | ||
| { | ||
| var factory = new ConnectionFactory | ||
| { | ||
| Uri = new Uri(connectionString), | ||
| AutomaticRecoveryEnabled = false, | ||
| RequestedConnectionTimeout = TimeSpan.FromSeconds(15) | ||
| }; | ||
| return new BrokerQueues(await factory.CreateConnectionAsync("bitween-startup")); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// The name the bus gives a consumer's queue: <c>v3.{environment}.{queue prefix}.{consumer}.{message}</c>. | ||
| /// </summary> | ||
| public static string QueueName(string environment, string queuePrefix, string consumer, string message) => | ||
| $"{new BusOptions(environment).ProcessExchange}.{queuePrefix}.{consumer}.{message}".ToLowerInvariant(); | ||
|
|
||
| /// <returns>The queue's message and consumer counts, or null when there is no such queue.</returns> | ||
| public async Task<QueueDeclareOk> InspectAsync(string queue) | ||
| { | ||
| // Asking about a missing queue closes the channel it was asked on, so each question gets its own. | ||
| await using var channel = await _connection.CreateChannelAsync(); | ||
| try | ||
| { | ||
| return await channel.QueueDeclarePassiveAsync(queue); | ||
| } | ||
| catch (OperationInterruptedException ex) when (ex.ShutdownReason?.ReplyCode == 404) | ||
| { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| /// <summary>Deletes the queue only while it is empty and nobody consumes it.</summary> | ||
| /// <returns>Whether it was deleted.</returns> | ||
| public async Task<bool> DeleteIfIdleAsync(string queue) | ||
| { | ||
| await using var channel = await _connection.CreateChannelAsync(); | ||
| try | ||
| { | ||
| await channel.QueueDeleteAsync(queue, ifUnused: true, ifEmpty: true); | ||
| return true; | ||
| } | ||
| catch (OperationInterruptedException) | ||
| { | ||
| // Not empty, or consumed, by now: it waits for the next start. | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| public ValueTask DisposeAsync() => _connection.DisposeAsync(); | ||
| } |
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,125 @@ | ||
| using System; | ||
| using System.Linq; | ||
| using System.Threading.Tasks; | ||
| using Microsoft.EntityFrameworkCore; | ||
| using Microsoft.Extensions.Configuration; | ||
| using Microsoft.Extensions.DependencyInjection; | ||
| using Microsoft.Extensions.Hosting; | ||
| using Microsoft.Extensions.Logging; | ||
| using SW.Bitween.Domain; | ||
|
|
||
| namespace SW.Bitween.Services; | ||
|
|
||
| /// <summary> | ||
| /// The bus names its exchange and every queue after ASPNETCORE_ENVIRONMENT. A new name moves Bitween | ||
| /// to new queues while the other applications on the same RabbitMQ keep sending to the old ones, and | ||
| /// what was waiting in them stays there. So a name Bitween didn't run with before is refused until | ||
| /// it is confirmed. | ||
| /// </summary> | ||
| /// <remarks> | ||
| /// Not a hypothetical: the chart's default environment changed from Development to Production, so a | ||
| /// deployment that never chose one changed it on upgrade without anyone deciding to. | ||
| /// </remarks> | ||
| public static class BusEnvironment | ||
| { | ||
| /// <summary> | ||
| /// The name Bitween last ran with. A Settings row nobody edits and the settings page doesn't list, | ||
| /// as <see cref="SettingsService.LegacyDocumentPrefixKey"/>. | ||
| /// </summary> | ||
| public const string RecordedKey = "Bitween.BusEnvironment"; | ||
|
|
||
| public const string ConfirmSetting = "Bitween:ConfirmBusEnvironment"; | ||
|
|
||
| private static readonly string[] UsualNames = ["Development", "Staging", "Production"]; | ||
|
|
||
| public static IHost CheckBusEnvironment(this IHost host) | ||
| { | ||
| using var scope = host.Services.CreateScope(); | ||
| var services = scope.ServiceProvider; | ||
| var configuration = services.GetRequiredService<IConfiguration>(); | ||
| var current = services.GetRequiredService<IHostEnvironment>().EnvironmentName; | ||
| var queuePrefix = services.GetRequiredService<BitweenOptions>().QueuePrefix; | ||
|
|
||
| var problem = Check(services.GetRequiredService<BitweenDbContext>(), current, configuration[ConfirmSetting], | ||
| () => FormerName(configuration.GetConnectionString("RabbitMQ"), queuePrefix, current), | ||
| services.GetRequiredService<ILogger<BitweenDbContext>>()) | ||
| .GetAwaiter().GetResult(); | ||
| if (problem is not null) host.Services.GetRequiredService<StartupProblems>().Add(problem); | ||
| return host; | ||
| } | ||
|
|
||
| /// <param name="formerName"> | ||
| /// For a Bitween that ran before names were recorded: the name its queues were made under. | ||
| /// </param> | ||
| /// <returns>The startup problem, or null when this name may run.</returns> | ||
| public static async Task<string> Check(BitweenDbContext db, string current, string confirmed, | ||
| Func<Task<string>> formerName, ILogger logger) | ||
| { | ||
| var row = await db.Set<Setting>().SingleOrDefaultAsync(s => s.Id == RecordedKey); | ||
| var recorded = row?.Value; | ||
|
|
||
| // A new installation has nothing to strand. One already in use tells by its queues. | ||
| if (recorded is null && await db.Set<Subscription>().AnyAsync()) | ||
| { | ||
| try | ||
| { | ||
| recorded = await formerName(); | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| // Not left to a later start: by then the bus has made queues under the new name, and they | ||
| // would be all that start finds. Without the broker the bus can't run anyway. | ||
| logger.LogWarning(ex, "Could not reach RabbitMQ to tell which environment this Bitween ran as before."); | ||
| return "RabbitMQ could not be reached to tell which ASPNETCORE_ENVIRONMENT this Bitween's queues were " + | ||
| $"made under ({ex.GetBaseException().Message}). Check ConnectionStrings:RabbitMQ and that the " + | ||
| "broker is up."; | ||
| } | ||
| } | ||
|
|
||
| if (recorded is not null && !Same(recorded, current) && !Same(confirmed, current)) | ||
| return $"ASPNETCORE_ENVIRONMENT is {current}, but this Bitween's RabbitMQ queues are named after " + | ||
| $"{recorded}, which it ran with before. With the new name it would read from new queues, " + | ||
| "while other applications on this RabbitMQ keep sending to the old ones. If the change is a " + | ||
| $"mistake, set ASPNETCORE_ENVIRONMENT (the chart's global.environment) back to {recorded}. If " + | ||
| $"every application on this RabbitMQ moved to {current} on purpose, set " + | ||
| $"Bitween__ConfirmBusEnvironment={current}."; | ||
|
|
||
| if (Same(row?.Value, current)) return null; | ||
|
|
||
| if (row is null) db.Add(new Setting { Id = RecordedKey, Value = current }); | ||
| else row.Value = current; | ||
| try | ||
| { | ||
| await db.SaveChangesAsync(); | ||
| logger.LogInformation("Bitween's RabbitMQ queues are named after the environment {Environment}.", current); | ||
| } | ||
| catch (DbUpdateException ex) | ||
| { | ||
| // Another node starting at the same moment recorded it first, with the same name. | ||
| logger.LogWarning(ex, "The bus environment was recorded by another node."); | ||
| } | ||
|
|
||
| return null; | ||
| } | ||
|
|
||
| private static async Task<string> FormerName(string connectionString, string queuePrefix, string current) | ||
| { | ||
| await using var broker = await BrokerQueues.ConnectAsync(connectionString); | ||
| foreach (var name in UsualNames.Prepend(current).Distinct(StringComparer.OrdinalIgnoreCase)) | ||
| { | ||
| // The lane of exchanges without a work group, which every Bitween since work groups has; | ||
| // before them, the queue of exchanges from API calls. | ||
| if (await broker.InspectAsync(BrokerQueues.QueueName(name, queuePrefix, nameof(XchangeService), | ||
| WorkGroup.None.GetBusMessageName())) is not null || | ||
| await broker.InspectAsync(BrokerQueues.QueueName(name, queuePrefix, nameof(XchangeService), | ||
| nameof(ApiXchangeCreatedEvent))) is not null) | ||
| return name; | ||
| } | ||
|
|
||
| // Nothing under any of them, so nothing to leave behind. | ||
| return current; | ||
| } | ||
|
|
||
| // The bus lowercases the name in every exchange and queue. | ||
| private static bool Same(string a, string b) => string.Equals(a, b, StringComparison.OrdinalIgnoreCase); | ||
| } | ||
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
An unreachable broker on one start permanently disables the former-name detection.
The stranding check can be bypassed by one transient failure. The sequence is:
Checkreturnsnulland records nothing.currentname.recordedis stillnull.FormerNameprobescurrentfirst (UsualNames.Prepend(current), Line 105) and finds the queues created in step 3.FormerNamereturnscurrent. That value is recorded, and the old queues under the previous name are never reported.Trigger: a short RabbitMQ outage, or a 15 s connect timeout, during the first start after the upgrade. The chart's Development→Production default change happens on that same upgrade.
Consequence: exchanges in the old-name queues stay stranded with no startup problem reported. Preventing that outcome is the purpose of this check.
Refuse startup in this case instead of passing it. The bus cannot work without the broker anyway, so the refusal costs nothing on a real outage.
Proposed fix
catch (Exception ex) { - logger.LogWarning(ex, "Could not reach RabbitMQ to tell which environment this Bitween ran as " + - "before; it is checked on a later start."); - return null; + logger.LogWarning(ex, "Could not reach RabbitMQ to tell which environment this Bitween ran as before."); + return "RabbitMQ could not be reached to tell which ASPNETCORE_ENVIRONMENT this Bitween's queues " + + $"were made under ({ex.GetBaseException().Message}). Check ConnectionStrings:RabbitMQ and " + + "that the broker is up."; }Update
An_unreachable_broker_leaves_the_check_to_a_later_startto match this change.📝 Committable suggestion
🤖 Prompt for AI Agents