Skip to content

bug(kb): search_by_metadata examines at most 500 facts, chosen by Redis key order, and reports success either way #17713

Open

Description

@mrveiss

search_by_metadata examines at most 500 facts, chosen by Redis key order, and reports success either way

Found while building #17546's removal path, which addresses a Knowledge Base document by its
file_path metadata. Filed separately because the limitation belongs to a shared capability,
not to the watch folder — and it is reachable from a public HTTP route.

The mechanism

autobot-backend/knowledge/metadata.py:

async def _get_filtered_fact_keys(self) -> List[str]:
    fact_keys = await asyncio.to_thread(self.redis_client.keys, "fact:*")
    return [k for k in fact_keys if not any(x in ... for x in [":tags", ":category", ":all"])]

async def search_by_metadata(self, field_name, value, operator="eq", limit=50):
    fact_keys = await self._get_filtered_fact_keys()
    for key in fact_keys[:500]:
        ...
    return {"status": "success", "fact_ids": matching_facts, "count": len(matching_facts), ...}

Three properties, and the third is the one that makes this more than a cap.

1. It is bounded at 500 regardless of corpus size. A KB with 5,000 facts has 4,500 that this
search cannot reach. limit (default 50) bounds the results; the 500 bounds what is examined,
and only the first is a caller-visible parameter.

2. The 500 are not a stable subset. redis.keys() returns keys in keyspace iteration order,
which is not guaranteed and shifts as keys are added, removed or rehashed. So which 500 facts get
examined can differ between two consecutive identical calls. The same query can find a document
and then not find it
, with nothing changed by the caller.

3. Both outcomes report "status": "success". A truncated scan is indistinguishable from an
exhaustive one. There is no truncated flag, no examined count, nothing that says 500 of N were
looked at.

Why this is not only an internal concern

api/knowledge_metadata.py:297 exposes it as an HTTP route, and the handler only branches on
status != "success". A truncated search returns 200 with a partial result set presented as
complete
. The route's own docstring advertises operator and limit as the controls; neither
tells the caller what was searched.

The shape

Did not find and could not look are rendered identically, and that is the family this
repository has been finding all week (#15826). It is sharper here than usual because count is a
real number and status is a real success
— nothing about the response is malformed. It is a
correct answer to "what matched among the first 500 keys Redis happened to return", presented as an
answer to "what matches".

What #17546 does about it, and why that is not a fix

#17546's remove_watched_file reports not_found to the error counter rather than treating it as
a clean no-op, precisely because the search cannot promise exhaustiveness — so an unremovable
document is visible rather than looking like a file that was never ingested. That is containment
at one call site, not a repair.
Every other caller, including the HTTP route, still reads a
truncated result as complete.

Acceptance criteria

  • A truncated search is distinguishable from an exhaustive one in the return value and in
    the HTTP response — an examined count and a truncated flag, or an explicit error when the
    corpus exceeds what the scan can cover. The current status: success for both is the defect.
  • The scan is either exhaustive for the queried predicate, or bounded in a way the caller
    chooses and can see. If a bound stays, it is a named constant with its reason recorded, not a
    literal [:500] mid-expression.
  • Ordering is not left to redis.keys(). Either iterate deterministically or state that the
    subset is arbitrary — a result that changes between identical calls, with no input changed,
    is the property to remove or to disclose.
  • redis.keys("fact:*") is reviewed in the same pass. It is an O(N) blocking command against
    the whole keyspace on every call, which is a production hazard independent of the 500 —
    SCAN is the usual answer. Not folded in here: decide it, do not let it ride.
  • A test with more than 500 facts asserts that a fact beyond the scan is either found or
    reported as unexamined — never silently absent.

Related

#17546 (found here; contains it at one call site), #15826 (the umbrella: a check that reports
clean having examined nothing), #15165 (knowledge search under load)

Activity

  1. mrveiss commented on Sep 28, 2026

    @mrveiss
    OwnerAuthor

    Triage: priority: high, and deliberately no milestone — it does not meet #17639's blocking criteria

    Labelled priority: high for the reasons in the body: a public route returns 200 with a partial result set presented as complete, and the subset examined is not stable between identical calls.

    Milestone left unset on purpose, by applying #17639's stated test rather than by feel. That test is:

    "v0.9.0 ships something broken or missing without it: a dead user-facing path, a security exposure in shipped code, data loss, a broken install or update path, or a release-pipeline defect. A priority label alone was not enough; the issue itself had to show the shipped breakage."

    Checked against each:

    criterion this issue
    dead user-facing path no — the route answers, and answers plausibly
    security exposure in shipped code no
    data loss no — nothing is destroyed; a document can become unfindable by this search, which is different
    broken install or update path no
    release-pipeline defect no

    So it does not qualify, and adding it would be exactly the pattern #17639 measured — 41 issues in the milestone that do not belong there, taking it from 123 to 82 if moved out. An issue being genuinely serious is not the test; the test is whether the release ships broken without it.

    The honest summary: this is a high-priority correctness bug that is not release-blocking under the criteria this repo has written down. If the owner reads the non-determinism as worse than that — a search that finds a document and then does not, with no input changed, is hard to reason about from outside — that is a milestone decision for them, and the argument is here to be picked up rather than assumed.

    One thing that would change the answer: if any shipped feature depends on this search being exhaustive, that feature is a dead-or-wrong user-facing path and the first criterion is met. I checked the callers I could see — api/knowledge_metadata.py:297 (the route itself) and #17546's removal path, which is not shipped yet. I did not audit every consumer, so that gap is stated rather than left as an implied "no".

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions