0% прочитано

Derived Counters Can Drift: Build a Repair Path

If usage_count can be fully rebuilt from canonical relationships, it is not the source of truth. Events can be missed, legacy code can leave drift, and migrations can bypass the normal flow, so derived counters need a safe rebuild path.

20 жовтня 2026 р. 10 хв читанняLaravel

A derived counter is convenient. Instead of running an expensive COUNT() for every query, we store a ready value such as usage_count and read it in O(1).

The problem begins when that number is gradually treated as the source of truth.

In my case, the media usage counter could be fully rebuilt from canonical usage relationships. That means usage_count is only a derived projection, and projections can drift.

If a value can be deterministically rebuilt from other data, I treat those other data as canonical and the counter as a cached result that needs a repair path.

First, Identify the Real Source of Truth

Relationships are canonical, the counter is derived
Canonical data media_usages  media_id  usage_context  usable_type  usable_id  Derived data media.usage_count

If usage_count disappears, the system can rebuild it by counting rows in media_usages.

If the usage relationships disappear and only the number remains, there is no way to reconstruct which records actually use the file.

That asymmetry makes the source of truth clear.

A Derived Counter Can Still Be Useful

The fact that a counter is not the source of truth does not mean it should be removed.

It may be useful for lists, filters, quick "is this file in use?" checks, sorting, or admin statistics.

But its role should be explicit: it is an optimized representation of canonical data.

Drift Does Not Come Only from Bugs

A derived value can diverge from reality for many reasons:

  • an event or listener did not run;
  • legacy code changed the relationship directly and skipped the counter update;
  • a migration or one-off script inserted canonical rows outside the normal flow;
  • the main transaction completed but an asynchronous counter update failed;
  • a manual database correction changed only one side;
  • new counter logic was introduced after old data already existed.

Events Maintain a Counter but Do Not Make It Infallible

The normal runtime flow
Canonical relation changes        |        vevent dispatched        |        vlistener recalculates counter        |        vmedia.usage_count updated

This flow is useful for keeping the counter close to real time.

But it cannot be the only correctness mechanism. If any step is skipped, the derived value remains wrong until something explicitly rebuilds it.

Blind Increment and Decrement Accumulate Errors

Fast, but depends on perfect event history
$media->increment('usage_count');

Increment/decrement works only when every canonical relationship change is guaranteed to produce exactly one matching counter change.

One missed increment creates drift. One duplicated event can create drift in the opposite direction.

For an important derived counter, I prefer "recalculate the current value" over "trust the complete history of previous increments."

Recalculating One Record Should Be Simple and Idempotent

The result depends only on current canonical state
final class RecalculateMediaUsageCount{    public function handle(        Media $media,    ): void {        $count = DB::table('media_usages')            ->where(                'media_id',                $media->id,            )            ->count();         $media->update([            'usage_count' => $count,        ]);    }

This recalculation is idempotent: it can run once, twice, or ten times, and with unchanged canonical data the result is the same.

That is an important property for a repair operation.

The Full Data Set Needs a Dedicated Repair Command

The repair path processes the full data set without loading everything into memory
final class RepairMediaUsageCounters extends Command{    protected $signature =        'media:repair-usage-counters';     public function handle(        RecalculateMediaUsageCount $recalculate,    ): int {        Media::query()            ->select([                'id',                'usage_count',            ])            ->chunkById(                500,                function ($mediaItems) use (

A Repair Command Is More Useful When It Measures Drift First

Before repairing, the command can show the size of the drift
$actual = DB::table('media_usages')    ->where(        'media_id',        $media->id,    )    ->count(); if (    $media->usage_count    !== $actual) {    $this->line(        sprintf(            'media=%d stored=%d actual=%d',            $media->id,            $media->usage_count,

I prefer repair tools that do more than silently fix data. They should also show what was wrong.

That makes it easier to see whether the problem was one legacy record, a systematic bug, or the result of a particular migration.

For Production Repairs I Add a Dry-Run Mode

See the impact first, then change data
$ php artisan media:repair-usage-counters --dry-run Scanned: 1240Correct: 1227Drifted: 13Would update: 13

A dry run is especially useful the first time a repair path is introduced.

It lets us measure drift without changing production data and investigate suspicious records before writing anything.

A Repair Must Not Trust the Existing Derived Value

A repair rebuilds state from canonical data
// Wrong for repair:$media->usage_count += $delta; // Better for repair:$media->usage_count =    $canonicalUsageCount;

If drift is already suspected, the old counter cannot be trusted even as a starting point.

A repair should be a function of canonical state, not an adjustment to accumulated error.

A Full Repair Does Not Necessarily Need One Huge Transaction

For thousands of rows, one long transaction may create unnecessary locks and make the repair more expensive than needed.

If each counter can be independently rebuilt from canonical relationships, records can be repaired in smaller batches.

Idempotency makes it safe to rerun the command if it stops halfway through.

A Repair Path Should Consider Concurrent Writes

If production is actively creating and deleting usage relationships during repair, state may change between COUNT() and UPDATE.

For a non-critical cached counter, that may be acceptable, especially if the normal runtime flow will reconcile the next change.

If stronger guarantees are required, locking, a maintenance window, or a more atomic SQL rebuild should be considered explicitly.

A New Counter Migration Should Include a Backfill

Another practical lesson is that adding a usage_count column with a default of 0 and wiring new events is not enough.

The new code may correctly maintain future changes while all historical records remain wrong.

For a derived field, I think about three things together: schema, runtime maintenance, and initial backfill.

The Repair Path Can Double as a Consistency Check

Once a rebuild mechanism exists, it can be useful before an incident too.

For example, a scheduled dry run can periodically count drifted records and report when the number becomes non-zero.

The repair path then also becomes a way to verify that runtime maintenance is actually working.

What I Check for Every Derived Counter

  • I know exactly which data are canonical.
  • The counter can be fully rebuilt without using its previous value.
  • The runtime flow maintains it after create, update, and delete.
  • A new counter column has a backfill for historical data.
  • There is an idempotent repair operation for one record.
  • There is a full-data-set command with chunking.
  • There is a dry run or at least a drift report.
  • Rerunning the repair is safe.
  • Concurrency expectations are explicit.

A Derived Counter Without a Repair Path Is Hidden Risk

The main shift for me is simple: a counter does not become true just because it is stored in a database column.

If its value is derived from other records, it remains derived. Events and listeners help keep it current, but they do not eliminate drift.

That is why every important derived counter should come with a rebuild formula, an idempotent recalculation operation, and a command that can reconstruct it from canonical data.

A derived counter is a read optimization, not an archive of truth. If it cannot be safely rebuilt, the system trusts derived state too much.