Skip to content

fix(store): report a failed COUNT read instead of returning zero - #2065

Open
kavish-19 wants to merge 6 commits into
DeusData:mainfrom
kavish-19:fix-count-failed-read
Open

kavish-19 wants to merge 6 commits into
DeusData:mainfrom
kavish-19:fix-count-failed-read

Conversation

@kavish-19

@kavish-19 kavish-19 commented Sep 5, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #2012.

Symptom

cbm_store_count_nodes() treats every sqlite3_step() result other than SQLITE_ROW as a count of zero. A read that failed — SQLITE_CORRUPT, SQLITE_BUSY, SQLITE_IOERR — is indistinguishable from a project that genuinely holds no nodes, and index_status renders it as the positive assertion status: "empty". A user or agent reading that concludes the repository was never indexed and kicks off a multi-minute re-index, while the corruption is never surfaced.

Root cause

    bind_text(stmt, SKIP_ONE, project);
    int count = 0;
    if (sqlite3_step(stmt) == SQLITE_ROW) {
        count = sqlite3_column_int(stmt, 0);
    }
    sqlite3_reset(stmt);
    return count;          /* a failed step returns 0, same as an empty table */

There is no else. Both functions already carry an error channel — each returns CBM_STORE_ERR when prepare_cached fails — so callers already receive a negative value from this API today. Only the step result was never reported through it.

Correction (was wrong in the first version of this description). I originally claimed the consumer was already written for a negative count, quoting a if (nodes < 0) { degraded = true; ... } guard. That guard is in build_index_success_response — index_repository's response builder — not in handle_index_status. I found it by grep and did not check the enclosing function. Thanks to @DeusData for catching it.

handle_index_status (src/mcp/mcp.c) reads the counts directly and renders:

yyjson_mut_obj_add_str(doc, root, "status", nodes > 0 ? "ready" : "empty");
...
if (nodes == 0) { /* "Project is empty. Re-run index_repository..." */ }

So the store change on its own would have left the corrupt database from #2012 answering nodes: -1, edges: -1, status: "empty" — the same false all-clear, now with a bare -1 as the count and no hint at all, since nodes == 0 is false. That is why this PR now carries a second commit.

The fix

Two commits, one claim — a failed read must never read as an empty project:

  1. fix(store) — cbm_store_count_nodes / cbm_store_count_edges initialise count to CBM_STORE_ERR, so a non-row step is reported through the error channel each already uses for a failed prepare. A successful step overwrites it, so the healthy path is unchanged.
  2. fix(mcp) — handle_index_status treats a negative node or edge count as a failed read: status: "error", the negative numbers suppressed rather than emitted as counts, and a hint naming the table that could not be read in place of the "Project is empty" re-index advice.

I included count_edges because handle_index_status reads the two as a pair — fixing only the node side would leave a corrupt edges table contributing a bare -1 to the same response. The issue's own reproduction shows the pair contradicting itself (nodes: 0 beside edges: 8).

Not changed: cbm_store_count_edges_by_type has the same shape but is not read by index_status, and cbm_store_count_vectors has the shape without an existing error channel — giving it one would be a new contract. Both felt like separate changes rather than part of this one; happy to follow up if you want them.

Verification

Store half — harness linked against src/store/store.c, inserting a node then dropping the tables after the statements are cached, so the step fails rather than the prepare:

BEFORE (main)   healthy  nodes=1 edges=0
                dropped  nodes=0  edges=0     <- indistinguishable from "empty"

AFTER  (branch) healthy  nodes=1 edges=0      <- healthy path unchanged
                dropped  nodes=-1 edges=-1

index_status half — every branch of the new decision, checked directly:

in(nodes=  8,edges=  8) -> status=ready  nodes=8 edges=8  hint=(none)
in(nodes=  0,edges=  0) -> status=empty  nodes=0 edges=0  hint=Project is empty...
in(nodes= -1,edges=  8) -> status=error  nodes=0 edges=0  hint=nodes table could not be read
in(nodes=  8,edges= -1) -> status=error  nodes=0 edges=0  hint=edges table could not be read
in(nodes= -1,edges= -1) -> status=error  nodes=0 edges=0  hint=nodes and edges tables could not be read

The healthy and genuinely-empty paths are untouched; only a negative count changes behaviour.

Tests added:

  • tests/test_store_nodes.c → store_count_failed_read_is_not_zero
  • tests/test_mcp.c → tool_index_status_reports_an_unreadable_count_as_an_error, using the in-memory server as you suggested: call index_status once (caching the statements), drop the tables, call again, and assert status: "error", no negative counts, and a hint naming the table.

What I could not run locally, and why

