Skip to content

feat(sync): add --force-missing-dependencies to import command#550

Open
michael-richey wants to merge 11 commits into
mainfrom
hamr/force-missing-deps-2-import-resolution
Open

feat(sync): add --force-missing-dependencies to import command#550
michael-richey wants to merge 11 commits into
mainfrom
hamr/force-missing-deps-2-import-resolution

Conversation

@michael-richey
Copy link
Copy Markdown
Collaborator

@michael-richey michael-richey commented Apr 27, 2026

Summary

  • Adds _discover_missing_dependencies() to ResourcesHandler: scans imported source state via _resource_connections() and returns the set of (resource_type, id) pairs not yet in source state
  • Adds _import_missing_dep_cb(): async worker callback that imports a single dep by ID from the source API, emits success/skip/failure events, then recursively discovers and enqueues transitive deps
  • Modifies import_resources() to run the discovery + worker loop between import_resources_without_saving() and dump_state(Origin.SOURCE) when force_missing_dependencies=True
  • Wires --force-missing-dependencies into the integration test helper's import_resources() method
  • 27 new unit tests (TDD: RED→GREEN cycles A–D covering discovery, callback, wiring, and integration helper)

Stacked on: #549

Use case

In split import/sync workflows (import on machine A, sync on machine B), this flag lets import produce a fully self-contained local state. sync can then run without any source API access for missing deps.

datadog-sync import \
  --resources="dashboard_lists" \
  --filter='Type=dashboard_lists;Name=id;Value=^539651$' \
  --force-missing-dependencies
# source/ now contains dashboard_lists + dashboards + monitors + ...

datadog-sync sync --resources="dashboard_lists"
# no source API calls for missing deps

Test plan

  • pytest tests/unit/test_import_force_missing_deps.py -v — 27 tests, all green
  • pytest tests/unit/ -v — 363 tests, full regression
  • tox -e ruff,black — lint clean
  • Manual smoke test: import with --force-missing-dependencies, verify transitive dep types appear in resources/source/

🤖 Generated with Claude Code

@michael-richey
Copy link
Copy Markdown
Collaborator Author

Review notes from my pass:

P2: import dependency discovery is still affected by destination state

ResourcesHandler._discover_missing_dependencies() reuses _resource_connections(), which calls each resource class connect_id() path. That path is sync-oriented and checks config.state.destination when deciding whether a referenced dependency is unresolved. During import, this means a stale or existing destination mapping can make a source dependency look complete even when it was never fetched into state.source. Example: if state.destination["dashboards"]["dash-1"] exists, a dashboard list that references dash-1 will not enqueue dashboards/dash-1 for import, so the local source state is not self-contained.

Suggested fix: add an import-specific dependency extractor that compares referenced IDs only against config.state.source, and use that from _discover_missing_dependencies() and _import_missing_dep_cb() instead of _resource_connections().

def _missing_source_dependencies_for_resource(self, resource_type: str, _id: str) -> set[tuple[str, str]]:
    missing = set()
    resource = deepcopy(self.config.state.source[resource_type][_id])
    r_class = self.config.resources[resource_type]

    def source_connect_id(key: str, r_obj: dict, dep_type: str) -> list[str]:
        values = r_obj[key] if isinstance(r_obj[key], list) else [r_obj[key]]
        return [str(value) for value in values if str(value) not in self.config.state.source[dep_type]]

    for dep_type, paths in r_class.resource_config.resource_connections.items():
        for path in paths:
            failed = find_attr(path, dep_type, resource, source_connect_id)
            for dep_id in failed or []:
                missing.add((dep_type, dep_id))

    return missing

P2: dependency discovery does not compute closure through already-present source deps

_discover_missing_dependencies() only scans resources_arg, then _import_missing_dep_cb() scans newly imported missing deps. If a direct dependency is already present in state.source from an earlier partial import, it is treated as complete and its own transitive dependencies are never scanned. Example: importing only dashboard_lists --force-missing-dependencies where dashboards/dash-1 already exists locally but references missing monitor 123 leaves monitors/123 absent.

Suggested fix: make discovery a source-state closure walk. Start from the imported resources_arg nodes, scan every reachable node once, enqueue already-present dependencies for scanning, and collect dependencies that must be fetched. After each fetched dependency, scan that node with the same helper.

def _discover_missing_dependencies(self) -> set[tuple[str, str]]:
    missing = set()
    seen = set()
    queue = [
        (resource_type, _id)
        for resource_type in self.config.resources_arg
        for _id in self.config.state.source[resource_type].keys()
    ]

    while queue:
        resource_type, _id = queue.pop()
        node = (resource_type, _id)
        if node in seen:
            continue
        seen.add(node)

        r_class = self.config.resources[resource_type]
        if not r_class.resource_config.resource_connections:
            continue

        for dep_type, dep_id in self._dependencies_for_source_resource(resource_type, _id):
            dep = (dep_type, dep_id)
            if dep_id in self.config.state.source[dep_type]:
                queue.append(dep)
            else:
                missing.add(dep)

    return missing

