Content Relations, Layer 3: SweepStructural
The first two posts in this series covered two of the three correctness layers for Smeldr's relation graph: the save-path sync that fires on every write, and the reactive cascade that fires when a target item changes state. Together they handle almost everything. But "almost" is the word that matters.
Consider what they do not cover:
- A bulk import that writes directly to the database, bypassing the save path.
- A database restore from backup that puts items in a state the running application
never saw.
- A signal that was lost because the server restarted mid-delivery.
- A manual deletion performed by an operator directly in SQLite.
In any of these cases, stale edges accumulate silently. A relation still records that post A depends on source B, but source B no longer exists or is no longer published. Neither Layer 1 nor Layer 2 will ever notice, because neither of them saw the event that made the edge stale.
Layer 3 is the answer: a periodic structural sweep.
SweepStructural
RelationStore.SweepStructural iterates all active edges, those where invalid_at is null or in the future, and valid_at is null or in the past, and calls a TargetChecker for each unique target:
// TargetChecker reports whether a relation target is still live.
// Error means the check could not be performed — the edge is skipped, not flagged.
type TargetChecker func(ctx context.Context, targetType, targetID string) (alive bool, err error)
When a target is not alive, the sweep stamps invalid_at = now on each source edge pointing to that target and calls onStale for each one:
flagged, skipped, err := store.SweepStructural(ctx, check, func(ctx context.Context, e smeldr.RelationEdge) {
// called once per stale edge
log.Printf("stale: %s/%s -> %s/%s via %s",
e.SourceType, e.SourceID, e.TargetType, e.TargetID, e.RelationKind)
})
The function groups edges by target before calling the checker, so a target with fifty source edges is checked once. Errors from the checker are logged at Warn level and increment the skipped counter; they never abort the sweep. flagged is the count of edges whose target was found not alive.
App.SweepStructural
App.SweepStructural is a convenience wrapper with a built-in TargetChecker for runtime-defined content types. It queries smeldr_dynamic_content by ID and checks status = 'published'. For each stale edge it fires AfterRelationCascade, so existing signal handlers receive the notification through the same path as Layer 2:
flagged, skipped, err := app.SweepStructural(ctx)
The default checker only covers runtime-defined content types. Applications that also use compiled content types (via Module) need to call store.SweepStructural directly and supply a TargetChecker that queries their own tables.
SweepScheduler
The agent module provides SweepScheduler to run the sweep on a cron schedule. It wraps gocron with singleton mode (LimitModeReschedule), which means if a sweep is still running when the next tick arrives, the tick is rescheduled rather than starting a second concurrent sweep:
sweep, err := agent.NewSweepScheduler(
"0 * * * *", // 5-field cron: every hour at :00
"Europe/Copenhagen",
app.SweepStructural,
)
if err != nil {
log.Fatal(err)
}
sweep.Start()
defer sweep.Stop()
SweepFunc is the function type accepted by NewSweepScheduler:
type SweepFunc func(ctx context.Context) (flagged, skipped int, err error)
App.SweepStructural satisfies SweepFunc directly, so no adapter is needed. The scheduler logs at Debug level when there is nothing to report and at Info level when flagged or skipped is non-zero. Stop calls Scheduler.Shutdown and waits for any in-flight sweep to finish before returning.
The timezone parameter is an IANA location string. Empty string defaults to UTC. This matters if your sweep schedule aligns with business hours: a daily sweep at midnight local time is "0 0 * * *" with the correct location, not UTC midnight.
Why invalid_at and not delete
Stale edges are stamped with invalid_at rather than deleted. This preserves the history of the relation graph. An audit trail, a dashboard showing that an item was once depended on by X others, or a governance workflow that reviews stale edges before removing them: all of these need the edge records to persist. invalid_at takes the edge out of the active set without destroying it. Hard delete is always available via store.Delete(ctx, edgeID) when the application wants it.
Acting on stale edges
When App.SweepStructural marks an edge as stale, it fires AfterRelationCascade for the source item via the same signal path as Layer 2. Any handler registered with App.OnSignal(AfterRelationCascade, ...) will receive it. The handler does not need to know whether the notification came from a reactive cascade or from the sweep.
Common patterns in that handler:
- Queue a review job so an editor can decide whether to update the dependent item,
re-point it to a replacement, or archive it as well.
- Notify via webhook so an external system can react.
- Trigger an AI agent to propose a replacement target or a corrected field value.
- Set a derived field on the dependent item (
has_stale_relation: true) so the
dashboard can surface items that need attention.
The sweep result is also a graph health metric. flagged > 0 after a run means something changed outside the normal save path: an import bypassed the hook, a restore happened, or an item was deleted via the database directly. A monitoring system that watches the flagged count gives early warning when the graph drifts between scheduled sweeps.
If the application needs to act on stale edges at sweep time rather than via the signal bus, call store.SweepStructural with a custom onStale:
flagged, _, err := store.SweepStructural(ctx, check, func(ctx context.Context, e smeldr.RelationEdge) {
// hard-delete the stale edge immediately
_ = store.Delete(ctx, e.ID)
// or push to a review queue
reviewQueue.Enqueue(e.SourceType, e.SourceID)
})
App.SweepStructural fires AfterRelationCascade and stamps invalid_at. store.SweepStructural gives you the raw callback and the same stamp, without touching the signal bus. Both are available; the choice is whether stale edges discovered by the sweep should flow through the same handler as reactively-detected ones, or be handled separately.
The full picture
The three layers give the relation graph a layered correctness guarantee:
| Layer | Trigger | Latency | Coverage |
|---|---|---|---|
| Layer 1: save-path sync | Every save | Synchronous | Source-side writes |
| Layer 2: reactive cascade | Target state change | ~500ms (debounced) | Observed lifecycle events |
| Layer 3: structural sweep | Cron (e.g. hourly) | Periodic | Everything else |
No single layer is sufficient. Together they cover the full space: fast on the common path, reactive on observed events, and corrective on everything that slipped through.
Reference: SweepScheduler · Content Relations