Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .changeset/knowledge-reap-guard-deindex.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
'@objectstack/service-knowledge': patch
---

知识索引在保留期回收(reap)删除行之前先去索引,不再留下孤儿条目(#4672)。

`object` 知识源是**逐记录投影**,而 `LifecycleService` 的保留期回收按谓词删行 ——
ADR-0057 §3.3 又禁止把它扇出成 N 条逐记录事件(清理会回灌正在清空的表)。结果是行没了、
文档还在:孤儿条目仍会占掉 `topK` 名额(权限过滤发生在适配器返回之后),`isSystem`
调用方还会直接读到它们。

现在 `KnowledgeServicePlugin` 会为每个被 `object` 源投影的对象注册一个 ADR-0057
**reap guard**:sweep 在删除前把候选行交给 guard,guard 按 id 删除对应文档,只确认删除
成功的行。

- **失败方向**:`adapter.delete` 失败 ⇒ 该行本轮**保留**,下次 sweep 重试。允许「行比索引
条目活得久」,绝不允许反过来。
- **组合语义**:多个 guard 按交集组合(#5535),因此它既不会顶掉 `service-storage` 的字节
回收 guard,也不会被其顶掉。
- **分批**沿用 sweep 自身的约束(每批 500 行、每轮 20 批)。
- **退出开关**:源上的 `refresh.onRecordChange: false` 或插件的 `enableEventSync: false`
会同时关掉两个方向的内联同步(事件订阅与 reap 去索引)。

零新增契约面:未新增任何 spec 键、`IKnowledgeAdapter` 成员或 `packages/objectql` 改动 ——
经由既有的 `ctx.getService('lifecycle')` duck-type + `registerReapGuard` seam 接入,与
`service-storage` 回收 `sys_file` 字节的方式一致。

应用层谓词写(调用方自己的 `multi: true` 写)**仍不覆盖**,#4639 的 warn 保留并已改写为
准确描述这条分界。
35 changes: 32 additions & 3 deletions content/docs/protocol/knowledge.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -316,11 +316,40 @@ from the event's `after`, and a delete's id from its required `recordId`.
than per-record events. A knowledge index is a per-record projection and
`matched: 40` names no record, so there is no upsert or delete to derive —
the service logs a warning naming the object and count instead of failing
silently. Reconciliation against the source object is the durable fix and is
tracked in [#4672](https://github.com/objectstack-ai/objectstack/issues/4672):
events keep the index *fresh*, reconciliation keeps it *correct*.
silently. Repair such an index with an explicit `reindexSource`.
</Callout>

### Retention deletes are covered by a reap guard

The platform's own predicate delete — `LifecycleService` reaping rows past
their retention window — is the one bulk path that *is* handled, and it is
handled at the source rather than after the fact
([#4672](https://github.com/objectstack-ai/objectstack/issues/4672)).

For every object an `object` source projects, the plugin registers an ADR-0057
**reap guard** with the lifecycle service. The sweep hands the guard the
candidate rows before deleting them; the guard deletes each row's document and
returns the ids it confirms. Rows it does not confirm stay put.

- **Failure direction.** If `adapter.delete` fails, the row is *kept* and
retried on the next sweep. A row may outlive its index entry; an index entry
must never outlive its row, because an orphan still consumes a `topK` slot
ahead of the permission filter and is read straight through by an `isSystem`
caller.
- **Composition.** Guards from different packages compose by intersection, so
this one cannot displace another registrar's cleanup (or be displaced by it).
- **Batching** is the sweep's: 500 rows per batch, 20 batches per sweep, the
backlog draining across sweeps.
- **Opt-out.** `refresh.onRecordChange: false` on a source — or
`enableEventSync: false` on the plugin — turns off inline index sync in both
directions, event subscription and reap-time de-index alike. An index an
external indexer owns is never a reason to hold up another package's
retention.

Application-level predicate writes remain uncovered: de-indexing them needs an
adapter-side enumeration capability that no adapter declares today, and that
surface stays unbuilt until a real `object` source calls for it.

`file` / `http` sources rely on explicit `reindexSource` calls
(typically triggered by a cron job, a Console button, or a webhook).

Expand Down
1 change: 1 addition & 0 deletions packages/services/service-knowledge/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"@objectstack/spec": "workspace:*"
},
"devDependencies": {
"@objectstack/objectql": "workspace:*",
"@types/node": "^26.1.2",
"typescript": "^6.0.3",
"vitest": "^4.1.10"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,12 +141,30 @@ describe('#4626 — KnowledgeServicePlugin event sync on data.record.*', () => {
// record, so there is nothing to upsert or delete — the adapters take
// neither a count nor a predicate. The index is now stale in a way this
// subscription cannot repair, and a silent no-op here would read exactly
// like "nothing happened". Reconciliation is tracked in #4672.
// like "nothing happened".
expect(upsert).not.toHaveBeenCalled();
expect(del).not.toHaveBeenCalled();
expect(harness.ctx.logger.warn).toHaveBeenCalledWith(
expect.stringContaining('may now be stale'),
expect.objectContaining({ object: 'task', matched: 12 }),
);
});

it('[#4672] the warn draws the honest line: reap covered, application predicate writes not', async () => {
// #4672 shipped the reap guard, which covers the platform's OWN predicate
// delete (the retention sweep). It did NOT cover a caller's `multi: true`
// write, and the decision was to say so rather than imply a reconciliation
// pass that does not exist. The warn is the signal that stays honest, so
// its two halves are pinned: a message that stopped naming either one
// would be over- or under-claiming coverage.
const harness = makeCtx();
const { deliver } = await harness.boot(new KnowledgeServicePlugin());

await deliver(BULK_DELETED);

const [message] = harness.ctx.logger.warn.mock.calls.at(-1) as [string];
expect(message).toContain('#4639');
expect(message).toContain('lifecycle reap guard (#4672)');
expect(message).toContain('application-level predicate writes are not');
});
});
Loading
Loading