The key point is that import-time resolution should be source-state closure logic only. Sync-time _resource_connections() can stay destination-oriented because it also performs ID remapping for apply.

@michael-richey michael-richey marked this pull request as ready for review April 28, 2026 14:10
@michael-richey michael-richey requested a review from a team as a code owner April 28, 2026 14:10
riyazsh
riyazsh previously approved these changes Apr 28, 2026
Base automatically changed from hamr/force-missing-deps-1-option-extraction to main April 28, 2026 15:40
@michael-richey michael-richey dismissed riyazsh’s stale review April 28, 2026 15:40

The base branch was changed.

michael-richey and others added 5 commits April 28, 2026 11:42
Allows import to resolve and fetch all transitive dependencies, producing
a self-contained local state so sync never calls the source API for missing
deps in split import/sync workflows.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fixes two P2 bugs in --force-missing-dependencies import discovery:

Bug #1 (destination state leak): _discover_missing_dependencies() was
reusing _resource_connections() → connect_id(), which checks
config.state.destination first. If a dep existed in stale destination
state, it appeared "resolved" and was never checked against source,
causing it to be silently skipped even when not imported.

Bug #2 (no closure through present deps): Discovery only scanned
resources_arg types. Deps already in source state from a prior partial
import had their own transitive deps ignored. Example: dashboard_list
→ dashboard (present) → monitor (missing) — monitor was never found.

Fix: Introduce a parallel import-time path:
- BaseResource.extract_source_ids(): source-only mirror of connect_id,
  no destination state check, no mutation. Overridden in Monitors,
  RestrictionPolicies, TeamMemberships, SyntheticsTests for types that
  need regex/prefix/type-dispatch logic.
- ResourcesHandler._dep_in_source_state(): exact + prefix match for
  composite synthetics_tests keys ('{public_id}#{monitor_id}').
- ResourcesHandler._source_dependencies_for_resource(): find_attr()
  traversal using extract_source_ids instead of connect_id.
- Rewrite _discover_missing_dependencies() as a BFS closure walk that
  seeds from resources_arg nodes, follows edges through already-present
  source resources, and collects only nodes not yet in source state.
- Update _import_missing_dep_cb() transitive discovery to use the same
  source-only path.

The sync-time path (_resource_connections, connect_id,
get_dependency_graph) is entirely untouched.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
P2: synthetics_tests referenceId was emitted under the wrong dep type.
extract_source_ids (and connect_id) internally called super() with
"synthetics_mobile_applications" but _source_dependencies_for_resource
always tagged the result with the outer resource_connections dict key
("synthetics_mobile_applications_versions"). Import would then try to
fetch the application ID from the versions endpoint.

Fix: add "options.mobileApplication.referenceId" as a path under
synthetics_mobile_applications in resource_connections, and split both
connect_id and extract_source_ids into two explicit referenceId branches:
- synthetics_mobile_applications + referenceType=latest → handle
- synthetics_mobile_applications_versions + referenceType=latest → []
(and vice versa for pinned references). Each branch now produces the
correct dep type without needing cross-type redirection.

P3: import_test fixture (module-scoped config) did not save/restore
destination state or force_missing_dependencies. Tests mutating either
leaked state into later tests. Fix uses deepcopy to snapshot both source
and destination state before each test and fully restores all mutated
fields in the teardown.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…itor ID discovery

- Replace _dep_in_source_state (bool) with _source_state_key (Optional[str])
  so BFS queues the actual dict key (e.g. 'pub#999') rather than the bare
  public_id, preventing KeyError in _source_dependencies_for_resource
- Add SLO extract_source_ids override that mirrors connect_id's suffix-match
  logic, filtering out monitor_ids that are backed by synthetics_tests to
  prevent false ("monitors", id) missing-dep entries
- Update enforcement test allowlist: remove service_level_objectives now that
  it has a custom extract_source_ids override
- Rename 4 _dep_in_source_state tests to _source_state_key; add 2 regression
  tests covering the KeyError and SLO synthetics false-miss bugs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…source

- Replace class-level mutable `versions: List = []` with instance-level
  `self.versions: Optional[List[Dict]] = None` in __init__ to prevent
  state leaking across instances
- Add _ensure_mobile_versions_loaded(client) lazy loader that fetches
  SyntheticsMobileApplicationsVersions.get_resources() on first call only;
  uses None sentinel so orgs with zero versions don't refetch every import
- Update get_resources() and import_resource() mobile branch to use the
  lazy loader, fixing the bug where force-missing-dep imports of mobile
  tests set mobileApplicationsVersions=[] because get_resources() was
  never called
- Add 2 regression tests: lazy load on direct import, and None-vs-[]
  sentinel (no double fetch for empty version list)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@michael-richey michael-richey force-pushed the hamr/force-missing-deps-2-import-resolution branch from 32f213a to 6f41167 Compare April 28, 2026 15:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants