Skip to content

Inactive-Connection Data-Flow Gating

This page explains why every data-flow entry point in Conflux re-checks whether a SystemConnection is still active before doing any work, how the two mechanisms (entry-point guards and transition-driven batch cancellation) cooperate, and what trade-offs were made. For the status values themselves, see System Connections. For how bulk sync batches are structured, see Sync Batches.

The Problem

A SystemConnection can become inactive at any time — a merchant disconnects their shop, a plugin is uninstalled, or an operator manually changes the status in the admin panel. When that happens, jobs for that connection may already be queued or running. Without active guards, those jobs would keep reading from and writing to the database on behalf of a connection the merchant has explicitly ended.

Three data-flow paths are exposed to this race:

  1. Background sync jobs — BulkSyncJob, FetchBulkPageRangeJob, PersistBulkDataJob, and FetchDeletionLogJob are dispatched to the queue and may sit there for seconds or minutes before a worker picks them up.
  2. Webhook processing — ProcessWebhookJob is enqueued when a webhook arrives and processed asynchronously.
  3. Widget API calls — the connector widget makes synchronous HTTP requests that are authenticated by a per-connection access token.

Each path needs its own guard because they reach the application through different layers.

isHealthy(): the Single Source of Truth

All guards share a single predicate: SystemConnection::isHealthy(). It returns true only when the status is Active: it delegates to the state's allowsDataFlow(), which only Active answers with true. Any other status (Inactive, Disconnected, Uninstalled, Error, Pending) returns false.

Centralising the check in one method means callers never reason about individual status values. If the definition of "healthy" widens or narrows in the future (for example, if a grace-period reconnect were ever allowed to keep webhooks alive), the change happens in one place.

Entry-Point Guards

BulkSyncJob

BulkSyncJob::handle() re-fetches the connection from the database before doing any work:

$syncConnection = $this->syncConnection->fresh();

if ($syncConnection === null || ! $syncConnection->isHealthy()) {
    return;
}

The job was serialised with the connection model when it was dispatched. By the time a worker picks it up, the connection's status may have changed. The re-fetch (fresh()) ensures the guard sees the current state, not the snapshot from dispatch time. If the connection is gone or unhealthy, the job returns silently — no exception, no retry.

The same job stores the Laravel Bus batch ID on the application-level SyncBatch record immediately after dispatching the Bus batch:

SyncBatch::where('id', $syncBatchId)->update(['bus_batch_id' => $busBatch->id]);

This linkage is what makes transition-driven cancellation possible (see below).

FetchBulkPageRangeJob and PersistBulkDataJob

Both of these jobs run inside the Laravel Bus batch created by BulkSyncJob. They apply the same re-fetch-and-check pattern, but instead of returning silently they cancel the Bus batch:

if ($connection === null || ! $connection->isHealthy()) {
    $this->batch()?->cancel();
    return;
}

Cancelling the batch signals every other job still queued in the batch to abort (they each check $this->batch()?->cancelled() at the top of handle()). The Bus batch's then() callback detects cancellation and marks the SyncBatch as Failed rather than dispatching a deletion log fetch, so no reconciliation is triggered for a partial run:

->then(static function (Batch $batch) use ($syncBatchId, $modifiedAfter): void {
    if ($batch->cancelled()) {
        SyncBatch::where('id', $syncBatchId)->update(['status' => SyncBatchStatus::Failed]);
        return;
    }
    // ... leave the batch InProgress and dispatch FetchDeletionLogJob
})

The difference from BulkSyncJob's guard is deliberate: BulkSyncJob acts before any Bus batch exists, so it can just return. By the time fetch or persist jobs run, a Bus batch is live with many sibling jobs; returning silently from one job would leave the rest running. Cancelling the batch is the only way to stop the whole run.

FetchDeletionLogJob

FetchDeletionLogJob also re-fetches the connection and returns early if it is unhealthy:

$connection = $syncBatch->systemConnection;

if ($connection === null || ! $connection->isHealthy()) {
    return;
}

The job soft-deletes records the source platform reports as deleted since the batch's SyncBatch was created (see Sync Batches). If a connection went inactive after the bulk sync finished but before this job ran, those soft-deletes would affect data owned by a connection that is no longer active — a pointless write that could also interfere with a later reconnect that re-imports the same records. The guard prevents that. It also means the batch is left InProgress rather than transitioning to Completed — the same outcome as when systemConnection is null, since neither case has a deletion sweep to report as done.

ProcessWebhookJob

