Files
wehub-resource-sync bf2343b7e4
Integration Tests - MySQL + Elasticsearch / Detect Changes (push) Has been cancelled
Integration Tests - MySQL + Elasticsearch / integration-tests-mysql-elasticsearch (push) Has been cancelled
Integration Tests - PostgreSQL + Elasticsearch + Redis / Detect Changes (push) Has been cancelled
Integration Tests - PostgreSQL + Elasticsearch + Redis / integration-tests-postgres-elasticsearch-redis (push) Has been cancelled
Integration Tests - PostgreSQL + OpenSearch / Detect Changes (push) Has been cancelled
Integration Tests - PostgreSQL + OpenSearch / integration-tests-postgres-opensearch (push) Has been cancelled
Java Checkstyle / java-checkstyle (push) Has been cancelled
Maven Collate Tests / maven-collate-ci (push) Has been cancelled
OpenMetadata Service Unit Tests / openmetadata-service-unit-tests-status (push) Has been cancelled
Publish Package to Maven Central Repository / publish-maven-packages (push) Has been cancelled
OpenMetadata Service Unit Tests / Detect Changes (push) Has been cancelled
OpenMetadata Service Unit Tests / openmetadata-service-unit-tests (push) Has been cancelled
OpenMetadata Service Unit Tests / k8s_operator-unit-tests (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:35:45 +08:00

8.4 KiB
Raw Permalink Blame History

Performance Standards

The Silent Data Loss Problem

The most dangerous performance bug in connectors is missing pagination. When a REST API returns paginated results and the connector only fetches the first page, it silently ingests a subset of entities with no error or warning. Users see partial metadata and assume it's complete.

This is a BLOCKER, not a suggestion. Every list endpoint that can return more results than fit in one response MUST implement pagination.

Pagination

Rule: Every List Endpoint Must Paginate

Before implementing a client method that fetches a list of entities, check the API documentation for:

  • @odata.nextLink (OData APIs like SSRS, SharePoint)
  • next_cursor / nextPage / next_token (cursor-based APIs)
  • offset + limit / page + page_size (offset-based APIs)
  • Link: <url>; rel="next" headers (GitHub-style APIs)
  • Response fields like has_more, total_count, count

If the API supports pagination, you MUST implement it. If unsure, assume it paginates.

Anti-Pattern: Single-Page Fetch (BLOCKER)

# WRONG — only gets first page, silently drops remaining entities
def get_reports(self) -> list[SsrsReport]:
    data = self._get("/Reports")
    return SsrsReportListResponse(**data).value

# WRONG — fetches all entities without any pagination handling
def get_dashboards(self) -> list:
    return self._get("/api/dashboards")["dashboards"]

Correct: Offset-Based Pagination

def get_reports(self) -> list[SsrsReport]:
    results = []
    offset = 0
    while True:
        data = self._get(f"/Reports?$skip={offset}&$top={self.PAGE_SIZE}")
        page = SsrsReportListResponse(**data).value
        results.extend(page)
        if len(page) < self.PAGE_SIZE:
            break
        offset += self.PAGE_SIZE
    return results
def get_reports(self) -> list[SsrsReport]:
    results = []
    path = "/Reports"
    while path:
        data = self._get(path)
        results.extend(SsrsReportListResponse(**data).value)
        next_link = data.get("@odata.nextLink")
        path = next_link.replace(self.base_url, "") if next_link else None
    return results

Correct: Generator-Based Pagination (Preferred)

When the caller doesn't need all results at once, use a generator:

def _paginate(self, endpoint: str):
    """Yield items one page at a time."""
    offset = 0
    while True:
        data = self._get(endpoint, params={"offset": offset, "limit": self.PAGE_SIZE})
        items = data.get("data", [])
        if not items:
            break
        yield from items
        if len(items) < self.PAGE_SIZE:
            break
        offset += len(items)

Verification Checklist

For every client.py method that returns a list:

[ ] Does the API documentation say this endpoint paginates?
[ ] If yes, does the method follow pagination links / increment offset?
[ ] Does it stop when: empty page, page < page_size, or no next link?
[ ] On large instances (1000+ entities), will this return ALL entities?

Lookup Complexity

Rule: Pre-Build Dicts for Repeated Lookups

When you need to look up entities by ID, path, or name during iteration, build a dictionary ONCE and use O(1) lookups — don't iterate a list every time.

Anti-Pattern: O(n*m) Iteration Lookup (WARNING)

# WRONG — for each dashboard (m), iterates all folders (n) → O(n*m)
def get_project_name(self, dashboard_details):
    parts = dashboard_details.path.split("/")
    folder_path = f"/{parts[1]}" if len(parts) > 1 else None
    if folder_path:
        for folder in self.folders:       # O(n) per call
            if folder.path == folder_path:
                return folder.name
    return None

Correct: Dict Lookup (O(1) per call)

# Build dict once in prepare()
def prepare(self):
    super().prepare()
    self.folders = self.client.get_folders()
    self._folder_by_path = {f.path: f for f in self.folders}

# O(1) lookup
def get_project_name(self, dashboard_details):
    parts = dashboard_details.path.split("/")
    folder_path = f"/{parts[1]}" if len(parts) > 1 else None
    folder = self._folder_by_path.get(folder_path)
    return folder.name if folder else None

When This Matters

This pattern applies whenever you:

  • Look up a parent entity for each child entity (folders for reports, projects for dashboards)
  • Map IDs to names during iteration
  • Resolve references between entity types

The impact scales with entity count: 100 folders × 500 reports = 50,000 iterations vs 500 dict lookups.

Connection Reuse

  • SQLAlchemy: The BaseConnection class handles connection caching automatically
  • REST clients: Create one requests.Session() and reuse it for all requests
  • SDK clients: Initialize once in get_connection(), not per-entity

Anti-Pattern: Per-Request Sessions

# WRONG — creates new session per request
def _get(self, endpoint):
    response = requests.get(f"{self.base_url}{endpoint}")
    return response.json()

Correct: Shared Session

def __init__(self, config):
    self._session = requests.Session()
    self._session.headers["Authorization"] = f"Bearer {config.token.get_secret_value()}"

def _get(self, endpoint):
    response = self._session.get(f"{self.base_url}{endpoint}")
    response.raise_for_status()
    return response.json()

Batch Operations

When fetching details for each entity, prefer batch endpoints if available:

# Prefer batch fetch
details = self.client.get_dashboards_batch(ids=[d.id for d in dashboards])

# Over individual fetches (N+1 problem)
for dashboard in dashboards:
    detail = self.client.get_dashboard(dashboard.id)

Rate Limiting

For REST APIs with rate limits, implement retry with backoff in the client:

from tenacity import retry, stop_after_attempt, wait_exponential

@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, max=30))
def _get(self, endpoint):
    response = self._session.get(f"{self._base_url}{endpoint}")
    if response.status_code == 429:
        retry_after = int(response.headers.get("Retry-After", 30))
        logger.warning(f"Rate limited, retrying after {retry_after}s")
        raise RateLimitError(retry_after)
    response.raise_for_status()
    return response.json()

