Skip to content

fix(mcp): phantom child rows in cache — filter tombstones + reconcile on parent sync#825

Merged
dougborg merged 5 commits into
mainfrom
fix/803-phantom-child-rows
May 26, 2026
Merged

fix(mcp): phantom child rows in cache — filter tombstones + reconcile on parent sync#825
dougborg merged 5 commits into
mainfrom
fix/803-phantom-child-rows

Conversation

@dougborg
Copy link
Copy Markdown
Owner

Closes #803

Summary

list_sales_orders(include_rows=true) and list_purchase_orders were returning line items — sometimes duplicated — that didn't exist on the order in Katana, while get_<entity> (which bypasses the cache) showed the truth. Repros on 4 sales orders (#WEB20523, #WEB20561, #WEB20518, #WEB20484) all traced to a row deleted in the Katana UI that never propagated to the cache.

Two independent root causes, two layered fixes shipped together as the issue prescribes.

Fix A — filter deleted_at IS NULL on row reads

The eager-load and count-subquery paths in _list_<entity>_impl lacked the predicate, so tombstoned children leaked through even after the row sync had correctly tombstoned them. Applied in 3 files:

  • sales_orders.py (selectinload + row_count_subq)
  • purchase_orders.py (same pair)
  • stock_transfers.py (same pair)

Stock-adjustment row reads aren't affected here — CachedStockAdjustmentRow doesn't carry deleted_at, so Fix B alone handles its dropped rows via hard-delete reconciliation.

Fix B — reconcile children on parent sync

The watermarked row sync (_SALES_ORDER_ROW_SPEC and friends) misses any row deleted upstream without both a deleted_at propagation and an updated_at bump. UI deletions, color-swap edits, and bad-import cleanup all fall in that gap — the row vanishes from find_<entity> and never returns from the /.../rows feed, so the cache holds it forever until manual rebuild_cache.

New EntitySpec.reconcile_children opts in. After the parent batch's bulk upsert lands, the sync fires one
DELETE FROM <child> WHERE fk = parent_id AND id NOT IN (seen_ids) per parent — treating the parent's nested row list as authoritative.

Enabled on the 4 specs whose parent payload returns the full current row set: SO, PO, stock_adjustment, stock_transfer. Left False on manufacturing orders (recipe rows live behind a separate watermarked endpoint).

Key invariants pinned in tests:

  • Seeding seen_by_parent from cached_parents (not cached_children) so empty-rows responses still wipe the cache for that parent.
  • synchronize_session=False because aiosqlite refuses to commit with the ORM-aware SELECT cursor still open.
  • WHERE fk = parent_id per parent keeps the DELETE scoped — unrelated parents are untouched.
  • __post_init__ validates the flag requires the child-cls triple.

Test plan

  • uv run poe check passes (3553 unit/integration + 21 browser tests, lint, typecheck, format)
  • Fix A regression: 3 tests across SO/PO/ST verify both include_rows=True payload and row_count exclude soft-deleted rows
  • Fix B regression: 5 tests cover deletes-missing-rows, empty-array case, scoped-per-parent, disabled-by-default (via MO spec), and post-init validation
  • End-to-end: test_phantom_rows_cleared_on_next_list_sales_orders exercises both fixes through _list_sales_orders_impl (the actual user-facing surface)

Out of scope (filed if needed)

Per the issue's follow-ups:

  • Retiring _<entity>_ROW_SPEC polling now that Fix B covers the gap — verify in a week of real use first.
  • Propagating request-level include_deleted into Fix A's row filter so opt-in callers see deleted rows too.
  • Auditing verify_order_document and other non-list consumers of selectinload(<rows>) for the same filter.

🤖 Generated with Claude Code

Copilot AI review requested due to automatic review settings May 22, 2026 20:39
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Fixes “phantom” (stale/duplicated) cached child rows for several entities by (1) filtering soft-deleted rows out of list responses and (2) reconciling cached children against the authoritative nested row list returned by the parent endpoint during sync.

Changes:

  • Add deleted_at IS NULL filtering to both eager-loaded row reads (selectinload) and correlated row_count subqueries for SO/PO/Stock Transfers.
  • Introduce EntitySpec.reconcile_children and implement per-parent child reconciliation deletes during sync for SO/PO/Stock Adjustments/Stock Transfers.
  • Add regression + end-to-end tests validating both the row filtering and reconciliation behavior.

Reviewed changes

Copilot reviewed 8 out of 9 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
uv.lock Bumps katana-mcp-server version (lockfile update).
katana_mcp_server/src/katana_mcp/tools/foundation/sales_orders.py Filters soft-deleted SO rows in both include_rows and row_count paths.
katana_mcp_server/src/katana_mcp/tools/foundation/purchase_orders.py Filters soft-deleted PO rows in both include_rows and row_count paths.
katana_mcp_server/src/katana_mcp/tools/foundation/stock_transfers.py Filters soft-deleted transfer rows in both include_rows and row_count paths.
katana_mcp_server/src/katana_mcp/typed_cache/sync.py Adds reconcile_children to EntitySpec and performs per-parent child reconciliation deletes after upserts.
katana_mcp_server/tests/tools/test_sales_orders.py Adds tests pinning SO row filtering behavior in both query paths.
katana_mcp_server/tests/tools/test_purchase_orders.py Adds tests pinning PO row filtering behavior in both query paths.
katana_mcp_server/tests/tools/test_stock_transfers.py Adds test ensuring stock transfer soft-deleted rows are excluded.
katana_mcp_server/tests/test_typed_cache_invalidation.py Adds reconciliation-focused tests + an end-to-end phantom-row regression test.

