mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-10 21:48:48 +08:00
feat(server): impl doc gc (#15282)
#### PR Dependency Tree * **PR #15282** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added automated document cleanup to reconcile missing workspace docs, delete related stored data, and recover if the doc returns. * Added effect-based follow-up reconciliation for search indexing, Copilot embeddings, and comment attachment cleanup with explicit acknowledgements. * **Bug Fixes** * Deleted-document references now persist as dangling references rather than disappearing. * Improved document deletion flow to enforce permissions and ensure authorized deletions succeed. * **Tests** * Expanded coverage for cleanup recovery, indexing/embedding reconciliation, permissions, and reference semantics. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { LinkExtension } from '@blocksuite/affine-inline-link';
|
import { LinkExtension } from '@blocksuite/affine-inline-link';
|
||||||
import { textKeymap } from '@blocksuite/affine-inline-preset';
|
import { textKeymap } from '@blocksuite/affine-inline-preset';
|
||||||
|
import type { AffineReference } from '@blocksuite/affine-inline-reference';
|
||||||
import type {
|
import type {
|
||||||
ListBlockModel,
|
ListBlockModel,
|
||||||
ParagraphBlockModel,
|
ParagraphBlockModel,
|
||||||
@@ -312,6 +313,16 @@ describe('hotkey/bracket/linked-page', () => {
|
|||||||
const richText = getRichTextByBlockId(paragraphId);
|
const richText = getRichTextByBlockId(paragraphId);
|
||||||
expect(richText.querySelectorAll('affine-reference').length).toBe(2);
|
expect(richText.querySelectorAll('affine-reference').length).toBe(2);
|
||||||
expect(richText.inlineEditor.yTextString.length).toBe(2);
|
expect(richText.inlineEditor.yTextString.length).toBe(2);
|
||||||
|
|
||||||
|
collection.removeDoc(linkedDoc.id);
|
||||||
|
await wait();
|
||||||
|
expect(collection.docs.has(linkedDoc.id)).toBe(false);
|
||||||
|
const danglingReferences =
|
||||||
|
richText.querySelectorAll<AffineReference>('affine-reference');
|
||||||
|
expect(danglingReferences.length).toBe(2);
|
||||||
|
expect([...danglingReferences].every(reference => !reference.refMeta)).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Vendored
+34
-1
@@ -22,7 +22,6 @@ export declare class BackendRuntime {
|
|||||||
compactPendingDocUpdates(workspaceId: string, docId: string, batchLimit: number, historyMinIntervalMs: number, historyMaxAgeSeconds: number, owner: string, leaseTtlMs: number): Promise<RuntimeDocCompactionResult>
|
compactPendingDocUpdates(workspaceId: string, docId: string, batchLimit: number, historyMinIntervalMs: number, historyMaxAgeSeconds: number, owner: string, leaseTtlMs: number): Promise<RuntimeDocCompactionResult>
|
||||||
upsertDocSnapshot(workspaceId: string, docId: string, blob: Buffer, timestampMs: number, editorId?: string | undefined | null): Promise<boolean>
|
upsertDocSnapshot(workspaceId: string, docId: string, blob: Buffer, timestampMs: number, editorId?: string | undefined | null): Promise<boolean>
|
||||||
createDocHistory(input: RuntimeDocHistoryInput): Promise<boolean>
|
createDocHistory(input: RuntimeDocHistoryInput): Promise<boolean>
|
||||||
deleteDocStorage(workspaceId: string, docId: string): Promise<void>
|
|
||||||
putRuntimeGateIfAbsent(key: string, ttlMs: number): Promise<boolean>
|
putRuntimeGateIfAbsent(key: string, ttlMs: number): Promise<boolean>
|
||||||
cleanupExpiredRuntimeGates(limit: number): Promise<number>
|
cleanupExpiredRuntimeGates(limit: number): Promise<number>
|
||||||
cleanupExpiredUserSessions(limit: number): Promise<number>
|
cleanupExpiredUserSessions(limit: number): Promise<number>
|
||||||
@@ -78,6 +77,9 @@ export declare class StorageRuntime {
|
|||||||
backfillMissingBlobMetadata(workspaceId: string | undefined | null, limit: number): Promise<RuntimeBlobMetadataBackfillResult>
|
backfillMissingBlobMetadata(workspaceId: string | undefined | null, limit: number): Promise<RuntimeBlobMetadataBackfillResult>
|
||||||
rebuildDocBlobRefs(workspaceId: string, docId: string): Promise<RuntimeDocBlobRefsResult>
|
rebuildDocBlobRefs(workspaceId: string, docId: string): Promise<RuntimeDocBlobRefsResult>
|
||||||
rebuildWorkspaceDocBlobRefs(workspaceId: string, limit: number): Promise<RuntimeDocBlobRefsResult>
|
rebuildWorkspaceDocBlobRefs(workspaceId: string, limit: number): Promise<RuntimeDocBlobRefsResult>
|
||||||
|
reconcileWorkspaceDocuments(workspaceId: string): Promise<RuntimeDocumentCleanupReconcileResult>
|
||||||
|
executeDocumentCleanupCandidates(workspaceId: string | undefined | null, gracePeriodDays: number, limit: number): Promise<RuntimeDocumentCleanupExecuteResult>
|
||||||
|
ackDocumentCleanupEffect(workspaceId: string, docId: string, cleanupVersion: string, effect: string): Promise<RuntimeDocumentCleanupAckResult>
|
||||||
constructor()
|
constructor()
|
||||||
start(): Promise<void>
|
start(): Promise<void>
|
||||||
configure(configJson: string): void
|
configure(configJson: string): void
|
||||||
@@ -976,6 +978,37 @@ export interface RuntimeDocHistoryInput {
|
|||||||
historyMaxAgeMs: number
|
historyMaxAgeMs: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RuntimeDocumentCleanupAckResult {
|
||||||
|
completed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeDocumentCleanupEffect {
|
||||||
|
workspaceId: string
|
||||||
|
docId: string
|
||||||
|
cleanupVersion: string
|
||||||
|
commentObjectsDone: boolean
|
||||||
|
searchDone: boolean
|
||||||
|
copilotDone: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeDocumentCleanupExecuteResult {
|
||||||
|
scannedCandidates: number
|
||||||
|
serializationRetries: number
|
||||||
|
executed: number
|
||||||
|
recovered: number
|
||||||
|
reset: number
|
||||||
|
failed: number
|
||||||
|
deletedRows: number
|
||||||
|
effects: Array<RuntimeDocumentCleanupEffect>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeDocumentCleanupReconcileResult {
|
||||||
|
scannedDocs: number
|
||||||
|
marked: number
|
||||||
|
reset: number
|
||||||
|
recovered: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface RuntimeInviteAbuseActionRequired {
|
export interface RuntimeInviteAbuseActionRequired {
|
||||||
action: string
|
action: string
|
||||||
subjectKey: string
|
subjectKey: string
|
||||||
|
|||||||
@@ -126,37 +126,4 @@ impl BackendRuntime {
|
|||||||
|
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[napi]
|
|
||||||
pub async fn delete_doc_storage(&self, workspace_id: String, doc_id: String) -> napi::Result<()> {
|
|
||||||
let pool = self.pool().await?;
|
|
||||||
let mut tx = pool
|
|
||||||
.begin()
|
|
||||||
.await
|
|
||||||
.map_err(|err| RuntimeError::database("DocStorage delete begin transaction failed", err))?;
|
|
||||||
|
|
||||||
sqlx::query("DELETE FROM snapshots WHERE workspace_id = $1 AND guid = $2")
|
|
||||||
.bind(&workspace_id)
|
|
||||||
.bind(&doc_id)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await
|
|
||||||
.map_err(|err| RuntimeError::database("DocStorage delete snapshot failed", err))?;
|
|
||||||
sqlx::query("DELETE FROM updates WHERE workspace_id = $1 AND guid = $2")
|
|
||||||
.bind(&workspace_id)
|
|
||||||
.bind(&doc_id)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await
|
|
||||||
.map_err(|err| RuntimeError::database("DocStorage delete updates failed", err))?;
|
|
||||||
sqlx::query("DELETE FROM snapshot_histories WHERE workspace_id = $1 AND guid = $2")
|
|
||||||
.bind(&workspace_id)
|
|
||||||
.bind(&doc_id)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await
|
|
||||||
.map_err(|err| RuntimeError::database("DocStorage delete histories failed", err))?;
|
|
||||||
|
|
||||||
tx.commit()
|
|
||||||
.await
|
|
||||||
.map_err(|err| RuntimeError::database("DocStorage delete commit failed", err))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ fn migrations_include_runtime_tables_without_worker_heartbeats() {
|
|||||||
assert!(RUNTIME_MIGRATIONS.contains("runtime_states"));
|
assert!(RUNTIME_MIGRATIONS.contains("runtime_states"));
|
||||||
assert!(RUNTIME_MIGRATIONS.contains("runtime_gates"));
|
assert!(RUNTIME_MIGRATIONS.contains("runtime_gates"));
|
||||||
assert!(RUNTIME_MIGRATIONS.contains("runtime_leases"));
|
assert!(RUNTIME_MIGRATIONS.contains("runtime_leases"));
|
||||||
assert!(RUNTIME_MIGRATIONS.contains("blob_reconciliation_runs"));
|
assert!(RUNTIME_MIGRATIONS.contains("storage_reconciliation_runs"));
|
||||||
assert!(RUNTIME_MIGRATIONS.contains("blob_reconciliation_checkpoints"));
|
assert!(RUNTIME_MIGRATIONS.contains("storage_reconciliation_checkpoints"));
|
||||||
|
assert!(RUNTIME_MIGRATIONS.contains("document_cleanup_candidates"));
|
||||||
assert!(RUNTIME_MIGRATIONS.contains("doc_blob_refs"));
|
assert!(RUNTIME_MIGRATIONS.contains("doc_blob_refs"));
|
||||||
assert!(RUNTIME_MIGRATIONS.contains("blob_cleanup_candidates"));
|
assert!(RUNTIME_MIGRATIONS.contains("blob_cleanup_candidates"));
|
||||||
assert!(!RUNTIME_MIGRATIONS.contains("runtime_worker_heartbeats"));
|
assert!(!RUNTIME_MIGRATIONS.contains("runtime_worker_heartbeats"));
|
||||||
@@ -136,12 +137,10 @@ async fn insert_invite_quota_fixture(
|
|||||||
.bind(email)
|
.bind(email)
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
.await?;
|
.await?;
|
||||||
sqlx::query(
|
sqlx::query("INSERT INTO workspaces (id, created_at) VALUES ($1, clock_timestamp() - interval '60 days')")
|
||||||
"INSERT INTO workspaces (id, public, created_at) VALUES ($1, false, clock_timestamp() - interval '60 days')",
|
.bind(&workspace_id)
|
||||||
)
|
.execute(&pool)
|
||||||
.bind(&workspace_id)
|
.await?;
|
||||||
.execute(&pool)
|
|
||||||
.await?;
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO effective_workspace_quota_states (
|
INSERT INTO effective_workspace_quota_states (
|
||||||
|
|||||||
@@ -97,6 +97,16 @@ impl RuntimeError {
|
|||||||
_ => false,
|
_ => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_serialization_failure(&self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self,
|
||||||
|
Self::Database {
|
||||||
|
source: sqlx::Error::Database(source),
|
||||||
|
..
|
||||||
|
} if source.code().as_deref() == Some("40001")
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn to_napi_error(error: RuntimeError) -> Error {
|
pub(crate) fn to_napi_error(error: RuntimeError) -> Error {
|
||||||
|
|||||||
@@ -5,16 +5,10 @@ use super::{RuntimeError, RuntimeResult};
|
|||||||
pub(crate) const RUNTIME_MIGRATIONS: &str = include_str!("sql/runtime_migrations.sql");
|
pub(crate) const RUNTIME_MIGRATIONS: &str = include_str!("sql/runtime_migrations.sql");
|
||||||
|
|
||||||
pub(crate) async fn migrate_runtime_tables(pool: &PgPool) -> RuntimeResult<()> {
|
pub(crate) async fn migrate_runtime_tables(pool: &PgPool) -> RuntimeResult<()> {
|
||||||
for statement in RUNTIME_MIGRATIONS
|
sqlx::raw_sql(RUNTIME_MIGRATIONS)
|
||||||
.split(';')
|
.execute(pool)
|
||||||
.map(str::trim)
|
.await
|
||||||
.filter(|statement| !statement.is_empty())
|
.map_err(|err| RuntimeError::database("Runtime migration failed", err))?;
|
||||||
{
|
|
||||||
sqlx::query(statement)
|
|
||||||
.execute(pool)
|
|
||||||
.await
|
|
||||||
.map_err(|err| RuntimeError::database("Runtime migration failed", err))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ CREATE TABLE IF NOT EXISTS runtime_leases (
|
|||||||
CREATE INDEX IF NOT EXISTS runtime_leases_expires_at_idx
|
CREATE INDEX IF NOT EXISTS runtime_leases_expires_at_idx
|
||||||
ON runtime_leases (expires_at);
|
ON runtime_leases (expires_at);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS blob_reconciliation_runs (
|
CREATE TABLE IF NOT EXISTS storage_reconciliation_runs (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
kind TEXT NOT NULL,
|
kind TEXT NOT NULL,
|
||||||
mode TEXT NOT NULL,
|
mode TEXT NOT NULL,
|
||||||
@@ -54,10 +54,10 @@ CREATE TABLE IF NOT EXISTS blob_reconciliation_runs (
|
|||||||
metadata JSONB NOT NULL DEFAULT '{}'
|
metadata JSONB NOT NULL DEFAULT '{}'
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS blob_reconciliation_runs_workspace_idx
|
CREATE INDEX IF NOT EXISTS storage_reconciliation_runs_workspace_idx
|
||||||
ON blob_reconciliation_runs (workspace_id, started_at DESC);
|
ON storage_reconciliation_runs (workspace_id, started_at DESC);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS blob_reconciliation_checkpoints (
|
CREATE TABLE IF NOT EXISTS storage_reconciliation_checkpoints (
|
||||||
kind TEXT NOT NULL,
|
kind TEXT NOT NULL,
|
||||||
scope TEXT NOT NULL,
|
scope TEXT NOT NULL,
|
||||||
status TEXT NOT NULL,
|
status TEXT NOT NULL,
|
||||||
@@ -70,8 +70,28 @@ CREATE TABLE IF NOT EXISTS blob_reconciliation_checkpoints (
|
|||||||
PRIMARY KEY (kind, scope)
|
PRIMARY KEY (kind, scope)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS blob_reconciliation_checkpoints_status_idx
|
CREATE INDEX IF NOT EXISTS storage_reconciliation_checkpoints_status_idx
|
||||||
ON blob_reconciliation_checkpoints (kind, status, updated_at DESC);
|
ON storage_reconciliation_checkpoints (kind, status, updated_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS document_cleanup_candidates (
|
||||||
|
workspace_id TEXT NOT NULL,
|
||||||
|
doc_id TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('marked', 'effects_pending', 'failed')),
|
||||||
|
missing_since TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
last_observed_missing_at TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
last_doc_activity_at TIMESTAMPTZ(3),
|
||||||
|
cleanup_payload JSONB NOT NULL DEFAULT '{}',
|
||||||
|
attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0),
|
||||||
|
error TEXT,
|
||||||
|
updated_at TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (workspace_id, doc_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS document_cleanup_candidates_status_missing_idx
|
||||||
|
ON document_cleanup_candidates (status, missing_since);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS document_cleanup_candidates_workspace_status_idx
|
||||||
|
ON document_cleanup_candidates (workspace_id, status, updated_at DESC);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS doc_blob_refs (
|
CREATE TABLE IF NOT EXISTS doc_blob_refs (
|
||||||
workspace_id TEXT NOT NULL,
|
workspace_id TEXT NOT NULL,
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ fn push_workspace_once(workspace_ids: &mut Vec<String>, workspace_id: &str) {
|
|||||||
|
|
||||||
async fn checkpoint_completed(pool: &PgPool, kind: &str, scope: &str) -> RuntimeResult<bool> {
|
async fn checkpoint_completed(pool: &PgPool, kind: &str, scope: &str) -> RuntimeResult<bool> {
|
||||||
sqlx::query_scalar::<_, bool>(
|
sqlx::query_scalar::<_, bool>(
|
||||||
"SELECT EXISTS(SELECT 1 FROM blob_reconciliation_checkpoints WHERE kind = $1 AND scope = $2 AND status = \
|
"SELECT EXISTS(SELECT 1 FROM storage_reconciliation_checkpoints WHERE kind = $1 AND scope = $2 AND status = \
|
||||||
'completed')",
|
'completed')",
|
||||||
)
|
)
|
||||||
.bind(kind)
|
.bind(kind)
|
||||||
@@ -46,7 +46,43 @@ async fn checkpoint_completed(pool: &PgPool, kind: &str, scope: &str) -> Runtime
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn projection_is_stale(pool: &PgPool, workspace_id: &str) -> RuntimeResult<bool> {
|
async fn projection_is_stale(pool: &PgPool, workspace_id: &str) -> RuntimeResult<bool> {
|
||||||
let checkpoint_fresh = checkpoint_completed(pool, "doc_blob_refs", workspace_id).await?;
|
let checkpoint_completed_at = sqlx::query_scalar::<_, Option<chrono::DateTime<chrono::Utc>>>(
|
||||||
|
r#"
|
||||||
|
SELECT MIN(completed_at)
|
||||||
|
FROM storage_reconciliation_checkpoints
|
||||||
|
WHERE scope = $1
|
||||||
|
AND kind IN ('document_cleanup', 'doc_blob_refs')
|
||||||
|
AND status = 'completed'
|
||||||
|
HAVING COUNT(*) = 2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(workspace_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|err| RuntimeError::database("Blob cleanup retention checkpoint load failed", err))?
|
||||||
|
.flatten();
|
||||||
|
let Some(checkpoint_completed_at) = checkpoint_completed_at else {
|
||||||
|
return Ok(true);
|
||||||
|
};
|
||||||
|
let activity_after_checkpoint = sqlx::query_scalar::<_, bool>(
|
||||||
|
r#"
|
||||||
|
SELECT EXISTS(
|
||||||
|
SELECT 1 FROM snapshots
|
||||||
|
WHERE workspace_id = $1 AND updated_at > $2
|
||||||
|
UNION ALL
|
||||||
|
SELECT 1 FROM updates
|
||||||
|
WHERE workspace_id = $1 AND created_at > $2
|
||||||
|
UNION ALL
|
||||||
|
SELECT 1 FROM snapshot_histories
|
||||||
|
WHERE workspace_id = $1 AND timestamp > $2
|
||||||
|
)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(workspace_id)
|
||||||
|
.bind(checkpoint_completed_at)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|err| RuntimeError::database("Blob cleanup retention activity check failed", err))?;
|
||||||
let has_stale_rows = sqlx::query_scalar::<_, bool>(
|
let has_stale_rows = sqlx::query_scalar::<_, bool>(
|
||||||
"SELECT EXISTS(SELECT 1 FROM doc_blob_refs WHERE workspace_id = $1 AND status <> 'fresh')",
|
"SELECT EXISTS(SELECT 1 FROM doc_blob_refs WHERE workspace_id = $1 AND status <> 'fresh')",
|
||||||
)
|
)
|
||||||
@@ -54,7 +90,7 @@ async fn projection_is_stale(pool: &PgPool, workspace_id: &str) -> RuntimeResult
|
|||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|err| RuntimeError::database("Blob cleanup projection freshness check failed", err))?;
|
.map_err(|err| RuntimeError::database("Blob cleanup projection freshness check failed", err))?;
|
||||||
Ok(!checkpoint_fresh || has_stale_rows)
|
Ok(activity_after_checkpoint || has_stale_rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn stale_projection_workspaces(pool: &PgPool, workspace_id: &str) -> RuntimeResult<Vec<String>> {
|
async fn stale_projection_workspaces(pool: &PgPool, workspace_id: &str) -> RuntimeResult<Vec<String>> {
|
||||||
@@ -170,7 +206,7 @@ async fn load_completed_blobs(
|
|||||||
|
|
||||||
async fn load_plan_cursor(pool: &PgPool, workspace_id: &str) -> RuntimeResult<Option<String>> {
|
async fn load_plan_cursor(pool: &PgPool, workspace_id: &str) -> RuntimeResult<Option<String>> {
|
||||||
let row = sqlx::query_as::<_, (String, serde_json::Value)>(
|
let row = sqlx::query_as::<_, (String, serde_json::Value)>(
|
||||||
"SELECT status, cursor FROM blob_reconciliation_checkpoints WHERE kind = 'blob_cleanup_plan' AND scope = $1",
|
"SELECT status, cursor FROM storage_reconciliation_checkpoints WHERE kind = 'blob_cleanup_plan' AND scope = $1",
|
||||||
)
|
)
|
||||||
.bind(workspace_id)
|
.bind(workspace_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
@@ -199,13 +235,13 @@ async fn upsert_plan_checkpoint(
|
|||||||
let status = if completed { "completed" } else { "running" };
|
let status = if completed { "completed" } else { "running" };
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO blob_reconciliation_checkpoints
|
INSERT INTO storage_reconciliation_checkpoints
|
||||||
(kind, scope, status, cursor, last_key, completed_at)
|
(kind, scope, status, cursor, last_key, completed_at)
|
||||||
VALUES ('blob_cleanup_plan', $1, $2, $3, $4, CASE WHEN $5 THEN CURRENT_TIMESTAMP ELSE NULL END)
|
VALUES ('blob_cleanup_plan', $1, $2, $3, $4, CASE WHEN $5 THEN CURRENT_TIMESTAMP ELSE NULL END)
|
||||||
ON CONFLICT (kind, scope) DO UPDATE
|
ON CONFLICT (kind, scope) DO UPDATE
|
||||||
SET status = EXCLUDED.status,
|
SET status = EXCLUDED.status,
|
||||||
cursor = EXCLUDED.cursor,
|
cursor = EXCLUDED.cursor,
|
||||||
last_key = COALESCE(EXCLUDED.last_key, blob_reconciliation_checkpoints.last_key),
|
last_key = COALESCE(EXCLUDED.last_key, storage_reconciliation_checkpoints.last_key),
|
||||||
completed_at = CASE WHEN $5 THEN CURRENT_TIMESTAMP ELSE NULL END,
|
completed_at = CASE WHEN $5 THEN CURRENT_TIMESTAMP ELSE NULL END,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
"#,
|
"#,
|
||||||
@@ -224,7 +260,7 @@ async fn upsert_plan_checkpoint(
|
|||||||
async fn create_run(pool: &PgPool, workspace_id: &str) -> RuntimeResult<String> {
|
async fn create_run(pool: &PgPool, workspace_id: &str) -> RuntimeResult<String> {
|
||||||
sqlx::query_scalar::<_, String>(
|
sqlx::query_scalar::<_, String>(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO blob_reconciliation_runs (kind, mode, status, workspace_id)
|
INSERT INTO storage_reconciliation_runs (kind, mode, status, workspace_id)
|
||||||
VALUES ('blob_cleanup_plan', 'mark_only', 'running', $1)
|
VALUES ('blob_cleanup_plan', 'mark_only', 'running', $1)
|
||||||
RETURNING id::text
|
RETURNING id::text
|
||||||
"#,
|
"#,
|
||||||
@@ -252,7 +288,7 @@ async fn finish_run(
|
|||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
UPDATE blob_reconciliation_runs
|
UPDATE storage_reconciliation_runs
|
||||||
SET status = 'finished',
|
SET status = 'finished',
|
||||||
finished_at = CURRENT_TIMESTAMP,
|
finished_at = CURRENT_TIMESTAMP,
|
||||||
scanned = $2,
|
scanned = $2,
|
||||||
@@ -318,7 +354,7 @@ async fn finish_execute_run(
|
|||||||
) -> RuntimeResult<()> {
|
) -> RuntimeResult<()> {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
UPDATE blob_reconciliation_runs
|
UPDATE storage_reconciliation_runs
|
||||||
SET status = 'finished',
|
SET status = 'finished',
|
||||||
finished_at = CURRENT_TIMESTAMP,
|
finished_at = CURRENT_TIMESTAMP,
|
||||||
scanned = $2,
|
scanned = $2,
|
||||||
|
|||||||
@@ -85,7 +85,8 @@ impl BackfillCheckpoint {
|
|||||||
|
|
||||||
async fn load_checkpoint(pool: &PgPool, scope: &str) -> RuntimeResult<Option<BackfillCheckpoint>> {
|
async fn load_checkpoint(pool: &PgPool, scope: &str) -> RuntimeResult<Option<BackfillCheckpoint>> {
|
||||||
sqlx::query_as::<_, BackfillCheckpoint>(
|
sqlx::query_as::<_, BackfillCheckpoint>(
|
||||||
"SELECT last_key, cursor FROM blob_reconciliation_checkpoints WHERE kind = 'blob_metadata_backfill' AND scope = $1",
|
"SELECT last_key, cursor FROM storage_reconciliation_checkpoints WHERE kind = 'blob_metadata_backfill' AND scope \
|
||||||
|
= $1",
|
||||||
)
|
)
|
||||||
.bind(scope)
|
.bind(scope)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
@@ -103,13 +104,13 @@ async fn upsert_checkpoint(
|
|||||||
let status = if completed { "completed" } else { "running" };
|
let status = if completed { "completed" } else { "running" };
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO blob_reconciliation_checkpoints
|
INSERT INTO storage_reconciliation_checkpoints
|
||||||
(kind, scope, status, cursor, last_key, completed_at, metadata)
|
(kind, scope, status, cursor, last_key, completed_at, metadata)
|
||||||
VALUES ('blob_metadata_backfill', $1, $2, $3, $4, CASE WHEN $5 THEN CURRENT_TIMESTAMP ELSE NULL END, $6)
|
VALUES ('blob_metadata_backfill', $1, $2, $3, $4, CASE WHEN $5 THEN CURRENT_TIMESTAMP ELSE NULL END, $6)
|
||||||
ON CONFLICT (kind, scope) DO UPDATE
|
ON CONFLICT (kind, scope) DO UPDATE
|
||||||
SET status = EXCLUDED.status,
|
SET status = EXCLUDED.status,
|
||||||
cursor = EXCLUDED.cursor,
|
cursor = EXCLUDED.cursor,
|
||||||
last_key = COALESCE(EXCLUDED.last_key, blob_reconciliation_checkpoints.last_key),
|
last_key = COALESCE(EXCLUDED.last_key, storage_reconciliation_checkpoints.last_key),
|
||||||
completed_at = CASE WHEN $5 THEN CURRENT_TIMESTAMP ELSE NULL END,
|
completed_at = CASE WHEN $5 THEN CURRENT_TIMESTAMP ELSE NULL END,
|
||||||
updated_at = CURRENT_TIMESTAMP,
|
updated_at = CURRENT_TIMESTAMP,
|
||||||
metadata = EXCLUDED.metadata
|
metadata = EXCLUDED.metadata
|
||||||
@@ -247,7 +248,7 @@ impl StorageRuntime {
|
|||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO blob_reconciliation_runs
|
INSERT INTO storage_reconciliation_runs
|
||||||
(kind, mode, status, workspace_id, finished_at, scanned, changed, failed, metadata)
|
(kind, mode, status, workspace_id, finished_at, scanned, changed, failed, metadata)
|
||||||
VALUES ('blob_metadata_backfill', 'execute', 'finished', $1, CURRENT_TIMESTAMP, $2, $3, $4, $5)
|
VALUES ('blob_metadata_backfill', 'execute', 'finished', $1, CURRENT_TIMESTAMP, $2, $3, $4, $5)
|
||||||
"#,
|
"#,
|
||||||
|
|||||||
@@ -1,111 +1,32 @@
|
|||||||
use affine_doc_loader as doc_loader;
|
use affine_doc_loader as doc_loader;
|
||||||
use chrono::{DateTime, Utc};
|
use sqlx::PgPool;
|
||||||
use sqlx::{FromRow, PgPool};
|
|
||||||
use y_octo::Doc;
|
|
||||||
|
|
||||||
use super::{RuntimeDocBlobRefsResult, RuntimeError, RuntimeResult, StorageRuntime, napi_error};
|
use super::{
|
||||||
|
CurrentDoc, RuntimeDocBlobRefsResult, RuntimeError, RuntimeResult, StorageRuntime, load_current_doc,
|
||||||
|
load_workspace_live_doc_ids, napi_error,
|
||||||
|
};
|
||||||
|
|
||||||
const PARSER_VERSION: i32 = 1;
|
const PARSER_VERSION: i32 = 1;
|
||||||
|
|
||||||
#[derive(FromRow)]
|
|
||||||
struct SnapshotRow {
|
|
||||||
workspace_id: String,
|
|
||||||
doc_id: String,
|
|
||||||
blob: Vec<u8>,
|
|
||||||
updated_at: DateTime<Utc>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(FromRow)]
|
|
||||||
struct UpdateRow {
|
|
||||||
blob: Vec<u8>,
|
|
||||||
created_at: DateTime<Utc>,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ExtractedRef {
|
struct ExtractedRef {
|
||||||
blob_key: String,
|
blob_key: String,
|
||||||
block_id: String,
|
block_id: String,
|
||||||
flavour: String,
|
flavour: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn load_snapshot(pool: &PgPool, workspace_id: &str, doc_id: &str) -> RuntimeResult<Option<SnapshotRow>> {
|
async fn load_workspace_doc_ids(pool: &PgPool, workspace_id: &str) -> RuntimeResult<Vec<String>> {
|
||||||
sqlx::query_as::<_, SnapshotRow>(
|
let mut ids = load_workspace_live_doc_ids(pool, workspace_id).await?;
|
||||||
r#"
|
let retained = sqlx::query_scalar::<_, String>(
|
||||||
SELECT workspace_id, guid AS doc_id, blob, updated_at
|
"SELECT doc_id FROM document_cleanup_candidates WHERE workspace_id = $1 AND status IN ('marked', 'failed') ORDER \
|
||||||
FROM snapshots
|
BY doc_id",
|
||||||
WHERE workspace_id = $1 AND guid = $2
|
|
||||||
"#,
|
|
||||||
)
|
)
|
||||||
.bind(workspace_id)
|
.bind(workspace_id)
|
||||||
.bind(doc_id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await
|
|
||||||
.map_err(|err| RuntimeError::database("Doc blob refs load snapshot failed", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn load_updates(pool: &PgPool, workspace_id: &str, doc_id: &str) -> RuntimeResult<Vec<UpdateRow>> {
|
|
||||||
sqlx::query_as::<_, UpdateRow>(
|
|
||||||
r#"
|
|
||||||
SELECT blob, created_at
|
|
||||||
FROM updates
|
|
||||||
WHERE workspace_id = $1 AND guid = $2
|
|
||||||
ORDER BY created_at ASC
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(workspace_id)
|
|
||||||
.bind(doc_id)
|
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|err| RuntimeError::database("Doc blob refs load updates failed", err))
|
.map_err(|err| RuntimeError::database("Doc blob refs candidate load failed", err))?;
|
||||||
}
|
ids.extend(retained);
|
||||||
|
|
||||||
fn apply_doc_updates(updates: impl IntoIterator<Item = Vec<u8>>) -> RuntimeResult<Vec<u8>> {
|
|
||||||
let mut doc = Doc::default();
|
|
||||||
for update in updates {
|
|
||||||
doc
|
|
||||||
.apply_update_from_binary_v1(&update)
|
|
||||||
.map_err(|err| RuntimeError::invalid_state(format!("Doc blob refs merge failed: {err}")))?;
|
|
||||||
}
|
|
||||||
doc
|
|
||||||
.encode_update_v1()
|
|
||||||
.map_err(|err| RuntimeError::invalid_state(format!("Doc blob refs encode failed: {err}")))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn load_current_doc(pool: &PgPool, workspace_id: &str, doc_id: &str) -> RuntimeResult<Option<SnapshotRow>> {
|
|
||||||
let snapshot = load_snapshot(pool, workspace_id, doc_id).await?;
|
|
||||||
let updates = load_updates(pool, workspace_id, doc_id).await?;
|
|
||||||
if snapshot.is_none() && updates.is_empty() {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut merge_inputs = Vec::with_capacity(updates.len() + usize::from(snapshot.is_some()));
|
|
||||||
let mut updated_at = snapshot
|
|
||||||
.as_ref()
|
|
||||||
.map(|snapshot| snapshot.updated_at)
|
|
||||||
.unwrap_or_else(Utc::now);
|
|
||||||
if let Some(snapshot) = snapshot {
|
|
||||||
merge_inputs.push(snapshot.blob);
|
|
||||||
}
|
|
||||||
for update in updates {
|
|
||||||
updated_at = update.created_at;
|
|
||||||
merge_inputs.push(update.blob);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Some(SnapshotRow {
|
|
||||||
workspace_id: workspace_id.to_string(),
|
|
||||||
doc_id: doc_id.to_string(),
|
|
||||||
blob: apply_doc_updates(merge_inputs)?,
|
|
||||||
updated_at,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn load_workspace_doc_ids(pool: &PgPool, workspace_id: &str) -> RuntimeResult<Vec<String>> {
|
|
||||||
let Some(root) = load_current_doc(pool, workspace_id, workspace_id).await? else {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
};
|
|
||||||
let ids = doc_loader::get_doc_ids_from_binary(root.blob, true)
|
|
||||||
.map_err(|err| RuntimeError::invalid_state(format!("Doc blob refs root doc parse failed: {err}")))?;
|
|
||||||
let mut ids = ids;
|
|
||||||
ids.sort();
|
ids.sort();
|
||||||
|
ids.dedup();
|
||||||
Ok(ids)
|
Ok(ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,7 +45,7 @@ async fn upsert_projection_checkpoint(
|
|||||||
};
|
};
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO blob_reconciliation_checkpoints
|
INSERT INTO storage_reconciliation_checkpoints
|
||||||
(kind, scope, status, cursor, completed_at, metadata)
|
(kind, scope, status, cursor, completed_at, metadata)
|
||||||
VALUES ('doc_blob_refs', $1, $2, $3, CASE WHEN $4 THEN CURRENT_TIMESTAMP ELSE NULL END, $5)
|
VALUES ('doc_blob_refs', $1, $2, $3, CASE WHEN $4 THEN CURRENT_TIMESTAMP ELSE NULL END, $5)
|
||||||
ON CONFLICT (kind, scope) DO UPDATE
|
ON CONFLICT (kind, scope) DO UPDATE
|
||||||
@@ -151,7 +72,7 @@ async fn upsert_projection_checkpoint(
|
|||||||
async fn upsert_projection_failure_checkpoint(pool: &PgPool, workspace_id: &str, error: &str) -> RuntimeResult<()> {
|
async fn upsert_projection_failure_checkpoint(pool: &PgPool, workspace_id: &str, error: &str) -> RuntimeResult<()> {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO blob_reconciliation_checkpoints
|
INSERT INTO storage_reconciliation_checkpoints
|
||||||
(kind, scope, status, cursor, completed_at, metadata)
|
(kind, scope, status, cursor, completed_at, metadata)
|
||||||
VALUES ('doc_blob_refs', $1, 'failed', '{}', NULL, $2)
|
VALUES ('doc_blob_refs', $1, 'failed', '{}', NULL, $2)
|
||||||
ON CONFLICT (kind, scope) DO UPDATE
|
ON CONFLICT (kind, scope) DO UPDATE
|
||||||
@@ -174,14 +95,17 @@ async fn upsert_projection_failure_checkpoint(pool: &PgPool, workspace_id: &str,
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn load_projection_cursor(pool: &PgPool, workspace_id: &str) -> RuntimeResult<Option<String>> {
|
async fn load_projection_cursor(pool: &PgPool, workspace_id: &str) -> RuntimeResult<Option<String>> {
|
||||||
let cursor = sqlx::query_scalar::<_, serde_json::Value>(
|
let checkpoint = sqlx::query_as::<_, (String, serde_json::Value)>(
|
||||||
"SELECT cursor FROM blob_reconciliation_checkpoints WHERE kind = 'doc_blob_refs' AND scope = $1",
|
"SELECT status, cursor FROM storage_reconciliation_checkpoints WHERE kind = 'doc_blob_refs' AND scope = $1",
|
||||||
)
|
)
|
||||||
.bind(workspace_id)
|
.bind(workspace_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|err| RuntimeError::database("Doc blob refs checkpoint load failed", err))?;
|
.map_err(|err| RuntimeError::database("Doc blob refs checkpoint load failed", err))?;
|
||||||
Ok(cursor.and_then(|cursor| {
|
Ok(checkpoint.and_then(|(status, cursor)| {
|
||||||
|
if status != "running" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
cursor
|
cursor
|
||||||
.get("lastDocId")
|
.get("lastDocId")
|
||||||
.and_then(|value| value.as_str())
|
.and_then(|value| value.as_str())
|
||||||
@@ -205,7 +129,7 @@ async fn purge_removed_doc_refs(pool: &PgPool, workspace_id: &str, current_doc_i
|
|||||||
Ok(result.rows_affected() as i64)
|
Ok(result.rows_affected() as i64)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extract_refs(snapshot: &SnapshotRow) -> RuntimeResult<Vec<ExtractedRef>> {
|
fn extract_refs(snapshot: &CurrentDoc) -> RuntimeResult<Vec<ExtractedRef>> {
|
||||||
let parsed = doc_loader::parse_doc_from_binary(snapshot.blob.clone(), snapshot.doc_id.clone())
|
let parsed = doc_loader::parse_doc_from_binary(snapshot.blob.clone(), snapshot.doc_id.clone())
|
||||||
.map_err(|err| RuntimeError::invalid_state(format!("Doc blob refs parse failed: {err}")))?;
|
.map_err(|err| RuntimeError::invalid_state(format!("Doc blob refs parse failed: {err}")))?;
|
||||||
let mut refs = Vec::new();
|
let mut refs = Vec::new();
|
||||||
@@ -226,6 +150,9 @@ fn extract_refs(snapshot: &SnapshotRow) -> RuntimeResult<Vec<ExtractedRef>> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use chrono::Utc;
|
||||||
|
use y_octo::Doc;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -233,7 +160,7 @@ mod tests {
|
|||||||
let doc_id = "doc-blob-ref-test".to_string();
|
let doc_id = "doc-blob-ref-test".to_string();
|
||||||
let blob =
|
let blob =
|
||||||
doc_loader::build_full_doc("Doc", "", &doc_id).expect("doc fixture should build");
|
doc_loader::build_full_doc("Doc", "", &doc_id).expect("doc fixture should build");
|
||||||
let snapshot = SnapshotRow {
|
let snapshot = CurrentDoc {
|
||||||
workspace_id: "workspace".to_string(),
|
workspace_id: "workspace".to_string(),
|
||||||
doc_id,
|
doc_id,
|
||||||
blob,
|
blob,
|
||||||
@@ -272,9 +199,21 @@ mod tests {
|
|||||||
let ids = doc_loader::get_doc_ids_from_binary(root, true).expect("root doc ids should parse");
|
let ids = doc_loader::get_doc_ids_from_binary(root, true).expect("root doc ids should parse");
|
||||||
assert_eq!(ids, vec!["active-doc", "trashed-doc"]);
|
assert_eq!(ids, vec!["active-doc", "trashed-doc"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn doc_blob_refs_rejects_corrupt_docs() {
|
||||||
|
let snapshot = CurrentDoc {
|
||||||
|
workspace_id: "workspace".to_string(),
|
||||||
|
doc_id: "corrupt".to_string(),
|
||||||
|
blob: vec![0xff],
|
||||||
|
updated_at: Utc::now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(extract_refs(&snapshot).is_err());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn replace_doc_refs(pool: &PgPool, snapshot: &SnapshotRow, refs: Vec<ExtractedRef>) -> RuntimeResult<(i64, i64)> {
|
async fn replace_doc_refs(pool: &PgPool, snapshot: &CurrentDoc, refs: Vec<ExtractedRef>) -> RuntimeResult<(i64, i64)> {
|
||||||
let mut tx = pool
|
let mut tx = pool
|
||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -7,18 +7,21 @@ use std::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
use napi::bindgen_prelude::Buffer;
|
use napi::bindgen_prelude::Buffer;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{Map, Value};
|
use serde_json::{Map, Value};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use sqlx::{PgPool, Row, postgres::PgPoolOptions};
|
use sqlx::{FromRow, PgPool, Row, postgres::PgPoolOptions};
|
||||||
use tokio::{sync::Mutex, task::JoinSet};
|
use tokio::{sync::Mutex, task::JoinSet};
|
||||||
|
use y_octo::Doc;
|
||||||
|
|
||||||
mod assetpack;
|
mod assetpack;
|
||||||
mod blob_cleanup;
|
mod blob_cleanup;
|
||||||
mod blob_reclaimer;
|
mod blob_reclaimer;
|
||||||
mod blob_reconciliation;
|
mod blob_reconciliation;
|
||||||
mod doc_blob_refs;
|
mod doc_blob_refs;
|
||||||
|
mod document_cleanup;
|
||||||
pub(crate) mod object_storage;
|
pub(crate) mod object_storage;
|
||||||
|
|
||||||
use self::object_storage::{
|
use self::object_storage::{
|
||||||
@@ -33,9 +36,10 @@ pub(super) use super::{
|
|||||||
napi_error, to_napi_error,
|
napi_error, to_napi_error,
|
||||||
types::{
|
types::{
|
||||||
RuntimeBlobCleanupExecuteResult, RuntimeBlobCleanupPlanResult, RuntimeBlobCleanupResult, RuntimeBlobCompleteResult,
|
RuntimeBlobCleanupExecuteResult, RuntimeBlobCleanupPlanResult, RuntimeBlobCleanupResult, RuntimeBlobCompleteResult,
|
||||||
RuntimeBlobMetadataBackfillResult, RuntimeDocBlobRefsResult, RuntimeMultipartUploadInit,
|
RuntimeBlobMetadataBackfillResult, RuntimeDocBlobRefsResult, RuntimeDocumentCleanupAckResult,
|
||||||
RuntimeMultipartUploadPart, RuntimeObjectGetResult, RuntimeObjectListEntry, RuntimeObjectMetadata,
|
RuntimeDocumentCleanupEffect, RuntimeDocumentCleanupExecuteResult, RuntimeDocumentCleanupReconcileResult,
|
||||||
RuntimeObjectStoragePutOptions, RuntimePresignedObjectRequest,
|
RuntimeMultipartUploadInit, RuntimeMultipartUploadPart, RuntimeObjectGetResult, RuntimeObjectListEntry,
|
||||||
|
RuntimeObjectMetadata, RuntimeObjectStoragePutOptions, RuntimePresignedObjectRequest,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -45,6 +49,100 @@ const OBJECT_DELETE_MANY_CONCURRENCY: usize = 3;
|
|||||||
|
|
||||||
type Result<T> = RuntimeResult<T>;
|
type Result<T> = RuntimeResult<T>;
|
||||||
|
|
||||||
|
#[derive(FromRow)]
|
||||||
|
struct CurrentDoc {
|
||||||
|
workspace_id: String,
|
||||||
|
doc_id: String,
|
||||||
|
blob: Vec<u8>,
|
||||||
|
updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(FromRow)]
|
||||||
|
struct CurrentDocUpdate {
|
||||||
|
blob: Vec<u8>,
|
||||||
|
created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_current_doc(pool: &PgPool, workspace_id: &str, doc_id: &str) -> RuntimeResult<Option<CurrentDoc>> {
|
||||||
|
let snapshot = sqlx::query_as::<_, CurrentDoc>(
|
||||||
|
r#"
|
||||||
|
SELECT workspace_id, guid AS doc_id, blob, updated_at
|
||||||
|
FROM snapshots
|
||||||
|
WHERE workspace_id = $1 AND guid = $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(workspace_id)
|
||||||
|
.bind(doc_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|err| RuntimeError::database("Current doc snapshot load failed", err))?;
|
||||||
|
let updates = sqlx::query_as::<_, CurrentDocUpdate>(
|
||||||
|
r#"
|
||||||
|
SELECT blob, created_at
|
||||||
|
FROM updates
|
||||||
|
WHERE workspace_id = $1 AND guid = $2
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(workspace_id)
|
||||||
|
.bind(doc_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|err| RuntimeError::database("Current doc updates load failed", err))?;
|
||||||
|
merge_current_doc(workspace_id, doc_id, snapshot, updates)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_current_doc(
|
||||||
|
workspace_id: &str,
|
||||||
|
doc_id: &str,
|
||||||
|
snapshot: Option<CurrentDoc>,
|
||||||
|
updates: Vec<CurrentDocUpdate>,
|
||||||
|
) -> RuntimeResult<Option<CurrentDoc>> {
|
||||||
|
if snapshot.is_none() && updates.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let mut doc = Doc::default();
|
||||||
|
let mut updated_at = snapshot
|
||||||
|
.as_ref()
|
||||||
|
.map(|snapshot| snapshot.updated_at)
|
||||||
|
.or_else(|| updates.first().map(|update| update.created_at))
|
||||||
|
.unwrap_or_else(Utc::now);
|
||||||
|
if let Some(snapshot) = &snapshot {
|
||||||
|
doc
|
||||||
|
.apply_update_from_binary_v1(&snapshot.blob)
|
||||||
|
.map_err(|err| RuntimeError::invalid_state(format!("Current doc snapshot merge failed: {err}")))?;
|
||||||
|
}
|
||||||
|
for update in updates {
|
||||||
|
updated_at = updated_at.max(update.created_at);
|
||||||
|
doc
|
||||||
|
.apply_update_from_binary_v1(&update.blob)
|
||||||
|
.map_err(|err| RuntimeError::invalid_state(format!("Current doc update merge failed: {err}")))?;
|
||||||
|
}
|
||||||
|
let blob = doc
|
||||||
|
.encode_update_v1()
|
||||||
|
.map_err(|err| RuntimeError::invalid_state(format!("Current doc encode failed: {err}")))?;
|
||||||
|
|
||||||
|
Ok(Some(CurrentDoc {
|
||||||
|
workspace_id: workspace_id.to_string(),
|
||||||
|
doc_id: doc_id.to_string(),
|
||||||
|
blob,
|
||||||
|
updated_at,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_workspace_live_doc_ids(pool: &PgPool, workspace_id: &str) -> RuntimeResult<Vec<String>> {
|
||||||
|
workspace_live_doc_ids(load_current_doc(pool, workspace_id, workspace_id).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn workspace_live_doc_ids(root: Option<CurrentDoc>) -> RuntimeResult<Vec<String>> {
|
||||||
|
let root = root.ok_or_else(|| RuntimeError::invalid_state("Workspace root doc is missing"))?;
|
||||||
|
let mut ids = affine_doc_loader::get_doc_ids_from_binary(root.blob, true)
|
||||||
|
.map_err(|err| RuntimeError::invalid_state(format!("Workspace root doc parse failed: {err}")))?;
|
||||||
|
ids.sort();
|
||||||
|
ids.dedup();
|
||||||
|
Ok(ids)
|
||||||
|
}
|
||||||
|
|
||||||
#[napi_derive::napi(object)]
|
#[napi_derive::napi(object)]
|
||||||
pub struct StorageRuntimeHealth {
|
pub struct StorageRuntimeHealth {
|
||||||
pub started: bool,
|
pub started: bool,
|
||||||
@@ -1385,6 +1483,81 @@ fn system_time_ms(time: SystemTime) -> Result<i64> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_live_set_merges_pending_updates_and_includes_trash() {
|
||||||
|
use y_octo::{Any, Value};
|
||||||
|
|
||||||
|
let snapshot = affine_doc_loader::add_doc_to_root_doc(Vec::new(), "live", None).unwrap();
|
||||||
|
let pending = affine_doc_loader::add_doc_to_root_doc(snapshot.clone(), "trash", None).unwrap();
|
||||||
|
let merged = merge_current_doc(
|
||||||
|
"workspace",
|
||||||
|
"workspace",
|
||||||
|
Some(CurrentDoc {
|
||||||
|
workspace_id: "workspace".to_string(),
|
||||||
|
doc_id: "workspace".to_string(),
|
||||||
|
blob: snapshot,
|
||||||
|
updated_at: Utc::now(),
|
||||||
|
}),
|
||||||
|
vec![CurrentDocUpdate {
|
||||||
|
blob: pending,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
let mut root = Doc::default();
|
||||||
|
root.apply_update_from_binary_v1(&merged.blob).unwrap();
|
||||||
|
let meta = root.get_map("meta").unwrap();
|
||||||
|
let mut pages = meta.get("pages").and_then(|value| value.to_array()).unwrap();
|
||||||
|
let mut trash = pages
|
||||||
|
.iter()
|
||||||
|
.find_map(|value| {
|
||||||
|
let page = value.to_map()?;
|
||||||
|
(page.get("id")?.to_any()? == Any::String("trash".to_string())).then_some(page)
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
trash.insert("trash".to_string(), Value::Any(Any::True)).unwrap();
|
||||||
|
|
||||||
|
let ids = workspace_live_doc_ids(Some(CurrentDoc {
|
||||||
|
workspace_id: "workspace".to_string(),
|
||||||
|
doc_id: "workspace".to_string(),
|
||||||
|
blob: root.encode_update_v1().unwrap(),
|
||||||
|
updated_at: Utc::now(),
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(ids, ["live", "trash"]);
|
||||||
|
|
||||||
|
let trash_index = pages
|
||||||
|
.iter()
|
||||||
|
.position(|value| {
|
||||||
|
value.to_map().and_then(|page| page.get("id")) == Some(Value::Any(Any::String("trash".to_string())))
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
pages.remove(trash_index as u64, 1).unwrap();
|
||||||
|
let ids = workspace_live_doc_ids(Some(CurrentDoc {
|
||||||
|
workspace_id: "workspace".to_string(),
|
||||||
|
doc_id: "workspace".to_string(),
|
||||||
|
blob: root.encode_update_v1().unwrap(),
|
||||||
|
updated_at: Utc::now(),
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(ids, ["live"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_live_set_fails_closed_for_missing_or_corrupt_root() {
|
||||||
|
assert!(workspace_live_doc_ids(None).is_err());
|
||||||
|
assert!(
|
||||||
|
workspace_live_doc_ids(Some(CurrentDoc {
|
||||||
|
workspace_id: "workspace".to_string(),
|
||||||
|
doc_id: "workspace".to_string(),
|
||||||
|
blob: vec![0xff],
|
||||||
|
updated_at: Utc::now(),
|
||||||
|
}))
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn fs_key_normalization_rejects_traversal() {
|
fn fs_key_normalization_rejects_traversal() {
|
||||||
for (key, valid) in [
|
for (key, valid) in [
|
||||||
|
|||||||
@@ -243,6 +243,41 @@ pub struct RuntimeDocBlobRefsResult {
|
|||||||
pub next_cursor: Option<String>,
|
pub next_cursor: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[napi_derive::napi(object)]
|
||||||
|
pub struct RuntimeDocumentCleanupReconcileResult {
|
||||||
|
pub scanned_docs: i64,
|
||||||
|
pub marked: i64,
|
||||||
|
pub reset: i64,
|
||||||
|
pub recovered: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[napi_derive::napi(object)]
|
||||||
|
pub struct RuntimeDocumentCleanupEffect {
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub doc_id: String,
|
||||||
|
pub cleanup_version: String,
|
||||||
|
pub comment_objects_done: bool,
|
||||||
|
pub search_done: bool,
|
||||||
|
pub copilot_done: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[napi_derive::napi(object)]
|
||||||
|
pub struct RuntimeDocumentCleanupExecuteResult {
|
||||||
|
pub scanned_candidates: i64,
|
||||||
|
pub serialization_retries: i64,
|
||||||
|
pub executed: i64,
|
||||||
|
pub recovered: i64,
|
||||||
|
pub reset: i64,
|
||||||
|
pub failed: i64,
|
||||||
|
pub deleted_rows: i64,
|
||||||
|
pub effects: Vec<RuntimeDocumentCleanupEffect>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[napi_derive::napi(object)]
|
||||||
|
pub struct RuntimeDocumentCleanupAckResult {
|
||||||
|
pub completed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
#[napi_derive::napi(object)]
|
#[napi_derive::napi(object)]
|
||||||
pub struct RuntimeBlobCleanupPlanResult {
|
pub struct RuntimeBlobCleanupPlanResult {
|
||||||
pub run_id: Option<String>,
|
pub run_id: Option<String>,
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import {
|
|||||||
WorkspaceModel,
|
WorkspaceModel,
|
||||||
WorkspaceRole,
|
WorkspaceRole,
|
||||||
} from '../../models';
|
} from '../../models';
|
||||||
import type { LlmToolCallbackRequest } from '../../native';
|
import { addDocToRootDoc, type LlmToolCallbackRequest } from '../../native';
|
||||||
import { CopilotModule } from '../../plugins/copilot';
|
import { CopilotModule } from '../../plugins/copilot';
|
||||||
import { CopilotContextService } from '../../plugins/copilot/context';
|
import { CopilotContextService } from '../../plugins/copilot/context';
|
||||||
import { CopilotContextResolver } from '../../plugins/copilot/context/resolver';
|
import { CopilotContextResolver } from '../../plugins/copilot/context/resolver';
|
||||||
@@ -260,6 +260,80 @@ test.after.always(async t => {
|
|||||||
await t.context.module?.close();
|
await t.context.module?.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('document cleanup reconciles missing and restored copilot state before ack', async t => {
|
||||||
|
const { db, jobs, models, module, workspace } = t.context;
|
||||||
|
const queue = module.get(JobQueue);
|
||||||
|
const deleteEmbedding = Sinon.spy(
|
||||||
|
models.copilotContext,
|
||||||
|
'purgeWorkspaceEmbedding'
|
||||||
|
);
|
||||||
|
const scheduleEmbedding = Sinon.stub(
|
||||||
|
jobs,
|
||||||
|
'addDocEmbeddingQueueFromEvent'
|
||||||
|
).resolves();
|
||||||
|
|
||||||
|
for (const [docId, restored, cleanupVersion] of [
|
||||||
|
['missing-doc', false, 'missing-version'],
|
||||||
|
['restored-doc', true, 'restored-version'],
|
||||||
|
] as const) {
|
||||||
|
const ws = await workspace.create(userId);
|
||||||
|
const root = addDocToRootDoc(Buffer.from([0, 0]), docId, docId);
|
||||||
|
const missingRoot = addDocToRootDoc(
|
||||||
|
Buffer.from([0, 0]),
|
||||||
|
'live-doc',
|
||||||
|
'Live'
|
||||||
|
);
|
||||||
|
await db.snapshot.create({
|
||||||
|
data: {
|
||||||
|
workspaceId: ws.id,
|
||||||
|
id: ws.id,
|
||||||
|
blob: restored ? root : missingRoot,
|
||||||
|
state: Buffer.from([0, 0]),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
createdAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (restored) {
|
||||||
|
await db.snapshot.create({
|
||||||
|
data: {
|
||||||
|
workspaceId: ws.id,
|
||||||
|
id: docId,
|
||||||
|
blob: addDocToRootDoc(Buffer.from([0, 0]), 'content', 'Content'),
|
||||||
|
state: Buffer.from([0, 0]),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
createdAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await jobs.reconcileDocumentCleanup({
|
||||||
|
workspaceId: ws.id,
|
||||||
|
docId,
|
||||||
|
cleanupVersion,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (restored) {
|
||||||
|
t.true(scheduleEmbedding.calledOnceWith({ workspaceId: ws.id, docId }));
|
||||||
|
t.false(deleteEmbedding.called);
|
||||||
|
} else {
|
||||||
|
t.true(deleteEmbedding.calledOnceWith(ws.id, docId));
|
||||||
|
t.false(scheduleEmbedding.called);
|
||||||
|
}
|
||||||
|
const { payload } = await module.queue.waitFor(
|
||||||
|
'backendRuntime.ackDocumentCleanupEffect'
|
||||||
|
);
|
||||||
|
t.deepEqual(payload, {
|
||||||
|
workspaceId: ws.id,
|
||||||
|
docId,
|
||||||
|
cleanupVersion,
|
||||||
|
effect: 'copilot',
|
||||||
|
});
|
||||||
|
deleteEmbedding.resetHistory();
|
||||||
|
scheduleEmbedding.resetHistory();
|
||||||
|
(queue.add as Sinon.SinonStub).resetHistory();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('MCP credentials stay bound to their endpoint, workspace, and profile', async t => {
|
test('MCP credentials stay bound to their endpoint, workspace, and profile', async t => {
|
||||||
const { db, mcpCredentials, mcpProvider, models, workspace } = t.context;
|
const { db, mcpCredentials, mcpProvider, models, workspace } = t.context;
|
||||||
const ws = await workspace.create(userId);
|
const ws = await workspace.create(userId);
|
||||||
|
|||||||
@@ -671,7 +671,7 @@ test('active users metric should dedupe multiple sockets for one user', async t
|
|||||||
test('workspace sync delete-doc should enforce doc permissions', async t => {
|
test('workspace sync delete-doc should enforce doc permissions', async t => {
|
||||||
const db = app.get(PrismaClient);
|
const db = app.get(PrismaClient);
|
||||||
const models = app.get(Models);
|
const models = app.get(Models);
|
||||||
const { user: owner } = await login(app);
|
const { user: owner, cookieHeader: ownerCookieHeader } = await login(app);
|
||||||
const { user: collaborator, cookieHeader } = await login(app);
|
const { user: collaborator, cookieHeader } = await login(app);
|
||||||
const workspace = await models.workspace.create(owner.id);
|
const workspace = await models.workspace.create(owner.id);
|
||||||
const docId = 'private-doc';
|
const docId = 'private-doc';
|
||||||
@@ -692,9 +692,10 @@ test('workspace sync delete-doc should enforce doc permissions', async t => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const socket = createClient(url, cookieHeader);
|
const socket = createClient(url, cookieHeader);
|
||||||
|
const ownerSocket = createClient(url, ownerCookieHeader);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await waitForConnect(socket);
|
await Promise.all([waitForConnect(socket), waitForConnect(ownerSocket)]);
|
||||||
|
|
||||||
const join = unwrapResponse(
|
const join = unwrapResponse(
|
||||||
t,
|
t,
|
||||||
@@ -719,8 +720,37 @@ test('workspace sync delete-doc should enforce doc permissions', async t => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
t.true(error.message.includes('Doc.Delete'));
|
t.true(error.message.includes('Doc.Delete'));
|
||||||
|
|
||||||
|
const ownerJoin = unwrapResponse(
|
||||||
|
t,
|
||||||
|
await emitWithAck<{ clientId: string; success: boolean }>(
|
||||||
|
ownerSocket,
|
||||||
|
'space:join',
|
||||||
|
{
|
||||||
|
spaceType: 'workspace',
|
||||||
|
spaceId: workspace.id,
|
||||||
|
clientVersion: '0.26.0',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
t.true(ownerJoin.success);
|
||||||
|
unwrapResponse(
|
||||||
|
t,
|
||||||
|
await emitWithAck(ownerSocket, 'space:delete-doc', {
|
||||||
|
spaceType: 'workspace',
|
||||||
|
spaceId: workspace.id,
|
||||||
|
docId,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
t.is(
|
||||||
|
await db.snapshot.count({
|
||||||
|
where: { workspaceId: workspace.id, id: docId },
|
||||||
|
}),
|
||||||
|
1
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
socket.disconnect();
|
socket.disconnect();
|
||||||
|
ownerSocket.disconnect();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -137,8 +137,8 @@ export class PgWorkspaceDocStorageAdapter extends DocStorageAdapter {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteDoc(workspaceId: string, docId: string) {
|
async deleteDoc(_workspaceId: string, _docId: string) {
|
||||||
await this.models.doc.delete(workspaceId, docId);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteSpace(workspaceId: string) {
|
async deleteSpace(workspaceId: string) {
|
||||||
|
|||||||
@@ -258,6 +258,33 @@ export class StorageRuntimeProvider
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async reconcileWorkspaceDocuments(workspaceId: string) {
|
||||||
|
return await this.measured('reconcileWorkspaceDocuments', rt =>
|
||||||
|
rt.reconcileWorkspaceDocuments(workspaceId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async executeDocumentCleanupCandidates(
|
||||||
|
workspaceId: string | null | undefined,
|
||||||
|
gracePeriodDays: number,
|
||||||
|
limit: number
|
||||||
|
) {
|
||||||
|
return await this.measured('executeDocumentCleanupCandidates', rt =>
|
||||||
|
rt.executeDocumentCleanupCandidates(workspaceId, gracePeriodDays, limit)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async ackDocumentCleanupEffect(
|
||||||
|
workspaceId: string,
|
||||||
|
docId: string,
|
||||||
|
cleanupVersion: string,
|
||||||
|
effect: 'search' | 'copilot'
|
||||||
|
) {
|
||||||
|
return await this.measured('ackDocumentCleanupEffect', rt =>
|
||||||
|
rt.ackDocumentCleanupEffect(workspaceId, docId, cleanupVersion, effect)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async planUnreferencedWorkspaceBlobs(
|
async planUnreferencedWorkspaceBlobs(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
gracePeriodDays: number,
|
gracePeriodDays: number,
|
||||||
|
|||||||
@@ -6,10 +6,13 @@ import { StorageBlobJob } from '../blob-job';
|
|||||||
interface Context {
|
interface Context {
|
||||||
runtime: {
|
runtime: {
|
||||||
health: Sinon.SinonStub;
|
health: Sinon.SinonStub;
|
||||||
|
reconcileWorkspaceDocuments: Sinon.SinonStub;
|
||||||
backfillMissingBlobMetadata: Sinon.SinonStub;
|
backfillMissingBlobMetadata: Sinon.SinonStub;
|
||||||
rebuildWorkspaceDocBlobRefs: Sinon.SinonStub;
|
rebuildWorkspaceDocBlobRefs: Sinon.SinonStub;
|
||||||
planUnreferencedWorkspaceBlobs: Sinon.SinonStub;
|
planUnreferencedWorkspaceBlobs: Sinon.SinonStub;
|
||||||
executeBlobCleanupCandidates: Sinon.SinonStub;
|
executeBlobCleanupCandidates: Sinon.SinonStub;
|
||||||
|
executeDocumentCleanupCandidates: Sinon.SinonStub;
|
||||||
|
ackDocumentCleanupEffect: Sinon.SinonStub;
|
||||||
};
|
};
|
||||||
event: {
|
event: {
|
||||||
emitAsync: Sinon.SinonStub;
|
emitAsync: Sinon.SinonStub;
|
||||||
@@ -35,10 +38,18 @@ test.beforeEach(t => {
|
|||||||
providerConfigured: true,
|
providerConfigured: true,
|
||||||
provider: 'fs',
|
provider: 'fs',
|
||||||
}),
|
}),
|
||||||
|
reconcileWorkspaceDocuments: Sinon.stub().resolves({
|
||||||
|
scannedDocs: 1,
|
||||||
|
marked: 0,
|
||||||
|
reset: 0,
|
||||||
|
recovered: 0,
|
||||||
|
}),
|
||||||
backfillMissingBlobMetadata: Sinon.stub(),
|
backfillMissingBlobMetadata: Sinon.stub(),
|
||||||
rebuildWorkspaceDocBlobRefs: Sinon.stub(),
|
rebuildWorkspaceDocBlobRefs: Sinon.stub(),
|
||||||
planUnreferencedWorkspaceBlobs: Sinon.stub(),
|
planUnreferencedWorkspaceBlobs: Sinon.stub(),
|
||||||
executeBlobCleanupCandidates: Sinon.stub(),
|
executeBlobCleanupCandidates: Sinon.stub(),
|
||||||
|
executeDocumentCleanupCandidates: Sinon.stub(),
|
||||||
|
ackDocumentCleanupEffect: Sinon.stub(),
|
||||||
};
|
};
|
||||||
t.context.event = {
|
t.context.event = {
|
||||||
emitAsync: Sinon.stub().resolves(undefined),
|
emitAsync: Sinon.stub().resolves(undefined),
|
||||||
@@ -47,7 +58,18 @@ test.beforeEach(t => {
|
|||||||
add: Sinon.stub().resolves(undefined),
|
add: Sinon.stub().resolves(undefined),
|
||||||
};
|
};
|
||||||
t.context.db = {
|
t.context.db = {
|
||||||
$queryRaw: Sinon.stub(),
|
$queryRaw: Sinon.stub().resolves([
|
||||||
|
{
|
||||||
|
marked: 0n,
|
||||||
|
failed: 0n,
|
||||||
|
effectsPending: 0n,
|
||||||
|
failedWorkspaceCheckpoints: 0n,
|
||||||
|
rootFailureCheckpoints: 0n,
|
||||||
|
staleProjectionBlocks: 0n,
|
||||||
|
oldestFailedSeconds: null,
|
||||||
|
oldestEffectsPendingSeconds: null,
|
||||||
|
},
|
||||||
|
]),
|
||||||
workspace: {
|
workspace: {
|
||||||
findMany: Sinon.stub(),
|
findMany: Sinon.stub(),
|
||||||
},
|
},
|
||||||
@@ -129,46 +151,6 @@ for (const scenario of objectStorageRequiredCases) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
test('doc blob refs sweep continues after one workspace fails', async t => {
|
|
||||||
t.context.db.workspace.findMany.resolves([
|
|
||||||
{ id: 'workspace-1', sid: 1 },
|
|
||||||
{ id: 'workspace-2', sid: 2 },
|
|
||||||
]);
|
|
||||||
t.context.runtime.rebuildWorkspaceDocBlobRefs
|
|
||||||
.onFirstCall()
|
|
||||||
.rejects(new Error('bad root doc'))
|
|
||||||
.onSecondCall()
|
|
||||||
.resolves({
|
|
||||||
scannedDocs: 1,
|
|
||||||
parsedDocs: 1,
|
|
||||||
refsWritten: 0,
|
|
||||||
refsDeleted: 0,
|
|
||||||
failedDocs: 0,
|
|
||||||
nextCursor: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
await t.context.job.rebuildWorkspaceDocBlobRefsBySid({
|
|
||||||
workspaceLimit: 2,
|
|
||||||
docLimit: 100,
|
|
||||||
});
|
|
||||||
|
|
||||||
t.is(t.context.runtime.rebuildWorkspaceDocBlobRefs.callCount, 2);
|
|
||||||
t.deepEqual(t.context.runtime.rebuildWorkspaceDocBlobRefs.firstCall.args, [
|
|
||||||
'workspace-1',
|
|
||||||
100,
|
|
||||||
]);
|
|
||||||
t.deepEqual(t.context.runtime.rebuildWorkspaceDocBlobRefs.secondCall.args, [
|
|
||||||
'workspace-2',
|
|
||||||
100,
|
|
||||||
]);
|
|
||||||
t.true(
|
|
||||||
t.context.queue.add.calledWith(
|
|
||||||
'backendRuntime.rebuildWorkspaceDocBlobRefsBySid',
|
|
||||||
{ lastSid: 2, workspaceLimit: 2, docLimit: 100 }
|
|
||||||
)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('blob cleanup planning drains each workspace cursor before continuing', async t => {
|
test('blob cleanup planning drains each workspace cursor before continuing', async t => {
|
||||||
t.context.db.workspace.findMany.resolves([
|
t.context.db.workspace.findMany.resolves([
|
||||||
{ id: 'workspace-1', sid: 1 },
|
{ id: 'workspace-1', sid: 1 },
|
||||||
@@ -229,6 +211,13 @@ test('blob cleanup planning drains each workspace cursor before continuing', asy
|
|||||||
test('daily blob cleanup execution uses a fixed job id', async t => {
|
test('daily blob cleanup execution uses a fixed job id', async t => {
|
||||||
await t.context.job.dailyBlobCleanupExecution();
|
await t.context.job.dailyBlobCleanupExecution();
|
||||||
|
|
||||||
|
t.true(
|
||||||
|
t.context.queue.add.calledWith(
|
||||||
|
'backendRuntime.executeDocumentCleanupCandidates',
|
||||||
|
{},
|
||||||
|
{ jobId: 'daily-backend-runtime-document-cleanup-execution' }
|
||||||
|
)
|
||||||
|
);
|
||||||
t.true(
|
t.true(
|
||||||
t.context.queue.add.calledWith(
|
t.context.queue.add.calledWith(
|
||||||
'backendRuntime.executeBlobCleanupCandidatesByMarkedRuns',
|
'backendRuntime.executeBlobCleanupCandidatesByMarkedRuns',
|
||||||
@@ -238,6 +227,132 @@ test('daily blob cleanup execution uses a fixed job id', async t => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('daily storage reconciliation uses a fixed job id', async t => {
|
||||||
|
await t.context.job.dailyStorageReconciliation();
|
||||||
|
|
||||||
|
t.true(
|
||||||
|
t.context.queue.add.calledWith(
|
||||||
|
'backendRuntime.reconcileWorkspaceStorageBySid',
|
||||||
|
{},
|
||||||
|
{ jobId: 'daily-backend-runtime-storage-reconciliation' }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('storage reconciliation orders document retention before blob cleanup', async t => {
|
||||||
|
t.context.db.workspace.findMany.resolves([{ id: 'workspace-1', sid: 1 }]);
|
||||||
|
t.context.runtime.rebuildWorkspaceDocBlobRefs.resolves({
|
||||||
|
scannedDocs: 1,
|
||||||
|
parsedDocs: 1,
|
||||||
|
refsWritten: 1,
|
||||||
|
refsDeleted: 0,
|
||||||
|
failedDocs: 0,
|
||||||
|
nextCursor: null,
|
||||||
|
});
|
||||||
|
t.context.runtime.planUnreferencedWorkspaceBlobs.resolves({
|
||||||
|
runId: 'run-1',
|
||||||
|
scannedBlobs: 1,
|
||||||
|
candidatesMarked: 0,
|
||||||
|
nextCursor: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.context.job.reconcileWorkspaceStorageBySid({ workspaceLimit: 10 });
|
||||||
|
|
||||||
|
Sinon.assert.callOrder(
|
||||||
|
t.context.runtime.reconcileWorkspaceDocuments,
|
||||||
|
t.context.runtime.rebuildWorkspaceDocBlobRefs,
|
||||||
|
t.context.runtime.planUnreferencedWorkspaceBlobs
|
||||||
|
);
|
||||||
|
t.pass();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('storage reconciliation still refreshes document retention without object storage', async t => {
|
||||||
|
t.context.runtime.health.resolves({
|
||||||
|
databaseConnected: true,
|
||||||
|
providerConfigured: true,
|
||||||
|
provider: undefined,
|
||||||
|
});
|
||||||
|
t.context.db.workspace.findMany.resolves([{ id: 'workspace-1', sid: 1 }]);
|
||||||
|
t.context.runtime.rebuildWorkspaceDocBlobRefs.resolves({
|
||||||
|
scannedDocs: 1,
|
||||||
|
parsedDocs: 1,
|
||||||
|
refsWritten: 0,
|
||||||
|
refsDeleted: 0,
|
||||||
|
failedDocs: 0,
|
||||||
|
nextCursor: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.context.job.reconcileWorkspaceStorageBySid({});
|
||||||
|
|
||||||
|
t.true(t.context.runtime.reconcileWorkspaceDocuments.calledOnce);
|
||||||
|
t.true(t.context.runtime.rebuildWorkspaceDocBlobRefs.calledOnce);
|
||||||
|
t.false(t.context.runtime.planUnreferencedWorkspaceBlobs.called);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('document cleanup dispatches independent stable search and copilot effects', async t => {
|
||||||
|
t.context.runtime.executeDocumentCleanupCandidates.resolves({
|
||||||
|
scannedCandidates: 1,
|
||||||
|
serializationRetries: 0,
|
||||||
|
executed: 1,
|
||||||
|
recovered: 0,
|
||||||
|
reset: 0,
|
||||||
|
failed: 0,
|
||||||
|
deletedRows: 3,
|
||||||
|
effects: [
|
||||||
|
{
|
||||||
|
workspaceId: 'workspace-1',
|
||||||
|
docId: 'doc-1',
|
||||||
|
cleanupVersion: 'version-1',
|
||||||
|
commentObjectsDone: true,
|
||||||
|
searchDone: false,
|
||||||
|
copilotDone: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.context.job.executeDocumentCleanupCandidates({});
|
||||||
|
|
||||||
|
t.true(
|
||||||
|
t.context.queue.add.calledWith(
|
||||||
|
'indexer.reconcileDocumentCleanup',
|
||||||
|
Sinon.match({ docId: 'doc-1' }),
|
||||||
|
{
|
||||||
|
jobId: 'document-cleanup:search:workspace-1:doc-1:version-1',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
t.true(
|
||||||
|
t.context.queue.add.calledWith(
|
||||||
|
'copilot.embedding.reconcileDocumentCleanup',
|
||||||
|
Sinon.match({ docId: 'doc-1' }),
|
||||||
|
{
|
||||||
|
jobId: 'document-cleanup:copilot:workspace-1:doc-1:version-1',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
t.true(
|
||||||
|
t.context.event.emitAsync.calledWith('workspace.blobs.updated', {
|
||||||
|
workspaceId: 'workspace-1',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('document cleanup effect ack delegates to storage runtime', async t => {
|
||||||
|
await t.context.job.ackDocumentCleanupEffect({
|
||||||
|
workspaceId: 'workspace-1',
|
||||||
|
docId: 'doc-1',
|
||||||
|
cleanupVersion: 'version-1',
|
||||||
|
effect: 'search',
|
||||||
|
});
|
||||||
|
|
||||||
|
t.deepEqual(t.context.runtime.ackDocumentCleanupEffect.firstCall.args, [
|
||||||
|
'workspace-1',
|
||||||
|
'doc-1',
|
||||||
|
'version-1',
|
||||||
|
'search',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test('blob cleanup execution sweep drains marked runs and continues by page', async t => {
|
test('blob cleanup execution sweep drains marked runs and continues by page', async t => {
|
||||||
t.context.db.$queryRaw
|
t.context.db.$queryRaw
|
||||||
.onFirstCall()
|
.onFirstCall()
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
|
|||||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
import { EventBus, JobQueue, OnJob } from '../../base';
|
import { EventBus, JobQueue, metrics, OnJob } from '../../base';
|
||||||
import { StorageRuntimeProvider } from '../storage-runtime';
|
import { StorageRuntimeProvider } from '../storage-runtime';
|
||||||
|
|
||||||
// Queue keys are persisted API; keep the legacy backendRuntime.* names while
|
// Queue keys are persisted API; keep the legacy backendRuntime.* names while
|
||||||
@@ -18,15 +18,22 @@ declare global {
|
|||||||
workspaceLimit?: number;
|
workspaceLimit?: number;
|
||||||
objectLimit?: number;
|
objectLimit?: number;
|
||||||
};
|
};
|
||||||
'backendRuntime.rebuildWorkspaceDocBlobRefs': {
|
'backendRuntime.reconcileWorkspaceStorageBySid': {
|
||||||
workspaceId: string;
|
|
||||||
limit?: number;
|
|
||||||
};
|
|
||||||
'backendRuntime.rebuildWorkspaceDocBlobRefsBySid': {
|
|
||||||
lastSid?: number;
|
lastSid?: number;
|
||||||
workspaceLimit?: number;
|
workspaceLimit?: number;
|
||||||
docLimit?: number;
|
docLimit?: number;
|
||||||
};
|
};
|
||||||
|
'backendRuntime.executeDocumentCleanupCandidates': {
|
||||||
|
workspaceId?: string;
|
||||||
|
gracePeriodDays?: number;
|
||||||
|
limit?: number;
|
||||||
|
};
|
||||||
|
'backendRuntime.ackDocumentCleanupEffect': {
|
||||||
|
workspaceId: string;
|
||||||
|
docId: string;
|
||||||
|
cleanupVersion: string;
|
||||||
|
effect: 'search' | 'copilot';
|
||||||
|
};
|
||||||
'backendRuntime.planUnreferencedWorkspaceBlobs': {
|
'backendRuntime.planUnreferencedWorkspaceBlobs': {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
gracePeriodDays?: number;
|
gracePeriodDays?: number;
|
||||||
@@ -89,25 +96,6 @@ export class StorageBlobJob {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async enqueueRebuildWorkspaceDocBlobRefs(workspaceId: string, limit = 1000) {
|
|
||||||
await this.queue.add('backendRuntime.rebuildWorkspaceDocBlobRefs', {
|
|
||||||
workspaceId,
|
|
||||||
limit,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async enqueueRebuildWorkspaceDocBlobRefsBySid(
|
|
||||||
lastSid = 0,
|
|
||||||
workspaceLimit = 100,
|
|
||||||
docLimit = 1000
|
|
||||||
) {
|
|
||||||
await this.queue.add('backendRuntime.rebuildWorkspaceDocBlobRefsBySid', {
|
|
||||||
lastSid,
|
|
||||||
workspaceLimit,
|
|
||||||
docLimit,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@OnJob('backendRuntime.backfillMissingBlobMetadataBySid')
|
@OnJob('backendRuntime.backfillMissingBlobMetadataBySid')
|
||||||
async backfillMissingBlobMetadataBySid({
|
async backfillMissingBlobMetadataBySid({
|
||||||
lastSid = 0,
|
lastSid = 0,
|
||||||
@@ -211,25 +199,21 @@ export class StorageBlobJob {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Cron(CronExpression.EVERY_DAY_AT_2AM)
|
@Cron(CronExpression.EVERY_DAY_AT_2AM)
|
||||||
async dailyDocBlobRefsRebuild() {
|
async dailyStorageReconciliation() {
|
||||||
await this.queue.add(
|
await this.queue.add(
|
||||||
'backendRuntime.rebuildWorkspaceDocBlobRefsBySid',
|
'backendRuntime.reconcileWorkspaceStorageBySid',
|
||||||
{},
|
{},
|
||||||
{ jobId: 'daily-backend-runtime-doc-blob-refs-rebuild' }
|
{ jobId: 'daily-backend-runtime-storage-reconciliation' }
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Cron(CronExpression.EVERY_DAY_AT_3AM)
|
|
||||||
async dailyBlobCleanupPlanning() {
|
|
||||||
await this.queue.add(
|
|
||||||
'backendRuntime.planUnreferencedWorkspaceBlobsBySid',
|
|
||||||
{},
|
|
||||||
{ jobId: 'daily-backend-runtime-blob-cleanup-planning' }
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Cron(CronExpression.EVERY_DAY_AT_4AM)
|
@Cron(CronExpression.EVERY_DAY_AT_4AM)
|
||||||
async dailyBlobCleanupExecution() {
|
async dailyBlobCleanupExecution() {
|
||||||
|
await this.queue.add(
|
||||||
|
'backendRuntime.executeDocumentCleanupCandidates',
|
||||||
|
{},
|
||||||
|
{ jobId: 'daily-backend-runtime-document-cleanup-execution' }
|
||||||
|
);
|
||||||
await this.queue.add(
|
await this.queue.add(
|
||||||
'backendRuntime.executeBlobCleanupCandidatesByMarkedRuns',
|
'backendRuntime.executeBlobCleanupCandidatesByMarkedRuns',
|
||||||
{},
|
{},
|
||||||
@@ -249,44 +233,39 @@ export class StorageBlobJob {
|
|||||||
await this.drainBlobMetadataBackfill(workspaceId, limit);
|
await this.drainBlobMetadataBackfill(workspaceId, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnJob('backendRuntime.rebuildWorkspaceDocBlobRefs')
|
@OnJob('backendRuntime.reconcileWorkspaceStorageBySid')
|
||||||
async rebuildWorkspaceDocBlobRefs({
|
async reconcileWorkspaceStorageBySid({
|
||||||
workspaceId,
|
|
||||||
limit = 1000,
|
|
||||||
}: Jobs['backendRuntime.rebuildWorkspaceDocBlobRefs']) {
|
|
||||||
await this.drainWorkspaceDocBlobRefs(workspaceId, limit);
|
|
||||||
}
|
|
||||||
|
|
||||||
@OnJob('backendRuntime.rebuildWorkspaceDocBlobRefsBySid')
|
|
||||||
async rebuildWorkspaceDocBlobRefsBySid({
|
|
||||||
lastSid = 0,
|
lastSid = 0,
|
||||||
workspaceLimit = 100,
|
workspaceLimit = 100,
|
||||||
docLimit = 1000,
|
docLimit = 1000,
|
||||||
}: Jobs['backendRuntime.rebuildWorkspaceDocBlobRefsBySid']) {
|
}: Jobs['backendRuntime.reconcileWorkspaceStorageBySid']) {
|
||||||
const workspaces = await this.db.workspace.findMany({
|
const workspaces = await this.db.workspace.findMany({
|
||||||
where: {
|
where: { sid: { gt: lastSid } },
|
||||||
sid: {
|
orderBy: { sid: 'asc' },
|
||||||
gt: lastSid,
|
select: { id: true, sid: true },
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: {
|
|
||||||
sid: 'asc',
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
sid: true,
|
|
||||||
},
|
|
||||||
take: workspaceLimit,
|
take: workspaceLimit,
|
||||||
});
|
});
|
||||||
|
const objectStorageConfigured = await this.hasObjectStorage(
|
||||||
|
'storage reconciliation blob cleanup planning'
|
||||||
|
);
|
||||||
|
|
||||||
for (const workspace of workspaces) {
|
for (const workspace of workspaces) {
|
||||||
try {
|
try {
|
||||||
|
await this.rt.reconcileWorkspaceDocuments(workspace.id);
|
||||||
await this.drainWorkspaceDocBlobRefs(workspace.id, docLimit, {
|
await this.drainWorkspaceDocBlobRefs(workspace.id, docLimit, {
|
||||||
sid: workspace.sid,
|
sid: workspace.sid,
|
||||||
});
|
});
|
||||||
|
if (objectStorageConfigured) {
|
||||||
|
await this.drainBlobCleanupPlanning(workspace.id, 30, 1000, {
|
||||||
|
sid: workspace.sid,
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
metrics.storage
|
||||||
|
.counter('document_cleanup_workspace_failure_total')
|
||||||
|
.add(1);
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`doc blob refs rebuild failed workspace=${workspace.id} sid=${workspace.sid}`,
|
`storage reconciliation failed workspace=${workspace.id} sid=${workspace.sid}`,
|
||||||
err
|
err
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -294,14 +273,71 @@ export class StorageBlobJob {
|
|||||||
|
|
||||||
const nextSid = workspaces.at(-1)?.sid;
|
const nextSid = workspaces.at(-1)?.sid;
|
||||||
if (nextSid !== undefined && workspaces.length === workspaceLimit) {
|
if (nextSid !== undefined && workspaces.length === workspaceLimit) {
|
||||||
await this.enqueueRebuildWorkspaceDocBlobRefsBySid(
|
await this.queue.add('backendRuntime.reconcileWorkspaceStorageBySid', {
|
||||||
nextSid,
|
lastSid: nextSid,
|
||||||
workspaceLimit,
|
workspaceLimit,
|
||||||
docLimit
|
docLimit,
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OnJob('backendRuntime.executeDocumentCleanupCandidates')
|
||||||
|
async executeDocumentCleanupCandidates({
|
||||||
|
workspaceId,
|
||||||
|
gracePeriodDays = 30,
|
||||||
|
limit = 100,
|
||||||
|
}: Jobs['backendRuntime.executeDocumentCleanupCandidates']) {
|
||||||
|
const result = await this.rt.executeDocumentCleanupCandidates(
|
||||||
|
workspaceId,
|
||||||
|
gracePeriodDays,
|
||||||
|
limit
|
||||||
|
);
|
||||||
|
metrics.storage
|
||||||
|
.counter('document_cleanup_serialization_retry_total')
|
||||||
|
.add(result.serializationRetries);
|
||||||
|
metrics.storage
|
||||||
|
.counter('document_cleanup_execute_failure_total')
|
||||||
|
.add(result.failed);
|
||||||
|
for (const effect of result.effects) {
|
||||||
|
if (!effect.searchDone) {
|
||||||
|
await this.queue.add('indexer.reconcileDocumentCleanup', effect, {
|
||||||
|
jobId: `document-cleanup:search:${effect.workspaceId}:${effect.docId}:${effect.cleanupVersion}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!effect.copilotDone) {
|
||||||
|
await this.queue.add(
|
||||||
|
'copilot.embedding.reconcileDocumentCleanup',
|
||||||
|
effect,
|
||||||
|
{
|
||||||
|
jobId: `document-cleanup:copilot:${effect.workspaceId}:${effect.docId}:${effect.cleanupVersion}`,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (effect.commentObjectsDone) {
|
||||||
|
await this.event.emitAsync('workspace.blobs.updated', {
|
||||||
|
workspaceId: effect.workspaceId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.recordDocumentCleanupHealth();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnJob('backendRuntime.ackDocumentCleanupEffect')
|
||||||
|
async ackDocumentCleanupEffect({
|
||||||
|
workspaceId,
|
||||||
|
docId,
|
||||||
|
cleanupVersion,
|
||||||
|
effect,
|
||||||
|
}: Jobs['backendRuntime.ackDocumentCleanupEffect']) {
|
||||||
|
await this.rt.ackDocumentCleanupEffect(
|
||||||
|
workspaceId,
|
||||||
|
docId,
|
||||||
|
cleanupVersion,
|
||||||
|
effect
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@OnJob('backendRuntime.planUnreferencedWorkspaceBlobs')
|
@OnJob('backendRuntime.planUnreferencedWorkspaceBlobs')
|
||||||
async planUnreferencedWorkspaceBlobs({
|
async planUnreferencedWorkspaceBlobs({
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -545,6 +581,82 @@ export class StorageBlobJob {
|
|||||||
return rows.map(row => row.runId);
|
return rows.map(row => row.runId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async recordDocumentCleanupHealth() {
|
||||||
|
const [health] = await this.db.$queryRaw<
|
||||||
|
{
|
||||||
|
marked: bigint;
|
||||||
|
failed: bigint;
|
||||||
|
effectsPending: bigint;
|
||||||
|
failedWorkspaceCheckpoints: bigint;
|
||||||
|
rootFailureCheckpoints: bigint;
|
||||||
|
staleProjectionBlocks: bigint;
|
||||||
|
oldestFailedSeconds: number | null;
|
||||||
|
oldestEffectsPendingSeconds: number | null;
|
||||||
|
}[]
|
||||||
|
>`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) FILTER (WHERE status = 'marked') AS marked,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'failed') AS failed,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'effects_pending') AS "effectsPending",
|
||||||
|
EXTRACT(EPOCH FROM CURRENT_TIMESTAMP -
|
||||||
|
MIN(updated_at) FILTER (WHERE status = 'failed'))::double precision AS "oldestFailedSeconds",
|
||||||
|
EXTRACT(EPOCH FROM CURRENT_TIMESTAMP -
|
||||||
|
MIN(updated_at) FILTER (WHERE status = 'effects_pending'))::double precision AS "oldestEffectsPendingSeconds",
|
||||||
|
(SELECT COUNT(*) FROM storage_reconciliation_checkpoints
|
||||||
|
WHERE kind = 'document_cleanup' AND status = 'failed') AS "failedWorkspaceCheckpoints",
|
||||||
|
(SELECT COUNT(*) FROM storage_reconciliation_checkpoints
|
||||||
|
WHERE kind = 'document_cleanup' AND status = 'failed'
|
||||||
|
AND metadata->>'failureKind' = 'root') AS "rootFailureCheckpoints",
|
||||||
|
(SELECT COUNT(*) FROM storage_reconciliation_runs
|
||||||
|
WHERE kind = 'blob_cleanup_plan'
|
||||||
|
AND started_at >= CURRENT_TIMESTAMP - INTERVAL '1 day'
|
||||||
|
AND jsonb_array_length(COALESCE(metadata->'staleOrFailedProjectionWorkspaces', '[]')) > 0
|
||||||
|
) AS "staleProjectionBlocks"
|
||||||
|
FROM document_cleanup_candidates
|
||||||
|
`;
|
||||||
|
if (!health) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const [name, value] of [
|
||||||
|
['document_cleanup_marked', health.marked],
|
||||||
|
['document_cleanup_failed', health.failed],
|
||||||
|
['document_cleanup_effects_pending', health.effectsPending],
|
||||||
|
[
|
||||||
|
'document_cleanup_failed_workspace_checkpoints',
|
||||||
|
health.failedWorkspaceCheckpoints,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'document_cleanup_root_failure_checkpoints',
|
||||||
|
health.rootFailureCheckpoints,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'document_cleanup_stale_projection_blocks',
|
||||||
|
health.staleProjectionBlocks,
|
||||||
|
],
|
||||||
|
] as const) {
|
||||||
|
metrics.storage.gauge(name).record(Number(value));
|
||||||
|
}
|
||||||
|
metrics.storage
|
||||||
|
.gauge('document_cleanup_oldest_failed_seconds')
|
||||||
|
.record(health.oldestFailedSeconds ?? 0);
|
||||||
|
metrics.storage
|
||||||
|
.gauge('document_cleanup_oldest_effects_pending_seconds')
|
||||||
|
.record(health.oldestEffectsPendingSeconds ?? 0);
|
||||||
|
|
||||||
|
if (
|
||||||
|
health.failedWorkspaceCheckpoints > 0n ||
|
||||||
|
health.rootFailureCheckpoints > 0n ||
|
||||||
|
health.staleProjectionBlocks > 0n ||
|
||||||
|
(health.oldestFailedSeconds ?? 0) >= 86_400 ||
|
||||||
|
(health.oldestEffectsPendingSeconds ?? 0) >= 86_400 ||
|
||||||
|
health.marked >= 10_000n
|
||||||
|
) {
|
||||||
|
this.logger.warn(
|
||||||
|
`document cleanup health marked=${health.marked} failed=${health.failed} effectsPending=${health.effectsPending} failedWorkspaceCheckpoints=${health.failedWorkspaceCheckpoints} rootFailureCheckpoints=${health.rootFailureCheckpoints} staleProjectionBlocks=${health.staleProjectionBlocks} oldestFailedSeconds=${health.oldestFailedSeconds ?? 0} oldestEffectsPendingSeconds=${health.oldestEffectsPendingSeconds ?? 0}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async hasMarkedBlobCleanupCandidates() {
|
private async hasMarkedBlobCleanupCandidates() {
|
||||||
const rows = await this.db.$queryRaw<{ exists: boolean }[]>`
|
const rows = await this.db.$queryRaw<{ exists: boolean }[]>`
|
||||||
SELECT EXISTS(
|
SELECT EXISTS(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import test from 'ava';
|
import test from 'ava';
|
||||||
import { omit } from 'lodash-es';
|
import { omit } from 'lodash-es';
|
||||||
|
import * as Y from 'yjs';
|
||||||
|
|
||||||
import { createModule } from '../../../__tests__/create-module';
|
import { createModule } from '../../../__tests__/create-module';
|
||||||
import { Mockers } from '../../../__tests__/mocks';
|
import { Mockers } from '../../../__tests__/mocks';
|
||||||
@@ -38,6 +39,86 @@ test('can read all doc ids from workspace snapshot', async t => {
|
|||||||
t.snapshot(docIds);
|
t.snapshot(docIds);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('merged root updates retain trash and exclude permanently removed docs', async t => {
|
||||||
|
const rootDoc = await models.doc.get(workspace.id, workspace.id);
|
||||||
|
t.truthy(rootDoc);
|
||||||
|
|
||||||
|
const root = new Y.Doc();
|
||||||
|
Y.applyUpdate(root, rootDoc!.blob);
|
||||||
|
const pending = new Y.Doc();
|
||||||
|
Y.applyUpdate(pending, Y.encodeStateAsUpdate(root));
|
||||||
|
const trashMeta = new Y.Map<unknown>();
|
||||||
|
trashMeta.set('id', 'trash-doc');
|
||||||
|
trashMeta.set('title', 'Trash');
|
||||||
|
trashMeta.set('trash', true);
|
||||||
|
(pending.getMap('meta').get('pages') as Y.Array<Y.Map<unknown>>).push([
|
||||||
|
trashMeta,
|
||||||
|
]);
|
||||||
|
const pendingUpdate = Y.encodeStateAsUpdate(
|
||||||
|
pending,
|
||||||
|
Y.encodeStateVector(root)
|
||||||
|
);
|
||||||
|
const merged = Y.mergeUpdates([rootDoc!.blob, pendingUpdate]);
|
||||||
|
|
||||||
|
t.false(readAllDocIdsFromWorkspaceSnapshot(merged).includes('trash-doc'));
|
||||||
|
t.true(
|
||||||
|
readAllDocIdsFromWorkspaceSnapshot(merged, true).includes('trash-doc')
|
||||||
|
);
|
||||||
|
|
||||||
|
const removed = new Y.Doc();
|
||||||
|
Y.applyUpdate(removed, merged);
|
||||||
|
const pages = removed.getMap('meta').get('pages') as Y.Array<Y.Map<unknown>>;
|
||||||
|
const target = pages
|
||||||
|
.toArray()
|
||||||
|
.findIndex(meta => meta instanceof Y.Map && meta.get('id') === 'trash-doc');
|
||||||
|
pages.delete(target, 1);
|
||||||
|
t.false(
|
||||||
|
readAllDocIdsFromWorkspaceSnapshot(
|
||||||
|
Y.encodeStateAsUpdate(removed),
|
||||||
|
true
|
||||||
|
).includes('trash-doc')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nested concurrent meta edits do not restore a deleted entry', t => {
|
||||||
|
const initial = new Y.Doc();
|
||||||
|
const pages = new Y.Array<Y.Map<unknown>>();
|
||||||
|
const meta = new Y.Map<unknown>();
|
||||||
|
meta.set('id', 'doc-1');
|
||||||
|
meta.set('title', 'Initial');
|
||||||
|
pages.push([meta]);
|
||||||
|
initial.getMap('meta').set('pages', pages);
|
||||||
|
const state = Y.encodeStateAsUpdate(initial);
|
||||||
|
|
||||||
|
const deletingClient = new Y.Doc();
|
||||||
|
const editingClient = new Y.Doc();
|
||||||
|
Y.applyUpdate(deletingClient, state);
|
||||||
|
Y.applyUpdate(editingClient, state);
|
||||||
|
(
|
||||||
|
deletingClient.getMap('meta').get('pages') as Y.Array<Y.Map<unknown>>
|
||||||
|
).delete(0, 1);
|
||||||
|
(editingClient.getMap('meta').get('pages') as Y.Array<Y.Map<unknown>>)
|
||||||
|
.get(0)
|
||||||
|
.set('title', 'Offline edit');
|
||||||
|
|
||||||
|
const merged = new Y.Doc();
|
||||||
|
Y.applyUpdate(merged, Y.encodeStateAsUpdate(deletingClient));
|
||||||
|
Y.applyUpdate(merged, Y.encodeStateAsUpdate(editingClient));
|
||||||
|
t.is(
|
||||||
|
(merged.getMap('meta').get('pages') as Y.Array<Y.Map<unknown>>).length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
(editingClient.getMap('meta').get('pages') as Y.Array<Y.Map<unknown>>).push([
|
||||||
|
meta.clone(),
|
||||||
|
]);
|
||||||
|
Y.applyUpdate(merged, Y.encodeStateAsUpdate(editingClient));
|
||||||
|
t.is(
|
||||||
|
(merged.getMap('meta').get('pages') as Y.Array<Y.Map<unknown>>).length,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('can read all blocks from doc snapshot', async t => {
|
test('can read all blocks from doc snapshot', async t => {
|
||||||
const rootDoc = await models.doc.get(workspace.id, workspace.id);
|
const rootDoc = await models.doc.get(workspace.id, workspace.id);
|
||||||
t.truthy(rootDoc);
|
t.truthy(rootDoc);
|
||||||
|
|||||||
@@ -45,8 +45,11 @@ export function parsePageDoc(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readAllDocIdsFromWorkspaceSnapshot(snapshot: Uint8Array) {
|
export function readAllDocIdsFromWorkspaceSnapshot(
|
||||||
return readAllDocIdsFromRootDoc(Buffer.from(snapshot), false);
|
snapshot: Uint8Array,
|
||||||
|
includeTrash = false
|
||||||
|
) {
|
||||||
|
return readAllDocIdsFromRootDoc(Buffer.from(snapshot), includeTrash);
|
||||||
}
|
}
|
||||||
|
|
||||||
function safeParseJson<T>(str: string): T | undefined {
|
function safeParseJson<T>(str: string): T | undefined {
|
||||||
|
|||||||
@@ -337,10 +337,14 @@ export class CopilotContextModel extends BaseModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async deleteWorkspaceEmbedding(workspaceId: string, docId: string) {
|
async deleteWorkspaceEmbedding(workspaceId: string, docId: string) {
|
||||||
|
await this.purgeWorkspaceEmbedding(workspaceId, docId);
|
||||||
|
await this.fulfillEmptyEmbedding(workspaceId, docId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async purgeWorkspaceEmbedding(workspaceId: string, docId: string) {
|
||||||
await this.db.aiWorkspaceEmbedding.deleteMany({
|
await this.db.aiWorkspaceEmbedding.deleteMany({
|
||||||
where: { workspaceId, docId },
|
where: { workspaceId, docId },
|
||||||
});
|
});
|
||||||
await this.fulfillEmptyEmbedding(workspaceId, docId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async matchWorkspaceEmbedding(
|
async matchWorkspaceEmbedding(
|
||||||
|
|||||||
@@ -205,20 +205,49 @@ export class CopilotEmbeddingJob {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnJob('copilot.embedding.deleteDoc')
|
private async deleteDocEmbedding(doc: {
|
||||||
async deleteDocEmbeddingQueueFromEvent(
|
workspaceId: string;
|
||||||
doc: Jobs['copilot.embedding.deleteDoc']
|
docId: string;
|
||||||
) {
|
}) {
|
||||||
await this.queue.remove(
|
await this.queue.remove(
|
||||||
`workspace:embedding:${doc.workspaceId}:${doc.docId}`,
|
`workspace:embedding:${doc.workspaceId}:${doc.docId}`,
|
||||||
'copilot.embedding.docs'
|
'copilot.embedding.docs'
|
||||||
);
|
);
|
||||||
await this.models.copilotContext.deleteWorkspaceEmbedding(
|
await this.models.copilotContext.purgeWorkspaceEmbedding(
|
||||||
doc.workspaceId,
|
doc.workspaceId,
|
||||||
doc.docId
|
doc.docId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OnJob('copilot.embedding.reconcileDocumentCleanup')
|
||||||
|
async reconcileDocumentCleanup({
|
||||||
|
workspaceId,
|
||||||
|
docId,
|
||||||
|
cleanupVersion,
|
||||||
|
}: Jobs['copilot.embedding.reconcileDocumentCleanup']) {
|
||||||
|
const root = await this.doc.getDoc(workspaceId, workspaceId);
|
||||||
|
if (!root) {
|
||||||
|
throw new Error(`workspace root ${workspaceId} not found`);
|
||||||
|
}
|
||||||
|
const live = readAllDocIdsFromWorkspaceSnapshot(root.bin, true).includes(
|
||||||
|
docId
|
||||||
|
);
|
||||||
|
if (live) {
|
||||||
|
if (!(await this.doc.getDoc(workspaceId, docId))) {
|
||||||
|
throw new Error(`restored document ${workspaceId}/${docId} not found`);
|
||||||
|
}
|
||||||
|
await this.addDocEmbeddingQueueFromEvent({ workspaceId, docId });
|
||||||
|
} else {
|
||||||
|
await this.deleteDocEmbedding({ workspaceId, docId });
|
||||||
|
}
|
||||||
|
await this.queue.add('backendRuntime.ackDocumentCleanupEffect', {
|
||||||
|
workspaceId,
|
||||||
|
docId,
|
||||||
|
cleanupVersion,
|
||||||
|
effect: 'copilot',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private async readCopilotBlob(
|
private async readCopilotBlob(
|
||||||
userId: string,
|
userId: string,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
|
|||||||
@@ -68,9 +68,10 @@ declare global {
|
|||||||
docId: string;
|
docId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
'copilot.embedding.deleteDoc': {
|
'copilot.embedding.reconcileDocumentCleanup': {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
docId: string;
|
docId: string;
|
||||||
|
cleanupVersion: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
'copilot.embedding.files': {
|
'copilot.embedding.files': {
|
||||||
|
|||||||
@@ -39,10 +39,6 @@ declare global {
|
|||||||
'copilot.session.generateTitle': {
|
'copilot.session.generateTitle': {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
};
|
};
|
||||||
'copilot.session.deleteDoc': {
|
|
||||||
workspaceId: string;
|
|
||||||
docId: string;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -512,23 +508,6 @@ export class ChatSessionService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnJob('copilot.session.deleteDoc')
|
|
||||||
async deleteDocSessions(doc: Jobs['copilot.session.deleteDoc']) {
|
|
||||||
const sessionIds = await this.models.copilotSession
|
|
||||||
.list({
|
|
||||||
userId: undefined,
|
|
||||||
workspaceId: doc.workspaceId,
|
|
||||||
docId: doc.docId,
|
|
||||||
})
|
|
||||||
.then(s => s.map(s => [s.userId, s.id]));
|
|
||||||
for (const [userId, sessionId] of sessionIds) {
|
|
||||||
await this.models.copilotSession.update(
|
|
||||||
{ userId, sessionId, docId: null },
|
|
||||||
true
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OnJob('copilot.session.generateTitle')
|
@OnJob('copilot.session.generateTitle')
|
||||||
async generateSessionTitle(job: Jobs['copilot.session.generateTitle']) {
|
async generateSessionTitle(job: Jobs['copilot.session.generateTitle']) {
|
||||||
const { sessionId } = job;
|
const { sessionId } = job;
|
||||||
|
|||||||
@@ -6,14 +6,17 @@ import Sinon from 'sinon';
|
|||||||
|
|
||||||
import { createModule } from '../../../__tests__/create-module';
|
import { createModule } from '../../../__tests__/create-module';
|
||||||
import { Mockers } from '../../../__tests__/mocks';
|
import { Mockers } from '../../../__tests__/mocks';
|
||||||
import { JOB_SIGNAL } from '../../../base';
|
import { Config, JOB_SIGNAL } from '../../../base';
|
||||||
import { ConfigModule } from '../../../base/config';
|
import { ConfigModule } from '../../../base/config';
|
||||||
import { ServerConfigModule } from '../../../core/config';
|
import { ServerConfigModule } from '../../../core/config';
|
||||||
|
import { DocReader } from '../../../core/doc';
|
||||||
import { Models } from '../../../models';
|
import { Models } from '../../../models';
|
||||||
|
import { addDocToRootDoc } from '../../../native';
|
||||||
import { SearchProviderFactory } from '../factory';
|
import { SearchProviderFactory } from '../factory';
|
||||||
import { IndexerModule, IndexerService } from '../index';
|
import { IndexerModule, IndexerService } from '../index';
|
||||||
import { IndexerJob } from '../job';
|
import { IndexerJob } from '../job';
|
||||||
import { ManticoresearchProvider } from '../providers';
|
import { ManticoresearchProvider } from '../providers';
|
||||||
|
import { blockSQL, docSQL, SearchTable } from '../tables';
|
||||||
|
|
||||||
const module = await createModule({
|
const module = await createModule({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -32,6 +35,8 @@ const indexerJob = module.get(IndexerJob);
|
|||||||
const searchProviderFactory = module.get(SearchProviderFactory);
|
const searchProviderFactory = module.get(SearchProviderFactory);
|
||||||
const manticoresearch = module.get(ManticoresearchProvider);
|
const manticoresearch = module.get(ManticoresearchProvider);
|
||||||
const models = module.get(Models);
|
const models = module.get(Models);
|
||||||
|
const docReader = module.get(DocReader);
|
||||||
|
const config = module.get(Config);
|
||||||
|
|
||||||
const user = await module.create(Mockers.User);
|
const user = await module.create(Mockers.User);
|
||||||
const workspace = await module.create(Mockers.Workspace, {
|
const workspace = await module.create(Mockers.Workspace, {
|
||||||
@@ -39,6 +44,11 @@ const workspace = await module.create(Mockers.Workspace, {
|
|||||||
owner: user,
|
owner: user,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.before(async () => {
|
||||||
|
await manticoresearch.recreateTable(SearchTable.block, blockSQL);
|
||||||
|
await manticoresearch.recreateTable(SearchTable.doc, docSQL);
|
||||||
|
});
|
||||||
|
|
||||||
test.after.always(async () => {
|
test.after.always(async () => {
|
||||||
await module.close();
|
await module.close();
|
||||||
});
|
});
|
||||||
@@ -105,7 +115,7 @@ test('should not sync existing doc', async t => {
|
|||||||
t.is(module.queue.count('indexer.indexDoc'), count);
|
t.is(module.queue.count('indexer.indexDoc'), count);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should delete doc from indexer when docId is not in workspace', async t => {
|
test('should delete dangling indexed docs absent from the root live set', async t => {
|
||||||
const count = module.queue.count('indexer.deleteDoc');
|
const count = module.queue.count('indexer.deleteDoc');
|
||||||
mock.method(indexerService, 'listDocIds', async () => {
|
mock.method(indexerService, 'listDocIds', async () => {
|
||||||
return ['mock-doc-id1', 'mock-doc-id2'];
|
return ['mock-doc-id1', 'mock-doc-id2'];
|
||||||
@@ -121,6 +131,104 @@ test('should delete doc from indexer when docId is not in workspace', async t =>
|
|||||||
t.is(module.queue.count('indexer.deleteDoc'), count + 2);
|
t.is(module.queue.count('indexer.deleteDoc'), count + 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('document cleanup reconcile deletes missing search state before ack', async t => {
|
||||||
|
const deleteSpy = Sinon.spy(indexerService, 'deleteDoc');
|
||||||
|
const indexSpy = Sinon.spy(indexerService, 'indexDoc');
|
||||||
|
const cleanupWorkspace = await module.create(Mockers.Workspace, {
|
||||||
|
owner: user,
|
||||||
|
});
|
||||||
|
await module.create(Mockers.DocSnapshot, {
|
||||||
|
workspaceId: cleanupWorkspace.id,
|
||||||
|
docId: cleanupWorkspace.id,
|
||||||
|
user,
|
||||||
|
blob: addDocToRootDoc(Buffer.from([0, 0]), 'live-doc', 'Live'),
|
||||||
|
});
|
||||||
|
|
||||||
|
await indexerJob.reconcileDocumentCleanup({
|
||||||
|
workspaceId: cleanupWorkspace.id,
|
||||||
|
docId: 'missing-doc',
|
||||||
|
cleanupVersion: 'version-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
t.true(deleteSpy.calledOnceWith(cleanupWorkspace.id, 'missing-doc'));
|
||||||
|
t.false(indexSpy.called);
|
||||||
|
const { payload } = await module.queue.waitFor(
|
||||||
|
'backendRuntime.ackDocumentCleanupEffect'
|
||||||
|
);
|
||||||
|
t.deepEqual(payload, {
|
||||||
|
workspaceId: cleanupWorkspace.id,
|
||||||
|
docId: 'missing-doc',
|
||||||
|
cleanupVersion: 'version-1',
|
||||||
|
effect: 'search',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('document cleanup reconcile reindexes restored doc before ack', async t => {
|
||||||
|
const deleteSpy = Sinon.spy(indexerService, 'deleteDoc');
|
||||||
|
const indexSpy = Sinon.spy(indexerService, 'indexDoc');
|
||||||
|
const cleanupWorkspace = await module.create(Mockers.Workspace, {
|
||||||
|
owner: user,
|
||||||
|
});
|
||||||
|
await module.create(Mockers.DocSnapshot, {
|
||||||
|
workspaceId: cleanupWorkspace.id,
|
||||||
|
docId: cleanupWorkspace.id,
|
||||||
|
user,
|
||||||
|
blob: addDocToRootDoc(Buffer.from([0, 0]), 'restored-doc', 'Restored'),
|
||||||
|
});
|
||||||
|
await module.create(Mockers.DocSnapshot, {
|
||||||
|
workspaceId: cleanupWorkspace.id,
|
||||||
|
docId: 'restored-doc',
|
||||||
|
user,
|
||||||
|
});
|
||||||
|
const getDocSpy = Sinon.spy(docReader, 'getDoc');
|
||||||
|
|
||||||
|
await indexerJob.reconcileDocumentCleanup({
|
||||||
|
workspaceId: cleanupWorkspace.id,
|
||||||
|
docId: 'restored-doc',
|
||||||
|
cleanupVersion: 'version-2',
|
||||||
|
});
|
||||||
|
|
||||||
|
t.true(indexSpy.calledOnceWith(cleanupWorkspace.id, 'restored-doc'));
|
||||||
|
t.false(deleteSpy.called);
|
||||||
|
t.true(getDocSpy.calledWith(cleanupWorkspace.id, cleanupWorkspace.id));
|
||||||
|
t.true(getDocSpy.calledWith(cleanupWorkspace.id, 'restored-doc'));
|
||||||
|
const { payload } = await module.queue.waitFor(
|
||||||
|
'backendRuntime.ackDocumentCleanupEffect'
|
||||||
|
);
|
||||||
|
t.deepEqual(payload, {
|
||||||
|
workspaceId: cleanupWorkspace.id,
|
||||||
|
docId: 'restored-doc',
|
||||||
|
cleanupVersion: 'version-2',
|
||||||
|
effect: 'search',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('document cleanup reconcile only acknowledges when indexer is disabled', async t => {
|
||||||
|
Sinon.stub(config.indexer, 'enabled').value(false);
|
||||||
|
const deleteSpy = Sinon.spy(indexerService, 'deleteDoc');
|
||||||
|
const indexSpy = Sinon.spy(indexerService, 'indexDoc');
|
||||||
|
const getDocSpy = Sinon.spy(docReader, 'getDoc');
|
||||||
|
|
||||||
|
await indexerJob.reconcileDocumentCleanup({
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
docId: 'disabled-doc',
|
||||||
|
cleanupVersion: 'version-disabled',
|
||||||
|
});
|
||||||
|
|
||||||
|
t.false(deleteSpy.called);
|
||||||
|
t.false(indexSpy.called);
|
||||||
|
t.false(getDocSpy.called);
|
||||||
|
const { payload } = await module.queue.waitFor(
|
||||||
|
'backendRuntime.ackDocumentCleanupEffect'
|
||||||
|
);
|
||||||
|
t.deepEqual(payload, {
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
docId: 'disabled-doc',
|
||||||
|
cleanupVersion: 'version-disabled',
|
||||||
|
effect: 'search',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('should handle indexer.deleteWorkspace job', async t => {
|
test('should handle indexer.deleteWorkspace job', async t => {
|
||||||
const spy = Sinon.spy(indexerService, 'deleteWorkspace');
|
const spy = Sinon.spy(indexerService, 'deleteWorkspace');
|
||||||
|
|
||||||
|
|||||||
@@ -1887,12 +1887,9 @@ test('should delete doc work', async t => {
|
|||||||
t.is(result4.nodes.length, 1);
|
t.is(result4.nodes.length, 1);
|
||||||
t.deepEqual(result4.nodes[0].fields.docId, [docId2]);
|
t.deepEqual(result4.nodes[0].fields.docId, [docId2]);
|
||||||
|
|
||||||
const count = module.queue.count('copilot.embedding.deleteDoc');
|
|
||||||
|
|
||||||
await indexerService.deleteDoc(workspaceId, docId1, {
|
await indexerService.deleteDoc(workspaceId, docId1, {
|
||||||
refresh: true,
|
refresh: true,
|
||||||
});
|
});
|
||||||
t.is(module.queue.count('copilot.embedding.deleteDoc'), count + 1);
|
|
||||||
|
|
||||||
// make sure the docId1 is deleted
|
// make sure the docId1 is deleted
|
||||||
result1 = await indexerService.search({
|
result1 = await indexerService.search({
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import './config';
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { ServerConfigModule } from '../../core/config';
|
import { ServerConfigModule } from '../../core/config';
|
||||||
|
import { DocStorageModule } from '../../core/doc';
|
||||||
import { PermissionModule } from '../../core/permission';
|
import { PermissionModule } from '../../core/permission';
|
||||||
import { QuotaServiceModule } from '../../core/quota';
|
import { QuotaServiceModule } from '../../core/quota';
|
||||||
import { IndexerEvent } from './event';
|
import { IndexerEvent } from './event';
|
||||||
@@ -13,7 +14,12 @@ import { IndexerResolver } from './resolver';
|
|||||||
import { IndexerService } from './service';
|
import { IndexerService } from './service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ServerConfigModule, PermissionModule, QuotaServiceModule],
|
imports: [
|
||||||
|
ServerConfigModule,
|
||||||
|
DocStorageModule,
|
||||||
|
PermissionModule,
|
||||||
|
QuotaServiceModule,
|
||||||
|
],
|
||||||
providers: [
|
providers: [
|
||||||
IndexerResolver,
|
IndexerResolver,
|
||||||
IndexerService,
|
IndexerService,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
import { Config, JOB_SIGNAL, JobQueue, OnJob } from '../../base';
|
import { Config, JOB_SIGNAL, JobQueue, OnJob } from '../../base';
|
||||||
|
import { DocReader } from '../../core/doc';
|
||||||
import { readAllDocIdsFromWorkspaceSnapshot } from '../../core/utils/blocksuite';
|
import { readAllDocIdsFromWorkspaceSnapshot } from '../../core/utils/blocksuite';
|
||||||
import { Models } from '../../models';
|
import { Models } from '../../models';
|
||||||
import { IndexerService } from './service';
|
import { IndexerService } from './service';
|
||||||
@@ -24,6 +25,11 @@ declare global {
|
|||||||
'indexer.autoIndexWorkspaces': {
|
'indexer.autoIndexWorkspaces': {
|
||||||
lastIndexedWorkspaceSid?: number;
|
lastIndexedWorkspaceSid?: number;
|
||||||
};
|
};
|
||||||
|
'indexer.reconcileDocumentCleanup': {
|
||||||
|
workspaceId: string;
|
||||||
|
docId: string;
|
||||||
|
cleanupVersion: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +41,8 @@ export class IndexerJob {
|
|||||||
private readonly models: Models,
|
private readonly models: Models,
|
||||||
private readonly service: IndexerService,
|
private readonly service: IndexerService,
|
||||||
private readonly queue: JobQueue,
|
private readonly queue: JobQueue,
|
||||||
private readonly config: Config
|
private readonly config: Config,
|
||||||
|
private readonly doc: DocReader
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@OnJob('indexer.indexDoc')
|
@OnJob('indexer.indexDoc')
|
||||||
@@ -66,6 +73,39 @@ export class IndexerJob {
|
|||||||
await this.service.deleteDoc(workspaceId, docId);
|
await this.service.deleteDoc(workspaceId, docId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OnJob('indexer.reconcileDocumentCleanup')
|
||||||
|
async reconcileDocumentCleanup({
|
||||||
|
workspaceId,
|
||||||
|
docId,
|
||||||
|
cleanupVersion,
|
||||||
|
}: Jobs['indexer.reconcileDocumentCleanup']) {
|
||||||
|
if (this.config.indexer.enabled) {
|
||||||
|
const root = await this.doc.getDoc(workspaceId, workspaceId);
|
||||||
|
if (!root) {
|
||||||
|
throw new Error(`workspace root ${workspaceId} not found`);
|
||||||
|
}
|
||||||
|
const live = readAllDocIdsFromWorkspaceSnapshot(root.bin, true).includes(
|
||||||
|
docId
|
||||||
|
);
|
||||||
|
if (live) {
|
||||||
|
if (!(await this.doc.getDoc(workspaceId, docId))) {
|
||||||
|
throw new Error(
|
||||||
|
`restored document ${workspaceId}/${docId} not found`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.service.indexDoc(workspaceId, docId);
|
||||||
|
} else {
|
||||||
|
await this.service.deleteDoc(workspaceId, docId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.queue.add('backendRuntime.ackDocumentCleanupEffect', {
|
||||||
|
workspaceId,
|
||||||
|
docId,
|
||||||
|
cleanupVersion,
|
||||||
|
effect: 'search',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@OnJob('indexer.indexWorkspace')
|
@OnJob('indexer.indexWorkspace')
|
||||||
async indexWorkspace({ workspaceId }: Jobs['indexer.indexWorkspace']) {
|
async indexWorkspace({ workspaceId }: Jobs['indexer.indexWorkspace']) {
|
||||||
if (!this.config.indexer.enabled) {
|
if (!this.config.indexer.enabled) {
|
||||||
@@ -79,16 +119,13 @@ export class IndexerJob {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const snapshot = await this.models.doc.getSnapshot(
|
const root = await this.doc.getDoc(workspaceId, workspaceId);
|
||||||
workspaceId,
|
if (!root) {
|
||||||
workspaceId
|
|
||||||
);
|
|
||||||
if (!snapshot) {
|
|
||||||
this.logger.warn(`workspace snapshot ${workspaceId} not found`);
|
this.logger.warn(`workspace snapshot ${workspaceId} not found`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const docIdsInWorkspace = readAllDocIdsFromWorkspaceSnapshot(snapshot.blob);
|
const docIdsInWorkspace = readAllDocIdsFromWorkspaceSnapshot(root.bin);
|
||||||
const docIdsInIndexer = await this.service.listDocIds(workspaceId);
|
const docIdsInIndexer = await this.service.listDocIds(workspaceId);
|
||||||
|
|
||||||
const docIdsInWorkspaceSet = new Set(docIdsInWorkspace);
|
const docIdsInWorkspaceSet = new Set(docIdsInWorkspace);
|
||||||
|
|||||||
@@ -374,14 +374,6 @@ export class IndexerService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await this.deleteBlocksByDocId(workspaceId, docId, options);
|
await this.deleteBlocksByDocId(workspaceId, docId, options);
|
||||||
await this.queue.add('copilot.session.deleteDoc', {
|
|
||||||
workspaceId,
|
|
||||||
docId,
|
|
||||||
});
|
|
||||||
await this.queue.add('copilot.embedding.deleteDoc', {
|
|
||||||
workspaceId,
|
|
||||||
docId,
|
|
||||||
});
|
|
||||||
this.logger.log(`deleted doc ${workspaceId}/${docId}`);
|
this.logger.log(`deleted doc ${workspaceId}/${docId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user