Skip to content

Rule engine

The rule engine evaluates a configurable set of rules against every artist in the library and records violations. Each rule has three modes: disabled, manual, and auto. In auto mode the engine fixes violations without user intervention; in manual mode it presents candidate fixes and waits for user approval.

Fix-all dispatch flow

The diagram below shows the path taken when a user (or the scheduler) triggers a full rule run. The orchestration entry point is internal/rule.Service.RunAllScoped.

Only one full rule run executes at a time. A second start attempt while a run is in flight receives a 409 Conflict. An in-memory, mutex-protected progress tracker coordinates the single active run and surfaces live progress to the UI over SSE.

flowchart TD
    Trigger["Run Rules triggered\n(HTTP handler or scheduler)"]
    Scope{"Scope?"}
    Incremental["Collect dirty artists only\n(dirty_subscriber tracks changes)"]
    All["Collect all eligible artists\n(paginated, page size 200)"]
    PassCtx["Build shared provider cache\nfor the whole pass (LRU, 500 entries)"]
    EvalLoop["Evaluate each artist\n(bounded worker pool, default 2)"]
    CheckEnabled{"Rule enabled?"}
    SkipRule["Skip rule"]
    RunChecker["Run checker function\n(returns nil or Violation)"]
    NoViolation["Record pass\nincrement RulesPassed"]
    AutoMode{"Automation mode?"}
    ManualMode["Store violation for\nuser review"]
    FixAttempt["Attempt automatic fix"]
    ConflictGate{"Conflict gate\nallow write?"}
    Blocked["Record blocked\nlog warning"]
    PersistFix["Update artist in DB\nwrite NFO / image to disk"]
    PublishFix["Publish metadata and images\nto connected platforms"]
    MarkDirty["Mark artist dirty\nfor next incremental run"]
    StoreResult["Store EvaluationResult\n(violation rows)"]
    BusEvent["Publish operation.progress\nand bulk.completed events"]

    Trigger --> Scope
    Scope -->|incremental| Incremental
    Scope -->|full| All
    Incremental --> PassCtx
    All --> PassCtx
    PassCtx --> EvalLoop
    EvalLoop --> CheckEnabled
    CheckEnabled -->|no| SkipRule
    CheckEnabled -->|yes| RunChecker
    RunChecker -->|pass| NoViolation
    RunChecker -->|violation| AutoMode
    AutoMode -->|manual| ManualMode
    AutoMode -->|auto| FixAttempt
    FixAttempt --> ConflictGate
    ConflictGate -->|blocked| Blocked
    ConflictGate -->|allowed| PersistFix
    PersistFix --> PublishFix
    PersistFix --> MarkDirty
    NoViolation --> StoreResult
    ManualMode --> StoreResult
    Blocked --> StoreResult
    MarkDirty --> StoreResult
    PublishFix --> StoreResult
    StoreResult --> BusEvent

Artist-level concurrency

RunAllScoped and RunRuleScoped walk artists through a shared iterator (walkScopedArtists). The walk is a bounded worker pool: independent artists are evaluated, fixed, and persisted concurrently so their provider-fetch latency overlaps instead of summing. The pool size is SW_RULE_ENGINE_ARTIST_WORKERS (or rule_engine.artist_workers in the config file), default 2. Setting it to 1 restores the original strictly- sequential walk (no pool, no goroutines); higher values overlap more.

Two invariants keep this safe:

  • Throughput is still capped by the limiter. Every provider request goes through the shared, FIFO-fair per-provider rate limiter (see Singleton rate limiters). More workers never exceed a provider's request budget; they only hide latency. MusicBrainz stays at one request per second across the whole pool.
  • Per-artist work is isolated. Each worker accumulates its counters and fix results in a local value and the walker folds them into the run result under a single mutex, so concurrent artists never race on shared state. The pass-level provider cache (PassContext) and per-artist EvaluationContext are already concurrency-safe.

One visible consequence: with more than one worker the order of FixResult entries in the run result follows completion order, not artist enumeration order. Setting the count to 1 restores the original ordering and fully sequential behavior; raise it deliberately after benchmarking your deployment.

Deferred-resolved rows and the persistence chain

Each evaluation run produces an EvaluationResult per artist (defined in internal/rule/model.go). The service writes violation rows to the database via internal/rule.Service.StoreViolations, keyed by (artist_id, rule_id). A previous violation row with fixed=true or dismissed=true is considered resolved; a new row for the same pair supersedes it.

The FixResult struct (returned by each fixer) has three significant states:

State Meaning
Fixed: true Fixer successfully applied the change; the violation is cleared
Dismissed: true Violation is valid but no fix is possible (e.g. artist has no MBID); recorded so the user is not re-notified
Neither Fixer attempted a fix but failed; violation stays open for retry

After a successful fix, the fixer or the pipeline calls Publisher.PublishMetadata or Publisher.SyncImageToPlatforms (both in internal/publish) to propagate changes to connected Emby and Jellyfin instances.

Pass-level provider cache

During a run-all pass, many artists may share the same MusicBrainz ID or need the same provider payload. Fetching it repeatedly for each artist wastes network round-trips. PassContext (in internal/rule/pass_context.go) holds an LRU cache (default 500 entries) that is shared across all artist evaluations in a single RunAllScoped call. When a fixer writes changes for an artist it must call PassContext.Invalidate for that artist so subsequent re-evaluations see fresh data.

Where to look

Topic File
Engine wiring and Evaluate internal/rule/engine.go
RunAllScoped, RunScope, FixResult internal/rule/fixer.go
Rule, Violation, RuleConfig model types internal/rule/model.go
Checker registration and built-in checkers internal/rule/checkers.go
Individual checkers internal/rule/checkers_*.go
Fixer implementations internal/rule/fixers.go, internal/rule/fixers_*.go
Bulk executor (fix-all job lifecycle) internal/rule/bulk_executor.go
Pass-level provider cache internal/rule/pass_context.go
Violation persistence internal/rule/sqlite_rule_results.go
Dirty-artist tracking internal/rule/dirty_subscriber.go

See also Architecture decisions for the ADRs that shaped the rule engine's incremental-evaluation and automation-mode design.