5. Execution Flow
5.1 Fire-and-forget flow
The framework supports this structurally
(IBackgroundJobScheduler.EnqueueAsync → IHangfireBackgroundJobAdapter.EnqueueAsync →
IBackgroundJobClient.Enqueue), but no currently-migrated production job uses it — all six migrated
jobs are recurring. The legacy Product Import fire-and-forget flow
(ProductService.StartImportBackgroundAsync → IBackgroundJobService.Enqueue<IProductImportBackgroundService>)
still runs entirely on the pre-framework Hangfire filter-chain path and has not been migrated — see
§14.5.
5.2 Recurring job flow — the only flow currently in production for this framework
Registered via a direct, imperative Hangfire.RecurringJob.AddOrUpdate<TPipelineJob>(recurringId, job => job.RunAsync(ct), cron, ...) call in the ERP host's ApplicationBuilderExtensions.cs — not via
IBackgroundJobScheduler. This is worth being explicit about: the scheduler abstraction exists and is
registered in DI, but every one of the six migrated jobs' recurring registration still uses raw Hangfire
directly, pointed at the new *PipelineJob bridge class instead of the old worker class. See
§10 — Scheduler for why.
5.3 Failure flow
A business exception thrown inside the real worker service propagates unmodified through *JobAdapter.RunAsync
→ BackgroundJobExecutor.ExecuteAsync (no catch there) → the pipeline's catch (Exception ex) classifies it
(discarded — see AP-02) → re-throws → back up through
*PipelineJob.RunAsync → Hangfire sees the exception and applies its own default AutomaticRetry.
One documented, deliberate exception to this shape: CampaignWorkflowExecutorService (Notification
Framework Core) catches per-execution exceptions internally — logging and continuing its own loop — so
that a single bad campaign execution never fails the whole Hangfire job. The CampaignWorkflowExecutorPipelineJob
bridge only ever sees an exception escape if the service's own outer call (e.g. GetPendingAsync) fails, not
from a per-row dispatch failure inside the loop.
5.4 Cancellation flow
The pipeline has a dedicated catch (OperationCanceledException) { throw; } fast path — the failure
classifier is deliberately not invoked for cancellation, so cancelling a job never consumes retry budget.
Verified behavior: a pre-cancelled token throws before the job ever runs (and if a lock had been requested,
it is never acquired); cancellation raised mid-execution propagates the same way, and if a lock was held,
it is still released correctly (via await using on the lock handle).
5.5 Exception flow
There is no flow distinct from Failure — the pipeline's exception handling is the failure-classification/ retry-signal mechanism. The only real branch at the pipeline's catch boundary is cancellation vs. everything else.
5.6 Retry flow
Entirely Hangfire's own, unmodified AutomaticRetry behavior — there is no custom retry engine anywhere in
either the framework or the ERP host's integration of it. The framework's only contribution is the
(currently unconsumed) BackgroundJobFailureCategory classification and the RetryRequired/
PermanentFailure result factories, both of which exist as forward-looking hooks with no current caller
outside unit tests. Each retry is Hangfire re-invoking the job method from scratch: a fresh DI scope, a fresh
BackgroundJobExecutionContext, a fresh job adapter and executor — no cross-attempt state persists inside
the framework between retries. No job in the six migrations configures an explicit [AutomaticRetry(...)]
policy — this is a documented, open gap (AP-04), not a considered choice per job.
5.7 What's shared across every flow, and what differs
Shared structural guarantees: never swallow an exception the caller needs to see; always classify before
re-throwing (except cancellation, which skips classification entirely); always start a
BackgroundJobExecutionLifetime timer (currently write-only — nothing consumes its Elapsed value yet).
What differs between flows: only the exception type encountered at the pipeline's catch boundary, and
whether the specific *PipelineJob chose to manually compose tenant-context resolution or a distributed
lock around its executor call — which, as of today, none of the six production jobs do.