invalidation_coordinator 0.1.0
invalidation_coordinator: ^0.1.0 copied to clipboard
Coalesces keyed invalidations into serialized reconciliation batches with trailing execution and graceful shutdown.
invalidation_coordinator #
A small, pure Dart coordinator for keyed refresh and reconciliation work. It collapses duplicate invalidations, runs one batch at a time, preserves work that arrives during execution, and shuts down gracefully.
The problem #
Several independent producers can discover that the same resource is stale:
Profile screen ───────┐
Push notification ────┼──→ refresh profile
Lifecycle event ──────┘
Starting a refresh for every signal wastes work and can introduce races.
InvalidationCoordinator<K> treats equal pending keys as one logical need to
reconcile current state:
profile, profile, inbox, profile → {profile, inbox}
This is not an event bus. It does not broadcast every signal. It coordinates the minimum keyed work required to make current state fresh again.
Quick start #
import 'package:invalidation_coordinator/invalidation_coordinator.dart';
enum Resource { profile, inbox, transactions }
final coordinator = InvalidationCoordinator<Resource>(
handler: (resources) async {
if (resources.contains(Resource.profile)) {
await refreshProfile();
}
if (resources.contains(Resource.inbox)) {
await refreshInbox();
}
if (resources.contains(Resource.transactions)) {
await refreshTransactions();
}
},
onError: (resources, error, stackTrace) {
reportRefreshFailure(resources, error, stackTrace);
},
);
coordinator.invalidate(Resource.profile);
coordinator.invalidateAll([
Resource.profile,
Resource.inbox,
]);
await coordinator.idle;
await coordinator.close();
The full runnable example demonstrates signals arriving during an active refresh in the package example.
Trailing invalidations #
The active snapshot never changes. If more invalidations arrive while its handler is running, they form one trailing pending batch:
running: {profile, inbox}
arrives: profile, transactions, profile
trailing: {profile, transactions}
After the active handler settles, the coordinator yields to the event queue and then runs the trailing snapshot. More keys accepted during that yield can join the same batch. There is never more than one handler invocation running at once.
The yield prevents a self-invalidation chain from monopolizing Dart's microtask queue. Exact timing, iteration order, and batch boundaries are intentionally not guaranteed.
Waiting for idle #
isIdle is false whenever work is pending, scheduled, running, or being
reported synchronously through onError.
coordinator.invalidate(Resource.profile);
await coordinator.idle;
idle completes at the next quiescent point, including all trailing work
accepted before that point. Handler failures do not fail this Future; they are
reported through onError.
An already completed idle Future remains completed if later work arrives.
Read idle again to observe that later work. Do not await coordinator.idle
from its own handler, because the handler must finish before idle can complete.
Graceful close #
close() synchronously stops admission and asynchronously drains everything
already accepted:
final closed = coordinator.close();
// Throws StateError, even for invalidateAll([]).
coordinator.invalidate(Resource.inbox);
await closed;
Repeated calls are safe and await the same shutdown. Active work is not
cancelled and no timeout is imposed. Failures are reported while draining, and
remaining accepted work continues. Do not await close() from the handler.
Error handling #
onError is required and synchronous. It receives the exact immutable snapshot
passed to the failed handler, plus the original error and stack trace:
onError: (keys, error, stackTrace) {
logger.error('Refresh failed for $keys', error, stackTrace);
},
Failed work is never retried automatically. If another equivalent invalidation was accepted while the failed attempt ran, that independent trailing work still runs. Retry limits, cooldowns, and reporting policy belong to the application.
The handler and onError execute in the Zone where the coordinator was
constructed. Handler-returned Futures must be able to deliver their completion
in that error zone. See
handler Futures and error zones
if your application deliberately moves Futures across distinct error zones.
If onError itself throws, the secondary error goes to the construction Zone's
uncaught-error channel. Coordinator cleanup is preserved if that Zone allows
the application to continue.
Keys and snapshots #
- Equal pending keys collapse according to
==andhashCode. - Keys must have stable, non-throwing equality and hash codes.
- Every handler receives a non-empty, detached, unmodifiable
Set<K>. - Key objects themselves are not frozen.
- Completed keys are not retained for historical deduplication.
- Include account, session, or tenant scope in the key when isolation matters.
When not to use this package #
Do not use invalidation_coordinator when:
- every event, count, or ordering position must be preserved;
- callers need a result for each individual signal;
- work must survive process or isolate termination;
- you need automatic retries, backoff, cancellation, throttling, or rate limits;
- different keys should execute concurrently;
- you need an event bus, message broker, persistent queue, or state-management framework;
- a single-flight cache of one request result is the real requirement.
The package is in-memory, isolate-local, framework-neutral, and has no runtime dependencies. It works in Dart and Flutter applications on all Dart-supported platforms.
License #
MIT