RecSys · textbook
Trainer Widgets Revision About All chapters Sources →

Supplement · The X algorithm · page 2 of 11

The pipeline: how the feed is assembled from standard stages

The «For you» feed is described as a sequence of 75 stages of six types. Let us look at the framework that holds it together: what runs in parallel and what is obliged to go in order, what happens when a source falls over, and why query hydration is split into two rounds.

In brief

  • Six types of stage: query hydrator, source, candidate hydrator, filter, scorer, selector and side effect. Every stage is a structure with three methods, and that is all.
  • Every stage has a switch — the method enable(query), which decides per request whether to run. Experiments work through it.
  • Sources and hydrators run in parallel, filters and scorers sequentially. And that is not an accident: the former have no dependencies, the latter do.
  • A source falling over does not bring the request down. The error is silently discarded and the feed is assembled from whatever came back in time.
  • Side effects are started after the answer has been sent and do not keep the user waiting.

1. Six types of stage

The whole framework is 1234 lines across seven files. Each file describes one type of stage, and they are all built the same way.

pub trait Filter<Q, C>: Any + Send + Sync {
    fn enable(&self, _query: &Q) -> bool { true }
    fn filter(&self, query: &Q, candidates: Vec<C>) -> FilterResult<C>;
    fn name(&self) -> &'static str { ... }
}

A fragment of filter.rs · code by X, Apache 2.0, commit 28e414f

Three methods: whether it is on, what it does, what it is called. The name is needed for the metrics — it is what the counts of how much this stage dropped and how long it took are attributed to. The run method the pipeline calls is a wrapper around filter that adds the measurements.

Type of stageWhat it doesAn example from the feed
Query hydratorPulls data onto the request before any candidates existThe lists of follows, blocks, already shown posts
SourceReturns candidatesFresh posts from follows, retrieval, cluster similarity
Candidate hydratorPulls data onto every candidateThe text of the post, the author, counters, language, semantic ID
FilterThrows out part of the candidatesOlder than 48 hours, your own posts, already shown
ScorerPuts numbers on thingsThe model, the combination into a score, re-ranking
SelectorSelects and ordersTake the top-K, mix in the ads
Side effectDoes something after the answerRecord the impressions, update the cache, emit events
Why this matters more than it seems

Such typing looks like routine engineering, but it is exactly what makes everything else in the system possible.

Adding a source means writing one structure. Without touching anything that already works. Thirty filters exist in the feed because adding the thirty-first costs a dozen lines.

The measurements are the same for all. The run wrapper counts the time and the sizes for any stage, so the system automatically has an answer to «where are we losing time» and «which filter drops how much» — without a single line of special code.

Experiments are set at the level of a stage. The enable method receives the request and decides by it; inside, it reads parameters. Which means any stage can be switched on for a percentage of the traffic without branching the code.

Compare this with how we described the architecture of the runtime: there the point was that a recommender service is a conveyor and that its strength is in the uniformity of its stages. Here that has been taken as far as a library.

2. The order of execution

The main function of the pipeline reads like a table of contents of the whole feed:

let hydrated_query = self.hydrate_query(query).await;
let hydrated_query = self.hydrate_dependent_query(hydrated_query).await;
let candidates = self.fetch_candidates(&hydrated_query).await;
let hydrated_candidates = self.hydrate(&hydrated_query, candidates).await;
let (kept, mut filtered) = self.filter(&hydrated_query, hydrated_candidates.clone());
let scored = self.score(&hydrated_query, kept).await;
let SelectResult { selected, non_selected } = self.select(&hydrated_query, scored);
let post_selection = self.hydrate_post_selection(&hydrated_query, selected).await;
let (mut final_candidates, post_filtered) = self.filter_post_selection(&hydrated_query, post_selection);

A fragment of candidate_pipeline.rs · code by X, Apache 2.0, commit 28e414f

