Skip to content

Refactor(engine): Engine God-Class Refactor - Extract 4 Business Components + Stage 6 Strategy Tables + Value Objects - #176

Merged
zc277584121 merged 14 commits into
zilliztech:mainfrom
CodyTonn:rebuild_engine_v1
Jul 23, 2026
Merged

zc277584121 merged 14 commits into
zilliztech:mainfrom
CodyTonn:rebuild_engine_v1

Conversation

@CodyTonn

@CodyTonn CodyTonn commented Jul 22, 2026 •

Copy link
Copy Markdown
Contributor

resolve #166

Background

server/python/src/mfs_server/engine/engine.py was a 1504-line god-class bearing 9 responsibilities (infra assembly, pipeline singleton, connector factory, ingest write orchestration, worker scheduling, upload protocol, artifact cache, read-path retrieval, connector management). Prior PRs extracted InfraStack / PipelineSupervisor / IngestOrchestrator / ConnectorFactory / ObjectRepository / ArtifactCacheService. This PR extracts the remaining 4 business components, completes the stage-6 strategy tables, and pulls out value objects, reducing Engine to a pure Facade.

Overview

Engine shrinks from 1504 to 227 lines (-85%), now a pure Facade (thin delegates + lifecycle + _write_object_row test delegate). 9 commits, 18 files, +2283/-1378.

Commit Component engine.py Notes
ca9c65f ReadService 1504->887 8 read methods + pure helpers + locators
c10db97 UploadService 887->635 tar + manifest-diff upload
fec96bb WorkerScheduler 635->511 queue layer + concurrent workers + reclaim
6ba2450 ConnectorManager 511->247 wire probe/estimate/inspect/remove
7fbf50d _await_worker_drained 247 extract remove_connector's while-loop to a method
1246532 GrepStrategy chain - grep 3-branch -> Pushdown->BM25->Linear chain
6b5e7ea CatRouter - cat 6-branch -> 7-strategy first-match table
f8b96a9 BundleValidator + StagingLocator - upload validation + staging classes + shared drain tail
894b9b4 CircuitBreaker/BackoffPolicy/ErrorClassifier - retry/circuit-breaker/error value objects

Architecture

Engine composes 10 collaborating components (6 business + 2 infra + repository + process singleton); dependencies are unidirectional and acyclic.

HTTP/CLI -> Engine (Facade, 227 lines)
            ├─ reads: ReadService (+ CatRouter + GrepChain)
            ├─ upload: UploadService (+ BundleValidator + StagingLocator)
            ├─ worker: WorkerScheduler
            ├─ connector_manager: ConnectorManager
            ├─ ingest: IngestOrchestrator (+ policies: CircuitBreaker/BackoffPolicy/ErrorClassifier)
            ├─ pipeline: PipelineSupervisor
            ├─ factory: ConnectorFactory
            ├─ objects: ObjectRepository
            ├─ artifacts: ArtifactCacheService
            └─ infra: InfraStack

Design patterns:

  • ReadService locators (open_path/match_connector) are public methods connecting directly to ConnectorFactory+ObjectRepository, with no back-reference to Engine (decision D1: direct-connect + public method, not delayed-lambda)
  • cat uses a CatRouter first-match strategy table (7 strategies: Meta/LocatorLines/LocatorRecord/StructuredStream/ArtifactText/ImageDescription/PlainText); the D3 fallthrough (image+vlm failure) is expressed as read() -> None so the chain continues
  • grep uses a GrepStrategy chain (Pushdown->BM25->Linear), match() -> list|None (None=continue, list=terminal); Pushdown is terminal on hit, BM25 appends and continues, Linear appends and is terminal
  • UploadService's BundleValidator/StagingLocator are standalone classes; _drain_after_upload is a shared tail (partial Template Method)
  • IngestOrchestrator's retry/circuit-breaker logic is extracted into engine/policies.py value objects

Behavior Equivalence

Zero external behavior change: the 20 call sites in api/app.py and server/__main__.py keep their signatures + the infra attribute.