I could not run scripts/test.sh or scripts/lint.sh on this machine, and I would rather say so than imply a green run:

  • scripts/build.sh fails at internal/cbm/preprocessor.cpp with fatal error: 'cctype' file not found. The C++ standard-library headers are missing from this machine's Command Line Tools (the SDK contains .../MacOSX.sdk/usr/include/c++/v1/cctype, but clang does not resolve it even with -isysroot). It is a local toolchain fault, unrelated to this change, and it blocks the test runner because that also links preprocessor.o.
  • clang-format, clang-tidy and cppcheck are not installed here.

What I did instead: both changed files compile clean under the project's own warning set (-std=c11 -Wall -Wextra -Werror -Wno-unused-parameter -Wno-sign-compare), and I followed the surrounding formatting by hand. CI is the authority on the full suite and the linters. If it flags anything, I will fix it promptly.

@kavish-19
kavish-19 requested a review from DeusData as a code owner September 5, 2026 06:55
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown

Thanks for opening this — it has been seen, and it is queued.

This note is automated, but it is not a brush-off: it exists so you know where your PR stands instead of having to guess from silence.

Current review status: working through a backlog. 0.9.1-rc.1 is out, so the release freeze that held reviews is over — but it left a large queue of open pull requests behind it, and we are reading through them oldest-first. The background is in discussion #1144.

What that means for this PR, concretely:

  • It will not be closed for inactivity. No stale bot touches pull requests here.
  • It may still sit a while before a human reads it. That is on us, not on you.
  • Older PRs are read first, so a recent one is not being skipped — it is behind a queue.

Things that will genuinely speed it up whenever review does happen:

  • Keep it rebased on main — the tree is moving quickly right now, and a conflicting branch cannot be reviewed as the diff you intended.
  • Get CI green, or say which failures you believe are pre-existing.
  • Keep the change to one claim. Bundled features and refactors get split before they get merged, which costs you a round trip.
  • Every commit needs a sign-off (git commit -s) — CI enforces DCO.

If this fixes a bug, a reproduction we can run is worth more than a description of the symptom.

Thanks for contributing, and sorry in advance for the wait.

@DeusData DeusData added bug Something isn't working ux/behavior Display bugs, docs, adoption UX priority/high Needs near-term maintainer attention; high-impact bug, regression, safety issue, or release blocker. labels Sep 5, 2026
@DeusData

DeusData commented Sep 5, 2026

Copy link
Copy Markdown
Owner

Thanks for this — the store half is exactly right. I reproduced the defect on main (a failed COUNT step really does come back as 0), built the PR merged onto current main and ran 15 suites across store, dump, graph-buffer, artifact, incremental, mcp, httpd and index-resilience: 894 passed, 0 failed, and your new test goes red the moment the store change is reverted, so it binds. Lint is clean and the sign-off is in place.

One thing keeps me from merging it as "Closes #2012", and it is in the PR description rather than the code: the consumer is not already written for a negative count. The guard you quote lives in index_repository's response builder (build_index_success_response, src/mcp/mcp.c ~10185), which does map nodes < 0 to degraded. handle_index_status (src/mcp/mcp.c:6647) is a different path and still does this at ~6669–6674 and ~6691:

int nodes = cbm_store_count_nodes(store, project);
int edges = cbm_store_count_edges(store, project);
...
yyjson_mut_obj_add_str(doc, root, "status", nodes > 0 ? "ready" : "empty");
...
if (nodes == 0) { /* "Project is empty. Re-run index_repository..." hint */ }

So after your change the corrupt database from #2012 would answer index_status with nodes: -1, edges: -1, status: "empty" — the false "empty" the issue is about still fires, only now with a bare -1 in the output and no hint at all. Could you add, in this same PR:

  • in handle_index_status: if nodes < 0 || edges < 0 → status: "error" plus a hint that names the unreadable table (e.g. "the nodes table could not be read; the database may be corrupt — re-run index_repository or remove the project cache"), and do not emit the negative numbers as counts (omit them or emit 0 with the error status)
  • a test for that path next to your store test — the in-memory server in tests/test_mcp.c can produce the failed read the same way your store test does

Leave /api/project-health alone; that server is being replaced. list_projects / get_architecture printing -1 is visible but harmless and can stay for now. With that, this closes #2012 for real and I'll merge it on green. Welcome aboard — this is a good first change.

@kavish-19
kavish-19 force-pushed the fix-count-failed-read branch from 7dfd2c0 to 3e1a228 Compare September 5, 2026 17:20
@kavish-19

Copy link
Copy Markdown
Contributor Author

You're right, and thank you for checking the consumer rather than taking my word for it.

