Tag & Find Jobs
Attach labels and keyed tags to jobs at the type, at enqueue, and at runtime, then filter and facet them through the Monitor and the dashboard — with tenant correlation as the running example.
Attach labels and keyed tags to jobs at the type, at enqueue, and at runtime, then filter and facet them through the Monitor and the dashboard — with tenant correlation as the running example.
Tags are how you make a pile of jobs answerable: which jobs belong to this tenant, which ran under the priority label, how many failures carry this customer id. This guide covers the three places a tag can attach, the difference between a label and a keyed tag, and how to get answers back out through filtering and faceting. It also draws the two bright lines: tags never influence scheduling, and regulated data never belongs in one. The running example is the one the sample app ships: a tenant correlation tag paired with a priority classification label.
If you have not read Tags yet, skim it first. It defines the model this guide leans on: a Tag, and its two kinds, the Label and the Keyed Tag.
Labels and keyed tags#
A Tag comes in two shapes, told apart by structure rather than by parsing text. A Label is a bare string with no key: billing, priority, backfill. A Keyed Tag is a key paired with a string value: tenant = 42, orderId = abc. A Label is one kind of Tag, not a separate system that sits beside tags — everything in this guide applies to both kinds unless a section says otherwise.
The distinction is positional, never lexical. A colon or an equals sign inside a Label is ordinary data, not a hidden separator; the store does not crack a Label apart looking for a key. If you want a key, you build a Keyed Tag explicitly.
You build tags fluently from JobTags.Empty: WithLabel(value) adds a Label, WithTag(key, value) adds a Keyed Tag. The underlying factories are JobTag.Label(value) and JobTag.Keyed(key, value), and JobTag.IsLabel is how a reader tells the two apart after the fact. Both a Label's value and a Keyed Tag's key and value must be non-empty; an empty string is rejected, not stored.
Which shape to reach for is a question of intent:
- Labels classify. A Label marks a job as belonging to a category —
priority,backfill,billing. There is nothing to correlate on, only a yes-or-no membership. - Keyed Tags correlate. A Keyed Tag ties a job to an identity you will later group or filter by —
tenant,orderId,region. The key names the axis; the value picks the point on it.
Values are strings and only strings. There is no numeric, date, or boolean tag type, and the Monitor does no range comparison over tag values. If you tag a date, you tag a string you have canonicalized yourself, and you can match it only for equality, never as "after" or "before." A job's tags are a set: adding a tag identical to one already present is a no-op, so a type-default Label and an enqueue-time Label of the same value collapse to a single tag rather than doubling up.
Three attach points#
The same tag surface is reachable from three places in a job's life. Each differs in what kinds of tag it accepts and when the tag becomes visible.
| Attach point | How you attach | Kinds allowed | Visible |
|---|---|---|---|
| On the job type | [Job(Labels = ["billing", "report"])] | Labels only | Immediately |
| At enqueue | the tags: argument on EnqueueAsync | Labels and Keyed Tags | Immediately |
| At runtime | JobContext.AddLabel / AddTag inside the handler | Labels and Keyed Tags | Only at attempt end |
On the job type. The [Job] attribute's Labels property gives every instance of that type a starting set of Labels. It is additive and union-only: those Labels always merge into whatever you pass at enqueue, and there is no way to subtract one later. Because the attribute holds a compile-time constant array, it can carry Labels only — a Keyed Tag needs a runtime value and cannot be a constant, so it does not belong here.
At enqueue. The tags: argument on BackWaveClient.EnqueueAsync attaches per-instance facts the caller already knows — this is where tenant goes. The type's default Labels are always merged on top of what you pass, never instead of it, so you never lose a type Label by supplying enqueue tags. Build the set from JobTags.Empty.WithTag("tenant", tenant), adding .WithLabel("priority") when the instance warrants it.
At runtime. From inside the handler, JobContext.AddLabel and JobContext.AddTag record facts you only discover while running — the amount-band a report fell into, a processed marker. These behave differently in one important way: they are buffered on the context and flushed together with the attempt's outcome, so they are not visible in a search until that attempt ends. A clean completion writes them. So does a graceful failure — a thrown exception is still a recorded outcome, so any runtime tags added before the throw persist and can be searched on the dead-lettered job. They are lost only on an ungraceful death — a hard crash or a lost lease mid-run — where no outcome was ever written. Type-default Labels and enqueue-time tags carry none of this caveat; they are visible the moment the job is enqueued.
All three sources union onto one job. A tagged-report type declared with Labels = ["billing", "report"], enqueued with tenant and a priority label, then given processed and amount-band at runtime, ends up carrying every one of those tags as a single set.
Finding jobs: filter and facet#
Tags earn their keep on the read side, through the Monitor. Two operations matter here; the Monitor API page documents the full query surface, and the Dashboard exposes the same filters in its job views, so this section stays at the level of what is possible rather than every signature.
Filtering narrows a job listing. BackWaveMonitor.ListJobsAsync takes a JobQuery, and tag conditions go on its TagPredicates. Every predicate is AND-ed: a job matches only when it satisfies all of them at once. Tag predicates also compose with the query's scalar filters — State, Queue, WireName — under the same AND, so "every dead-lettered job for tenant 42" is one query that sets State to dead-lettered and adds a tenant = 42 predicate. There is no OR across predicates; when you need a union, run two queries and merge the results yourself.
Predicates come in three kinds:
JobTagPredicate.HasLabel(value)— the job carries this Label.JobTagPredicate.HasKeyValue(key, value)— the job carries this key with exactly this value.JobTagPredicate.HasKey(key)— the job carries this key with any value.
Each job the query returns exposes its full tag set on the read model (JobSnapshot.Tags), so you get the tags back alongside the jobs.
Faceting answers a counting question without pulling every job. BackWaveMonitor.GetTagFacetAsync takes a key and returns a list of TagFacet entries, each a value and a count, ordered from most common to least. Pass tenant and you get the per-tenant distribution — how many jobs each tenant has — computed in the store rather than by iterating results. A non-empty key facets a Keyed Tag; the empty string facets Labels, giving you the count of jobs per Label. An optional base query scopes the facet with the same filters as a listing, so "within the dead-lettered jobs, break down by tenant" is a single faceted call.
The two rules#
Two invariants govern what a tag is for, and both are load-bearing.
A tag is observational only. The scheduling core never reads tags — not for routing, not for dispatch order, not for concurrency, not for retry. Nothing you put in a tag can change how, when, or whether a job runs. The priority label in the running example is exactly this: it is a classification you can filter and facet on, and it does nothing to scheduling. If what you actually want is behavior — run these jobs before those — you want Queues, which is the real mechanism the sample wires up as a strict-priority worker group. Reaching for a tag to influence execution will silently do nothing.
No regulated data in a tag. Tags are the most visible data in the system. They render as pills in the dashboard, come back in Monitor listings, and are counted in facets. They sit outside the protection that covers payloads: the dashboard's sensitive-data gate governs whether a job's payload and output are rendered, but it does not cover tags, and tags are deliberately excluded from encryption at rest so they stay searchable. That searchability is the whole point of a tag and the exact reason it is the wrong place for anything regulated. Put an identifier in the tag and keep the personal or sensitive data behind it, retrieved through the payload and sensitive-data handling path. A tenant id belongs in a tag; a customer's name or email does not.
A practical note alongside the rules: keep tag keys and values short. There is no fixed maximum tag count per job and no promised value-length limit, but tags are meant to be compact, searchable handles, not free-form storage, and short values keep them efficient to index and read back.
Where to go next#
- Tags: the model behind Labels and Keyed Tags.
- Monitor API: the full listing and faceting query surface.
- The Monitor API: the tag query and faceting surface behind the dashboard's filters.
- Prioritize Work with Queues: the mechanism for actual scheduling behavior.
- Handle Sensitive Data: where regulated data belongs instead of a tag.