Commit 55c0f05
authored
fix: deterministic source-import missing detection and denial-guard supersession (#154)
* fix: deterministic source-import missing detection and denial-guard supersession
Two failing offline-gate tests and two latent defects:
- mark_source_import_items_missing() gains an explicit source_keys path: a
complete scan now marks exactly the planned-missing rows instead of relying
on the last_seen_at < run_started heuristic, which a renamed file defeated
(rename stamps last_seen_at=now, so the next run's delete never matched).
The documents importer shares ObsidianImporter.import_scan and inherits this.
- _clear_superseded_denial() now supersedes a billing denial on persisted-state
content change, not wall-clock comparison. Equal-timestamp entitlements made
the strict '>' stick forever after a valid reconnect, and a naive '>=' would
let a pre-denial record saved in the same coarse clock tick resurrect grants.
cloud_session.saved_session_digest() fingerprints the session bytes so write
order is observed without exposing credentials.
- tests: deflake ULID same-millisecond ordering (repair cursor sweep count) and
the archived_at == valid_from boundary (half-open temporal exclusion).
Verified: full offline suite green, ruff/pyright clean, eval gates unchanged.
* fix: review findings — unknown-baseline guard and full-manifest paging
P1 (v2_api): _clear_superseded_denial now requires both the captured baseline
digest and the current digest to be known before treating a difference as a
superseding rewrite. An unreadable state file at denial time left None as the
baseline; a later recovered read then differed from it and cleared the guard
even though the file still held the pre-denial active entitlement. Unknown
baselines now stick (fail-closed) until the next denial cycle or restart.
P2 (source imports): list_source_import_items gains offset paging and
ObsidianImporter._all_source_items() pages the full manifest (bounded at 200k
rows) for both import planning and link reconciliation. A manifest grown past
the 10k single-page cap left historical rows beyond the page invisible, so the
explicit source_keys missing-marking path skipped them while the run reported
itself complete.
Verified: unknown-baseline fail-closed probe, reconnect-supersedes probe,
10,050-row paging probe (complete, duplicate-free), full affected test files
(obsidian importer/schema, document importer, hosted plan) exit 0, ruff+pyright
clean.
* fix: round-2 review — generation-guarded missing marks and bounded paging
Three follow-up findings on the source-import missing path:
- Concurrent imports: the exact-key update is now per-key and conditioned on
the row still matching its planned generation (last_seen_at, last_seen_job_id,
live state). A newer run that re-upserts a row after an older run planned it
missing keeps its fresh state instead of being clobbered back to missing.
- SQLite host-parameter limits: the oversized IN clause is gone entirely —
per-key updates in one transaction scale to any manifest (200k keys marked
in ~6s locally).
- Paging cap: _all_source_items() now reports whether the full manifest was
read. Beyond the 200k-row memory bound the run is marked partial, missing
finalization is skipped, and link reconciliation refuses to retire edges on
the incomplete view.
Probes: stale-plan-vs-refreshed-row preserved; 200,050-row manifest flags
truncation and marks all 200k planned keys; affected suites (obsidian
importer/schema, document importer, hosted plan, consolidate) exit 0;
ruff+pyright clean.
* fix: round-3 review — parse-bound denial digests and keyset manifest paging
- Denial guard race: the supersession digest now travels with the parse.
_session_entitlement_snapshot() and _read_entitlement_cache_snapshot() return
the entitlement plus a sha256 of the exact bytes it was parsed from, and
_clear_superseded_denial() compares that observed digest against the denial
baseline instead of re-reading a file that may have changed since. The denial
persistence write landing between a license read's parse and its check can no
longer pose as a superseding reconnect. cloud_session gains
saved_entitlement_snapshot(); _session_entitlement()/_read_entitlement_cache()
remain as thin wrappers for their other callers.
- Manifest paging: OFFSET is replaced by a (relative_path, id) keyset cursor.
OFFSET on a live ORDER BY lets a concurrent rename shift an unread row across
the page boundary so it is silently skipped while the pager reports a complete
read; the keyset cursor returns every row at or after the cursor exactly once,
and a row renamed below the read range degrades into content-hash rename
detection.
- Cap boundary: _all_source_items() probes one row past the 200k-row memory cap,
so a manifest of exactly 200,000 rows reads as complete instead of forcing
every such import to partial.
Probes: mid-read denial write leaves the guard set while a post-write reconnect
parse clears it; exactly-at-cap manifest reports complete; beyond-cap reports
partial with a duplicate-free plan set; 200k-row keyset read in ~2s. Affected
suites (obsidian importer/schema, document importer, hosted plan, consolidate)
exit 0; ruff+pyright clean.
* fix: round-4 review — finalize only rows the guarded update actually marked
mark_source_import_items_missing() returns the source_keys it actually marked
instead of a count: the guarded per-key path collects keys whose generation
predicates matched, and the heuristic path selects its rows before updating
(chunked IN under host-parameter limits). import_scan records job history and
the completed report from that reality — rows the generation guard left live
because a concurrent import refreshed them are recorded as skipped, never as
missing, so the job receipt can no longer claim a live, newer source was
removed.
Probe: stale plan vs refreshed row -> only the stale key returned and marked;
refreshed row stays imported and out of the missing report; heuristic path
returns keys. Affected suites exit 0; ruff+pyright clean.
* fix: round-5 review — constant-time finalized check in missing finalization
The guard-skipped loop tested membership against the finalized list, scanning
up to 200k dicts per missing item — quadratic job finalization that can appear
hung on a large complete import. Test the item's source_key against the
already-built marked_keys set instead.
* test(hosted): pin byte-identical denial-supersession invariant
- Add the invariant test the review round identified as missing: replaying
the exact pre-denial session bytes after _mark_authoritative_denial must
NOT clear the process guard — supersession is content-digest-based, never
timestamp-based, so coarse-clock ties cannot resurrect grants.
- Clarify the finalized-keys check in obsidian_import: hash-set membership
(O(1) average) replaced a quadratic list scan; it is a complexity fix,
not a timing-sensitive comparison (commit 4813f6a's "constant-time"
wording overclaimed).
* fix(import): page import previews like execution
Preview plans read the vault manifest through the same keyset-paged reader as execution, so manifests larger than one list page no longer make previews silently drop beyond-boundary rows; they are reported as missing like any other unseen source. Regression test seeds a real 10k-row manifest and fails against the unpaged reader (stash-verified).
* fix(import): stabilize manifest pagination under concurrent renames
* fix(import): fail closed on truncated previews and denials
* fix(import): propagate late manifest truncation
* fix(entitlement): release lock during denial probes1 parent 92e70ed commit 55c0f05
10 files changed
Lines changed: 632 additions & 62 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
108 | 108 | | |
109 | 109 | | |
110 | 110 | | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
111 | 116 | | |
112 | 117 | | |
113 | 118 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
531 | 531 | | |
532 | 532 | | |
533 | 533 | | |
| 534 | + | |
| 535 | + | |
| 536 | + | |
| 537 | + | |
| 538 | + | |
| 539 | + | |
| 540 | + | |
| 541 | + | |
| 542 | + | |
| 543 | + | |
| 544 | + | |
| 545 | + | |
| 546 | + | |
| 547 | + | |
| 548 | + | |
| 549 | + | |
| 550 | + | |
| 551 | + | |
| 552 | + | |
| 553 | + | |
| 554 | + | |
| 555 | + | |
| 556 | + | |
| 557 | + | |
| 558 | + | |
| 559 | + | |
| 560 | + | |
| 561 | + | |
| 562 | + | |
| 563 | + | |
| 564 | + | |
| 565 | + | |
| 566 | + | |
| 567 | + | |
| 568 | + | |
| 569 | + | |
| 570 | + | |
| 571 | + | |
| 572 | + | |
| 573 | + | |
| 574 | + | |
| 575 | + | |
| 576 | + | |
| 577 | + | |
| 578 | + | |
| 579 | + | |
| 580 | + | |
| 581 | + | |
| 582 | + | |
| 583 | + | |
| 584 | + | |
| 585 | + | |
| 586 | + | |
| 587 | + | |
| 588 | + | |
| 589 | + | |
| 590 | + | |
| 591 | + | |
| 592 | + | |
| 593 | + | |
534 | 594 | | |
535 | 595 | | |
536 | 596 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
3733 | 3733 | | |
3734 | 3734 | | |
3735 | 3735 | | |
3736 | | - | |
| 3736 | + | |
| 3737 | + | |
| 3738 | + | |
| 3739 | + | |
| 3740 | + | |
| 3741 | + | |
| 3742 | + | |
| 3743 | + | |
| 3744 | + | |
3737 | 3745 | | |
3738 | 3746 | | |
3739 | 3747 | | |
3740 | 3748 | | |
| 3749 | + | |
| 3750 | + | |
| 3751 | + | |
3741 | 3752 | | |
3742 | 3753 | | |
3743 | 3754 | | |
3744 | 3755 | | |
3745 | 3756 | | |
3746 | | - | |
| 3757 | + | |
3747 | 3758 | | |
3748 | 3759 | | |
3749 | 3760 | | |
| |||
3830 | 3841 | | |
3831 | 3842 | | |
3832 | 3843 | | |
3833 | | - | |
| 3844 | + | |
| 3845 | + | |
| 3846 | + | |
| 3847 | + | |
| 3848 | + | |
| 3849 | + | |
| 3850 | + | |
| 3851 | + | |
| 3852 | + | |
| 3853 | + | |
| 3854 | + | |
| 3855 | + | |
| 3856 | + | |
3834 | 3857 | | |
3835 | | - | |
| 3858 | + | |
| 3859 | + | |
| 3860 | + | |
| 3861 | + | |
| 3862 | + | |
| 3863 | + | |
3836 | 3864 | | |
3837 | 3865 | | |
3838 | 3866 | | |
3839 | 3867 | | |
3840 | 3868 | | |
3841 | 3869 | | |
3842 | 3870 | | |
3843 | | - | |
3844 | | - | |
3845 | | - | |
3846 | | - | |
3847 | | - | |
3848 | | - | |
| 3871 | + | |
| 3872 | + | |
| 3873 | + | |
| 3874 | + | |
| 3875 | + | |
| 3876 | + | |
| 3877 | + | |
| 3878 | + | |
| 3879 | + | |
| 3880 | + | |
| 3881 | + | |
| 3882 | + | |
| 3883 | + | |
| 3884 | + | |
| 3885 | + | |
| 3886 | + | |
| 3887 | + | |
| 3888 | + | |
| 3889 | + | |
| 3890 | + | |
| 3891 | + | |
| 3892 | + | |
| 3893 | + | |
| 3894 | + | |
| 3895 | + | |
| 3896 | + | |
| 3897 | + | |
| 3898 | + | |
| 3899 | + | |
| 3900 | + | |
| 3901 | + | |
| 3902 | + | |
| 3903 | + | |
3849 | 3904 | | |
3850 | 3905 | | |
3851 | 3906 | | |
| |||
0 commit comments