Feed orchestrator. Builds one paginated feed from many independent async data sources, described by a declarative JSON/dict config tree. You register your own async fetch functions; SmartFeed handles parallel fetch, cross-source dedup, optional rerank, per-session caching, and cursor-based pagination.
This README is written for engineers evaluating how it works and how much integration effort it takes. For internal architecture and control flow, see ARCHITECTURE.md.
- Install
- Quick start
- How it works
- Public API
- Callable contracts you implement
- Config reference
- Wrapper pipeline semantics
- Dedup and dedup_priority
- Redis keys and state
- Error handling and resilience
_smartfeed_debug_info- Integration checklist and footguns
- Testing
- Integration effort: honest read
pip install epoch8-smartfeed
Requires: Python 3.9+, pydantic v2, orjson. An async Redis client (redis.asyncio.Redis) is required only if any Wrapper uses cache or cross-page dedup; without it those features silently fall back to uncached behavior.
Note the distribution name: an unrelated 2015 project owns the PyPI name smartfeed and installs the same top-level smartfeed package — install epoch8-smartfeed, and do not co-install the other one.
from redis.asyncio import Redis
from smartfeed.manager import FeedManager
from smartfeed.models.base import FeedResult
config = {
"feed": {
"type": "wrapper",
"node_id": "main",
"cache": {"session_size": 300, "session_ttl": 300},
"dedup": {"dedup_key": "id"},
"rerank": {"method_name": "my_rerank"},
# NOTE: cache_key is a field on the wrapper, NOT inside "cache".
# "cache_key": "shared_pool",
"data": {
"type": "merger_percentage",
"node_id": "mix",
"items": [
{"percentage": 40, "data": {"type": "subfeed", "subfeed_id": "source_a", "method_name": "source_a"}},
{"percentage": 60, "data": {"type": "subfeed", "subfeed_id": "source_b", "method_name": "source_b"}},
],
},
},
}
# A subfeed fetch function. **kwargs is REQUIRED (see Callable contracts).
async def source_a(user_id, limit, next_page, **kwargs):
# ... fetch your data ...
return FeedResult(data=[{"id": 1}, {"id": 2}], next_page={"page": 2}, has_next_page=True)
async def source_b(user_id, limit, next_page, **kwargs):
return FeedResult(data=[{"id": 3}], next_page={}, has_next_page=False)
# A rerank function. MUST return exactly len(items) items (reorder only).
async def my_rerank(items, session_id):
return sorted(items, key=lambda x: x.get("score", 0), reverse=True)
redis = Redis() # or None: cache/dedup then degrade to per-page passthrough
manager = FeedManager(
config=config,
methods_dict={"source_a": source_a, "source_b": source_b, "my_rerank": my_rerank},
redis_client=redis, # needed for cache/dedup persistence
)
result = await manager.get_feed(session_id="user_123", limit=20, cursor=None)
# result.data -> list of item dicts, each stamped with _smartfeed_debug_info
# result.next_page -> opaque cursor; pass back verbatim as `cursor` next call
# result.has_next_page -> boolConfig is validated when FeedManager is constructed. A malformed config raises pydantic.ValidationError at construction, not on the first get_feed. subfeed_id and node_id share one namespace (cursor keys, Redis keys) and must be unique across the whole tree — duplicates raise at construction.
A feed is a tree of nodes described in config. There are three node kinds (seven concrete type values):
- SubFeed (
subfeed) — a leaf. Calls one of your async functions frommethods_dictand adapts its result into SmartFeed's shape, tagging each item with its source. - Wrapper (
wrapper) — a pipeline over one child. Optional stages applied in order: dedup, rerank, cache, paginate. Each stage is independent. - Mixer — a combiner over several children, run concurrently and merged by a rule. Five concrete types:
merger_percentage,merger_append,merger_positional,merger_percentage_gradient,merger_distribute. "Mixer" is a documentation umbrella, not a class.
The executor walks the tree. For each node it either calls execute() (SubFeed, Wrapper) or builds a mix plan and runs the children concurrently via asyncio.gather, then assembles them. Nodes nest arbitrarily. Each node paginates independently: the returned next_page cursor is a dict keyed per node, and you round-trip it verbatim.
"Concurrent" here means asyncio concurrency on one event loop (sibling sources are awaited together, so page latency is close to the slowest source, not the sum). It is not multi-core.
The entire public surface is one class with two methods.
FeedManager(config: dict, methods_dict: dict, redis_client: Optional[redis.asyncio.Redis] = None)Parses and validates config into a FeedConfig immediately (pydantic v2). methods_dict maps names to your async fetch/rerank callables. redis_client must be an async redis.asyncio.Redis; if None, every Wrapper with cache/dedup degrades to uncached passthrough. Holds no per-request state.
async get_feed(session_id: str, limit: int, cursor: Optional[dict] = None) -> FeedResultcursor=None is treated as the first page ({}). Builds a fresh execution context per call and delegates to the executor, then stamps each dict item's final 0-based page position. Returns FeedResult(data, next_page, has_next_page). next_page is opaque; store it and pass it back as cursor.
Inputs are validated: a non-positive or non-int limit and an empty session_id raise ValueError; a non-dict cursor raises TypeError. Cursor contents are treated as untrusted (they round-trip through clients): a malformed per-node entry (non-dict slice, negative or non-int offset, non-str gen, non-positive-int page) raises ValueError naming the node, regardless of any raise_error setting.
There is no request-parameters channel: only session_id, limit, and the cursor are threaded to sources. Per-request context must be baked into subfeed_params when you build the config, or captured in closures when you build methods_dict for that request.
FeedResult (smartfeed.models.base): data: List[Any], next_page: dict, has_next_page: bool. Items are usually dicts; non-dict items are passed through untouched by stamping/dedup (hence the deliberately loose element type).
async def fetch(user_id: str, limit: int, next_page: dict, **kwargs) -> FeedResult: ...**kwargsis mandatory. The executor injectsctx=into every call. A function without**kwargsraisesTypeErroron every invocation. (And if that source hasraise_error=False, theTypeErroris swallowed into an empty result, so a signature bug looks exactly like "this source had no items today".)user_idis the feedsession_id(not necessarily an account id).limitis this leaf's computed demand for the page. Under a mixer it is a fraction of the page; under a dedup wrapper it is the outstanding refill deficit.next_pageis this subfeed's own cursor slice (cursor.get(subfeed_id, {})), opaque. Echo back whatever you need on the next page.- Return a
FeedResult(or any object exposing.data/.next_page/.has_next_page; there is noisinstancecheck). - On success each dict item is stamped with
_smartfeed_debug_info.source = subfeed_id. Non-dict items are passed through untouched.
async def rerank(items: list, session_id: str) -> list: ...- Must return a list of exactly
len(items)items. Reordering or replacing is allowed; adding or dropping is not — a length mismatch always raisesValueError, even withraise_error=False. itemsare already fetched and deduped, in pre-rerank order.- Exceptions thrown by your function are governed by
rerank.raise_error(see Error handling).
All defaults below are byte-exact from the code. FeedConfig is the top-level shape:
| Field | Type | Default | Meaning |
|---|---|---|---|
feed |
FeedNode |
required | Root node. Discriminated union on type over the 7 node types. |
Unknown top-level keys (e.g. a legacy version tag) are ignored.
| Field | Type | Default | Meaning |
|---|---|---|---|
subfeed_id |
str |
required | Logical name. Stamped as _smartfeed_debug_info.source, is the cursor namespace key, and the identity dedup priority resolves against. |
method_name |
str |
required | Key into methods_dict. Looked up before the try block, so a missing/typo'd name raises KeyError even with raise_error=False. |
subfeed_params |
dict |
{} |
Static kwargs merged into the fetch call. A key colliding with user_id/limit/next_page/ctx raises TypeError at call time (not validated at parse time). |
raise_error |
bool |
True |
True: exceptions propagate. False: swallow, return an empty page. On the swallow path next_page is {subfeed_id: <own cursor slice, unadvanced>} with has_next_page=False, so the source retries the same position next page without clobbering sibling cursors on merge. |
shuffle |
bool |
False |
Shuffle this source's page in place before stamping. Per-page only. |
dedup_priority |
int |
0 |
See dedup_priority. |
Wraps exactly one child with optional stages. cache, dedup, rerank are all independent and optional (all 8 on/off combinations parse).
| Field | Type | Default | Meaning |
|---|---|---|---|
node_id |
str |
required | Namespaces this wrapper's cursor, its cache keys (when cache_key unset), and its debug sub-dict. |
data |
node | required | The single child node. |
cache |
WrapperCache? |
None |
Enables per-session caching. Requires a live redis_client too, else passthrough. |
dedup |
WrapperDedup? |
None |
Enables duplicate removal with overfetch/refill. |
rerank |
WrapperRerank? |
None |
Enables a rerank pass (after fetch+dedup). |
cache_key |
str? |
None |
Sibling of cache, not inside it. Enables a shared base cache across wrappers with the same key (see Wrapper pipeline). No-op unless cache is also set. |
dedup_priority |
int |
0 |
See below. |
WrapperCache:
| Field | Type | Default | Meaning |
|---|---|---|---|
session_size |
int |
300 |
Items built and cached per session (cold build). Pages are sliced from this. |
session_ttl |
int |
300 |
Redis TTL (s) on the data + :meta keys. Refreshed on every warm read — an inactivity timeout, not a fixed expiry. |
WrapperDedup:
| Field | Type | Default | Meaning |
|---|---|---|---|
dedup_key |
str |
required | Item field for identity. Compared as str(item[key]), so 1 and "1" collide. |
missing_key_policy |
"error" | "keep" | "drop" |
"error" |
When an item lacks dedup_key: error raises, keep passes it through un-deduped, drop discards it. |
state_ttl |
int |
300 |
TTL (s) on the Redis seen-set (session-scoped dedup state, both cache and passthrough paths). Refreshed on every warm read, like the cache TTL. |
(The pre-release overfetch_factor / max_refill_loops knobs are gone: the dedup fetch pulls exactly the outstanding deficit and refills until the page is full or the child is exhausted. Unknown keys in the config are ignored, so configs still carrying them parse.)
WrapperRerank:
| Field | Type | Default | Meaning |
|---|---|---|---|
method_name |
str |
required | Key into methods_dict. KeyError if missing. |
raise_error |
bool |
True |
Governs only exceptions thrown by your rerank function. Does not govern the length-mismatch check, which always raises. |
MergerPercentage — type: "merger_percentage". Splits the page by fixed percentages, then concatenates a solid block per branch in config order (it does not interleave).
{"type": "merger_percentage", "node_id": "mix",
"items": [{"percentage": 40, "data": {…}}, {"percentage": 60, "data": {…}}]}| Field | Type | Default | Meaning |
|---|---|---|---|
node_id |
str |
required | Prefix for internal child ids. |
items |
list[{percentage:int, data:node}] |
required | Per-branch demand = limit * percentage // 100. Rounding remainder is topped up (largest-remainder) only when the percentages sum to exactly 100. percentage has no range validator; sum is not enforced. |
dedup_priority |
int |
0 |
There is no [:limit] trim: percentages summing over 100 overflow the page, under 100 underfill.
MergerAppend — type: "merger_append". Equal-share split, concatenated in order, then trimmed to limit.
{"type": "merger_append", "node_id": "append", "items": [{…}, {…}]}| Field | Type | Default | Meaning |
|---|---|---|---|
node_id |
str |
required | |
items |
list[node] |
required | Each child gets limit // n; the remainder goes one-each to the first children. Empty items yields an empty page (no error). |
dedup_priority |
int |
0 |
MergerPositional — type: "merger_positional". Places positional items at fixed slots, default items everywhere else.
{"type": "merger_positional", "node_id": "pos",
"positions": [1, 3, 5, 7], "positional": {…}, "default": {…}}| Field | Type | Default | Meaning |
|---|---|---|---|
node_id |
str |
required | |
positions |
list[int] |
[] |
1-indexed slots, relative to each page's 1..limit window (they repeat every page; they are not absolute feed positions). |
positional |
node | required | Fills the pinned slots. Demand = count of in-range positions. |
default |
node | required | Fills the rest. Demand = limit - pos_count, fixed regardless of actual returns. |
dedup_priority |
int |
0 |
If a source runs dry the other backfills; if the positional source is exhausted the page can underfill (default demand is fixed).
MergerPercentageGradient — type: "merger_percentage_gradient". A two-child percentage merger whose ratio shifts by step points every size_to_step cumulative items, tracked across pages via its own cursor counter.
{"type": "merger_percentage_gradient", "node_id": "grad",
"item_from": {"percentage": 80, "data": {…}}, "item_to": {"percentage": 20, "data": {…}},
"step": 10, "size_to_step": 30}| Field | Type | Default | Meaning |
|---|---|---|---|
node_id |
str |
required | Also the merged-cursor key holding {"page": n}. |
item_from |
{percentage:int, data:node} |
required | Starting branch. Its share falls toward 0. |
item_to |
{percentage:int, data:node} |
required | Ending branch. Its share rises toward 100, then clamps. |
step |
int |
required | Percentage points shifted per segment. Must be > 0 (validated at parse). |
size_to_step |
int |
required | Items per shift. Must be > 0 (validated at parse). |
dedup_priority |
int |
0 |
MergerAppendDistribute — type: "merger_distribute" (note the literal). Round-robin by a key so no two adjacent items share it.
{"type": "merger_distribute", "node_id": "diverse",
"distribution_key": "operator_id", "sorting_key": "score", "sorting_desc": true, "items": [{…}]}| Field | Type | Default | Meaning |
|---|---|---|---|
node_id |
str |
required | |
items |
list[node] |
required | Every child is asked for the full limit (up to N * limit fetched), then pooled and trimmed to limit. Higher backend load than items shown. |
distribution_key |
str |
required | Group key. entry[key] with no missing-key policy — KeyError if an item lacks it. |
sorting_key |
str? |
None |
If set, the whole pool is sorted by this key before grouping. KeyError if missing. |
sorting_desc |
bool |
False |
Sort direction. Ignored if sorting_key is None. |
dedup_priority |
int |
0 |
A Wrapper has two paths, chosen at runtime:
- Cached (
cacheset andredis_clientpresent). First request for a session is a cold build: fetchsession_sizeitems, dedup, rerank, store the batch in Redis, serve page 1. Later pages slice out of the stored batch with no refetch. When the batch is exhausted the pipeline continues from where the child left off (it does not restart the source). Concurrent first requests for a session are serialized by a cold-build lock, so the child is fetched once. Rerank runs once per cold build over the whole batch, not per page. - Passthrough (no
cache, or no Redis). The pipeline reruns every page.
Dedup contract (session-scoped). Within one continuous forward scroll, a user sees each item at most once — no matter how far they scroll, and across the internal rebuild boundaries the cursor crosses. Dedup is scoped to the session, not across sessions: re-sending an empty cursor (or page 1) starts a fresh scroll and may repeat items already shown in a previous scroll. Both paths honor this via a Redis seen-set (:seen): the passthrough path persists shown ids across pages; the cached path carries the seen-set across cold rebuilds and resets it when a fresh scroll begins (empty cursor). Neither path over-fetches, so no unique item is silently skipped, and refill fills each page to limit unless the source is genuinely exhausted.
Shared base cache (cache_key). Two wrappers with different node_id but the same cache_key share one fetched+deduped base dataset (keyed by cache_key + the child's config hash + the continuation position), each applying its own rerank on top. Each page-window is its own shared segment, so a shared wrapper paginates past session_size normally. A distributed lock ensures only one caller does each cold fetch. Caveat: whichever wrapper wins the cold-build race bakes in its session_size and dedup settings for all sharers; a sibling with different settings reads that same pool. The shared base dedups within each segment; for strict cross-scroll uniqueness prefer a non-shared cached wrapper. Use cache_key for A/B testing two rerankers over one item pool; do not rely on per-sharer dedup/size differences.
Every node has dedup_priority: int = 0. When a Wrapper's dedup sees the same key from two sources, the item whose source subtree has the higher effective priority wins; equal priority means first-seen wins (order follows the mixer's child list). Priority propagates down: a node with a non-zero dedup_priority overrides its whole subtree unless a descendant sets its own non-zero value. It is config-only and never written onto items.
All SmartFeed state lives in Redis under sf:{session_id}:.... config_hash is the first 8 hex chars of an md5 over the node's sorted JSON dump, so any config change moves the key and old data ages out.
sf:{session_id}:{cache_key or node_id}:{config_hash} cached session batch (Redis LIST of orjson items)
sf:{session_id}:{cache_key or node_id}:{config_hash}:meta gen nonce, child cursor, child has_next
sf:{session_id}:{cache_key or node_id}:{config_hash}:coldlock cold-build lock (ttl 10s)
sf:{session_id}:{node_id}:{config_hash}:seen dedup seen-set (session-scoped; cache + passthrough)
sf:{session_id}:{cache_key}:{child_config_hash}:{segment} shared base segment (when cache_key set)
sf:{session_id}:{cache_key}:{child_config_hash}:{segment}:meta shared base segment meta
sf:{session_id}:{cache_key}:{child_config_hash}:{segment}:lock shared segment cold-build lock (ttl 10s)
({segment} is "0" for page 1 and a short hash of the continuation cursor for later windows.)
TTL (session_ttl) is refreshed on every warm read, so an active user's cache does not expire mid-scroll. The :seen set has its own state_ttl. Each cold build stamps a random gen nonce into the cursor and :meta; if a returning cursor's gen no longer matches (cache expired or rebuilt), SmartFeed rebuilds cleanly from page 1 rather than resuming into an inconsistent state.
Note the next_page cursor shape is config-dependent: a cached wrapper hides the child cursor behind {node_id: {offset, gen}} (an absolute offset, so a client may change limit between pages without skipping or repeating items), an uncached wrapper exposes the raw child cursor, and the gradient injects {node_id: {page}}. Adding or removing a cache block changes the cursor your clients round-trip.
Error handling is opt-in per node. By default (raise_error=True everywhere) one failing source or rerank fails the entire get_feed call, including sibling branches that already succeeded — asyncio.gather runs without return_exceptions=True, and FeedManager has no try/except of its own. For fail-soft behavior, set raise_error=False on the specific SubFeed/rerank nodes.
Five sharp edges an integrator will hit:
- Missing
method_nameis looked up before the try block, so it raisesKeyErrorregardless ofraise_error. - Rerank length mismatch always raises
ValueError, regardless ofrerank.raise_error. - A subfeed function without
**kwargsraisesTypeErroron every call (the injectedctxhas nowhere to go). Withraise_error=Falsethis is swallowed into an empty page — a signature bug that reads as "no items". subfeed_paramskey collisions (withuser_id/limit/next_page/ctx) raiseTypeErrorat call time, with the same silent-swallow caveat underraise_error=False.- Malformed cursor contents raise
ValueErrorregardless ofraise_error— cursors are validated as untrusted client input (see Public API). Catch it at your API boundary and treat it as a bad request, not a server error.
Silent behavior downgrades (no exception): cache without a redis_client; cache_key without cache; divergent shared-cache_key settings. These ship without error and only show up as degraded cache/dedup metrics.
Every dict item carries a _smartfeed_debug_info bundle — a plain dict; there is no model class behind it. The core writes these (all positions 0-based):
source— the producingsubfeed_id.smartfeed_position— final 0-based page position (set byFeedManager). Wrappers also stamp a pre-rerank position under theirnode_id.rerank_position— 0-based post-rerank index under the wrapper'snode_id, only when rerank is configured.
Any other fields (strategy, rrf_score, feature_score, feature_position, ...) are conventions only; the core never writes them — your fetch/rerank callables may attach whatever extra keys they like. The pipeline operates on plain dicts throughout.
What you must provide:
- One async fetch function per
method_name, each accepting**kwargsand returning aFeedResult. - Any rerank functions, each returning exactly
len(items)items. - The
methods_dict(a plain dict; no registration API). - The config tree. pydantic checks types and shapes only; semantic correctness is yours — matching
dedup_keys across siblings, percentages summing to 100,cache_keyplaced on the Wrapper, distribution/sorting keys present on items. - An async
redis.asyncio.Redisif you want caching or cross-page dedup persistence. - Explicit
raise_error=Falseon each node you want to fail soft (no global switch). - Keep cached items orjson-serializable (
str/int/float/bool/None/list/dict). ADecimalthat works in passthrough throws once caching serializes the batch.
Footguns, condensed:
**kwargsis load-bearing on every subfeed function (absorbs injectedctx).cache_keygoes on the Wrapper, not insidecache, and is a no-op withoutcache.cachewithout Redis, and sharedcache_keywith divergent settings, degrade silently.- Percentage mixers do not interleave and do not enforce sum==100; positional slots are per-page; distribute over-fetches and has no missing-key policy.
- Cursor shape depends on whether a
cacheblock is present.
fakeredis.aioredis.FakeRedis()is the standard fixture; the whole integration is testable without a real Redis.pytest+pytest-asyncio(@pytest.mark.asyncio). Both are pinned dev deps.tests/conftest.pyhas a minimalMETHODSdict (items / empty / error) as a starting point.- There is no built-in validator for your subfeed signatures. Add a smoke test that runs each configured method through
get_feedonce before deploy — specifically to catch the**kwargs/ctxandsubfeed_params-collision classes, which surface only at call time.
pytest tests/ -v