Tags
Observational labels and keyed tags for searching and filtering jobs. The Core never reads them, so keep behavior and regulated data out of a tag.
A Tag is an observational annotation on a job. It exists for finding, filtering, and grouping jobs in the Dashboard and the Monitor, and for nothing else. The scheduling Core never reads a Tag, so a Tag never influences dispatch, routing, priority, retries, or execution order. You attach Tags to describe a job, then later you read them back to answer questions like "show me every job for this tenant" or "how many jobs carry the billing Label." That is the whole job of a Tag.
Because Tags are descriptive rather than functional, the rest of this page covers the shapes a Tag can take and the public surfaces that write and read them.
Two kinds: Labels and Keyed Tags#
Every Tag is one of two structural kinds, and they are told apart by their key, never by parsing a separator inside the text.
- A Label is a bare string with an empty key, like
urgentorbilling. It carries no key/value structure. - A Keyed Tag has a non-empty key and a string value, like
tenantpaired withacme. The key names a dimension and the value fills it.
One key can carry several values on the same job. A genomics job can hold variant -> BRCA1 and variant -> TP53 at once, and both are kept. Values are strings only. There is no stored numeric or date type, so to tag by an amount or a timestamp you canonicalize it into a string yourself. Tags do not model range queries.
A colon inside a Label is ordinary data, not a separator. note:resend is a single Label whose text happens to contain a colon. If you want a key and a value, build a Keyed Tag explicitly.
You build a tag set with JobTags and JobTag. A JobTags is the set of Tags for one job, and you add to it fluently.
var tags = JobTags.Empty
.WithLabel("urgent") // a Label: empty key
.WithTag("tenant", "acme") // a Keyed Tag: key -> value
.WithTag("variant", "BRCA1")
.WithTag("variant", "TP53"); // same key, second valueYou can also construct individual Tags with JobTag.Label(value) and JobTag.Keyed(key, value). Both throw when the value is null or empty, and JobTag.Keyed also throws on an empty key. A Tag value is always a non-empty string.
Default Labels on [Job]#
A job type can declare default Labels right on its [Job] attribute. Every job of that type is then tagged with those Labels automatically.
[Job("order-charged", Labels = ["billing", "tenant-critical"])]
public sealed record OrderCharged(Guid OrderId);The Labels argument is optional and sits alongside the Wire Name and the optional Queue. Two properties of defaults matter:
| Property | Behavior |
|---|---|
| Labels only | Only bare-string Labels are expressible here, because the attribute takes compile-time constants and a key/value pair cannot be encoded as one constant. To attach a Keyed Tag by default, add it at enqueue time instead. |
| Additive | Default Labels are always unioned into the per-enqueue Tags. They are never subtracted, so a job always carries at least its type's declared Labels. |
The Jobs and Handlers page introduces the Labels argument as part of the [Job] surface. This page owns the deeper rules: Labels only, additive, and merged as a set with whatever the caller supplies.
Tagging at enqueue and at runtime#
Beyond defaults, Tags come from two runtime places.
At enqueue time, both enqueue methods accept an optional tags: parameter. The job type's default Labels are always added on top of what you pass, and the merge is a set, so a Label that is both a default and supplied appears exactly once.
await client.EnqueueAsync(
new OrderCharged(orderId),
dueTime: DateTimeOffset.UtcNow,
tags: JobTags.Empty.WithTag("tenant", "acme"));Inside a handler, a job can tag itself while it runs through JobContext.AddLabel(value) and JobContext.AddTag(key, value). These buffer on the context and flush as a delta on the Attempt's outcome write, so the Tags commit atomically with the outcome rather than as a separate store call. A superseded Attempt discards its buffered Tags along with its outcome. The Jobs and Handlers page covers the JobContext surface itself.
Set semantics#
A job's Tags form a set. Adding a Tag that is already present is a no-op, whether the duplicate comes from a default, an enqueue argument, or a handler call. Iteration order follows first-seen order, and equality between two tag sets ignores order.
This is deliberate, and it lines up with At-Least-Once Execution. A handler body can run more than once for the same job, so a handler that calls AddTag("tenant", "acme") on every Attempt would otherwise pile up duplicates across re-runs. Set semantics make tag authorship idempotent: re-running the same tagging code lands on the same set every time.
Finding jobs by Tag#
Tags pay off on the read side. The Dashboard renders each job's Tags as pills and lets you filter and group by them. The Monitor exposes the same power programmatically.
BackWaveMonitor.ListJobsAsync takes a query whose TagPredicates are AND-ed together, and AND-composed with scalar filters like State, Queue, and Wire Name. An empty predicate list adds no constraint. There are three predicate shapes:
| Predicate | Matches a job that |
|---|---|
JobTagPredicate.HasLabel(value) | carries this Label |
JobTagPredicate.HasKeyValue(key, value) | carries this exact Keyed Tag |
JobTagPredicate.HasKey(key) | carries any value under this key |
Predicates only combine with AND. There is no OR or boolean tree, so a caller who wants OR runs two queries and merges the results.
For grouped counts, BackWaveMonitor.GetTagFacetAsync(key) returns one TagFacet per distinct value under a single key, each with a count of matching jobs, ordered by count descending. Pass a tenant key to get a per-tenant breakdown, or the empty string to facet plain Labels. Faceting is one dimension per call.
What Tags are not#
Tags are easy to reach for as a control knob, so it is worth being explicit about the line. A Tag is not a priority, not a routing hint, not a Queue selector, and not metadata the Core consults when it decides what to run. The Core never reads a Tag at all. Routing belongs to Queues, timing belongs to Scheduling, and anything that should change how a job runs belongs in those mechanisms or in the job payload, not in a Tag.
One constraint deserves its own note. Keep secrets and regulated data out of Tags. Tags are stored in the clear so they can be searched and grouped, and they stay searchable on every tier. Encryption at rest does not cover Tags, so a value placed in a Tag remains readable to anyone who can query jobs. Put sensitive material in the encrypted job payload or behind a reference, and tag with a non-sensitive identifier instead.
Where to go next#
- Jobs and Handlers: the
[Job]attribute'sLabelsargument and theJobContexttagging surface this page builds on. - The Monitor API: the tag query and faceting surface the Dashboard uses to filter by Tags.
- Execution Guarantee: At-Least-Once Execution, the reason idempotent set semantics matter.
- Queues: where routing actually happens, since Tags never do.