Bragi Docs Help

Waiting for a Job: a worked example

A common integration is "reload the data, then use it": trigger a Bragi Job, wait for it to finish, then carry on against the freshly loaded warehouse. Job Callbacks describes the mechanism; this is one way to build the calling side, in C# on ASP.NET Core.

There are three pieces:

  1. A registry of requests currently waiting for a run to finish

  2. An endpoint Bragi posts the outcome to, which releases the matching wait

  3. The caller, which registers a wait, triggers the Job and awaits the result

The payload

Bragi posts the JSON described in Job Callbacks. Model whichever fields you need:

public class JobCallbackPayload { public string JobName { get; set; } = string.Empty; public int JobInstanceId { get; set; } public string Status { get; set; } = string.Empty; public bool Success { get; set; } public DateTime? Finished { get; set; } public long? DurationMilliseconds { get; set; } public string[] FailedTasks { get; set; } = []; public string? CallerReference { get; set; } }

Bragi serialises camelCase, which is the default for ASP.NET Core, so no configuration is needed.

The registry

The callback arrives on a different request to the one waiting, so the two need somewhere to meet. CallerReference is what ties them together: a value the caller invents, hands to Bragi, and gets back on the callback.

public class JobCallbackRegistry { private readonly ConcurrentDictionary<string, TaskCompletionSource<JobCallbackPayload>> _waits = new(); public (string reference, Task<JobCallbackPayload> wait) Register() { var reference = Guid.NewGuid().ToString("N"); // Continuations must not run on the callback request's thread, otherwise the caller's work // happens inside Bragi's HTTP request and holds it open var completion = new TaskCompletionSource<JobCallbackPayload>( TaskCreationOptions.RunContinuationsAsynchronously); _waits[reference] = completion; return (reference, completion.Task); } public bool TryComplete(string? reference, JobCallbackPayload payload) { if (string.IsNullOrWhiteSpace(reference) || !_waits.TryGetValue(reference, out var completion)) { return false; } return completion.TrySetResult(payload); } public void Release(string reference) { if (!string.IsNullOrWhiteSpace(reference)) { _waits.TryRemove(reference, out _); } } }

Register it as a singleton.

The endpoint

app.MapPost("/api/bragi/job-callback", (JobCallbackPayload payload, JobCallbackRegistry registry) => { if (!registry.TryComplete(payload.CallerReference, payload)) { // Usually means the waiting request already gave up. Retrying would not help, so still // answer 200 rather than making Bragi burn its remaining attempts. logger.LogWarning("Bragi callback for run {RunId} had nothing waiting on it", payload.JobInstanceId); } return Results.Ok(); }) .AllowAnonymous();

Two things to be deliberate about:

  • Answer 200 either way. An unknown reference is not a delivery failure, and returning an error only makes Bragi retry something that cannot succeed.

  • The endpoint is anonymous. Bragi sends no credentials, so the caller reference stands in for them: it is a GUID minted per wait, is only valid while that wait is outstanding, and the most an unauthenticated caller could do with a guessed value is release one wait early. If your application requires authentication by default, this route needs an explicit exemption, and behind IIS it also needs Anonymous Authentication enabled for the path.

The caller

public async Task<bool> ReloadAndWait(string environment, string jobName, CancellationToken ct) { var (reference, wait) = _registry.Register(); try { var triggered = await _bragi.RunJobNow(environment, jobName, _callbackUrl, reference); if (!triggered.Success) { _logger.LogError("Bragi refused to run '{JobName}': {Message}", jobName, triggered.Message); return false; } JobCallbackPayload payload; try { payload = await wait.WaitAsync(_timeout, ct); } catch (TimeoutException) { _logger.LogError("Bragi job '{JobName}' did not report back within {Timeout}", jobName, _timeout); return false; } if (!payload.Success) { _logger.LogError("Bragi job '{JobName}' finished as {Status}. Failed tasks: {FailedTasks}", jobName, payload.Status, string.Join(", ", payload.FailedTasks)); return false; } return true; } finally { // Whatever happened, stop waiting, so a late callback finds nothing and the entry does not leak _registry.Release(reference); } }

RunJobNow here is a thin wrapper over the API call described in Bragi API, passing callbackUrl and callbackReference as query string parameters.

Points worth getting right

Always release the wait. The finally matters. Without it a timed out request leaves an entry in the dictionary forever, and a long running application slowly fills memory with waits nobody is on.

Choose a timeout you can defend. It has to exceed the Job's realistic worst case, or a slow night looks like a failure. Decide deliberately what happens when it expires: carrying on against data that may be half loaded is sometimes worse than returning nothing.

A success response does not always mean a new run. If the Job is already queued, Bragi attaches your callback to the queued run. If it has already started, your callback waits for the next run, which Bragi queues once the current one finishes. Either way the message says which, and the run your callback reports on always started after your call, so data you wrote beforehand is included. Allow for that in your timeout: the wait can cover the run in progress as well as your own. See Job Callbacks for the full set of outcomes.

Consider a no-callback fallback. If the calling system may be deployed against a Bragi that cannot reach it, make the callback URL configuration optional: with it set, wait; without it, trigger and carry on, exactly as a caller behaved before callbacks existed. Send no callback parameters at all in that mode so the request is identical to the old one.

02 October 2026