Comment thread katana_mcp_server/src/katana_mcp/typed_cache/sync.py Outdated
@dougborg dougborg force-pushed the fix/803-phantom-child-rows branch from 3d0cf25 to 57f82f0 Compare May 22, 2026 21:11
#803)

The cache-backed list paths for sales orders, purchase orders, and stock
transfers eager-load child rows via ``selectinload`` (or count them via a
correlated subquery when ``include_rows=False``). Both shapes lacked the
``deleted_at IS NULL`` predicate, so once the row-sync correctly
tombstoned a row, ``list_*`` still surfaced it as a phantom — diverging
from ``get_<entity>``, which reads live from Katana and is correct.

Apply the predicate inside the loader option for the eager-load case
(via ``relationship.and_(...)``) and in the ``where`` clause of the
count subquery. Stock-adjustment row reads aren't affected here:
``CachedStockAdjustmentRow`` doesn't carry ``deleted_at`` — its sync
spec hard-deletes via the reconciliation pass landing in a follow-up
commit.

Regression coverage seeds a mixed cache (one live row + one tombstoned
row) and asserts both the ``include_rows=True`` payload and the
``row_count`` summary drop the tombstone.
Copilot AI review requested due to automatic review settings May 22, 2026 21:13
@dougborg dougborg force-pushed the fix/803-phantom-child-rows branch from 57f82f0 to 6f9209c Compare May 22, 2026 21:13
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 8 out of 9 changed files in this pull request and generated 2 comments.

Comment thread katana_mcp_server/src/katana_mcp/typed_cache/sync.py Outdated
Comment thread katana_mcp_server/src/katana_mcp/typed_cache/sync.py
@dougborg dougborg force-pushed the fix/803-phantom-child-rows branch from 6f9209c to 65936c8 Compare May 22, 2026 21:22
@dougborg dougborg requested a review from Copilot May 22, 2026 21:49
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 8 out of 9 changed files in this pull request and generated 1 comment.

Comment thread katana_mcp_server/src/katana_mcp/typed_cache/sync.py Outdated
dougborg added 2 commits May 22, 2026 17:12
The watermarked row-sync (``_<entity>_ROW_SPEC``) misses any row deleted
upstream without a ``deleted_at`` propagation AND an ``updated_at`` bump.
UI deletions, color-swap edits, and bad-import cleanup all fall in that
gap — the row vanishes from ``find_<entity>`` *and* never returns from
the dedicated ``/.../rows`` feed, so the cache holds it forever until a
manual ``rebuild_cache``. This was the second root cause of the phantom-
row repros on #WEB20523 / #WEB20561 / #WEB20518 / #WEB20484.

Treat the parent payload's nested row list as authoritative. After the
bulk-upsert of parents and children lands, fire one
``DELETE FROM <child> WHERE fk = parent_id AND id NOT IN (seen_ids)``
per parent in the batch. Seeding ``seen_by_parent`` from
``cached_parents`` (not ``cached_children``) is load-bearing: a parent
in the response with zero children still wipes the cache for that
parent, which the cached-children-only walk would silently skip.
``synchronize_session=False`` keeps aiosqlite happy — the ORM-aware
sync strategy holds a SELECT cursor open through commit, which the
single-cursor backend refuses.

New ``EntitySpec.reconcile_children`` opts in. Enabled on SO, PO,
stock_adjustment, and stock_transfer — every spec whose parent endpoint
returns the full current row set. Left ``False`` on manufacturing
orders (no inline rows; recipe rows live behind their own watermark).
``__post_init__`` validates the flag requires the child-cls triple, so
misconfiguration surfaces at import time.

Coverage:

- ``test_reconcile_deletes_rows_missing_from_parent_response``
- ``test_reconcile_handles_empty_rows_array`` (the empty-rows seed case)
- ``test_reconcile_does_not_touch_unrelated_parents`` (scoped WHERE)
- ``test_reconcile_disabled_leaves_extra_rows`` (default-off contract,
  via the MO spec)
- ``test_reconcile_post_init_requires_children``
- ``test_phantom_rows_cleared_on_next_list_sales_orders`` (end-to-end
  through ``_list_sales_orders_impl``)
Catch up the lock with pyproject.toml — v0.93.1 release commit (288b1ec)
only updated pyproject.toml, leaving uv.lock pinned at v0.93.0.
@dougborg dougborg force-pushed the fix/803-phantom-child-rows branch from 65936c8 to dbc670c Compare May 22, 2026 23:12
@dougborg dougborg requested a review from Copilot May 26, 2026 16:08
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.

@dougborg dougborg enabled auto-merge (rebase) May 26, 2026 16:24
@dougborg dougborg merged commit 5e70f84 into main May 26, 2026
14 checks passed
@dougborg dougborg deleted the fix/803-phantom-child-rows branch May 26, 2026 16:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(mcp): phantom child rows in cache — reconcile on parent sync + filter tombstones on row reads

2 participants