1. Query hydration two rounds, inside a round — in parallel 2. Sources all at once, in parallel; the fallen ones stay silent 3. Candidate hydration 12 hydrators in parallel: text, author, counters… 4. Filters before scoring strictly in turn — each sees the previous output 5. Scorers in turn: the model → combination → re-ranking 6. Selector sorting and cutting down to the top-K 7. Hydration after selection the expensive kind — only for those that got through 8. Filters after selection visibility, conversation dedup the answer to the user and only then — the side effects Blue and amber are what runs in parallel. Pink and violet are what is obliged to go strictly in turn. The expensive hydration stands after the selection: asking about a hundred selected posts is cheaper than about a thousand candidates.
Eight steps and 75 stages inside them. The order is set by dependencies between data, not by taste.

What is parallel and what is not

The difference is visible right in the code and it is not cosmetic.

The sources — in parallel:

let source_futures = sources.iter().map(|s| s.run(query));
let results = join_all(source_futures).await;

The filters — sequentially:

for filter in enabled {
    let result = filter.run(query, candidates);
    ...
}

A fragment of candidate_pipeline.rs · code by X, Apache 2.0, commit 28e414f

Why so? The sources do not depend on each other: each takes the request and returns a list. They can be started simultaneously, and the total time becomes the time of the slowest rather than the sum.

The filters do depend: each receives what is left after the previous one. They could be run in parallel on one and the same input — the result would in theory be the same — but then every filter would process the full list instead of a truncated one. With thirty filters and thousands of candidates that is more expensive, not less. Besides, the order carries meaning: the cheap filters stand earlier and reduce the work for the expensive ones.

The arithmetic of parallelism

Let there be seven sources, answering in 3, 5, 8, 4, 12, 6 and 2 milliseconds. Sequentially that is 40 ms, in parallel 12 ms, that is, the time of the slowest.

Hence an important consequence for design: it is not the average source that has to be optimised but the worst one. Speeding the one that answers in 3 ms up twofold gains you nothing. This is the standard argument about tail latencies from the chapter «The architecture of the runtime», and here it is written straight into the structure of the code.

3. What happens when a source falls over

A line that is easy to skip past but which determines the behaviour of the system under load:

let results = join_all(source_futures).await;

let mut collected = Vec::new();
for mut candidates in results.into_iter().flatten() {
    collected.append(&mut candidates);
}

A fragment of candidate_pipeline.rs · code by X, Apache 2.0, commit 28e414f

Every source returns Result<Vec<C>, String> — either candidates or an error. And flatten over an iterator of results silently throws out every error and keeps only the successful ones.

So if the service of fresh posts from follows has fallen over, the request does not fall. The feed will be assembled from what the other sources returned: it will get worse — there will be no posts from the people you follow — but it will exist.

Is that the right decision

Yes, and it is the standard approach to resilience in recommender services, which we discussed in the chapter «Resilience» under the name «the ladder of fallbacks». A recommendation is not a bank transaction: showing an imperfect feed is better than showing nothing.

But the decision has a price, and it is worth naming out loud. The degradation is silent. The user will not learn that half the sources kept quiet, and from a single request it is indistinguishable from normal work. If a source dropped out on two percent of requests, no alert would fire — some people would simply have a systematically worse feed.

That is exactly why the run wrapper of every stage carries measurements: what saves you is not resilience itself but the fact that alongside it the number of times a stage ran and how much it returned is counted. Resilience without observability turns into a silent breakage.

4. Query hydration: two rounds

Note that query hydration is called twice:

let hydrated_query = self.hydrate_query(query).await;
let hydrated_query = self.hydrate_dependent_query(hydrated_query).await;

A fragment of candidate_pipeline.rs · code by X, Apache 2.0, commit 28e414f

The first round is the hydrators that need only the original request: the lists of follows, blocks, muted accounts, demographic data. They are all parallel to each other.

The second round is those that need the results of the first. To count mutual follows, for example, you first have to get the list of follows. Or to build the sequence of actions for the model, you have to know which posts have already been shown.

This is the simplest form of dependency planning: instead of a full graph, two levels. The decision is pragmatic: a real dependency graph would require describing the links between stages, while two levels cover almost every real case and require nothing beyond putting a hydrator into the right list.

What exactly is pulled onto the request

There are twenty query hydrators. Here are the most telling ones.