I verified the correction before acting on it: the guard I quoted is at build_index_success_response (index_repository's response builder), and handle_index_status at mcp.c:6647 is a separate path that does exactly what you describe — nodes > 0 ? "ready" : "empty" and a hint guarded on nodes == 0. So the store change on its own would have turned the #2012 database into nodes: -1, edges: -1, status: "empty" — the same false all-clear, now with a bare -1 and no hint, which is worse than what it replaced. I found that guard by grep and never checked its enclosing function; the PR description asserted it as fact. That was my error and I've corrected the description rather than quietly editing it.

Pushed as a second commit, rebased onto current main:

  • handle_index_status now treats a negative node or edge count as a failed read: status: "error", the negative numbers suppressed instead of emitted as counts, and a hint naming the table that could not be read ("The nodes table could not be read; the database may be corrupt. Re-run index_repository(repo_path=...) or remove the project cache and re-index.") in place of the "Project is empty" advice. Both-unreadable gets a combined message.
  • tests/test_mcp.c → tool_index_status_reports_an_unreadable_count_as_an_error, next to the existing index_status test and using the in-memory server as you suggested: call index_status once so the count statements are cached, drop nodes and edges, call again, and assert status: "error", no negative counts, and a hint that names the table and is not the empty-project one.

Left alone as you asked: /api/project-health, list_projects, get_architecture.

Every branch of the new decision, checked directly:

in(nodes=  8,edges=  8) -> status=ready  nodes=8 edges=8  hint=(none)
in(nodes=  0,edges=  0) -> status=empty  nodes=0 edges=0  hint=Project is empty...
in(nodes= -1,edges=  8) -> status=error  nodes=0 edges=0  hint=nodes table could not be read
in(nodes=  8,edges= -1) -> status=error  nodes=0 edges=0  hint=edges table could not be read
in(nodes= -1,edges= -1) -> status=error  nodes=0 edges=0  hint=nodes and edges tables could not be read

The healthy and genuinely-empty paths are unchanged; only a negative count behaves differently.

One thing I still cannot do on this machine, so you know what my "verified" covers: scripts/build.sh dies at internal/cbm/preprocessor.cpp with fatal error: 'cctype' file not found — the C++ standard-library headers are missing from this box's Command Line Tools — and that blocks the test runner, which links preprocessor.o too. clang-format/clang-tidy/cppcheck are not installed either. So both changed files compile clean under the project's own warning set (-Wall -Wextra -Werror -Wno-unused-parameter -Wno-sign-compare), and the table above is the decision logic exercised directly, but the two new tests have not been executed by me — CI is their first real run. If either fails I'll fix it promptly rather than leaving it to you.

cbm_store_count_nodes and cbm_store_count_edges treat every sqlite3_step
result other than SQLITE_ROW as a count of zero. A read that failed —
SQLITE_CORRUPT, SQLITE_BUSY, SQLITE_IOERR — is therefore indistinguishable
from a project that genuinely holds no rows, and index_status renders it
as the positive assertion status "empty". A user or agent reading that
concludes the repository was never indexed and starts a multi-minute
re-index, while the corruption itself is never surfaced.

Both functions already have an error channel: each returns CBM_STORE_ERR
when prepare_cached fails. Only the step result was not reported through
it. index_status is already written for that value — it sets degraded on
a negative node count and clamps a negative edge count — so the guard
existed and simply never fired.

Initialise count to CBM_STORE_ERR so a non-row step is reported as a
failed read. A successful step still overwrites it with the real count,
so the healthy path is unchanged.

Closes DeusData#2012

Signed-off-by: kavish-19 <63698788+kavish-19@users.noreply.github.com>
The store change alone does not close DeusData#2012. handle_index_status reads
the counts directly and renders status as `nodes > 0 ? "ready" : "empty"`,
so a negative count from a failed read still answered "empty" — now with
a bare -1 printed as the count, and with no hint at all, because the
"Project is empty" branch is guarded on `nodes == 0`.

The guard cited earlier lives in build_index_success_response, which is
index_repository's response builder, not this path.

Treat a negative node or edge count as a failed read: report status
"error", suppress the negative numbers rather than emitting them as
counts, and replace the re-index hint with one that names the table that
could not be read.

Closes DeusData#2012

Signed-off-by: kavish-19 <63698788+kavish-19@users.noreply.github.com>
@kavish-19
kavish-19 force-pushed the fix-count-failed-read branch from 3e1a228 to 73c509f Compare September 5, 2026 17:25
@DeusData

DeusData commented Sep 5, 2026

Copy link
Copy Markdown
Owner

The index_status change is exactly what was needed — thank you. One CI gate is red: cppcheck (a lint check, our CI runs cppcheck 2.20) flags the new hint selection: src/mcp/mcp.c:6705:30: style: Condition 'nodes<0' is always false [knownConditionTrueFalse]. It is the nested ternary — after the 'nodes < 0 && edges < 0' branch, cppcheck reasons about the remaining cases and rejects the second 'nodes < 0' test. Our rule for linter findings is to refactor rather than suppress: pick the hint with a plain if / else-if / else chain into a 'const char *hint' (both-unreadable, nodes-unreadable, else edges-unreadable), then add it once — or use a single message that names 'the nodes and/or edges table'. Everything else on the run is green. Keep the sign-off and I'll merge on green.

cppcheck 2.20 flags the nested ternary in handle_index_status:

    src/mcp/mcp.c:6705:30: style: Condition 'nodes<0' is always false
    [knownConditionTrueFalse]

After the `nodes < 0 && edges < 0` arm, cppcheck's value flow takes
`nodes < 0` to be the false half of that conjunction and rejects the
second test. Select the hint with a plain if / else-if / else chain into
a `const char *hint` and add it once, rather than suppressing the check.

The three messages are byte-identical to before — only the line wrapping
of the adjacent literals moved — so status, counts and hint text are
unchanged and the tests added in the previous commit still describe the
behaviour.

Verified with cppcheck 2.21 locally using the flags from
Makefile.cbm's lint-cppcheck target: the finding is reproduced on the
parent commit and gone here, with no new ones. clang-format reports no
drift over the changed lines.

Signed-off-by: kavish-19 <63698788+kavish-19@users.noreply.github.com>
@kavish-19
kavish-19 force-pushed the fix-count-failed-read branch from c72fe32 to 60449f8 Compare September 6, 2026 05:39

@AmirF194 AmirF194 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ran this against 8eb63ec in a clean container (test-infrastructure/Dockerfile, scripts/test.sh --suites httpd with a scratch test added locally).

GET /api/project-health (src/ui/http_server.c:1368-1374) has the same shape handle_index_status had before this PR, and neither commit here touches it. It plugs cbm_store_count_nodes/count_edges straight into the reply with no check, so a corrupt project still comes back "healthy":

baseline:                      {"status":"healthy","nodes":1,"edges":0,...}
after DROP TABLE nodes, edges: {"status":"healthy","nodes":-1,"edges":-1,...}

Same false all-clear #2012 is about, just a bare -1 now instead of a bare 0. Looks like it wants the same nodes < 0 || edges < 0 guard as handle_index_status, with status "corrupt" instead of "healthy".

@kavish-19

Copy link
Copy Markdown
Contributor Author

@DeusData the cppcheck refactor is in as 60449f8: the hint is now picked with a plain if / else-if / else chain into a const char *hint and added once, the message text is byte-identical, and the sign-off is kept. The lint gate is green on the latest run.

The one remaining red job is test-windows-guards, and it isn't from this change: tests/windows/test_daemon_stability.py → section_cold_storm timed out on a daemon-spawn race ("could not accept this client within 30000 ms"). The same job also failed on the unrelated fix/1642-… PR run on 2026-09-16. Could you re-run it, or update the branch? The branch is 149 commits behind main, and merging it from my side needs a token scope I don't have, because main has touched the workflow files since.

@DeusData

Copy link
Copy Markdown
Owner

Thank you for addressing the requested error-path refactor. Current store counting can still turn a failed step into a zero count, and index_status can then report empty, so the underlying concern remains concrete. The requested follow-up is supplied; review and CI attribution are now on the maintainer side. We also need to reconcile the separately discussed HTTP health scope with #2232 rather than silently expand this PR.

@AmirF194

Copy link
Copy Markdown
Contributor

Thanks, tracking the HTTP health scope separately in #2232 makes sense.

@DeusData

Copy link
Copy Markdown
Owner

Thank you, @kavish-19, for seeing this all the way through, including the honest correction to the description and the if/else refactor for cppcheck. And thank you, @AmirF194, for testing the head in a clean container and spotting that /api/project-health has the same shape. As agreed, that one is tracked separately in #2232 and stays out of this PR.

We re-checked this PR against current main:

  • It merges cleanly.
  • No other open PR touches the count functions or the index_status status logic.
  • Every caller that can now see a negative count handles it safely: the plausibility gate treats it as "no dump", and index_repository already maps it to degraded.

The only red on the last run is the known test-windows-guards cold-storm race (#2057). We'll update the branch from our side so CI runs fresh against current main, and it merges on green. Nothing further is needed from you.

This branch has not been deployed

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

Labels

bug Something isn't working priority/high Needs near-term maintainer attention; high-impact bug, regression, safety issue, or release blocker. ux/behavior Display bugs, docs, adoption UX

Projects

None yet

Development

Successfully merging this pull request may close these issues.

cbm_store_count_nodes returns 0 for a failed COUNT(*), so index_status reports a corrupt project as nodes: 0, status: "empty"

3 participants