Webhook jobs are processed asynchronously. ProcessWebhookJob looks up the connection by its ID at run time:

$connection = SystemConnection::find($webhookCall->connection_id);

if ($connection === null || ! $connection->isHealthy()) {
    Log::warning('Discarding webhook: connection is no longer active.', [...]);
    return;
}

Unlike bulk sync jobs, there is no Bus batch to cancel. The job simply discards the webhook and logs a warning. The webhook payload is not retried — the assumption is that a webhook arriving for an inactive connection is data the merchant has chosen to stop syncing.

AuthenticateWidgetToken and EnsureWidgetConnectionIsActive middleware

Widget API calls are synchronous HTTP requests, gated by a chain of middlewares. AuthenticateWidgetToken validates the bearer token and resolves the connection; EnsureWidgetConnectionIsActive then checks it:

abort_unless($connection->isHealthy(), 403, 'Connection is no longer active.');

AuthenticateWidgetToken alone, with no status check, also guards the plugin's disconnect and uninstall signals — those must stay reachable regardless of status, since they report an event rather than serve data. Only the five routes under the widget prefix (/widget/configuration, /widget/sync-batches, /widget/connections/{connection}/status, /widget/external-credentials) carry EnsureWidgetConnectionIsActive on top. See Connection Flow — Why the Widget's Lifecycle Endpoints Are Looser Than Everything Else and Connection Widget — Token authentication for the full middleware chain, including EnsureWidgetTokenIsAuthorizedForRoute, which sits between these two and is unrelated to connection status.

A 403 is returned rather than a 401 because the token itself is valid — the connection it belongs to is simply no longer active. This distinction lets a widget client distinguish "bad token" (401) from "connection ended" (403) and surface an appropriate message.

Transition-Driven Batch Cancellation

Entry-point guards handle the case where a job runs after a connection goes inactive. But a bulk sync can also be in the middle of processing when the connection status changes — dozens of fetch and persist jobs may already be running concurrently. Waiting for each of them to pick up the connection's new status individually would let the run continue for an arbitrarily long time.

The status transitions address this directly. Every transition that takes a connection off Active calls cancelInProgressBatches() from its side effects, a helper of the CancelsInProgressBatches trait in app/States/SystemConnection/Transitions/Concerns/. It cancels every in-progress Bus batch linked to the connection:

SyncBatch::where('system_connection_id', $this->connection->id)
    ->where('status', SyncBatchStatus::InProgress)
    ->whereNotNull('bus_batch_id')
    ->get()
    ->each(static function (SyncBatch $syncBatch): void {
        Bus::findBatch($syncBatch->bus_batch_id)?->cancel();
    });

This runs in Deactivate, Disconnect, RecordError and Uninstall, the transitions to Inactive, Disconnected, Error and Uninstalled, because none of those states should allow further data processing. No transition leads to Pending. The transitions to Active (Activate, Reconnect, Reactivate) run sync rule generation instead.

Bus::findBatch($id)?->cancel() marks the batch as cancelled in Laravel's batch tracking table. Workers checking $this->batch()?->cancelled() at the start of each job will then exit immediately without processing. The then() callback interprets a cancelled batch as failure and marks the SyncBatch as Failed.

Why Both Mechanisms Are Needed

The transition cancels the Bus batch synchronously, as part of the status change, which tells the queue infrastructure to stop. But jobs that are already mid-execution, past their cancelled-check at the top, will finish their current unit of work. The entry-point guards inside those jobs are a second line of defence: if a job started running before the transition ran, it will still find the connection unhealthy when it does its own re-fetch.

The two mechanisms are complementary, not redundant:

  • The transition stops the flood of queued jobs as quickly as possible by cancelling the batch the moment status changes.
  • The entry-point guards catch jobs that slipped past the cancellation because they were already running, and prevent any further jobs from doing work for an unhealthy connection even without the cancellation.

What Is Not Stopped

The guard design stops new writes for an inactive connection, but it does not undo work already completed. Sync runs that finished before the status change are not rolled back, and records already persisted to the database remain. If a connection is later reconnected and made Active again, the next sync will update or overwrite those records normally.

Webhooks received before the status change but not yet processed are discarded when their queued job eventually runs. The webhook payload is not replayed on reconnect.

See also

  • System Connections — status values, isHealthy() method, scopes
  • Sync Batches — how SyncBatch tracks progress across a bulk or delta sync
  • Delta Sync — how the modifiedAfter cursor interacts with failed batches
  • ADR-0013 — why disconnect and uninstall are distinct states with different retention consequences