HydratorWhat it bringsWhat for
ScoringSequenceQueryHydratorThe sequence of actions for the ranking modelThe model's main input — that same history from x04
RetrievalSequenceQueryHydratorThe sequence for retrievalA separate one, because the model is different
FollowedUserIdsQueryHydratorThe list of followsNeeded by the source of posts from follows and by the in-network labelling
BlockedUserIdsQueryHydrator and Muted…Blocks and muted accountsFiltering
MutualFollowQueryHydratorMutual followsAn addition to the weight of a reply: a conversation with someone you know is valued separately
ImpressionBloomFilterQueryHydratorA Bloom filter with the shown postsDo not show twice
UserDemographicsQueryHydratorCountry, language, ageFeatures of the model
UserInferredGenderQueryHydratorInferred genderA feature of the model
A Bloom filter — right on the request track

The line ImpressionBloomFilterQueryHydrator deserves a stop of its own, because this is that same Bloom filter from the chapter «The Bloom filter and pagination», met in production.

The task: do not show a post the person has already seen. The naive solution is to carry the full list of shown identifiers around with the user. For an active reader that is tens of thousands of numbers on every request — unacceptable both by volume of data and by time of transfer.

A Bloom filter gives a compact structure of a few kilobytes with a one-sided error: if it says «not seen», it was definitely not seen; if «seen», it is wrong with a small probability. That error costs us hiding a good post unnecessarily. An error the other way — showing a duplicate — is far more noticeable to the user.

You can compute what that trade-off costs at particular sizes in the Bloom filter widget. Curiously, in the feed three more filters over what has already been shown work alongside the Bloom filter — about that on the page about filters.

5. Two pipes: posts and everything else

There are in fact two pipelines, and they are nested one inside the other.

The Post Pipeline is what we discussed above: 75 stages that find and order posts.

The Blending Pipeline is a wrapper around it. For it, the ranked posts are just one source among others. The other sources bring what the model does not rank: ads, the «who to follow» block, promo prompts.

Blending Pipeline — assembles what the person will see Post Pipeline 75 stages sources → hydration → filters → scoring → selection → filters = the ranked posts ads who to follow promos and prompts BlenderSelector interleaves by rules, fixed positions the feed on the screen
The outer pipeline sees the inner one as an ordinary source of candidates. The same framework, nested inside itself.
Why this is a good separation

Ads and recommendations live by different laws. Ads have their own auction, their own obligations to the advertiser, their own restrictions on what may stand next to them. Trying to express that through the same score as organic posts is a knowingly doomed undertaking: they have no common unit of measurement.

Separating the pipes relieves the system of that task entirely. Ranking posts answers the question «which post is better than which». Blending answers a different one — «at which positions to put the non-posts». The second question is solved by rules rather than by a model, and that is right: the share of ads in the feed is a decision of the business, not a prediction.

We reached the same conclusion in the chapter «How to design a system from scratch»: layers with different objective functions are not combined into one score, they are spread across stages.

6. Side effects: after the answer

fn run_side_effects(&self, input: Arc<SideEffectInput<Q, C>>) {
    tokio::spawn(... async move {
        let futures = side_effects.iter()
            .filter(|se| se.enable(input.query.clone()))
            .map(|se| se.run(input.clone()));
        let _ = join_all(futures).await;
    });
}

A fragment of candidate_pipeline.rs · code by X, Apache 2.0, commit 28e414f

The key things here are tokio::spawn and let _ =. The task is started in the background, its result is neither awaited nor checked. The answer to the user has already been sent.

What the side effects do in the feed: record which posts were shown (so as not to show them again), update the cache of ranked posts, emit events about the ads, write statistics about the authors and the logs of the experiments.

All of them must be off the critical path. Recording the impressions may take tens of milliseconds — making the user wait for that, for the sake of something only the next request needs, makes no sense.

The flip side

Side effects are the place where a mismatch between what was shown and what was logged is born. The answer has gone out, while the recording of the impressions has not happened yet, or happened with an error nobody checked: let _ = says outright that the result is ignored.