Lazy Loading

Only fetch entity details when needed. The framework applies filter patterns between get_dashboards_list() and get_dashboard_details(), so filtered entities never trigger detail fetches:

def get_dashboard_details(self, dashboard):
    """Called only for dashboards that pass filters."""
    return self.client.get_dashboard(dashboard.id)

Memory

See memory.md for the full memory management standard. Key rules:

  • Never .read() an entire file without a size check — OOMs on large files
  • del large objects and call gc.collect() after processing
  • Bound all caches with lru_cache(maxsize=) or clear between scopes
  • Use generators in yield methods, not list accumulation
  • Stream query results with .fetchmany(), never .all() on large tables
  • Close cursors and file handles explicitly (context managers or finally)
  • Use json.load(stream) instead of json.loads(stream.read())
  • Storage connectors: use framework streaming readers (avro, parquet, dsv)

Empty Test Stubs

Test files with empty pass bodies are a performance anti-pattern for the project. They:

  • Give false confidence (100% of tests "pass")
  • Mask missing coverage
  • Signal that the author didn't validate the connector works
# WRONG — gives false confidence
def test_metadata_ingestion(self):
    pass

# If you can't write the test yet, don't create the file.
# If you must create a placeholder, mark it:
@pytest.mark.skip(reason="Requires SSRS instance - TODO")
def test_metadata_ingestion(self):
    ...

Review Checklist

When reviewing a connector for performance issues, verify:

[ ] Every client method that returns a list implements pagination
[ ] No list endpoint fetches only the first page without warning
[ ] Lookups inside loops use dicts, not list iteration
[ ] REST client uses a shared requests.Session
[ ] No N+1 API calls (batch where API supports it)
[ ] Test files have real assertions, not empty pass stubs
[ ] Generator-based pagination used where possible
[ ] No unbounded .read() on files without size checks (see memory.md)
[ ] Large objects del'd after use, gc.collect() called between batches
[ ] Caches bounded or cleared between scopes