Each component's method bodies are migrated verbatim; only self.<dep> resolution targets change (e.g. self.infra.milvus -> self._infra.milvus, self.objects -> self._objects). Key invariants are preserved explicitly:

  • WorkerScheduler two-layer try/except: the outer except Exception: jid = None (coroutine-immortal, sqlite single-worker semantics) must stay in run_forever._loop; the inner except (business-level: drain the queue) stays in run_worker_once
  • cat density only applies to ArtifactText/PlainText (the original D1/D2/E paths), via an _apply_density helper; meta/locator/structured/image strategies return before density (matching the original early returns)
  • grep D3 fallthrough: ImageDescriptionStrategy returns None on vlm failure, the chain falls to PlainTextStrategy
  • ConnectorManager.remove_connector's "wait for worker to leave running / stale heartbeat before taking over" invariant is extracted as _await_worker_drained
  • The chunk exists iff a committed objects row points at it invariant is not touched by the paths in this PR (read/upload/worker don't touch pipeline finalize)

Tests

  • 447 passed, 9 skipped (9 skip = missing optional connector SDKs)
  • 4 new independent unit-test files (constructed without an Engine/Milvus):
    • test_engine_read_service.py (16 cases: text_views pure helpers + locators + search short-circuit + resolve_connector_uri)
    • test_engine_upload_service.py (9 cases: _norm_rel + _validate_upload_member zip-slip + staging locator)
    • test_engine_worker.py (5 cases: _resolve_concurrency + queue-empty short-circuit)
    • cat/grep strategies are covered by existing test_cat_locator / test_grep_not_found / e2e tests
  • 4 test monkeypatch targets updated (eng._open_path -> eng.reads.open_path, the D1 direct-connect strategy); test_engine_forwards_to_ingest's bind_remover assertion changed to connector_manager.remove_connector (design change)
  • ruff format --check + ruff check clean (CI gate)

Files

File Change
engine/components/reads/{__init__,text_views,read_service}.py new: ReadService + pure helpers + read-path constants
engine/components/reads/cat_strategies.py new: CatRouter + 7 CatStrategy + _apply_density
engine/components/reads/grep_strategies.py new: GrepContext + GrepStrategy chain (Pushdown/BM25/Linear)
engine/components/upload.py new: UploadService + BundleValidator + StagingLocator + _drain tail
engine/worker.py new: WorkerScheduler + two worker constants
engine/manage.py new: ConnectorManager (probe/estimate/inspect/remove + _await_worker_drained)
engine/policies.py new: ErrorClassifier + BackoffPolicy + CircuitBreaker
engine/engine.py 1504->227: Facade forwarding + lifecycle
engine/ingest.py process_with_retry/run_job_loop use value objects
4 tests monkeypatch target / _Cfg fake / bind_remover assertion updates

Risk & Rollback

  • Low risk: method bodies migrated verbatim, only dependency resolution targets change; invariants preserved explicitly with comments
  • Rollback granularity: each of the 9 commits is independently green and revertible; a full rollback is recommended (components have DI dependencies on each other)
  • Not pushed: local commits, no release triggered
  • Remaining follow-up (not in this PR): UploadService staging-step full Template Method (extractall vs renames/bytes/deletions differ enough that a shared template would risk drift; left as a long-term follow-up with no behavioral benefit)

Verification

cd server/python
uv run --extra dev pytest                                      # 447 passed, 9 skipped
uv run --extra dev ruff format --check src/ tests/             # CI gate
uv run --extra dev ruff check src/mfs_server/engine/           # All checks passed

code2t added 12 commits July 21, 2026 12:00
Move search/ls/cat/head/tail/grep/export/resolve_connector_uri out of the
Engine god-class into a dedicated ReadService component (engine-redesign
§4.6 stage 3). Engine delegates to self.reads; the 8 public read methods
become thin forwards. Method bodies are unchanged; only self.<dep>
resolution targets moved.

- New engine/components/reads/{__init__,text_views,read_service}.py:
  ReadService + pure helpers (_density_view/_locator_matches) + read-path
  constants. Locator pair (open_path/match_connector) is a ReadService
  public method connecting directly to ConnectorFactory+ObjectRepository,
  no reverse reference to Engine (D1: direct connect, not delayed-lambda).
- engine.py: ~1504 -> 887 lines. Assemble self.reads; 8 read methods thin
  delegates; drop _read_full/_warn_if_huge_export/_has_registered_search_scope
  /_locator_matches/_density_view/_CODE_SYMBOL/_open_path + read-path
  constants + now-orphan imports; drop dead-code _now().
- Tests: patch target eng._open_path -> eng.reads.open_path (4 sites:
  test_cat_locator x2, test_engine_tail, test_grep_not_found).
- New tests/test_engine_read_service.py: 16 cases, constructs ReadService
  without an Engine/Milvus, covers text_views pure helpers + the locator
  pair + search short-circuits + resolve_connector_uri.

Verified: 433 passed, 9 skipped; ruff format --check + ruff check clean.
Move ingest_upload/files_manifest/files_upload out of the Engine god-class
into a dedicated UploadService component (engine-redesign §4.7 stage 5).
Engine delegates to self.upload; the 3 public upload methods become thin
forwards. Method bodies are unchanged; only self.<dep> resolution targets
moved. Reuses the already-public IngestOrchestrator entrypoints
(register_or_get_connector / open_sync_job / drain_job).

- New engine/components/upload.py: UploadService + module-level
  _norm_rel / _validate_upload_member (zip-slip guard). DI is precise:
  (cfg, infra, objects, ingest) - no unused factory/artifacts (UploadService
  touches infra.artifact_cache.files_root + meta, not the cache service).
- engine.py: 887 -> 635 lines. Assemble self.upload; 3 upload methods thin
  delegates; drop _staging_root/_staging_connector + module-level
  _norm_rel/_validate_upload_member + now-orphan FileStateStore import.
- BundleValidator / StagingLocator class extraction + shared Template
  Method are follow-up (plan §2.5) - this round is pure relocation.
- New tests/test_engine_upload_service.py: 9 cases, constructs UploadService
  without an Engine/meta, covers _norm_rel / _validate_upload_member
  (symlink/hardlink/absolute/traversal) + the staging locator
  (_staging_root sha1 sub / _staging_connector stable URI).

Verified: 442 passed, 9 skipped; ruff format --check + ruff check clean.
…workers

Move run_worker_once / run_worker_forever / _reclaim_stale_jobs /
_resolve_concurrency out of the Engine god-class into a dedicated
WorkerScheduler component (engine-redesign §4.5 stage 4c). Engine delegates
to self.worker; run_worker_forever becomes a thin forward. Method bodies are
unchanged; only self.<dep> resolution targets moved. Single-direction dep on
IngestOrchestrator (run_job + finalize_job), no cycle.

- New engine/worker.py (Engine-internal collaborator, not components/):
  WorkerScheduler + _JOB_STALE_AFTER_S / _WORKER_CONNECT_TIMEOUT_S. DI:
  (cfg, infra, factory, objects, ingest). run_worker_once connects directly
  to factory.build_plugin (was Engine._build_plugin).
- engine.py: 635 -> 511 lines. Assemble self.worker; run_worker_forever thin
  delegate; drop _claim_queued_job (direct objects.claim_queued_job) /
  run_worker_once / _resolve_concurrency / _reclaim_stale_jobs + the two
  worker constants + now-orphan ConnectorStateStore import. remove_connector
  imports _JOB_STALE_AFTER_S from .worker (still uses it for the heartbeat
  stale check). cancel_job stays (thin delegate to ingest.cancel_job, D4).
- Two-layer try/except invariant preserved verbatim: the OUTER
  `except Exception: jid = None` in run_forever._loop keeps a single failed
  job from killing the worker coroutine (sqlite single-worker would wedge
  all ingest); the INNER except in run_worker_once is business-level.
- New tests/test_engine_worker.py: 5 cases, _resolve_concurrency
  (auto/int/invalid/None) + the queue-empty short-circuit.

Verified: 447 passed, 9 skipped; ruff format --check + ruff check clean.
Wire the pre-existing engine/manage.py ConnectorManager draft. Engine delegates
to self.connector_manager; probe/estimate/inspect/remove_connector become thin
forwards. bind_remover now wires the add-failure rollback directly to
connector_manager.remove_connector (no longer Engine.remove_connector, plan §4.1).

- engine/manage.py (was untracked draft): drop _WORKER_CONNECT_TIMEOUT_S
  (unused); _JOB_STALE_AFTER_S now imported from .worker (single source, D2);
  fix probe/estimate/inspect/remove to call self._resolve_target (returns the
  tuple) instead of self._factory.resolve_target (returns TargetResolution, not
  unpackable) - the draft had 5 sites calling factory.resolve_target directly
  and crashing on unpack.
- engine.py: 511 -> 247 lines. Assemble self.connector_manager; 4 methods thin
  delegates; drop 6 now-dead thin delegates (_resolve_target/_build_plugin/
  _match_connector/_resolve_readonly_config/_read_text/_read_bytes - moved to
  manage.py); drop now-orphan imports (asyncio/json/os/uuid/datetime). bind_remover
  moved after the connector_manager assembly (it needs connector_manager.remove).
  _write_object_row stays (test_engine_orphan_gc calls it directly).
- Tests: test_engine_forwards_to_ingest updated - bind_remover now wires
  connector_manager.remove_connector (design change, plan §4.1).

_await_worker_drained extraction + stale comment cleanup left as follow-up
(manage.py remove_connector keeps the inline while-loop).

Verified: 447 passed, 9 skipped; ruff format --check + ruff check clean.
Pull the inline "wait for worker to leave running" while-loop out of
ConnectorManager.remove_connector into a dedicated _await_worker_drained(cid)
method (plan §4.8). Behavior unchanged - the loop body (heartbeat check +
stale reclaim via cancel_pending_running_tasks_for_job + cancel_running_job)
moves verbatim, now callable + testable on its own. Comments updated to
current method names (finalize_job/run_job; was _finalize_job/_run_job/_index_object).

Verified: 447 passed, 9 skipped; ruff format --check + ruff check clean.
Replace ReadService.grep's three-branch if/elif (pushdown -> BM25 -> linear)
with a GrepStrategy chain (engine-redesign §4.6 stage 6). Each strategy's
match(ctx) returns a final list (terminal - chain stops) or None (continue):
Pushdown is terminal on hit; BM25 appends and continues; Linear appends and
is terminal. Behavior identical to the original inline dispatch.

- New engine/components/reads/grep_strategies.py: GrepContext dataclass +
  GrepStrategy Protocol + PushdownGrep/BM25Grep/LinearGrep + GrepChain.
  Branch bodies migrated verbatim.
- read_service.py: import the chain; __init__ assembles self._grep_chain
  ([PushdownGrep(), BM25Grep(infra.milvus, ns), LinearGrep(objects)]); grep
  now builds a GrepContext + runs the chain. Drop now-unused re /
  _GREP_LINEAR_SCAN_MAX imports (moved to grep_strategies).
- test_engine_read_service.py: _build_reads fake infra now carries a milvus
  stub (BM25Grep construction needs it; the tests don't run grep).

cat CatRouter is left for a follow-up round (8 strategies, larger).

Verified: 447 passed, 9 skipped; ruff format --check + ruff check clean.
Replace ReadService.cat's 6-branch if/elif (meta/locator-lines/locator-record/
structured-stream/artifact-text/image-description/plain-text + density tail)
with a CatRouter first-match strategy table (engine-redesign §4.6 stage 6, cat
half). Each strategy applies(ctx)+read(ctx)->Any|None: None means "not
applicable / fall through", a non-None value is terminal. Behavior identical
to the original inline dispatch.

- New engine/components/reads/cat_strategies.py: CatContext dataclass +
  CatStrategy Protocol + 7 strategies (Meta/LocatorLines/LocatorRecord/
  StructuredStream/ArtifactText/ImageDescription/PlainText) + CatRouter +
  _apply_density helper. Branch bodies migrated verbatim.
  - D3 fallthrough (image+vlm try/except) becomes ImageDescriptionStrategy
    returning None on provider error -> chain falls to PlainTextStrategy.
  - density only applies to the artifact-text / plain-text paths (original
    D1/D2/E), so _apply_density lives in those two strategies; meta/locator/
    structured/image return before density (matching the original early returns).
- read_service.py: import the router; __init__ assembles self._cat_router;
  cat now builds a CatContext + runs the router. Drop now-unused imports
  (CONVERT_EXTS/_density_view/_locator_matches/_BARE_CAT_MAX_BYTES moved to
  cat_strategies; head/tail/_read_full still use CONVERT_EXTS).

Stage 6 complete (grep chain + cat router both done).

Verified: 447 passed, 9 skipped; ruff format --check + ruff check clean.
…rain tail

Pull the tar-probe/zip-slip guard and the staging-path/connector-registration
logic out of UploadService into standalone classes (engine-redesign §3.3/§4.7
follow-up), plus a shared _drain_after_upload tail (partial Template Method).

- BundleValidator.validate(data, require_meta=False) -> (members, meta): wraps
  the tar probe + _validate_upload_member + (manifest-diff) .mfs-meta.json parse.
  require_meta=False validates every member (tar snapshot); require_meta=True
  checks .mfs-meta.json is a file + parses it (manifest-diff). Behavior identical
  to the two inline probe blocks.
- StagingLocator(infra, ns, ingest): root(client_id, root) + connector(client_id,
  root) - the staging path sha1-sub + the file://<client_id><root> registration.
- UploadService._drain_after_upload(cid, connector_uri, job_id, full, process):
  shared tail (get_connector_config + drain_job) that both ingest_upload and
  files_upload ended with.
- ingest_upload / files_upload now call BundleValidator + self._locator +
  _drain_after_upload; the staging-step bodies (extractall vs renames/bytes/
  deletions) stay inline - they differ enough that a shared template would risk
  behavior drift (left as a future follow-up).
- test_engine_upload_service: staging-locator tests now call up._locator.root /
  up._locator.connector (the methods moved off UploadService).

Verified: 447 passed, 9 skipped; ruff format --check + ruff check clean.
…r value objects

Pull the retry/circuit-breaker logic out of IngestOrchestrator into pure
value objects (engine-redesign §4.4 follow-up).

- New engine/policies.py:
  - ErrorClassifier.classify(e) -> 'auth'/'quota'/'retryable' (was
    IngestOrchestrator.classify_error staticmethod).
  - BackoffPolicy(initial_ms, max_ms).delay_ms(attempt) = min(initial*2**attempt, max)
    (was the inline min(...) in process_with_retry).
  - CircuitBreaker(threshold, initial).record_success() resets;
    record_failure() increments and returns True once tripped (was the
    consec_fail int + `if consec_fail >= threshold` in run_job_loop).
- ingest.py: import the three; __init__ assembles self._backoff;
  process_with_retry uses ErrorClassifier.classify + self._backoff.delay_ms;
  run_job_loop builds a CircuitBreaker(threshold, consec_fail) and calls
  record_success/record_failure. run_job_loop signature is unchanged
  (threshold, consec_fail) so the e2e tests that call it directly still work.
- test_ingest_orchestrator _Cfg fake now carries object_task.backoff_*.

Verified: 447 passed, 9 skipped; ruff format --check + ruff check clean.
…line

Drop "Thin delegate / Kept on Engine" migration narrative, stage refs
(stage 3/5/stage-4c), the D1 decision ref, stale _index_object references,
and the design-doc section refs (§x.x) that littered engine.py and
pipeline.py docstrings/inline comments. Tighten a few over-long scenario
comments (cancelled_jobs, success-callback, retry/cancel) while keeping
the non-obvious WHY. Aligns with the concise style of pipeline_supervisor.py.

Zero behavior change: verified token-level that only COMMENT tokens and
docstring STRINGs differ; NAME/OP/NUMBER tokens are identical.
…ents

Drop the remaining "Extracted verbatim / Engine delegates / thin Engine
delegates" migration narrative and §x.x / stage-N design-doc refs from
the component docstrings: policies, worker, job_watcher, artifact_cache,
object_repository, connector_factory, reads/__init__, reads/read_service.

Two stale-claim fixes along the way: connector_factory no longer claims
the factory is invoked "only through thin Engine delegates" (ReadService
calls it directly), and artifact_cache's LocalArtifactCache is described
as living on InfraStack (not Engine). ingest.py verified already clean.

Zero behavior change: token-level check confirms only COMMENT and docstring
STRING tokens differ; NAME/OP/NUMBER identical across all 8 files.
…ine files

Final round of the engine comment cleanup. Drop "Extracted from ..."
migration narrative and §x.x / stage-N / commit-hash refs from the
remaining module docstrings: manage (stale _index_object -> mid-index),
cat_strategies, grep_strategies, upload (also drops the c10db97 commit
hash + stage-5/6 narrative), text_views.

job_lane/ and producers/ are intentionally untouched per the task scope.

Zero behavior change: token-level check confirms only COMMENT and docstring
STRING tokens differ across all 5 files. CI gate ruff format --check src/
tests/ passes (187 files); full engine test sweep 194 passed / 9 skipped.
@CodyTonn CodyTonn changed the title Engine God-Class Refactor - Extract 4 Business Components + Stage 6 Strategy Tables + Value Objects Refactor(engine): Engine God-Class Refactor - Extract 4 Business Components + Stage 6 Strategy Tables + Value Objects Jul 22, 2026
@CodyTonn
CodyTonn force-pushed the rebuild_engine_v1 branch from 95b1dbd to 6a99655 Compare July 22, 2026 03:37
build_plugin() returns a BuiltPlugin dataclass, but probe() and estimate()
still unpacked its result as a tuple (`plugin, _ = self._factory.build_plugin(...)`),
raising "cannot unpack non-iterable BuiltPlugin object" on
/v1/connectors/estimate (500). Use the existing _build_plugin helper, which
returns the (plugin, ctx) tuple both call sites expect.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The decorator was dropped during extraction, so Engine._is_secret_key("api_key")
bound "api_key" to cls and raised TypeError: missing 1 required positional
argument: 'key'. Restore @classmethod (matching the _redact_config sibling) and
add a facade compat test that calls the credential delegates on the class itself
to catch the regression.
@zc277584121
zc277584121 merged commit 28fe5ec into zilliztech:main Jul 23, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

refactor(engine): split the Engine monolithic class into a Facade + single-responsibility collaborating components

2 participants