2025-11-03 19:59:18 +08:00
|
|
|
#
|
|
|
|
|
# Copyright 2024 The InfiniFlow Authors. All Rights Reserved.
|
|
|
|
|
#
|
|
|
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
|
# you may not use this file except in compliance with the License.
|
|
|
|
|
# You may obtain a copy of the License at
|
|
|
|
|
#
|
|
|
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
|
#
|
|
|
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
|
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
|
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
|
|
|
# See the License for the specific language governing permissions and
|
|
|
|
|
# limitations under the License.
|
|
|
|
|
#
|
|
|
|
|
import logging
|
|
|
|
|
from datetime import datetime
|
2025-11-20 12:49:13 +08:00
|
|
|
import os
|
feat(connectors): ETag-based bypass for incremental S3 ingestion (#14628) (#14677)
### 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>
2026-05-09 05:03:56 -07:00
|
|
|
from typing import Optional, Tuple, List
|
2025-11-03 19:59:18 +08:00
|
|
|
|
|
|
|
|
from anthropic import BaseModel
|
|
|
|
|
from peewee import SQL, fn
|
|
|
|
|
|
2025-11-05 08:01:39 +08:00
|
|
|
from api.db import InputType
|
2026-05-17 22:09:26 -10:00
|
|
|
from api.db.db_models import DB, Connector, SyncLogs, Connector2Kb, Knowledgebase
|
2025-11-03 19:59:18 +08:00
|
|
|
from api.db.services.common_service import CommonService
|
|
|
|
|
from api.db.services.document_service import DocumentService
|
2026-01-28 13:29:34 +08:00
|
|
|
from api.db.services.document_service import DocMetadataService
|
2026-04-09 16:40:14 +08:00
|
|
|
from api.utils.common import hash128
|
2025-11-03 19:59:18 +08:00
|
|
|
from common.misc_utils import get_uuid
|
2026-05-19 10:07:11 +08:00
|
|
|
from common.constants import ConnectorTaskType, TaskStatus
|
2026-05-06 06:40:35 +00:00
|
|
|
from common.settings import TIMEZONE
|
2025-11-03 19:59:18 +08:00
|
|
|
from common.time_utils import current_timestamp, timestamp_to_date
|
|
|
|
|
|
2026-05-17 22:09:26 -10:00
|
|
|
LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
2025-11-03 19:59:18 +08:00
|
|
|
class ConnectorService(CommonService):
|
|
|
|
|
model = Connector
|
|
|
|
|
|
2026-05-19 10:07:11 +08:00
|
|
|
@classmethod
|
|
|
|
|
def cancel_tasks(cls, connector_id):
|
|
|
|
|
e, conn = cls.get_by_id(connector_id)
|
|
|
|
|
if not e:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
logging.info(
|
|
|
|
|
"[Connector] stop connector=%s(%s)",
|
|
|
|
|
conn.name,
|
|
|
|
|
connector_id,
|
|
|
|
|
)
|
|
|
|
|
for c2k in Connector2KbService.query(connector_id=connector_id):
|
|
|
|
|
SyncLogsService.filter_update(
|
|
|
|
|
[
|
|
|
|
|
SyncLogs.connector_id == connector_id,
|
|
|
|
|
SyncLogs.kb_id == c2k.kb_id,
|
|
|
|
|
SyncLogs.status.in_([TaskStatus.SCHEDULE, TaskStatus.RUNNING]),
|
|
|
|
|
],
|
|
|
|
|
{"status": TaskStatus.CANCEL},
|
|
|
|
|
)
|
|
|
|
|
ConnectorService.update_by_id(connector_id, {"status": TaskStatus.CANCEL})
|
|
|
|
|
logging.info(
|
|
|
|
|
"[Connector] connector=%s status updated to %s",
|
|
|
|
|
connector_id,
|
|
|
|
|
TaskStatus.CANCEL,
|
|
|
|
|
)
|
|
|
|
|
|
2026-05-17 22:09:26 -10:00
|
|
|
@classmethod
|
|
|
|
|
@DB.connection_context()
|
|
|
|
|
def accessible(cls, connector_id: str, user_id: str) -> bool:
|
|
|
|
|
"""Return whether the user can access the connector's tenant."""
|
|
|
|
|
e, connector = cls.get_by_id(connector_id)
|
|
|
|
|
if not e:
|
|
|
|
|
LOGGER.warning("connector access denied: connector not found connector_id=%s user_id=%s", connector_id, user_id)
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
if connector.tenant_id == user_id:
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
from api.db.services.user_service import TenantService
|
|
|
|
|
|
|
|
|
|
joined_tenants = TenantService.get_joined_tenants_by_user_id(user_id)
|
|
|
|
|
has_access = any(tenant["tenant_id"] == connector.tenant_id for tenant in joined_tenants)
|
|
|
|
|
if not has_access:
|
|
|
|
|
LOGGER.warning(
|
|
|
|
|
"connector access denied: tenant mismatch connector_id=%s user_id=%s tenant_id=%s",
|
|
|
|
|
connector_id,
|
|
|
|
|
user_id,
|
|
|
|
|
connector.tenant_id,
|
|
|
|
|
)
|
|
|
|
|
return has_access
|
|
|
|
|
|
2025-11-03 19:59:18 +08:00
|
|
|
@classmethod
|
2026-05-19 10:07:11 +08:00
|
|
|
def schedule_tasks(cls, connector_id):
|
|
|
|
|
e, conn = cls.get_by_id(connector_id)
|
|
|
|
|
if not e:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
logging.info("[Connector] schedule connector=%s(%s)", conn.name, connector_id)
|
|
|
|
|
prune_enabled = bool((conn.config or {}).get("sync_deleted_files"))
|
2025-11-03 19:59:18 +08:00
|
|
|
for c2k in Connector2KbService.query(connector_id=connector_id):
|
2026-05-19 10:07:11 +08:00
|
|
|
sync_task = SyncLogsService.get_latest_task(
|
|
|
|
|
connector_id,
|
|
|
|
|
c2k.kb_id,
|
|
|
|
|
ConnectorTaskType.SYNC,
|
|
|
|
|
)
|
|
|
|
|
poll_range_start = None
|
|
|
|
|
total_docs_indexed = 0
|
|
|
|
|
if sync_task and sync_task.status == TaskStatus.DONE:
|
|
|
|
|
poll_range_start = sync_task.poll_range_end
|
|
|
|
|
total_docs_indexed = sync_task.total_docs_indexed
|
|
|
|
|
|
|
|
|
|
SyncLogsService.schedule(
|
|
|
|
|
connector_id,
|
|
|
|
|
c2k.kb_id,
|
|
|
|
|
poll_range_start,
|
|
|
|
|
total_docs_indexed=total_docs_indexed,
|
|
|
|
|
task_type=ConnectorTaskType.SYNC,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if prune_enabled:
|
|
|
|
|
SyncLogsService.schedule(
|
|
|
|
|
connector_id,
|
|
|
|
|
c2k.kb_id,
|
|
|
|
|
task_type=ConnectorTaskType.PRUNE,
|
|
|
|
|
)
|
2025-11-03 19:59:18 +08:00
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
def list(cls, tenant_id):
|
|
|
|
|
fields = [
|
|
|
|
|
cls.model.id,
|
|
|
|
|
cls.model.name,
|
|
|
|
|
cls.model.source,
|
|
|
|
|
cls.model.status
|
|
|
|
|
]
|
2025-11-04 20:13:52 +08:00
|
|
|
return list(cls.model.select(*fields).where(
|
2025-11-03 19:59:18 +08:00
|
|
|
cls.model.tenant_id == tenant_id
|
2025-11-04 20:13:52 +08:00
|
|
|
).dicts())
|
2025-11-03 19:59:18 +08:00
|
|
|
|
2025-11-07 11:43:59 +08:00
|
|
|
@classmethod
|
|
|
|
|
def rebuild(cls, kb_id:str, connector_id: str, tenant_id:str):
|
2025-11-18 17:05:16 +08:00
|
|
|
from api.db.services.file_service import FileService
|
2025-11-07 11:43:59 +08:00
|
|
|
e, conn = cls.get_by_id(connector_id)
|
|
|
|
|
if not e:
|
2025-11-16 19:29:20 +08:00
|
|
|
return None
|
2025-11-07 11:43:59 +08:00
|
|
|
SyncLogsService.filter_delete([SyncLogs.connector_id==connector_id, SyncLogs.kb_id==kb_id])
|
2025-11-10 13:28:07 +08:00
|
|
|
docs = DocumentService.query(source_type=f"{conn.source}/{conn.id}", kb_id=kb_id)
|
2025-11-07 16:49:29 +08:00
|
|
|
err = FileService.delete_docs([d.id for d in docs], tenant_id)
|
2026-05-19 10:07:11 +08:00
|
|
|
SyncLogsService.schedule(connector_id, kb_id, reindex=True, task_type=ConnectorTaskType.SYNC)
|
|
|
|
|
if (conn.config or {}).get("sync_deleted_files"):
|
|
|
|
|
SyncLogsService.schedule(connector_id, kb_id, task_type=ConnectorTaskType.PRUNE)
|
2025-11-07 16:49:29 +08:00
|
|
|
return err
|
2025-11-07 11:43:59 +08:00
|
|
|
|
2026-04-09 16:40:14 +08:00
|
|
|
@classmethod
|
|
|
|
|
def cleanup_stale_documents_for_task(
|
|
|
|
|
cls,
|
|
|
|
|
task_id: str,
|
|
|
|
|
connector_id: str,
|
|
|
|
|
kb_id: str,
|
|
|
|
|
tenant_id: str,
|
|
|
|
|
file_list,
|
|
|
|
|
delete_batch_size: int = 100,
|
|
|
|
|
):
|
|
|
|
|
from api.db.services.file_service import FileService
|
|
|
|
|
|
|
|
|
|
if not Connector2KbService.query(connector_id=connector_id, kb_id=kb_id):
|
|
|
|
|
return 0, []
|
|
|
|
|
|
|
|
|
|
e, conn = cls.get_by_id(connector_id)
|
|
|
|
|
if not e:
|
|
|
|
|
return 0, []
|
|
|
|
|
|
|
|
|
|
source_type = f"{conn.source}/{conn.id}"
|
2026-05-09 10:33:54 +08:00
|
|
|
retain_doc_ids = {hash128(f"{connector_id}:{file.id}") for file in file_list}
|
2026-04-09 16:40:14 +08:00
|
|
|
existing_docs = DocumentService.list_doc_headers_by_kb_and_source_type(
|
|
|
|
|
kb_id,
|
|
|
|
|
source_type,
|
|
|
|
|
)
|
|
|
|
|
stale_doc_ids = [
|
|
|
|
|
doc["id"] for doc in existing_docs if doc["id"] not in retain_doc_ids
|
|
|
|
|
]
|
|
|
|
|
if not stale_doc_ids:
|
|
|
|
|
return 0, []
|
|
|
|
|
|
|
|
|
|
stale_doc_id_set = set(stale_doc_ids)
|
|
|
|
|
errors = []
|
|
|
|
|
for offset in range(0, len(stale_doc_ids), delete_batch_size):
|
|
|
|
|
err = FileService.delete_docs(
|
|
|
|
|
stale_doc_ids[offset : offset + delete_batch_size],
|
|
|
|
|
tenant_id,
|
|
|
|
|
)
|
|
|
|
|
if err:
|
|
|
|
|
errors.append(err)
|
|
|
|
|
|
|
|
|
|
remaining_doc_ids = {
|
|
|
|
|
doc["id"]
|
|
|
|
|
for doc in DocumentService.list_doc_headers_by_kb_and_source_type(
|
|
|
|
|
kb_id,
|
|
|
|
|
source_type,
|
|
|
|
|
)
|
|
|
|
|
if doc["id"] in stale_doc_id_set
|
|
|
|
|
}
|
|
|
|
|
removed_count = len(stale_doc_id_set) - len(remaining_doc_ids)
|
|
|
|
|
SyncLogsService.increase_removed_docs(
|
|
|
|
|
task_id,
|
|
|
|
|
removed_count,
|
|
|
|
|
"\n".join(errors),
|
|
|
|
|
len(errors),
|
|
|
|
|
)
|
|
|
|
|
return removed_count, errors
|
|
|
|
|
|
2025-11-03 19:59:18 +08:00
|
|
|
|
|
|
|
|
class SyncLogsService(CommonService):
|
|
|
|
|
model = SyncLogs
|
|
|
|
|
|
2026-05-19 10:07:11 +08:00
|
|
|
|
2025-11-03 19:59:18 +08:00
|
|
|
@classmethod
|
2025-11-06 16:48:04 +08:00
|
|
|
def list_sync_tasks(cls, connector_id=None, page_number=None, items_per_page=15) -> Tuple[List[dict], int]:
|
2025-11-03 19:59:18 +08:00
|
|
|
fields = [
|
|
|
|
|
cls.model.id,
|
|
|
|
|
cls.model.connector_id,
|
2026-05-19 10:07:11 +08:00
|
|
|
cls.model.task_type,
|
2025-11-03 19:59:18 +08:00
|
|
|
cls.model.kb_id,
|
2025-11-05 19:59:23 +08:00
|
|
|
cls.model.update_date,
|
2025-11-03 19:59:18 +08:00
|
|
|
cls.model.new_docs_indexed,
|
2025-11-05 19:59:23 +08:00
|
|
|
cls.model.total_docs_indexed,
|
2026-05-19 10:07:11 +08:00
|
|
|
cls.model.docs_removed_from_index,
|
2025-11-06 09:49:57 +08:00
|
|
|
cls.model.error_msg,
|
2025-11-03 19:59:18 +08:00
|
|
|
cls.model.error_count,
|
2026-05-19 10:07:11 +08:00
|
|
|
cls.model.time_started.alias("time_started"),
|
|
|
|
|
Connector.refresh_freq.alias("refresh_freq"),
|
|
|
|
|
Connector.prune_freq.alias("prune_freq"),
|
2025-11-03 19:59:18 +08:00
|
|
|
Knowledgebase.name.alias("kb_name"),
|
2025-11-20 12:49:13 +08:00
|
|
|
cls.model.status,
|
2025-11-03 19:59:18 +08:00
|
|
|
]
|
|
|
|
|
if not connector_id:
|
|
|
|
|
fields.append(Connector.config)
|
|
|
|
|
|
|
|
|
|
query = cls.model.select(*fields)\
|
|
|
|
|
.join(Connector, on=(cls.model.connector_id==Connector.id))\
|
|
|
|
|
.join(Connector2Kb, on=(cls.model.kb_id==Connector2Kb.kb_id))\
|
|
|
|
|
.join(Knowledgebase, on=(cls.model.kb_id==Knowledgebase.id))
|
|
|
|
|
|
|
|
|
|
if connector_id:
|
|
|
|
|
query = query.where(cls.model.connector_id == connector_id)
|
|
|
|
|
else:
|
2025-11-20 12:49:13 +08:00
|
|
|
database_type = os.getenv("DB_TYPE", "mysql")
|
|
|
|
|
if "postgres" in database_type.lower():
|
2026-05-06 06:40:35 +00:00
|
|
|
expr = SQL(f"NOW() AT TIME ZONE '{TIMEZONE}' - make_interval(mins => t2.refresh_freq)")
|
2025-11-20 12:49:13 +08:00
|
|
|
else:
|
2026-05-06 06:40:35 +00:00
|
|
|
expr = SQL("NOW() - INTERVAL `t2`.`refresh_freq` MINUTE")
|
2025-11-03 19:59:18 +08:00
|
|
|
query = query.where(
|
|
|
|
|
Connector.input_type == InputType.POLL,
|
|
|
|
|
Connector.status == TaskStatus.SCHEDULE,
|
|
|
|
|
cls.model.status == TaskStatus.SCHEDULE,
|
2026-05-06 06:40:35 +00:00
|
|
|
cls.model.update_date < expr
|
2025-11-03 19:59:18 +08:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
query = query.distinct().order_by(cls.model.update_time.desc())
|
2025-11-16 19:29:20 +08:00
|
|
|
total = query.count()
|
2025-11-03 19:59:18 +08:00
|
|
|
if page_number:
|
|
|
|
|
query = query.paginate(page_number, items_per_page)
|
|
|
|
|
|
2025-11-16 19:29:20 +08:00
|
|
|
return list(query.dicts()), total
|
2025-11-03 19:59:18 +08:00
|
|
|
|
2026-05-19 10:07:11 +08:00
|
|
|
@classmethod
|
|
|
|
|
def list_due_sync_tasks(cls) -> List[dict]:
|
|
|
|
|
return cls._list_due_tasks_for_freq(
|
|
|
|
|
ConnectorTaskType.SYNC,
|
|
|
|
|
"refresh_freq",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
def list_due_prune_tasks(cls) -> List[dict]:
|
|
|
|
|
tasks = cls._list_due_tasks_for_freq(
|
|
|
|
|
ConnectorTaskType.PRUNE,
|
|
|
|
|
"prune_freq",
|
|
|
|
|
)
|
|
|
|
|
return [
|
|
|
|
|
task for task in tasks
|
|
|
|
|
# Prune is opt-in at the connector config level; keep the scheduler
|
|
|
|
|
# blind to prune_freq until the flag is enabled.
|
|
|
|
|
if bool((task.get("config") or {}).get("sync_deleted_files"))
|
|
|
|
|
and int(task.get("prune_freq") or 0) > 0
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
def _list_due_tasks_for_freq(cls, task_type: str, freq_field: str) -> List[dict]:
|
|
|
|
|
fields = [
|
|
|
|
|
cls.model.id,
|
|
|
|
|
cls.model.connector_id,
|
|
|
|
|
cls.model.task_type,
|
|
|
|
|
cls.model.kb_id,
|
|
|
|
|
cls.model.update_date,
|
|
|
|
|
cls.model.poll_range_start,
|
|
|
|
|
cls.model.poll_range_end,
|
|
|
|
|
cls.model.new_docs_indexed,
|
|
|
|
|
cls.model.total_docs_indexed,
|
|
|
|
|
cls.model.error_msg,
|
|
|
|
|
cls.model.full_exception_trace,
|
|
|
|
|
cls.model.error_count,
|
|
|
|
|
Connector.name,
|
|
|
|
|
Connector.source,
|
|
|
|
|
Connector.tenant_id,
|
|
|
|
|
Connector.timeout_secs,
|
|
|
|
|
Connector.config,
|
|
|
|
|
Connector.refresh_freq,
|
|
|
|
|
Connector.prune_freq,
|
|
|
|
|
Knowledgebase.name.alias("kb_name"),
|
|
|
|
|
Knowledgebase.avatar.alias("kb_avatar"),
|
|
|
|
|
Connector2Kb.auto_parse,
|
|
|
|
|
cls.model.from_beginning.alias("reindex"),
|
|
|
|
|
cls.model.status,
|
|
|
|
|
cls.model.update_time,
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
query = cls.model.select(*fields)\
|
|
|
|
|
.join(Connector, on=(cls.model.connector_id==Connector.id))\
|
|
|
|
|
.join(Connector2Kb, on=(cls.model.kb_id==Connector2Kb.kb_id))\
|
|
|
|
|
.join(Knowledgebase, on=(cls.model.kb_id==Knowledgebase.id))
|
|
|
|
|
|
|
|
|
|
query = query.where(
|
|
|
|
|
Connector.input_type == InputType.POLL,
|
|
|
|
|
Connector.status == TaskStatus.SCHEDULE,
|
|
|
|
|
cls.model.status == TaskStatus.SCHEDULE,
|
|
|
|
|
cls.model.task_type == task_type,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
database_type = os.getenv("DB_TYPE", "mysql")
|
|
|
|
|
if "postgres" in database_type.lower():
|
|
|
|
|
expr = SQL(
|
|
|
|
|
f"NOW() AT TIME ZONE '{TIMEZONE}' - make_interval(mins => t2.{freq_field})"
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
expr = SQL(f"NOW() - INTERVAL `t2`.`{freq_field}` MINUTE")
|
|
|
|
|
query = query.where(cls.model.update_date < expr)
|
|
|
|
|
|
|
|
|
|
return list(query.distinct().order_by(cls.model.update_time.desc()).dicts())
|
|
|
|
|
|
2025-11-03 19:59:18 +08:00
|
|
|
@classmethod
|
2025-11-05 14:51:00 +08:00
|
|
|
def start(cls, id, connector_id):
|
2025-11-03 19:59:18 +08:00
|
|
|
cls.update_by_id(id, {"status": TaskStatus.RUNNING, "time_started": datetime.now().strftime('%Y-%m-%d %H:%M:%S') })
|
2025-11-05 14:51:00 +08:00
|
|
|
ConnectorService.update_by_id(connector_id, {"status": TaskStatus.RUNNING})
|
2025-11-03 19:59:18 +08:00
|
|
|
|
|
|
|
|
@classmethod
|
2025-11-05 14:51:00 +08:00
|
|
|
def done(cls, id, connector_id):
|
2025-11-03 19:59:18 +08:00
|
|
|
cls.update_by_id(id, {"status": TaskStatus.DONE})
|
2025-11-05 14:51:00 +08:00
|
|
|
ConnectorService.update_by_id(connector_id, {"status": TaskStatus.DONE})
|
2025-11-03 19:59:18 +08:00
|
|
|
|
|
|
|
|
@classmethod
|
2026-05-19 10:07:11 +08:00
|
|
|
def schedule(
|
|
|
|
|
cls,
|
|
|
|
|
connector_id,
|
|
|
|
|
kb_id,
|
|
|
|
|
poll_range_start=None,
|
|
|
|
|
reindex=False,
|
|
|
|
|
total_docs_indexed=0,
|
|
|
|
|
task_type=ConnectorTaskType.SYNC,
|
|
|
|
|
):
|
2025-11-06 16:48:04 +08:00
|
|
|
try:
|
|
|
|
|
if cls.model.select().where(cls.model.kb_id == kb_id, cls.model.connector_id == connector_id).count() > 100:
|
|
|
|
|
rm_ids = [m.id for m in cls.model.select(cls.model.id).where(cls.model.kb_id == kb_id, cls.model.connector_id == connector_id).order_by(cls.model.update_time.asc()).limit(70)]
|
|
|
|
|
deleted = cls.model.delete().where(cls.model.id.in_(rm_ids)).execute()
|
|
|
|
|
logging.info(f"[SyncLogService] Cleaned {deleted} old logs.")
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logging.exception(e)
|
|
|
|
|
|
2025-11-03 19:59:18 +08:00
|
|
|
try:
|
2026-05-19 10:07:11 +08:00
|
|
|
e = cls.query(
|
|
|
|
|
kb_id=kb_id,
|
|
|
|
|
connector_id=connector_id,
|
|
|
|
|
status=TaskStatus.SCHEDULE,
|
|
|
|
|
task_type=task_type,
|
|
|
|
|
)
|
2025-11-03 19:59:18 +08:00
|
|
|
if e:
|
2026-05-19 10:07:11 +08:00
|
|
|
logging.warning(
|
|
|
|
|
"%s--%s already has a scheduled %s task.",
|
|
|
|
|
kb_id,
|
|
|
|
|
connector_id,
|
|
|
|
|
task_type,
|
|
|
|
|
)
|
2025-11-04 14:15:31 +08:00
|
|
|
return None
|
2025-11-03 19:59:18 +08:00
|
|
|
reindex = "1" if reindex else "0"
|
2025-11-05 19:59:23 +08:00
|
|
|
ConnectorService.update_by_id(connector_id, {"status": TaskStatus.SCHEDULE})
|
2025-11-03 19:59:18 +08:00
|
|
|
return cls.save(**{
|
|
|
|
|
"id": get_uuid(),
|
|
|
|
|
"kb_id": kb_id, "status": TaskStatus.SCHEDULE, "connector_id": connector_id,
|
2026-05-19 10:07:11 +08:00
|
|
|
"task_type": task_type,
|
2025-11-03 19:59:18 +08:00
|
|
|
"poll_range_start": poll_range_start, "from_beginning": reindex,
|
2026-05-19 10:07:11 +08:00
|
|
|
"total_docs_indexed": total_docs_indexed,
|
|
|
|
|
"time_started": datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
2025-11-03 19:59:18 +08:00
|
|
|
})
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logging.exception(e)
|
2026-05-19 10:07:11 +08:00
|
|
|
task = cls.get_latest_task(connector_id, kb_id, task_type)
|
2025-11-03 19:59:18 +08:00
|
|
|
if task:
|
|
|
|
|
cls.model.update(status=TaskStatus.SCHEDULE,
|
|
|
|
|
poll_range_start=poll_range_start,
|
|
|
|
|
error_msg=cls.model.error_msg + str(e),
|
|
|
|
|
full_exception_trace=cls.model.full_exception_trace + str(e)
|
|
|
|
|
) \
|
|
|
|
|
.where(cls.model.id == task.id).execute()
|
2025-11-05 19:59:23 +08:00
|
|
|
ConnectorService.update_by_id(connector_id, {"status": TaskStatus.SCHEDULE})
|
2025-11-03 19:59:18 +08:00
|
|
|
|
|
|
|
|
@classmethod
|
2026-03-18 09:31:05 -06:00
|
|
|
def increase_docs(cls, id, max_update, doc_num, err_msg="", error_count=0):
|
|
|
|
|
# Keep sync monotonic.
|
2025-11-03 19:59:18 +08:00
|
|
|
cls.model.update(new_docs_indexed=cls.model.new_docs_indexed + doc_num,
|
|
|
|
|
total_docs_indexed=cls.model.total_docs_indexed + doc_num,
|
2026-03-18 09:31:05 -06:00
|
|
|
poll_range_start=fn.COALESCE(fn.GREATEST(cls.model.poll_range_start, max_update), max_update),
|
2025-11-03 19:59:18 +08:00
|
|
|
poll_range_end=fn.COALESCE(fn.GREATEST(cls.model.poll_range_end, max_update), max_update),
|
|
|
|
|
error_msg=cls.model.error_msg + err_msg,
|
|
|
|
|
error_count=cls.model.error_count + error_count,
|
|
|
|
|
update_time=current_timestamp(),
|
|
|
|
|
update_date=timestamp_to_date(current_timestamp())
|
|
|
|
|
)\
|
|
|
|
|
.where(cls.model.id == id).execute()
|
|
|
|
|
|
2026-04-09 16:40:14 +08:00
|
|
|
@classmethod
|
|
|
|
|
def increase_removed_docs(cls, id, removed_count, err_msg="", error_count=0):
|
|
|
|
|
cls.model.update(
|
|
|
|
|
docs_removed_from_index=cls.model.docs_removed_from_index + removed_count,
|
|
|
|
|
error_msg=cls.model.error_msg + err_msg,
|
|
|
|
|
error_count=cls.model.error_count + error_count,
|
|
|
|
|
update_time=current_timestamp(),
|
|
|
|
|
update_date=timestamp_to_date(current_timestamp()),
|
|
|
|
|
).where(cls.model.id == id).execute()
|
|
|
|
|
|
2025-11-03 19:59:18 +08:00
|
|
|
@classmethod
|
2025-11-07 11:43:59 +08:00
|
|
|
def duplicate_and_parse(cls, kb, docs, tenant_id, src, auto_parse=True):
|
2025-11-18 17:05:16 +08:00
|
|
|
from api.db.services.file_service import FileService
|
2025-11-03 19:59:18 +08:00
|
|
|
if not docs:
|
2025-11-04 14:15:31 +08:00
|
|
|
return None
|
2025-11-03 19:59:18 +08:00
|
|
|
|
|
|
|
|
class FileObj(BaseModel):
|
2026-01-15 14:02:15 +08:00
|
|
|
id: str
|
2025-11-03 19:59:18 +08:00
|
|
|
filename: str
|
|
|
|
|
blob: bytes
|
feat(connectors): ETag-based bypass for incremental S3 ingestion (#14628) (#14677)
### 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>
2026-05-09 05:03:56 -07:00
|
|
|
fingerprint: Optional[str] = None
|
2025-11-03 19:59:18 +08:00
|
|
|
|
|
|
|
|
def read(self) -> bytes:
|
|
|
|
|
return self.blob
|
|
|
|
|
|
|
|
|
|
errs = []
|
feat(connectors): ETag-based bypass for incremental S3 ingestion (#14628) (#14677)
### 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>
2026-05-09 05:03:56 -07:00
|
|
|
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]
|
2025-11-03 19:59:18 +08:00
|
|
|
doc_ids = []
|
|
|
|
|
err, doc_blob_pairs = FileService.upload_document(kb, files, tenant_id, src)
|
|
|
|
|
errs.extend(err)
|
2025-11-07 11:43:59 +08:00
|
|
|
|
2025-11-26 12:55:48 +01:00
|
|
|
# Create a mapping from filename to metadata for later use
|
|
|
|
|
metadata_map = {}
|
|
|
|
|
for d in docs:
|
|
|
|
|
if d.get("metadata"):
|
|
|
|
|
filename = d["semantic_identifier"]+(f"{d['extension']}" if d["semantic_identifier"][::-1].find(d['extension'][::-1])<0 else "")
|
|
|
|
|
metadata_map[filename] = d["metadata"]
|
|
|
|
|
|
2025-11-06 16:48:04 +08:00
|
|
|
kb_table_num_map = {}
|
|
|
|
|
for doc, _ in doc_blob_pairs:
|
|
|
|
|
doc_ids.append(doc["id"])
|
2025-11-26 12:55:48 +01:00
|
|
|
|
|
|
|
|
# Set metadata if available for this document
|
|
|
|
|
if doc["name"] in metadata_map:
|
2026-01-28 13:29:34 +08:00
|
|
|
DocMetadataService.update_document_metadata(doc["id"], metadata_map[doc["name"]])
|
2025-11-26 12:55:48 +01:00
|
|
|
|
2025-11-07 11:43:59 +08:00
|
|
|
if not auto_parse or auto_parse == "0":
|
|
|
|
|
continue
|
|
|
|
|
DocumentService.run(tenant_id, doc, kb_table_num_map)
|
2025-11-03 19:59:18 +08:00
|
|
|
|
|
|
|
|
return errs, doc_ids
|
|
|
|
|
|
|
|
|
|
@classmethod
|
2026-05-19 10:07:11 +08:00
|
|
|
def get_latest_task(cls, connector_id, kb_id, task_type=None):
|
|
|
|
|
query = cls.model.select().where(
|
2025-11-03 19:59:18 +08:00
|
|
|
cls.model.connector_id==connector_id,
|
|
|
|
|
cls.model.kb_id == kb_id
|
2026-05-19 10:07:11 +08:00
|
|
|
)
|
|
|
|
|
if task_type is not None:
|
|
|
|
|
query = query.where(cls.model.task_type == task_type)
|
|
|
|
|
return query.order_by(cls.model.update_time.desc()).first()
|
2025-11-03 19:59:18 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class Connector2KbService(CommonService):
|
|
|
|
|
model = Connector2Kb
|
|
|
|
|
|
2025-11-04 20:13:52 +08:00
|
|
|
@classmethod
|
2025-11-07 16:49:29 +08:00
|
|
|
def link_connectors(cls, kb_id:str, connectors: list[dict], tenant_id:str):
|
2025-11-04 20:13:52 +08:00
|
|
|
arr = cls.query(kb_id=kb_id)
|
|
|
|
|
old_conn_ids = [a.connector_id for a in arr]
|
2025-11-07 16:49:29 +08:00
|
|
|
connector_ids = []
|
|
|
|
|
for conn in connectors:
|
|
|
|
|
conn_id = conn["id"]
|
|
|
|
|
connector_ids.append(conn_id)
|
2025-11-04 20:13:52 +08:00
|
|
|
if conn_id in old_conn_ids:
|
2025-11-12 11:50:39 +08:00
|
|
|
cls.filter_update([cls.model.connector_id==conn_id, cls.model.kb_id==kb_id], {"auto_parse": conn.get("auto_parse", "1")})
|
2025-11-04 20:13:52 +08:00
|
|
|
continue
|
|
|
|
|
cls.save(**{
|
|
|
|
|
"id": get_uuid(),
|
|
|
|
|
"connector_id": conn_id,
|
2025-11-07 16:49:29 +08:00
|
|
|
"kb_id": kb_id,
|
2025-11-13 09:59:55 +08:00
|
|
|
"auto_parse": conn.get("auto_parse", "1")
|
2025-11-04 20:13:52 +08:00
|
|
|
})
|
2026-05-19 10:07:11 +08:00
|
|
|
SyncLogsService.schedule(conn_id, kb_id, reindex=True, task_type=ConnectorTaskType.SYNC)
|
|
|
|
|
e, full_conn = ConnectorService.get_by_id(conn_id)
|
|
|
|
|
if e and (full_conn.config or {}).get("sync_deleted_files"):
|
|
|
|
|
SyncLogsService.schedule(conn_id, kb_id, task_type=ConnectorTaskType.PRUNE)
|
2025-11-04 20:13:52 +08:00
|
|
|
|
|
|
|
|
errs = []
|
|
|
|
|
for conn_id in old_conn_ids:
|
|
|
|
|
if conn_id in connector_ids:
|
|
|
|
|
continue
|
|
|
|
|
cls.filter_delete([cls.model.kb_id==kb_id, cls.model.connector_id==conn_id])
|
|
|
|
|
e, conn = ConnectorService.get_by_id(conn_id)
|
2025-11-07 11:43:59 +08:00
|
|
|
if not e:
|
|
|
|
|
continue
|
|
|
|
|
#SyncLogsService.filter_delete([SyncLogs.connector_id==conn_id, SyncLogs.kb_id==kb_id])
|
|
|
|
|
# Do not delete docs while unlinking.
|
|
|
|
|
SyncLogsService.filter_update([SyncLogs.connector_id==conn_id, SyncLogs.kb_id==kb_id, SyncLogs.status.in_([TaskStatus.SCHEDULE, TaskStatus.RUNNING])], {"status": TaskStatus.CANCEL})
|
|
|
|
|
#docs = DocumentService.query(source_type=f"{conn.source}/{conn.id}")
|
|
|
|
|
#err = FileService.delete_docs([d.id for d in docs], tenant_id)
|
|
|
|
|
#if err:
|
|
|
|
|
# errs.append(err)
|
2025-11-04 20:13:52 +08:00
|
|
|
return "\n".join(errs)
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
def list_connectors(cls, kb_id):
|
|
|
|
|
fields = [
|
|
|
|
|
Connector.id,
|
|
|
|
|
Connector.source,
|
|
|
|
|
Connector.name,
|
2025-11-11 12:22:43 +08:00
|
|
|
cls.model.auto_parse,
|
2025-11-04 20:13:52 +08:00
|
|
|
Connector.status
|
|
|
|
|
]
|
|
|
|
|
return list(cls.model.select(*fields)\
|
|
|
|
|
.join(Connector, on=(cls.model.connector_id==Connector.id))\
|
|
|
|
|
.where(
|
|
|
|
|
cls.model.kb_id==kb_id
|
|
|
|
|
).dicts()
|
|
|
|
|
)
|