Skip to main content
A judgment can read the one document the judged document points at through its own attribute: its referenced document. An order line reading its product, a message reading its conversation and a task reading its project all have this shape. When the referenced document changes, Brussle re-judges the documents that point at it. That re-judging is a fan-out, and this guide is mostly about keeping it affordable. For the other direction, where many documents point at the judged one, such as an account and its tickets, see judge a document with its related documents. Relations compares the two.

Define one

The relation’s join is {"theirs": "id", "mine": "attributes.<name>"}: the judged document’s mine attribute holds the referenced document’s id. In this example, documents of kind order_line each point at a product through attributes.product_id:
  • applies_to limits which documents the judgment judges, answers and bills: here the order lines, not the products.
  • One document. The relation reads at most one, so it takes neither last_n nor window: sending either is invalid_request. match, fields and aggregate work as for any relation, so related.product has at most one record, and count is 0 or 1.
  • What it points at. The judged document’s mine attribute names the referenced document when it holds a valid document id. When it is missing, is not a string, or names a document that does not exist or does not match match, the relation is empty.
  • One snapshot. The referenced document is read at the same log position as everything else, so the answer’s watermark also says which version of it the answer read, and the evaluation’s related_documents names that revision.
  • The reverse lookup from a referenced document to the judged documents that point at it is a reference index on the judged documents’ mine attribute. The first version that needs it builds it as a reference_index job, listed in the create response’s job_ids, and the judgment’s answers are unavailable until the job is done. It counts toward the 8 reference indexes a namespace can have, and an attribute used in several ways is one index. A version whose judged documents the existing index does not cover yet, such as one with another applies_to, or one using the attribute the other way round, gets a job that rebuilds it, and its answers wait for that job the same way.
  • Bands. {"path": "state.score", "bands": [0.3, 0.7], "labels": ["low", "medium", "high"]} shows the label of the band a number falls in, not the number: 0.29 is low, 0.3 is medium, and 0.7 or more is high. Bands work in any relation. See bands.
  • confirm. Creating a version with related on a judgment that runs on_change returns a replay estimate and creates nothing until you send confirm: true. What you confirm includes the rolling limit: after that, no fan-out waits for a person.

Fan-out

A write to a judged document re-judges it, as always. A write to its referenced document re-judges every judged document that points at it and is inside the judgment’s re-judge scope, but only when the write changes what the relation shows of it. That is the fan-out, and it is what this kind of judgment costs:
re-judgments a month = referenced-document changes a month × judged documents per change inside the scope
A referenced document with many judged documents pointing at it can turn one small edit into hundreds of thousands of evaluations. Every control below exists to shrink one of those two factors, or to cap what they can add up to.

What a fan-out costs

The worked numbers use one namespace:
  • 100,000 referenced documents, each pointed at by 200 judged documents on average: 20M judged documents.
  • 4% of the judged documents were created in the last 30 days, so 8 per referenced document, and a quarter of those match the scope’s where (an open status, say), so 2.
  • Each judged document’s context and question come to about 900 tokens, so each is a standard judgment, at $0.25 per 1,000, and less past 100M judgments in a billing period.
B costs $95,000 a month. The scope cuts the judged documents per change a hundredfold, and bands cut the changes thirtyfold, which brings B down to the size of A. Without bands, B with the scope and where is 6,000,000 re-judgments a month, twenty times the default rolling limit, so the fan-outs past it would be deferred: raise the limit or add bands. Debounce is what keeps a much more volatile field bounded. A score rewritten every minute is 43,200 changes a month for each referenced document. The 10-minute fan-out debounce never settles on it, so the 1-hour ceiling fans it out once an hour for as long as it keeps changing, 720 times a month. With bands on top, only a change of band counts: a band change that settles fans out once, and a band that keeps flipping still fans out at least once an hour. One large referenced document, pointed at by 200,000 judged documents, fans out to all of them on one change: $50, and hours of background judging. It runs on its own and counts 200,000 against the judgment’s rolling limit. With the 30-day scope and where it is about 2,000, which takes minutes. Judged documents outside the scope cost nothing, and a re-judged document whose context did not change is not billed.

The controls, in order of effect

