# Event Granularity

## Why granular events?

A single user action in Apiera, such as saving a product with new attributes, tags, and images, can produce many
webhook events:


```
product.updated
product.attribute.linked
product.attribute.linked
product.attribute_term.linked
product.attribute_term.linked
product.tag.linked
product.asset.linked
product.asset.linked
product.input_value.created
product.input_value.created
```

This is intentional. Each event tells you exactly what changed, enabling precise integrations:

- **Selective subscriptions** - A search indexer only needs `product.published` and `product.unpublished`. An image
pipeline only needs `product.asset.linked`. Subscribe to exactly what you need.
- **Clear intent** - `product.attribute_term.linked` is unambiguous. A generic `product.updated` tells you nothing
about what actually changed.
- **Efficient routing** - Route events directly to the handler that cares, without parsing or diffing.


## The challenge

Granular events create a burst problem. If saving a product fires 10 events and your integration re-syncs the product
for each one, you perform 10 redundant syncs. The product hasn't changed between events. They all originate from the
same operation.

## The solution: fetch-on-signal with coalescing

The recommended consumer pattern treats webhook events as **signals**, not data. Each event says "something about this
entity changed" and your integration responds by fetching current state once, not once per event.

**Coalescing** collapses a burst of events for the same entity into a single API fetch.

Say five events arrive in quick succession for the same product:

1. `product.updated`
2. `product.attribute.linked`
3. `product.attribute.linked`
4. `product.tag.linked`
5. `product.asset.linked`


Instead of syncing five times, the coalescing scheduler waits for the burst to settle (debounce), then performs a
single `GET /v1/products/{uuid}` with full hydration and syncs the current state once.

### How coalescing works

A coalescing scheduler manages three states per entity:

**1. Debouncing** - A webhook arrives. Start a short timer (e.g. 2 seconds). If more webhooks arrive for the same
entity before the timer expires, reset the timer. Only start the actual sync after the burst settles.

**2. Active** - The timer expired and a sync is in progress. The entity is being fetched from the API and processed.

**3. Pending** - Webhooks arrived for an entity that is already being synced. Don't start a new sync, just mark it
as needing a re-sync when the current one finishes.


```
Timeline for product ABC:

T+0.0s  Webhook 1 arrives -> start 2s debounce timer
T+0.5s  Webhook 2 arrives -> reset timer (2s from now)
T+1.0s  Webhook 3 arrives -> reset timer (2s from now)
T+3.0s  Timer expires      -> start sync (state: Active)
T+3.5s  Webhook 4 arrives -> can't debounce (sync active), mark Pending
T+4.0s  Webhook 5 arrives -> already Pending, no-op
T+5.0s  Sync completes     -> check Pending -> yes -> sync again
T+7.0s  Re-sync completes  -> check Pending -> no -> done

Result: 5 webhooks -> 2 syncs (instead of 5)
```

### Implementation approach

The core logic is straightforward. Here is a simplified C# implementation showing the pattern:


```csharp
public sealed class CoalescingSyncScheduler
{
    private static readonly TimeSpan DebounceWindow = TimeSpan.FromSeconds(2);

    private readonly Dictionary<Guid, DebounceEntry> _debouncing = new();
    private readonly HashSet<Guid> _active = [];
    private readonly HashSet<Guid> _pending = [];
    private readonly Lock _lock = new();

    public void ScheduleSync(Guid entityUuid)
    {
        lock (_lock)
        {
            // If sync is already in progress, just mark as needing re-sync
            if (_active.Contains(entityUuid))
            {
                _pending.Add(entityUuid);
                return;
            }

            // Cancel existing debounce timer and start a new one
            if (_debouncing.TryGetValue(entityUuid, out var existing))
            {
                existing.Cts.Cancel();
                existing.Cts.Dispose();
            }

            var cts = new CancellationTokenSource();
            _debouncing[entityUuid] = new DebounceEntry(cts);

            _ = DelayThenSyncAsync(entityUuid, cts.Token);
        }
    }

    private async Task DelayThenSyncAsync(Guid entityUuid, CancellationToken ct)
    {
        try { await Task.Delay(DebounceWindow, ct); }
        catch (OperationCanceledException) { return; }

        lock (_lock)
        {
            _debouncing.Remove(entityUuid);
            _active.Add(entityUuid);
        }

        // Sync loop: keep syncing while new events arrive during sync
        while (true)
        {
            await SyncEntityAsync(entityUuid);

            lock (_lock)
            {
                if (_pending.Remove(entityUuid))
                    continue; // Events arrived during sync, go again

                _active.Remove(entityUuid);
            }

            return;
        }
    }

    private sealed record DebounceEntry(CancellationTokenSource Cts);
}
```

See [Examples](/webhooks/examples#coalescing-scheduler) for the full production-ready implementation.

### Which events to subscribe to

For coalescing, subscribe to **all events** for the resources you care about. The coalescing scheduler reduces them to
the minimum number of fetches regardless of how many events arrive.

You don't need to filter by specific sub-resource events. The scheduler handles the deduplication. This also makes
your integration resilient to new event types being added in the future.

### What to fetch

When the coalescing timer fires, make a single API call with full [hydration](/concepts/hydration) to get the complete
current state:


```bash
curl "https://api.apiera.io/v1/products/{productUuid}?include[]=productAssets&include[]=productAttributes&include[]=productCategories&include[]=productTags&expand[productAssets]=true&expand[productAttributes]=true&expand[productCategories]=true&expand[productTags]=true" \
  -H "Authorization: Bearer {token}"
```

This gives you everything in one request, regardless of which specific sub-resource triggered the events.

## When not to coalesce

Coalescing is the right default for **sync-style integrations** where you want to keep an external system in sync with
Apiera. But some use cases benefit from processing events individually:

- **Audit logging** - Record every individual event for compliance.
- **Notifications** - Send a notification for each specific change (e.g. "Product X was published").
- **Analytics** - Count event frequencies or track operation patterns.


For these cases, process each event as it arrives without coalescing. The thin payload design still applies. Fetch
current state if you need resource data.

## Reference implementation

Apiera's WooCommerce connector includes a production-ready coalescing scheduler. See the
[Examples](/webhooks/examples#coalescing-scheduler) section for the full implementation.