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 stage | What it does | An example from the feed |
|---|---|---|
| Query hydrator | Pulls data onto the request before any candidates exist | The lists of follows, blocks, already shown posts |
| Source | Returns candidates | Fresh posts from follows, retrieval, cluster similarity |
| Candidate hydrator | Pulls data onto every candidate | The text of the post, the author, counters, language, semantic ID |
| Filter | Throws out part of the candidates | Older than 48 hours, your own posts, already shown |
| Scorer | Puts numbers on things | The model, the combination into a score, re-ranking |
| Selector | Selects and orders | Take the top-K, mix in the ads |
| Side effect | Does something after the answer | Record the impressions, update the cache, emit events |
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
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.
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.
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.
| Hydrator | What it brings | What for |
|---|---|---|
ScoringSequenceQueryHydrator | The sequence of actions for the ranking model | The model's main input — that same history from x04 |
RetrievalSequenceQueryHydrator | The sequence for retrieval | A separate one, because the model is different |
FollowedUserIdsQueryHydrator | The list of follows | Needed by the source of posts from follows and by the in-network labelling |
BlockedUserIdsQueryHydrator and Muted… | Blocks and muted accounts | Filtering |
MutualFollowQueryHydrator | Mutual follows | An addition to the weight of a reply: a conversation with someone you know is valued separately |
ImpressionBloomFilterQueryHydrator | A Bloom filter with the shown posts | Do not show twice |
UserDemographicsQueryHydrator | Country, language, age | Features of the model |
UserInferredGenderQueryHydrator | Inferred gender | A feature of the model |
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.
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.
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
- 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.
flattenthrowsErraway 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
- candidate_pipeline.rs — the order of the stages, the parallelism, the error handling.
- filter.rs, source.rs and their neighbours — the definitions of the stage types.
- phoenix_candidate_pipeline.rs — all 75 stages of the feed in order.
- The course: the chapter «The architecture of the runtime» on the runtime architecture and resilience, the chapter «The multi-stage funnel» on multi-stage design, the chapter «How to design a system from scratch» on layers with different goals.