Every default is a setting you change with a PATCH and no new version. The judgment’s are in freshness.fanout, and share is the namespace’s. The fan-out debounce is separate from the judgment’s own debounce_ms, which still governs the judged documents’ own writes: a new judged document is judged within seconds, while changes to its referenced document wait 10 minutes. The scope’s age counts from the change, not from now, so one change’s scope stays fixed however long its fan-out waits. A document’s age is from its created_at: when it was first written to Brussle, or the creation time that write gave. Import judged documents with their original created_at, so only the recent ones fall inside the scope; imported without it, every one of them is inside the default scope for 30 days.
A PATCH like this one changes those two keys and keeps the others, scope.where included. GET returns every value in effect, and fanout_sources says where each comes from: judgment or default. Widening the scope or raising the rolling limit needs no confirm and can raise the bill a lot; the namespace budget still caps what is spent. Fan-out when a referenced document changes lists each setting with its bounds.

The rolling limit

fanout.rolling_limit is the most judged documents the judgment’s fan-outs re-judge in any 30 days: 300,000 by default. You confirm it once, with the judgment, and no fan-out ever waits for a person after that.
  • Within the limit a fan-out runs on its own, with no job, and its spend shows in the namespace’s spend.
  • Past it the fan-out is deferred. Its change is marked, the answers it would refresh read stale with stale_reason limit_reached, and the events feed records one judgment.limit_reached event for the change, with the judged documents it would re-judge in deferred:
  • Catching up. A deferred fan-out runs on its own once there is room: when older re-judgments leave the 30-day window, or at once when a PATCH raises rolling_limit. Raising it needs no confirm.
  • null removes the limit. Every change then re-judges every in-scope document, with no deferral, and only the namespace’s budget caps fan-out. Set it at create, in freshness.fanout, or later with a PATCH.
  • Background work. Fan-out runs behind the namespace’s own changes, like a backfill, on at most share of its in-flight engine requests (half by default). A budget pause stops it like all judging.
Subscribe a webhook endpoint to judgment.* to hear about a deferral as it happens.

What the answers show

  • In scope: an answer is pending from the change until the fan-out re-judges it, and fresh again once an answer lands with a watermark at or above the change. It is pending from the moment the write to the referenced document acks, so no read after the ack sees it fresh against the old version. A write that turns out not to change what the relation shows leaves the answer pending only until Brussle has checked it, moments later, and then fresh again with no engine call. While the fan-out is deferred past the rolling limit, it is stale with stale_reason limit_reached, and the change in its referenced_changes has deferred: true.
  • Outside the scope: nothing re-judges the answer, so it reads stale, with stale_reason referenced_changed, and answers: "fresh_only" leaves it out. It keeps its numbers until the judged document is judged again. Its watermark and its evaluation’s related_documents say which version of the referenced document it read, and a referenced document whose revision is above the watermark shows it read an earlier one. On a get, the answer also lists referenced_changes: the newest write to each document it points at that changed what the relation shows, with its revision and time. The judged document’s next own write re-judges it against the referenced document as it is then. The dashboard’s document view says which of these applies.
  • Only on_change fans out. Under on_read, periodic and manual, a change makes the in-scope answers stale, as any related write does.
  • updated_at changes on every write. A relation that shows a referenced document’s updated_at fans out on every write to it, so create warns about it.

What the replay estimate cannot count

Creating a version with related on a judgment that runs on_change, or switching such a judgment to on_change, returns the replay estimate of its monthly cost and does nothing until you send confirm: true. It cannot count fan-out: the replay cannot tell which past writes to a referenced document changed what the relation shows. For this kind of judgment it returns "excludes": ["fanout"] and lower_bound: true, and the dashboard shows the figures as “at least, not counting fan-out”. The replay still counts the judged documents’ own writes and every relation that reads documents pointing at them. What fan-out can add is bounded by the rolling limit instead, and the estimate prices it beside replay, in fanout, at the sample’s judgments per answer:
cost_usd_at_limit is the most fan-out can cost in any 30 days, and it is what you confirm with confirm: true. To confirm with no limit, send "freshness": {"fanout": {"rolling_limit": null}} with the create: both are then null, every change re-judges every in-scope document with no deferral, and only the namespace’s budget caps it.

Namespace fan-out settings

PATCH /namespaces/{ns} sets how fan-out runs for every judgment in the namespace:
share is the most of the namespace’s in-flight engine requests fan-out may use: 0.5 by default, always at least one request, so the rest serve its ordinary judging. It is above 0 and at most 1. GET returns the value in effect. See namespaces.

Not available yet

  • Counting fan-out in the replay estimate.
  • Limits on a namespace’s total fan-out other than its budget and each judgment’s rolling limit.