The practical consequence is visible in the feed itself: there are two independent filters over already shown posts, reading from different sources, plus the Bloom filter in the hydrators. Three mechanisms for one task is not developer paranoia but a direct admission that the recording of impressions is unreliable.

Common mistakes and hidden rocks

What people trip over
  • Thinking that filters can be parallelised. Formally yes, but then each processes the full list instead of the one truncated by the previous ones. With thirty filters that is more expensive.
  • Not noticing that source errors are swallowed. flatten throws Err away silently. The system is resilient but degrades silently — without per-stage metrics that is indistinguishable from normality.
  • Thinking that expensive hydration comes before the filters. It is the other way round: the cheap and mass kind before, the expensive and pointwise kind after the top-K has been selected.
  • Treating blending as part of ranking. It is a separate pipe, for which all the ranked posts are one source among others.
  • Relying on side effects as a guarantee. They start after the answer and their result is not checked. Hence three parallel mechanisms guarding against a repeat impression.

Interview questions

Design the pipeline of a recommender service. Which stages and in what order?

Query hydration → candidate sources → candidate hydration → cheap filters → scoring → selection of the top-K → expensive hydration → expensive filters → the answer → side effects in the background.

The key justifications for the order: sources do not depend on each other and go in parallel; filters do depend and go in turn, from cheap to expensive; everything that requires a trip to another service per «object and user» pair is placed after the selection, because there are an order of magnitude fewer objects there; everything the current request does not need, but the next one does, goes into the background after the answer has been sent.

What should happen if one of the candidate sources is unavailable?

The request should complete on the remaining sources. A recommendation is not a transaction: an incomplete feed is better than an error. In the code we looked at this is literally one line: the results of the sources pass through flatten, which discards the errors.

But a mandatory addition: the degradation has to be visible. Metrics per source are needed — how many times it was called, how many times an error came back, how many candidates were returned. Without that, a source failing on a few percent of the traffic will never be discovered, while some of the users will systematically get a worse feed.

What is the «is it on» method of every stage for?

For experiments. The method receives the request and decides by it — inside, it reads configuration parameters, and those are rolled out to a percentage of the traffic. In this way any stage can be switched on or off for part of the users without branching the code and without shipping a new version.

The second use is conditional logic: a source of topics only works for topical requests, a video filter only when the client asked to exclude video. Instead of branches inside the stage, the condition is lifted to the level of the framework, where it also lands in the metrics automatically.

Why is query hydration split into two rounds?

Because some hydrators depend on the results of others. Mutual follows cannot be computed without having the list of follows; the sequence of actions for the model is built taking the already shown posts into account.

Inside a round the hydrators are parallel, between rounds there is a barrier. This is a simplified substitute for a dependency graph: a real graph would require the links to be described explicitly, while two levels cover almost every case and require nothing beyond putting a hydrator into the right list.

How do you measure where the pipeline loses time?

The measurements have to be built into the framework rather than placed by hand across the stages. In the code we looked at, every type of stage has a run wrapper that calls the substantive method and along the way records the time, the number of input and output candidates, and the name of the stage.

From that, the answers to the two main operational questions come for free: where the bottleneck in time is and which filter drops how much. And under parallel execution it is the worst stage that has to be optimised, not the average one — the total time equals the time of the slowest.

One-screen cheat sheet

Types of stage

Query hydrator, source, candidate hydrator, filter, scorer, selector, side effect.

A stage's interface

Is it on · what it does · what it is called. The wrapper adds the measurements automatically.

In parallel

Sources and hydrators. Time = the time of the slowest.

Sequentially

Filters and scorers: each sees the output of the previous one.

When a source falls

The error is discarded, the feed is assembled from the rest. The degradation is silent — metrics are needed.

Two rounds of hydration

The second is for those that need the results of the first.

The expensive kind after selection

Asking about a hundred selected is cheaper than about a thousand candidates.

Two pipes

The Post Pipeline ranks posts; the Blending Pipeline mixes in ads and non-posts.

Side effects

After the answer, in the background, the result unchecked. Hence the duplicated guard against repeats.

Primary sources