Skip to content

feat: versioned artifact DAG reconciliation for #1679, with tombstone fix and artifact retention - #47

Draft
lyingbug wants to merge 12 commits into
mainfrom
cursor/reparse-artifact-dag-tombstone-fix-885f
Draft

feat: versioned artifact DAG reconciliation for #1679, with tombstone fix and artifact retention#47
lyingbug wants to merge 12 commits into
mainfrom
cursor/reparse-artifact-dag-tombstone-fix-885f

Conversation

@lyingbug

@lyingbug lyingbug commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Closes Tencent#1679.

This takes PR #2360 by @Even1115 as the base — of all the submissions for this issue it is the only one whose migrations load cleanly both on its own head and after merging main, so the service actually starts — and adds the two things it was missing.

Base: what Tencent#2360 provides

Reparse stops being a delete-and-rebuild and becomes a reconciliation against a desired state.

  • internal/artifact/key.go defines a versioned canonical key envelope: KeyVersion, Stage, ordered DirectInputs (upstream artifact digests, not upstream configuration), Processor identity, the rendered request, and the output schema version. Changing a VLM prompt therefore invalidates the VLM layer without cascading into layers whose inputs did not move. RejectSecretFields fails closed rather than silently dropping credential fields.
  • internal/artifact/identity.go anchors chunk identity on the content digest with a DuplicateOrdinal for repeated text, so inserting a paragraph does not shift the IDs of everything after it.
  • knowledge_reconcile.go computes Added / Updated / Stale, applies additions and updates before cleanup, and preserveLiveChunkFields keeps user-owned state (enabled flag, tags, metadata, relations) across a rebuild.
  • Migrations are complete: PostgreSQL 000079, SQLite 000002, plus the MySQL and ParadeDB bootstrap schemas.

Added here

1. Merge main and resolve the conflict correctly. main landed markKnowledgeProcessing, which clears a stale error_message when a run starts; Tencent#2360 replaced the same write with an attempt-conditional publish so a delayed worker cannot overwrite a newer attempt. Both are kept: the status write clears error_message and goes through UpdateKnowledgeIfAttemptCurrent.

2. Reclaim tombstoned chunk IDs. This was the one functional defect in the base. Because chunk IDs are content-addressed, a paragraph removed in one reparse and restored in a later one resolves to the same primary key. Stale cleanup soft-deletes, so that key is still held by a row the reconciliation read path cannot see, and the restore fails:

reconciliation sees 0 chunks (tombstone invisible)
UNIQUE constraint failed: chunks.id

PurgeSoftDeletedChunks hard-deletes only tombstoned rows among an explicit ID list, and processChunks calls it before inserting desired.Added. The predicate is tenant + explicit IDs + deleted_at IS NOT NULL, so a live row owned by a concurrent attempt can never be dropped.

3. Prune cold processing artifacts. processing_artifacts carries last_hit_at and a (tenant_id, created_at) index, but nothing ever reclaimed rows — a cache holding parse output and embedding payloads grew without bound. A new housekeeping sweep drops entries untouched for the retention window (30 days by default, retunable with WEKNORA_ARTIFACT_RETENTION_DAYS, 0 disables). Work per tick is bounded so a first run against a neglected table cannot hold locks for minutes, and offloaded payloads are skipped so their objects cannot be orphaned.

Verification

go build ./..., go vet ./internal/... and go test ./internal/... all pass — 65 packages, no failures.

New regression coverage:

  • TestPurgeSoftDeletedChunksAllowsStableIDReinsert — the restore path that previously failed.
  • TestPurgeSoftDeletedChunksLeavesLiveRowsIntact / ...IsTenantScoped — the purge cannot reach live rows or another tenant.
  • TestBuildDesiredDocumentChunksRestoresRemovedContentUnderSameID — proves at the reconciliation layer that a restored paragraph really does come back under the tombstoned key.
  • Five tests for the artifact sweep, covering the retention window, renewal on read, offloaded rows, the disable switch, and a missing table.

Follow-ups worth separate PRs

  • planChunkReuse from PR #2035 skips the vector write entirely for chunks that are already indexed with an unchanged embedding fingerprint. This branch still rewrites vectors; that change is a few hundred lines and is easier to review on its own.
  • The StableIdentity column from PR #2467 decouples identity from the row primary key, which makes the tombstone class of problem structurally impossible rather than handled.
  • Image sub-chunks (image_ocr, image_caption) still use random UUIDs, so their vectors churn on every rebuild even though the VLM text itself is cached.
Open in Web Open in Cursor 

Even1115 and others added 12 commits August 2, 2026 23:14
… RAG wait row

The wait row only appeared once a completed knowledge_search step existed, so
attachment-only turns (attachment_parsing / image_analysis with no KB hit) still
showed nothing between the last step and the first answer token. Model waiting is
now a three-state kind: 'model' after retrieval finished, the neutral 'preparing'
row otherwise.

A dropped SSE connection never sets is_completed (the stream layer only raises a
toast), so the row used to promise an answer forever. It now stops claiming
progress after RAG_WAIT_STALL_DELAY_MS and drops the shimmer.

Also share RAG_RETRIEVAL_TOOL_NAMES instead of repeating the knowledge_search /
search_knowledge alias check, name the reveal delay, and move the reveal/stall
timers into a controller with an injectable scheduler so the timing is covered by
tests instead of by source-matching alone.

Co-authored-by: lyingbug <lyingbug@users.noreply.github.com>
role="status" lived on the wait row itself, so the live region was inserted
together with its own text and screen readers had nothing to announce. Move the
announcement to an sr-only region that stays mounted for the whole turn, which
also covers the pre-pipeline wait row that had no announcement at all.

Co-authored-by: lyingbug <lyingbug@users.noreply.github.com>
Resolve knowledge_process.go conflicts by keeping the attempt-conditional
status publish from the reconciliation work while adopting
markKnowledgeProcessing so stale error_message is still cleared.

Co-authored-by: lyingbug <lyingbug@users.noreply.github.com>
Chunk IDs are content-addressed, so a paragraph that is removed in one
reparse and restored in a later one resolves to the same primary key.
Stale cleanup only soft-deletes, so that key is still held by a row the
reconciliation read path cannot see, and the restore fails with a
duplicate key error.

Add PurgeSoftDeletedChunks, which hard-deletes only tombstoned rows among
an explicit ID list, and call it from processChunks before inserting the
added chunks. Live rows are never touched, so a concurrent attempt cannot
lose data.

Co-authored-by: lyingbug <lyingbug@users.noreply.github.com>
The artifact table carries last_hit_at and a (tenant_id, created_at)
index but nothing ever reclaimed rows, so a cache holding parse output
and embedding payloads grew without bound.

Add a sweep that drops entries untouched for the retention window
(30 days by default, retunable via WEKNORA_ARTIFACT_RETENTION_DAYS,
0 to disable). Work per tick is bounded so a first run against a
neglected table cannot hold locks for minutes, and offloaded payloads
are skipped so their objects cannot be orphaned.

Co-authored-by: lyingbug <lyingbug@users.noreply.github.com>
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.

[Feature]: 重建/重解析知识时应复用 OCR、Embedding、Wiki Map 等缓存,避免全量重算

3 participants