mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-11 06:05:45 +08:00
### What problem does this PR solve? S3-family connector syncs currently re-download every in-window object just so we can compute `xxhash128(blob)` and compare against `Document.content_hash`. Anything that bumps `LastModified` without changing bytes (`aws s3 cp` touches, bucket re-encryption, etc.) pays full bandwidth and re-parses files that didn't actually change. #14628 covers the broader incremental-ingestion redesign; this PR is the first slice. The fix is a pre-listing short-circuit. `BlobStorageConnector` (S3 / R2 / GCS / OCI / S3-compat) now implements a new `FingerprintConnector` interface: `list_keys()` paginates `list_objects_v2` and yields `KeyRecord(key, fingerprint)` where `fingerprint = xxhash128(ETag)`. The orchestrator joins those against the connector's existing `{doc_id: content_hash}` map and only calls `get_value(key)` when the fingerprint differs. Unchanged keys are skipped entirely — no `GetObject`, no re-parse. No DDL. xxhash128(ETag) is 32 hex chars and reuses the existing `Document.content_hash` column per @yingfeng's suggestion; the connector decides at listing time whether to populate it. Local uploads and connectors that don't opt in fall through to the existing post-download `xxhash128(blob)` path with no behavior change. This is PR-1 of a 4-PR series — full design lives on #14628. Subsequent PRs extend tier 1 to local FS / WebDAV / Dropbox / Seafile / RDBMS (PR-2), wire up tier 2 cursor connectors with `SyncLogs.next_checkpoint` (PR-3), and unify deletion via `KeyRecord(deleted=True)` reconciliation (PR-4). Holding those back keeps this PR additive and reviewable on its own. #### Files touched - `common/data_source/models.py` — new `KeyRecord`; optional `fingerprint` on `Document` - `common/data_source/interfaces.py` — `IncrementalCapability` enum, `FingerprintConnector` ABC - `common/data_source/blob_connector.py` — `BlobStorageConnector` implements `FingerprintConnector`; per-object download factored into `_build_document_from_obj()` so `_yield_blob_objects`, `list_keys`, `get_value` all share it - `rag/svr/sync_data_source.py` — `_BlobLikeBase._fingerprint_filtered_generator` does the bypass loop; `_run_task_logic` plumbs `doc.fingerprint` into the upload dict - `api/db/services/document_service.py` — `list_id_content_hash_map_by_kb_and_source_type()` helper - `api/db/services/connector_service.py` + `file_service.py` — fingerprint flows through `duplicate_and_parse → upload_document` and lands in `content_hash` - `test/unit_test/common/test_blob_connector_fingerprint.py` — 14 tests covering ETag normalization (single-part, multipart, quoted, empty), `list_keys()` not calling `GetObject`, `get_value()` materializing with fingerprint, deterministic/stable fingerprints, and the bypass loop asserting `GetObject` is *not* called on a match #### Worth flagging for review Old `_BlobLikeBase._generate` called `poll_source(start, now)` with a `LastModified` window when `poll_range_start` was set. New code uses `_fingerprint_filtered_generator` (full bucket listing + fingerprint compare) outside of explicit `reindex=1`. Strictly better for unchanged-bucket cases since it skips `GetObject`, but it does mean every sync now does a full `list_objects_v2` paginate. Should still be cheap for most buckets — flagging in case anyone has a very large bucket where the time-window filter was meaningful. On migration: existing rows have `content_hash = xxhash128(blob)` from the old code. The first sync after this lands sees ETag-derived fingerprints that don't match, re-fetches every object once, and writes the new fingerprint. From the second sync onward the bypass works as expected. "Slow day one, fast every day after." A `fingerprint_backfill: trust` opt-out is sketched in the design doc but not in this PR. #### Test plan - [x] `uv run ruff check` — clean on all 8 touched files - [x] `uv run pytest test/unit_test/common/test_blob_connector_fingerprint.py -v` — 14 passed - [x] Broader unit-test suite — no regressions in anything I touched - [ ] Manual smoke against a real S3 bucket — configure a connector, run sync twice, expect the second sync to log `bypassed=N, fetched=0` and no `GetObject` calls in CloudTrail / bucket access logs - [ ] Manual smoke with `reindex=1` — confirm the full re-download path still works ### Type of change - [x] New Feature (non-breaking change which adds functionality) --------- Co-authored-by: Yingfeng <yingfeng.zhang@gmail.com>
This commit is contained in:
@@ -16,7 +16,7 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
import os
|
||||
from typing import Tuple, List
|
||||
from typing import Optional, Tuple, List
|
||||
|
||||
from anthropic import BaseModel
|
||||
from peewee import SQL, fn
|
||||
@@ -276,12 +276,13 @@ class SyncLogsService(CommonService):
|
||||
id: str
|
||||
filename: str
|
||||
blob: bytes
|
||||
fingerprint: Optional[str] = None
|
||||
|
||||
def read(self) -> bytes:
|
||||
return self.blob
|
||||
|
||||
errs = []
|
||||
files = [FileObj(id=d["id"], filename=d["semantic_identifier"]+(f"{d['extension']}" if d["semantic_identifier"][::-1].find(d['extension'][::-1])<0 else ""), blob=d["blob"]) for d in docs]
|
||||
files = [FileObj(id=d["id"], filename=d["semantic_identifier"]+(f"{d['extension']}" if d["semantic_identifier"][::-1].find(d['extension'][::-1])<0 else ""), blob=d["blob"], fingerprint=d.get("fingerprint")) for d in docs]
|
||||
doc_ids = []
|
||||
err, doc_blob_pairs = FileService.upload_document(kb, files, tenant_id, src)
|
||||
errs.extend(err)
|
||||
|
||||
@@ -388,6 +388,35 @@ class DocumentService(CommonService):
|
||||
offset += page_size
|
||||
return res
|
||||
|
||||
@classmethod
|
||||
@DB.connection_context()
|
||||
def list_id_content_hash_map_by_kb_and_source_type(cls, kb_id, source_type, page_size=500):
|
||||
"""Return {doc_id: content_hash} for the connector's existing docs.
|
||||
|
||||
Used by the fingerprint-bypass path to decide which keys can skip a
|
||||
re-fetch -- if the connector's listing fingerprint equals content_hash,
|
||||
the body hasn't changed since the last sync.
|
||||
|
||||
Ordered by create_time so LIMIT/OFFSET pagination is stable under
|
||||
concurrent writes; without this, page boundaries can drop or duplicate
|
||||
rows and the resulting map would silently miss entries.
|
||||
"""
|
||||
fields = [cls.model.id, cls.model.content_hash]
|
||||
docs = cls.model.select(*fields).where(
|
||||
cls.model.kb_id == kb_id,
|
||||
cls.model.source_type == source_type,
|
||||
).order_by(cls.model.create_time.asc())
|
||||
offset = 0
|
||||
result: dict[str, str] = {}
|
||||
while True:
|
||||
batch = list(docs.offset(offset).limit(page_size).dicts())
|
||||
if not batch:
|
||||
break
|
||||
for row in batch:
|
||||
result[row["id"]] = row.get("content_hash") or ""
|
||||
offset += page_size
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
@DB.connection_context()
|
||||
def get_all_docs_by_creator_id(cls, creator_id):
|
||||
|
||||
@@ -482,7 +482,12 @@ class FileService(CommonService):
|
||||
err.append(file.filename + ": " + user_msg)
|
||||
continue
|
||||
blob = file.read()
|
||||
new_hash = xxhash.xxh128(blob).hexdigest()
|
||||
# Connector-supplied fingerprint (e.g. xxhash128(S3 ETag))
|
||||
# takes precedence: for connector-sourced docs the bypass
|
||||
# path uses the fingerprint as content_hash, so reverting
|
||||
# to xxhash128(blob) here would defeat it.
|
||||
incoming_fp = getattr(file, "fingerprint", None)
|
||||
new_hash = incoming_fp or xxhash.xxh128(blob).hexdigest()
|
||||
old_hash = doc.content_hash or ""
|
||||
settings.STORAGE_IMPL.put(kb.id, doc.location, blob, kb.tenant_id)
|
||||
doc.size = len(blob)
|
||||
@@ -518,6 +523,7 @@ class FileService(CommonService):
|
||||
thumbnail_location = f"thumbnail_{doc_id}.png"
|
||||
settings.STORAGE_IMPL.put(kb.id, thumbnail_location, img)
|
||||
|
||||
incoming_fp = getattr(file, "fingerprint", None)
|
||||
doc = {
|
||||
"id": doc_id,
|
||||
"kb_id": kb.id,
|
||||
@@ -532,7 +538,7 @@ class FileService(CommonService):
|
||||
"location": location,
|
||||
"size": len(blob),
|
||||
"thumbnail": thumbnail_location,
|
||||
"content_hash": xxhash.xxh128(blob).hexdigest(),
|
||||
"content_hash": incoming_fp or xxhash.xxh128(blob).hexdigest(),
|
||||
}
|
||||
DocumentService.insert(doc)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user