DataCollector: Introduction & Architecture
EmpowerNow's enterprise data synchronization engine: streams, inventories, replay, the dispatch interface, stores and destinations, visibility rules, throttle groups, and the operational concerns of running DC at scale.
š What is DataCollector?
DataCollector (DC) is EmpowerNow's enterprise-grade data synchronization engine that fetches data from configured systems via their APIs, stores entities per-system in PostgreSQL, and publishes diffs to EmpowerID, Kafka, or local files.
It uses a plugin architecture where:
- Strategies define collection logic (what to collect and when)
- Connectors handle protocols (SCIM, LDAP, REST, etc.)
- Schema Mappings transform API responses to standard entity structures
Each system declares one or more streams ā named partitions that run inventories independently with their own scheduling and state. Multiple DC instances coordinate via per-stream database leases for high availability. When a downstream consumer falls behind, replay re-publishes stored entity data through the normal diff pipeline to bring the consumer back in sync.
š” Eventual consistency
Strategies are built to handle retries and failures gracefully, with step statistics available to verify execution state. Strategies may implement delta processing for incremental updates. DC guarantees eventual consistency through idempotent step execution and idempotent diff publishing ā strategies can safely retry without producing duplicate or inconsistent results.
š How this page references things
Each section below covers a concept at conceptual depth and points to the canonical reference
in DataCollector/docs/system-admins/ or
DataCollector/docs/strategies-development/ for the full specification, edge cases,
and worked examples. Read this page to build the mental model; consult the references when you
need precision.
šļø System Architecture
DataCollector integrates with the broader EmpowerNow ecosystem, coordinating with databases, secrets management, monitoring infrastructure, and downstream consumers.
Component Roles
| Component | Function | Purpose |
|---|---|---|
DC instances (app-1, app-2, app-3) |
Main DataCollector application with plugin system | Execute data collection workflows with horizontal scaling via per-stream database leases (heartbeat-based coordination) |
YugabyteDB / PostgreSQL |
SQL database for persistent storage | Streams, inventories, steps, entities, per-store hashes, and coordination leases |
dc-alembic |
Database schema management using Alembic | Keep database schema up to date with migrations |
DC-UI |
Web-based user interface (Experience Platform plugin) | Configure systems, trigger inventories, browse entities, monitor health, debug strategies |
OpenBao / Vault |
Centralized secrets storage | Secure credential management with rotation support; resolved per connector call |
Kafka |
Event streaming platform | Publishes monitoring events (datacollector-monitoring), admin events (datacollector.admin.events), and per-system diff topics |
analytics-service |
Analytics processing | Process monitoring events into ClickHouse for analysis |
EmpowerID |
Downstream consumer | Receives entity diffs for identity management with last-diff-wins deduplication and CREATED/UPDATED/DELETED contract |
Architecture Diagram
The following diagram illustrates how DataCollector instances coordinate with databases, secrets management, monitoring infrastructure, and downstream consumers. Note that work is partitioned by stream ā different streams of the same system can run on different instances.
(Experience plugin)"] --> LB["Load Balancer
(optional)"] LB --> DC1["DC-1
app-1"] LB --> DC2["DC-2
app-2"] LB --> DC3["DC-3
app-3"] DC1 --> DB["YugabyteDB or PostgreSQL"] DC2 --> DB DC3 --> DB Migration["dc-alembic"] --> DB DC1 --> Vault["OpenBao/Vault"] DC2 --> Vault DC3 --> Vault DC1 --> Kafka["Kafka"] DC2 --> Kafka DC3 --> Kafka Kafka --> Analytics["analytics-service"] Analytics --> ClickHouse["ClickHouse"] DC1 --> DiffFile["File
(local YAML)"] DC2 --> DiffFile DC3 --> DiffFile DC1 --> EmpowerID["EmpowerID API"] DC2 --> EmpowerID DC3 --> EmpowerID subgraph "Core Application" Frontend LB DC1 DC2 DC3 DB Migration Vault end subgraph "Monitoring and Analytics" Kafka Analytics ClickHouse end subgraph "Diff Publishing" DiffFile EmpowerID end
š Configuration Hierarchy
DC's configuration model separates concerns and enables reuse. Six layers, from least to most instance-specific:
1. Strategy ā business logic implementation
Python plugin defining collection logic:
- Universal Strategy:
builtin.strategies.universal.UniversalStrategyā YAML-driven collection with no Python code required. Handles standard API patterns purely via YAML configuration. - Custom Python strategies: hardcode collection logic in Python for complex scenarios not expressible in YAML, providing optimized logic and patterns.
šÆ Universal vs custom strategies
Universal: entire collection workflow (connector calls, success criteria, entity production, pagination)
configured in YAML at the system type level. Works with any connector ā params dicts are opaque to DC.
Custom: Python strategy implementing optimized logic. Used when YAML expression power isn't enough,
or when the strategy needs access to dispatch methods that the universal YAML doesn't expose (more on this in
The Dispatch Interface).
2. System Type ā shared configuration template
Reusable YAML template defining shared defaults:
- Connector references and schema mapping names
- Pagination sizes and timeouts
- Schedule configuration (interval, full-sync threshold, delta lookback)
- Default stores and storage settings
- Default monitoring exclusions and circuit-breaker config
3. System ā instance configuration
Instance YAML with:
- Credential pointer (vault URI like
openbao+kv2://secret/datacollector/sapias-prod) - Host / tenant / base_url (connection details)
resource_system_idfor EmpowerID sync- Optional overrides of system type defaults
- Variables (for universal-strategy systems ā values for the variables_schema declared in the system type)
4. Connector ā protocol implementation
Python plugin implementing the wire protocol. Built-in connectors include:
- SCIM ā SCIM 2.0 with pagination
- LDAP ā LDAP search with cookie-based pagination
- REST ā generic HTTP/REST
- AWS, Google, Echo, FlatFile ā additional protocols
The multi-method pattern is common: a single connector exposes multiple
operations (e.g., search / open_connection / paged_search / close_connection) via a
method parameter in the params dict.
5. Schema Mapping ā transformation rules
YAML transformation rules mapping connector response fields to entity fields. Three expression types:
- Direct ā dot-path field references like
result.id,pair.raw['email'] - Expression ā Python wrapped in
[[ ]]or marked with!exprtag - String Template ā Jinja
{{ }}templates for string concatenation and formatting
Schema mappings are applied by DC (via the SchemaMapper service); strategies just reference them by name. They're reusable across multiple strategies and systems.
6. Credentials ā authentication secrets
Auth secrets stored in vault (OpenBao/Vault):
- Referenced via
credential_pointerURIs - Contain only auth secrets ā no connection details (host, port, base_url belong in system config)
- Connector-specific structures (LDAP bind DN/password, OAuth tokens, API keys)
- Rotate independently of configuration; DC resolves the pointer per connector call, so rotation takes effect on the next call
ā ļø Why the YAML / Python split?
System types and systems are YAML because configuration needs UI designers.
Strategies and connectors are Python for complex logic.
This enables: shared defaults defined once at the system-type level; strategies implement
business logic; connectors handle protocols; credentials rotate independently; schema mappings
reuse across strategies.
Instance config with credentials"] SystemType["System Type (.yaml)
Shared template with defaults"] Strategy["Strategy (.py)
Business logic implementation"] Connector["Connector (.py)
Protocol client (SCIM, LDAP, REST)"] SchemaMapping["Schema Mapping (.yaml)
Field transformation rules"] CredStore["Credentials (Vault)
Authentication secrets"] System --> SystemType System --> CredStore SystemType --> Strategy SystemType --> Connector SystemType --> SchemaMapping UIDesigner -.-> SystemType UIDesigner -.-> System UIDesigner -.-> SchemaMapping UIEditor -.-> Strategy UIEditor -.-> Connector
š DC's Role & Boundaries
DataCollector orchestrates collection workflows but maintains clear boundaries between what it controls and what strategies and connectors control.
What DC validates vs. what's opaque
| Component | DC validates | Opaque to DC |
|---|---|---|
| System / System Type YAML | Root structure (system_type, group, monitoring, circuit_breaker) | config node content (validated by strategy via Pydantic) |
| Connector calls | Credential resolution, routing, transformation pipeline | params dict (private strategy-connector contract) |
| Schema mappings | YAML structure, expression syntax, type-safe application via SchemaMapper | The mapping logic is up to the strategy author ā DC just executes it |
What DC enforces vs. what strategies decide
DC enforces:
- Step dependencies and execution order (by step name)
- Retries with optional per-instance and per-throttle-group backoff
- Throttle-group parallelism limits (see Throttle Groups)
- Resumable state persistence to database
- Circuit-breaker patterns for external calls
- Monitoring event publishing (Kafka, files)
- Per-stream lease coordination across DC instances
Strategies decide:
- Step logic and implementation
- Dispatch patterns (pagination, fan-out, nested fetching)
- Execution decisions based on previously-collected data
- Which connector to use (configured in YAML ā strategies don't dynamically pick connectors at runtime)
- Their own config schemas via Pydantic models
- Stores, destinations, visibility rules, throttle-group definitions, replay profiles, commands
š Streams
A stream is a named partition within a system that runs inventories independently.
Each stream has its own scheduling, next_inventory_input, enable/disable status, and
coordination lease.
Example: Entra ID with two streams
An Entra ID system declares two streams ā "users" and "groups".
Each collects its entity types on its own schedule, can be enabled/disabled independently, and
can run on different DC instances concurrently.
Declaring streams
A strategy declares its streams via get_streams(). Every strategy must return at least one stream:
def get_streams(self, base_engine: BaseEngine) -> list[StreamDefinition]:
return [
StreamDefinition(name="users", entity_types=["account"]),
StreamDefinition(name="groups", entity_types=["group", "membership"]),
]
The returned list must be the same for a given system + system-type configuration ā it does not
depend on next_inventory_input values. entity_types on each stream is for
UI display grouping (many-to-many; not enforced by DC).
Per-stream methods
Most strategy methods receive stream_name to scope their behavior. Methods that operate system-wide do not.
Receives stream_name | System-wide |
|---|---|
get_next_trigger_atget_entry_step_nameget_step_storage_configget_step_dependenciesget_unique_key_logicget_step_execution_configget_throttle_group_configget_trigger_profiles
|
get_destination_configget_visibility_rulesget_replay_profilesget_commands (commands themselves can target a stream)
|
Per-stream next_inventory_input (NII)
Each stream has its own NII stored in the database. NII is the primary channel for cross-inventory state ā delta tokens, full-completed timestamps, failure counters, circuit-breaker state.
The snapshot model
When an inventory starts for a stream, the stream's NII is snapshotted onto the
inventory record (inventory_input_snapshot). The running inventory reads this snapshot
via ctx.inventory_input, so concurrent NII modifications (by other streams, by commands,
or by the running inventory's own cross-stream writes) do not affect the running inventory's
view. Editing NII while an inventory is in progress takes effect on the next inventory, not
the current one.
Cross-stream NII writes
Steps can read and write any stream's NII during execution:
# Read another stream's current NII (live from DB, not snapshot)
groups_nii = await engine.read_stream_next_inventory_input("groups")
# CAS write to another stream's NII
engine.dispatch_stream_next_inventory_input(
stream_name="groups",
old_value=groups_nii,
new_value={**groups_nii, "trigger_full": True},
)
Cross-stream writes are buffered on the step and applied at commit via compare-and-swap. If the CAS
fails (another process modified the value), DC calls
resolve_next_inventory_input_conflict() to reconcile ā that method must be a pure data
merge with no I/O and a 10-second timeout.
Per-stream control
Each stream can be independently:
- Enabled / disabled ā disabled streams don't schedule inventories
- Triggered ā trigger profiles are per-stream
- Stopped ā stop a running inventory for a specific stream
System-level operations span streams: cleanup-entities (deletes entities and resets all streams' NIIs, requires holding all stream leases) and system-wide replay.
For full reference: strategies-development/045-streams.md.
š Inventories & Trigger Modes
An inventory is one execution of data collection for a stream. Inventories are:
- Resumable ā state persisted in database, can continue after DC restart
- Exclusive per stream ā only one inventory per stream at a time, but a system with multiple streams runs them concurrently (possibly on different DC instances)
- Stateful ā receive
inventory_inputsnapshotted from the stream's NII at start
Five trigger modes
| Mode | Description | Use case |
|---|---|---|
WRITEABLE_INVENTORY |
Production with full database persistence | Normal scheduled runs and on-demand production triggers |
EXISTING_READONLY_INVENTORY |
Resume an existing inventory in read-only mode | Inspect an interrupted run without further changes |
DETACHED_READONLY_INVENTORY |
In-memory test, no database persistence | Test config changes safely without affecting production |
REPLAY |
Re-publish stored entity data to destinations | Recover from consumer drift (see Replay below) |
READONLY_REPLAY |
Like REPLAY but no destination publishing | Preview what a replay would publish without actually publishing |
Effects matrix
| Mode | Inventory in DB | Entity writes | Publishes | NII updated |
|---|---|---|---|---|
| Scheduled / WRITEABLE | Yes | Yes | Yes | Yes (on completion) |
| EXISTING_READONLY | Attaches existing | No | No | No |
| DETACHED_READONLY | No (in-memory) | No | No | No |
| REPLAY | No (in-memory) | No | Yes | No |
| READONLY_REPLAY | No (in-memory) | No | No | No |
Cross-inventory state via NII
Inventories pass data forward via next_inventory_input for state that crosses inventory boundaries:
- Delta sync timestamps ā track last successful sync time for incremental fetches
- Full-completed timestamps ā decide when to next run a full sync vs. delta
- Failure counters ā implement adaptive scheduling
- Circuit-breaker state ā remember system health across runs
Within-inventory pagination is not NII ā pagination tokens belong in step inputs
(dispatch_step parameters) or in the large-list cache. NII is for state across
inventory boundaries. See Range Fan-Out & Cache below.
For full per-mode lifecycle, persistence rules, and failure behavior:
system-admins/05-execution-modes.md.
š Replay
DC normally assumes every published diff was processed by the consumer. When that assumption breaks
ā consumer downtime, inbox failure, processing error ā the consumer's state drifts from DC's.
Replay fixes this by re-publishing stored entity data through the normal diff
pipeline. No connector calls, no strategy execution: DC reads attribute_transformed_data
from its database and pushes it through the same diff pipeline that normal inventories use.
Two modes
- REPLAY ā publishes diffs to destinations (File, Kafka, EmpowerID). The mode that actually fixes consumer state.
- READONLY_REPLAY ā formats diffs and emits monitoring events but skips destination publishing. Preview what a replay would publish before committing.
Replay profiles
Strategies declare replay profiles via get_replay_profiles(). Each profile targets
exactly one store (see Stores, Destinations &
Output Modes below) and lists the destination routings to use. To replay multiple stores,
trigger separate replays. The strategy controls which stores are replayable.
The since parameter
Optional timestamp. Controls which entities are replayed:
- Omitted ā all entities in the store are replayed (full re-publish)
- Provided ā only entities whose store data changed at or after that timestamp; recommended value is the start timestamp of the inventory whose diffs were not processed
EmpowerID compatibility (CREATED / UPDATED / DELETED contract)
The agreed contract:
- CREATED ā processed as upsert (works whether the entity exists at the consumer or not)
- UPDATED ā applied to an existing entity; skipped if the entity doesn't exist
- DELETED ā idempotent; applied if the entity exists, no error if it doesn't
Replay uses created_or_resurrected_on to decide diff type. With since,
visible entities established at the consumer before the replay boundary get UPDATED;
entities the consumer may not have get CREATED; gone-from-store entities get
DELETED. Without since (full replay), all visible entities get
CREATED ā safe at the consumer because CREATED is processed as upsert.
FK-consistent ordering
When visibility rules include ForeignKeyCheck (see
Visibility Rules below), replay produces diffs in FK-consistent
order so consumers never see a reference to a nonexistent entity:
- Alive pass ā parents before children (CREATED / UPDATED diffs)
- Dead pass ā children before parents (DELETED diffs)
For full reference (visible vs dead semantics, attribute-level tombstones in PER_ATTRIBUTE mode,
coordination lease behavior): system-admins/04-replay.md.
ā” Commands
Commands are user-invocable operations that modify a stream's
next_inventory_input via compare-and-swap, without starting an inventory.
Useful for administrative actions like "Force Full Sync", "Reset Delta Token", "Clear Failure
Counter".
Declaring commands
def get_commands(self, next_inventory_inputs, base_engine) -> list[CommandDefinition]:
return [
CommandDefinition(
name="force_full_sync",
description="Reset delta tokens and force a full sync next run",
stream_name="users", # appears as a button on the users-stream row
),
CommandDefinition(
name="reset_all",
description="Clear all state across streams",
stream_name=None, # system-level button
),
]
Executing commands
def execute_command(self, command_name, next_inventory_inputs, command_engine) -> CommandResult:
if command_name == "force_full_sync":
nii = next_inventory_inputs["users"]
command_engine.dispatch_stream_next_inventory_input(
"users", old_value=nii, new_value={**nii, "force_full": True},
)
return CommandResult(success=True, message="Full sync will run next")
Commands:
- Run any time, including during active inventories (CAS handles conflicts)
- Do not start inventories, do not acquire stream leases
- Use
CommandEngine(limited to NII read/write and template evaluation ā nocall_connector, no dispatch methods) - Appear in the UI as buttons on stream rows (if stream-scoped) or system rows (if
stream_name=None) - Result is shown to the user via a
CommandResultwith a success flag and a message
For full reference: strategies-development/045-streams.md.
šļø Stores, Destinations & Output Modes
DC's model for change tracking and publishing has two layers: stores (DC's record of what each consumer knows) and destinations (transports that deliver diffs). They are not the same thing.
Stores: named state slots
A store is a per-system, named slot that tracks per-entity state in the database: an entity-level
hash, per-attribute hashes, per-attribute timestamps, transformed data, and a visibility flag.
Each store operates independently ā the same entity can be visible in store "eid" and
invisible in store "kafka-feed", with different hashes and different transformed data.
Each entity's state in a store is one of three:
- Visible ā entity alive and published to consumers
- Invisible ā entity hidden from consumers (appears deleted to them) but data retained for potential resurrection
- Deleted ā entity gone from this store, data cleared
Strategies declare stores via get_destination_config()
def get_destination_config(self, base_engine) -> Dict[str, StoreConfig]:
return {
"eid": StoreConfig(
entity_types=["account", "group"],
empowerid=[EmpowerIDStoreRouting(
doc_type_ids={"account": 42, "group": 43},
resource_system_id=self.resource_system_id,
entity_message_formatter=empowerid1,
)],
kafka=[],
file=[],
),
"kafka-feed": StoreConfig(
entity_types=["account"],
empowerid=[],
kafka=[KafkaStoreRouting(
output_mode=OutputMode.PER_ATTRIBUTE,
entity_types=["account"],
topic=None, # None ā use DC default topic
attribute_message_formatter=empowernow1_per_attribute,
)],
file=[],
),
}
Destinations: transport types
Three destination types:
- EmpowerID ā API sync, deduplicated by entity (last diff wins), batched by
doc_type_id; always PER_ENTITY - Kafka ā streaming, no deduplication, message keyed by
system_name - File ā local YAML files (debugging-friendly; also used as a fallback when DC config diverts other destinations)
A store routes to zero or more destinations. The same store's change-detection output can publish to multiple destinations with different shapes. A store with zero destinations tracks changes but publishes nowhere ā useful for reserving a tracking slot for a future consumer.
Output modes per destination
Each Kafka or File route specifies an output_mode:
- PER_ENTITY ā one diff per entity. CREATED/UPDATED include full
transformed_data; DELETED carries entity_keys for the consumer to identify the gone entity. - PER_ATTRIBUTE ā one diff per changed attribute. When attributes are removed (or the entity is deleted), tombstone diffs are produced. Consumers can subscribe to specific attributes.
EmpowerID is always PER_ENTITY. Switching output modes is safe at any time ā the store's tracking state (hashes) doesn't change, only how diffs are formatted.
Message formatters
Strategies own the message shape. Each route can specify entity_message_formatter
(PER_ENTITY) or attribute_message_formatter (PER_ATTRIBUTE). The SDK provides reusable
formatters:
empowerid1ā for EmpowerID destinations (CREATED/UPDATED/DELETED with anActionfield)empowernow1_per_entityā for Kafka/file PER_ENTITY (entity envelope with payload)empowernow1_per_attributeā for Kafka attribute-flow consumers
Formatters receive a diff (EntityDiff or AttributeDiff protocol) plus a
MessageContext (system_name, inventory_id, inventory_run_id) and return a
Dict[str, Any]. Strategies can override formatters per route or define their own.
DC-level overrides
DC config (in config.yaml) can modify routing globally per destination type:
- disable ā destination removed from store routings
- mirror_to_file ā destination stays active, File destination added alongside
- divert_to_file ā destination removed, File destination added instead
- mirror_all_stores_to_file ā File destination added to every store
These are operational toggles for testing and incident response, not strategy concerns.
For full reference: strategies-development/050-changes-processing-pipeline.md.
š·ļø Entity Attributes
Each entity has three optional attributes stored on the entity row:
attribute1, attribute2, attribute3. They are nullable
strings, non-unique, and indexed.
Use cases
- ETags ā cheap freshness check before fetching full entity data
- Classification ā group entities for batch processing
- Soft-delete flags ā combined with
AttributeCheckvisibility rules (see below) - Strategy-specific metadata ā anything you'd want to look up later
Setting attributes
Attributes are set via dispatch_entity_confirmed_exact:
engine.dispatch_entity_confirmed_exact(
entity_type="account",
transformed_data=item.transformed,
key1=user_id,
attribute1=item.transformed['eid'].get('etag'),
attribute2="active" if item.transformed['eid']['enabled'] else "soft_deleted",
)
Looking up by attribute
entities = engine.lookup_entities_by_attribute(
entity_type="account",
attribute1=etag_value, # only non-None values are used as filters
)
For full reference: strategies-development/085-entity-lookup-and-dispatched-changes.md.
šļø Visibility Rules
Not all source systems provide FK-consistent data. An API may return a group membership before the group itself, or delete a parent entity while children still reference it. Visibility rules let strategies declare FK relationships; DC enforces referential integrity before publishing diffs to consumers.
Rules are returned by get_visibility_rules() as a flat list. Each rule targets a
specific (store_name, entity_type) pair. An entity is invisible in a store if
any of its rules evaluates to invisible.
Two rule types
AttributeCheck
Entity invisible when an entity-level attribute equals a specific value:
AttributeCheck(
store_name="eid",
entity_type="account",
attribute_name="attribute1",
value="soft_deleted", # entity invisible when attribute1 == "soft_deleted"
)
ForeignKeyCheck
Entity invisible when the referenced entity is not alive in the same store:
ForeignKeyCheck(
store_name="eid",
entity_type="membership",
relation_key_number=1, # uses relation1_key1 ā key1 of referenced
referenced_entity_type="group",
)
Backward cascade is automatic
When entity U changes state, DC finds entities referencing U via relation keys, re-evaluates their visibility, and produces appropriate diffs. The strategy declares the rules; DC handles the cascade.
- VāI (was visible, now invisible) ā DELETED diff with decision
"BecameInvisible" - IāV (was invisible, now visible) ā CREATED diff with decision
"BecameVisible" - Cascade through dependent entities ā
"CascadeBecameInvisible"/"CascadeBecameVisible"
Constraints
- No same-type references (
entity_typemust differ fromreferenced_entity_type) - FK dependency graph must be acyclic ā DC validates at inventory start
Consumer perspective
Invisible entities appear deleted to consumers. Data is retained on the DC side, so when the entity becomes visible again, the CREATED diff carries the latest data, not stale data from the moment it became invisible. Replay treats only visible entities as present; invisible entities are replayed as DELETED tombstones.
For full reference: strategies-development/090-visibility-rules.md.
š¦ Throttle Groups
Fan-out strategies dispatch many step instances that all hit the same external API. Without coordination, all instances retry independently after a failure wave ā collectively overwhelming the server. Throttle groups coordinate parallelism and backoff across step instances sharing a resource.
Declaring group membership
Steps declare group membership via throttle_groups in StepExecutionConfig:
async def get_step_execution_config(self, step_name, stream_name, context, engine):
if step_name == "fetch_user":
return StepExecutionConfig(
max_retries=5,
throttle_groups=["ldap_server", "all"],
)
return None
A step can belong to multiple groups; all group constraints must be satisfied (AND logic).
Defining group config
async def get_throttle_group_config(self, group_name, stream_name, base_engine):
if group_name == "ldap_server":
return ThrottleGroupConfig(
max_parallelism=3,
retry_backoff=RetryBackoff(
initial_delay_seconds=2.0, factor=2.0,
max_delay_seconds=90.0, jitter=0.3,
),
)
if group_name == "all":
return ThrottleGroupConfig(max_parallelism=10)
raise NotImplementedError(f"Unknown throttle group: {group_name}")
How it works
DC divides each group into max_parallelism virtual slots. Slot state is derived from
step_runs in the database (not in-memory). Each slot independently tracks consecutive
failures (slot_level); on RETRYABLE_FAILURE, the slot enters backoff with a delay
computed as initial Ć factor^slot_level, capped at max_delay, with
deterministic per-slot jitter so all slots don't expire simultaneously.
Recommended default pattern
For most fan-out strategies hitting one shared upstream resource:
- Put all relevant steps in a shared throttle group
- Set
ThrottleGroupConfig.max_parallelismto limit concurrent callers - Set
ThrottleGroupConfig.retry_backoffto protect the resource (slows new first attempts too ā group backoff applies to every member, not just retries) - Also set
StepExecutionConfig.retry_backoffso each individual failing step is paced predictably
DC enforces the longer of the two backoffs.
š” Steps with no group
Steps without throttle group membership (throttle_groups=[] or
get_step_execution_config returning None) are subject to a hardcoded
1000 concurrency cap. No backoff applies. Throttle groups are how you express explicit
parallelism limits.
For full reference (multi-group AND, virtual slot model, gradual recovery, jitter formula):
strategies-development/100-throttle-groups.md.
š¤ The Dispatch Interface
Strategies signal entity state changes to DC via the dispatch interface ā eight
typed methods on InventoryEngine. Each method says what happened, not what to
do about it. The strategy provides keys and data; DC handles diff types, visibility, hashing,
formatting, and publishing.
The eight methods
| Method | Meaning |
|---|---|
dispatch_entity_confirmed_exact | Entity exists, here is its full per-store data (full-replace upsert) |
dispatch_existing_entity_confirmed_partial | Entity exists, here are the fields that changed (merge into stored data) |
dispatch_existing_entity_seen | Entity still exists; updates last_confirmed_on only (cheap "alive" confirmation, no diff, no visibility re-evaluation) |
dispatch_entity_deleted | Specific entity removed |
dispatch_purge_unseen | Soft-delete entities of this type not confirmed this inventory |
dispatch_purge_unseen_by_relation | Same, scoped to a relation key |
dispatch_purge | Unconditional purge (all entities of this type, regardless of confirmation) |
dispatch_purge_by_relation | Unconditional purge scoped to a relation key |
Universal-strategy YAML exposes 4 of 8
The Universal Strategy's produces: blocks expose only four:
confirmādispatch_entity_confirmed_exactdeleteādispatch_entity_deletedpurge_unseenādispatch_purge_unseenpurge_by_relationādispatch_purge_unseen_by_relationā note that the YAML keyword drops theunseen_, but the underlying engine call is the unseen-since-cutoff variant, not an unconditional purge
Strategies needing the other four (dispatch_purge and
dispatch_purge_by_relation ā the two unconditional purges,
plus dispatch_existing_entity_seen and
dispatch_existing_entity_confirmed_partial) must be written as custom
Python strategies.
min_absent_inventories deferral
dispatch_purge_unseen and dispatch_purge_unseen_by_relation accept an
optional min_absent_inventories threshold (default 1). Entities below threshold get
their absent_inventory_count incremented and are deferred rather than
purged. Useful for tolerating non-snapshot-consistent pagination ā an entity may be missed in a
single enumeration pass but is not actually deleted.
engine.dispatch_purge_unseen(
entity_type="account",
min_absent_inventories=3, # purge only after absent for 3 consecutive inventories
)
since cutoff for purge
Purge methods also accept an optional since datetime ā entities with
last_confirmed_on before this time are purge candidates. Default is the inventory's
start time.
For full reference: strategies-development/005-strategy-dc-boundary.md,
strategies-development/020 sdk, engine, inventory execution overview.md.
šÆ Step Run Outcomes
Every step execution produces an outcome. DC has six outcome values, each with specific persistence rules. Inventories don't have a formal success/failure status ā they always complete. Outcomes affect what each step persists, not the inventory as a whole.
Six outcomes
| Outcome | Meaning | Will retry? |
|---|---|---|
SUCCESS | Step completed normally | No |
EXPECTED_FAILURE | Acceptable exceptional condition (e.g., resource not found when that's a valid state) | No |
FATAL_FAILURE | Permanent error requiring intervention (bugs, misconfiguration, exhausted retries) | No |
RETRYABLE_FAILURE | Transient error (rate limit, network glitch); DC retries up to max_retries | Yes |
SUCCESS_REDUCED | Step was merged via unique-key dedup; another step in the group executed | No |
DISCARDED | Eagerly-dispatched step whose parent did not commit successfully (monitoring-only, not persisted) | No |
Persistence rules ā the central rule
Only SUCCESS and EXPECTED_FAILURE persist a step's changes and
dispatched steps. All other outcomes discard them, with one exception (see
dispatch_step_on_fatal_failure below).
| Outcome | Changes persisted? | Dispatched steps persisted? | Dependents unblocked? |
|---|---|---|---|
SUCCESS | ā | ā | ā |
EXPECTED_FAILURE | ā | ā | ā |
FATAL_FAILURE | ā | ā (except dispatch_step_on_fatal_failure ā see below) | ā |
RETRYABLE_FAILURE | ā | ā | ā (blocks dependents until terminal) |
SUCCESS_REDUCED | ā | ā | ā |
DISCARDED | ā | ā | N/A (never became durable) |
Dependencies don't auto-fail ā a step with a FATAL_FAILURE dependency still runs (the dependent
step receives an unblocked terminal state, not a propagated failure). If you want cascading
failures, check the dependency's outcome via get_step_statistics().
The FATAL_FAILURE exception: dispatch_step_on_fatal_failure
Sometimes you want a cleanup or alert step to run only if the current step fails fatally. Use
engine.dispatch_step_on_fatal_failure(name, parameters): the dispatched step is
persisted only when the current step ends with FATAL_FAILURE (and discarded on all other outcomes
including SUCCESS). Works on every FATAL_FAILURE path: explicit
finish_step(FATAL_FAILURE), unhandled exceptions, commit-phase failures, and
max_retries exhaustion.
Setting outcomes
SUCCESS is automatic if the step finishes without an explicit finish_step() call. All
other outcomes must be explicit:
if result.status == ConnectorCallStatus.NOT_FOUND_ERROR:
engine.finish_step(Outcome.EXPECTED_FAILURE, "Resource not found, skipping")
return
if result.status == ConnectorCallStatus.THROTTLED_ERROR:
engine.finish_step(Outcome.RETRYABLE_FAILURE, "Rate limited")
return
if result.status != ConnectorCallStatus.SUCCESS:
engine.finish_step(Outcome.FATAL_FAILURE, f"Failed: {result.error_data}")
For full reference (unique-key behavior, dependency model, stuck-inventory detection):
strategies-development/030-inventory-flow-and-state.md.
š Eager Dispatch & DISCARDED
By default, dispatched steps don't start until the dispatching step commits. Eager
dispatch (immediate=True) starts the dispatched step before the parent
commits, overlapping the child's connector call with the parent's remaining work. Useful for
latency-sensitive fan-out where waiting for a slow commit is wasteful.
engine.dispatch_step("fetch_user_details", parameters={"id": user_id}, immediate=True)
If the parent fails
If the parent ends with FATAL_FAILURE or RETRYABLE_FAILURE, eagerly-dispatched children get the DISCARDED outcome ā monitoring-only, never persisted to DB. The connector call already happened, but entity processing and DB commit don't run.
Restrictions (footguns)
- The child must not declare a step-name dependency on the parent. If it does, the dependency check blocks the child until the parent commits, silently degrading
immediate=Trueto normal dispatch timing. - The child must not depend on the parent's entity changes being in the DB (parent hasn't committed).
- The child must not depend on NII written by the parent (cross-stream NII writes happen during commit).
- The child must not rely on step statistics reflecting the parent's outcome.
- Connector call side effects cannot be undone on DISCARDED ā the strategy developer's responsibility.
immediate=Truecannot be combined withunique_keyā raisesValueError. Unique-key merge logic requires durable Step rows that don't exist during eager dispatch's transient phase.
For full reference: strategies-development/080-strategy-ground-rules.md.
š Lookup Methods
Strategies can read entity state during step execution via four lookup methods on InventoryEngine.
Single entity by key
entity = await engine.lookup_entity(
entity_type="account",
source_key_number=1,
source_key_value=user_id,
)
if entity is not None:
# EntityInfo: key1-3, relation1-3_key1, attribute1-3, is_deleted
if entity.attribute1 == "etag_v2":
# cheap freshness check, skip full fetch
engine.dispatch_existing_entity_seen(entity_type="account", key1=user_id)
return
Multiple entities by attribute
entities = engine.lookup_entities_by_attribute(
entity_type="account",
attribute1="etag_value",
)
Multiple entities by relation
members = engine.lookup_entities_by_relation(
entity_type="membership",
relation1_key1=group_id,
)
Bulk key translation
id_map = await engine.lookup_entity_key(
entity_type="account",
source_key_number=1, # search by key1
target_key_number=2, # return key2 values
source_values=["a@x.com", "b@y.com"],
)
ā ļø Read isolation requires step dependencies
Lookups read at read-committed isolation. They never block step commits, and step commits never block lookups. But: if step A dispatches entities and step B looks them up, step B must declare a step-name dependency on A (and on every step in A's dispatch chain) to ensure A's data is committed before B reads. Without proper dependencies, B may see stale or missing data.
For full reference: strategies-development/085-entity-lookup-and-dispatched-changes.md.
šŖ Range Fan-Out & Cache
Each step must have a small, bounded memory footprint regardless of total system size. Strategies
use two patterns to break large work into small steps: range fan-out via
get_dispatched_changes, and the large-list cache for unpaginated
large responses.
Range fan-out via get_dispatched_changes
Step A dispatches N sub-steps with {offset, limit} parameters. Each sub-step reads its slice of A's dispatches:
# Step A: confirms N entities, then dispatches sub-steps
for offset in range(0, total, 100):
engine.dispatch_step(
"process_batch",
parameters={"offset": offset, "limit": 100},
)
# process_batch step: reads its slice
@step
async def process_batch(self, ctx, engine):
changes = engine.get_dispatched_changes(
step_name="step_a",
offset=ctx.step_input["offset"],
limit=ctx.step_input["limit"],
)
for change in changes:
# process each one
Results from get_dispatched_changes have stable, deterministic ordering, so
offset/limit pagination is safe across multiple calls within the same step execution. Step B must
declare a dependency on Step A so A's changes are committed before B reads them.
Large-list cache
If the source API returns all entities in a single unpaginated response, save the response to cache immediately and dispatch processing steps that read from cache in ranges:
cache_id = await engine.save_to_cache(items)
for offset in range(0, len(items), 1000):
engine.dispatch_step(
"process_chunk",
parameters={"cache_id": cache_id, "offset": offset, "count": 1000},
)
# process_chunk step
chunk = await engine.load_from_cache(
cache_id=ctx.step_input["cache_id"],
start_index=ctx.step_input["offset"],
count=ctx.step_input["count"],
)
š¦ Step inputs are coordinates, not the work
Dispatched step inputs must not exceed 1KB when serialized. Step inputs should describe what
the step will do ({"account_id": "123"},
{"offset": 100, "limit": 50}) ā not the work itself. Pagination tokens go in step
inputs; bulk data goes in the cache.
For full reference: strategies-development/085-entity-lookup-and-dispatched-changes.md,
strategies-development/080-strategy-ground-rules.md.
š” Connector Call Results
engine.call_connector() returns a ConnectorCallResult with a typed
status field. There are eleven status values across three categories.
The full enum
| Status | Meaning | Category | Triggers circuit? |
|---|---|---|---|
SUCCESS | 2xx response | ā | No |
BAD_REQUEST | HTTP 400 ā invalid params we sent | Remote | No |
NOT_FOUND_ERROR | HTTP 404 ā entity / resource not found | Remote | No |
THROTTLED_ERROR | HTTP 429 ā rate limit exceeded | Remote | Yes |
CONNECTIVITY_ERROR | DNS / connection refused / network failure | Remote | Yes |
TIMEOUT_ERROR | No response within timeout | Remote | Yes |
SERVER_ERROR | HTTP 5xx ā remote internal error | Remote | No |
AUTHENTICATION_ERROR | HTTP 401 / 403 ā credentials rejected | Remote | Yes |
CREDENTIAL_ERROR | Vault resolution failed before connector was called | Infrastructure | No |
FATAL_ERROR | Bug in connector or DC engine code | Internal | No |
CIRCUIT_OPEN | Circuit breaker is open, call skipped | Infrastructure | No |
Categories
- Remote ā connector interpreted the response from the external system
- Infrastructure ā DC engine produced this before or instead of calling the connector
- Internal ā bug in our code that needs fixing
Result structure
On SUCCESS:
transformed_data:Dict[str, List[...]]ā per-store transformed listscorrelations.raw_connector_response: original raw responsecorrelations.transformed_items: per-item correlation between raw and per-store transformedadditional_output: metadata extracted by the optionaladditional_outputcallable (e.g., pagination tokens)duration_ms: elapsed time
On any error status: error_data contains connector-specific error details, correlations is None.
Typical handling
Most strategies don't distinguish between failure types:
result = await engine.call_connector(...)
if result.status != ConnectorCallStatus.SUCCESS:
engine.finish_step(Outcome.RETRYABLE_FAILURE, f"Connector failed: {result.status.value}")
return
When a strategy needs to react differently:
if result.status == ConnectorCallStatus.AUTHENTICATION_ERROR:
engine.finish_step(Outcome.FATAL_FAILURE, "Credentials rejected by remote system")
return
if result.status == ConnectorCallStatus.NOT_FOUND_ERROR:
engine.finish_step(Outcome.EXPECTED_FAILURE, "Resource not found, skipping")
return
For full reference: strategies-development/020 sdk, engine, inventory execution overview.md.
šŖ Engine Tiers
The SDK provides five engine tiers at different lifecycle phases. Each tier
exposes only what's needed for that phase ā the type system enforces what's available when. You
can't call call_connector() during trigger evaluation, for example.
| Tier | Available to | Adds |
|---|---|---|
BaseEngine |
Configuration methods (get_streams, get_destination_config, get_visibility_rules, get_replay_profiles, get_throttle_group_config, etc.) |
Template evaluation, now(), generate_uuid() |
PreStepEngine |
get_step_execution_config() |
Adds get_step_statistics() for execution decisions based on inventory state |
CommandEngine |
execute_command() |
CAS read/write of stream NII, template evaluation |
InventoryEngine |
@step methods during execution |
Adds call_connector, all 8 dispatch methods, lookups, finish_step, finish_inventory, cache, log_message |
DebugEngine |
debug() / debug_streaming() static methods |
Debug operations: connector calls, full inventory runs (with memory event capture), schema-mapping testing |
For full reference: strategies-development/020 sdk, engine, inventory execution overview.md.
ā¤ļø Health Checks
Strategies declare what to check; DC handles how. Health-check results are surfaced as 24-hour mini-graphs on the Inventories page (per-system) and the Credentials list (per-credential).
Strategy contract
def get_health_check_details(self) -> List[HealthCheckDetails]:
return [HealthCheckDetails(
connector_name="builtin.connectors.scim.ScimConnector",
credential_pointer=self.credential_pointer,
params={"base_url": self.base_url, "endpoint": "/ServiceProviderConfig"},
auto_timeout_seconds=15.0,
manual_timeout_seconds=60.0,
)]
A system can have multiple health checks (one per connector). Testing a credential pointer runs only checks that use that credential.
Two timeout configurations
auto_timeout_secondsā capped timeout for scheduled automatic checks; prevents scheduler overrunmanual_timeout_secondsā uncapped timeout for on-demand testing via the UI; allows thorough validation
Default automatic schedule: every 15 minutes.
š¾ Storage Configuration
Storage controls what gets persisted to PostgreSQL per step. Strategies return a
StorageConfig from get_step_storage_config(step_name, stream_name, base_engine).
Different steps in the same inventory can have different storage configs.
Three controls
disable_changesā entity types to skip storingStepRunChangerecords (saves space; no history of dispatches for those types)enable_entities_transformed_dataā entity types to store full transformed data on the entity row (required fordispatch_existing_entity_confirmed_partialand for replay readiness)enable_changes_transformed_dataā entity types to store full transformed data on each change record (for audit andget_dispatched_changes(include_transformed_data=True); significantly increases DB size)
š” Storage best practices
- Enable
entities_transformed_datafor entity types you query in Entity Explorer or replay later - Enable
changes_transformed_dataonly temporarily for debugging - Use
disable_changesfor high-volume entity types where you only need current state, not history
Hashes are always stored regardless of transformed_data policy ā change detection works without full data.
š”ļø Monitoring & Circuit Breaker
Monitoring
DC publishes monitoring events to Kafka (the datacollector-monitoring topic) and
optionally to local YAML files. Configuration controls which events and fields are excluded:
- Disable entire event types (e.g.,
template_evaluationevents to reduce volume) - Disable specific fields within events (hide sensitive data)
- Per-destination rules (different rules for Kafka vs. files)
- Contextual filters (
decision_unchangedfor change events,template_use_case_regexfor template evaluations)
Circuit Breaker
Protects external system calls by opening after repeated failures, causing subsequent calls to fail fast without waiting for timeouts, then testing recovery after a timeout period.
Four ConnectorCallStatus values trigger the circuit (see Connector Call Results for the full enum):
THROTTLED_ERROR(429 rate limit)CONNECTIVITY_ERROR(network failure, DNS, connection refused)TIMEOUT_ERROR(request timeout)AUTHENTICATION_ERROR(credentials rejected ā likely systemic)
Other errors don't trigger:
- BAD_REQUEST / NOT_FOUND_ERROR ā request-specific, not system-wide
- SERVER_ERROR ā may be request-specific; default to not triggering to avoid false opens
- FATAL_ERROR ā connector or DC bug, not external system issue
Hierarchical configuration
Circuit breaker config merges field-by-field across three levels: global (DC config) ā system type
ā system. Each field independently overrides ā a system can override threshold
without specifying timeout, inheriting the latter from the system type.
āļø Horizontal Scaling
DC achieves high availability and load distribution through horizontal scaling ā multiple DC instances coordinate via database leases to ensure work is distributed without conflicts.
Per-stream lease coordination
Each stream has its own lease (not each system). A multi-stream system can have its streams running on different instances simultaneously.
- Acquisition ā instance attempts an atomic lease insert before starting work; the database
UNIQUE (system_name, stream_name)handles the race - Heartbeat ā owning instance updates
heartbeat_aton a fixed interval with version-based optimistic locking (defaultheartbeat_interval_seconds: 5) - Local expiration check ā owning instance treats the lease as expired if its last heartbeat started longer ago than
heartbeat_failure_timeout_seconds(default 30s; Python time, clock-drift immune) - DB expiration ā other instances can steal the lease once its
heartbeat_atis older thanlease_timeout_seconds(default 60s)
The three values are configurable in the coordination block of DC's
config.yaml; the relationship
lease_timeout > heartbeat_failure_timeout > heartbeat_interval Ć 2 is
enforced at construction. Production deployments often raise the two timeouts well
above their defaults to tolerate slow restarts and long-running operations.
Coordination lease (status coordinator)
A separate STATUS_COORDINATOR coordination lease (independent of stream leases)
elects one instance to poll system config files and update statuses. Prevents duplicate work and
races in system discovery.
Replay's CompoundLeaseInfo
Replays operate at the system level (re-publish data spanning entity types from multiple streams),
so they acquire all stream leases for the system at once via
CompoundLeaseInfo. Stream-level operations (cleanup-ongoing-inventory, edit NII,
stop-inventory) acquire only the relevant stream's lease.
Failure scenarios
- Concurrent triggers ā multiple instances try to acquire the same stream lease; database constraint ensures only one succeeds; losers get
None(expected contention, not an error) - Network partition ā instance A loses connection, heartbeats fail; after 30s A detects local expiration and terminates its inventory; after 60s instance B can steal the lease and resume
- Instance crash ā lease expires after 60s; another instance takes over and resumes the inventory's persisted state from where it stopped
š” Scaling guidance
Start with 2ā3 instances for HA. Each instance adds coordination overhead, so scale horizontally only when load justifies it. Monitor lease acquisition rates via admin events to spot uneven distribution.
š Authentication & Authorization
DC runs in one of three authorization modes, selected at startup by config.auth.mode.
Two endpoints bypass both layers: GET /health and GET /monitoring/schema.
Three modes
| Mode | Authentication | Authorization | Used in |
|---|---|---|---|
off |
Skipped | Skipped ā synthetic dev identity | DC's own unit tests, local dev stacks without a full platform |
scope |
IdP validates Bearer token | Token's scopes claim | Environments without PDP infrastructure |
pdp |
IdP validates Bearer token | AuthZEN request to PDP service; PDP consults PermissionGrants | Production |
auth.mode is mandatory in config.yaml. There is no default ā a missing or misspelled value fails startup.
Scope mode
The IdP issues a token carrying scopes. DC requires:
datacollector:readfor read endpoints (system list, entity browse, config read, health-check)datacollector:writefor mutations (enable/disable, trigger, edit configs, command execution)datacollector:debugfor debug endpoints (POST /debug/strategy,POST /debug/strategy/stream)
Missing scope returns 403 insufficient_scope.
PDP mode
DC sends an AuthZEN request per endpoint. The PDP returns permit or deny. The dc-ui PermissionGrants are roles like:
app_userā view permissions onlyapp_adminā view + manage non-strategy resourcesplatform_adminā full access including strategy management and debug
Trusted services + X-Actor-ARN
When a known service (e.g., CRUDService MCP) calls DC on behalf of a real user, the service forwards
an M2M token plus an X-Actor-ARN header carrying the real user's canonical ARN. DC's
auth middleware sees the service in auth.trusted_services and swaps the token subject
for the actor ARN. The PDP evaluation runs against the real user, so audit events show the user,
not the service.
Fail-closed: if the trusted-service token arrives without a canonical X-Actor-ARN, DC
returns 401 actor_arn_required. This prevents privilege escalation through borrowed
service tokens.
For full reference (troubleshooting 401/403, env var configuration, the AuthZEN request shape):
system-admins/06-authentication-and-authorization.md.
š Five Development Considerations
When designing a strategy, consider these five aspects (from strategies-development/010-inventory-strategies-development.md):
- Data consistency during inventory execution ā does your strategy handle source data changes gracefully during inventory execution? Ensure no false deletions occur when data is modified mid-collection.
- Resumability ā can the inventory resume properly if DataCollector is restarted? Essential for long-running strategies where DC may be interrupted during long inventories.
- Key change detection ā are key changes properly detected and handled? Your strategy should identify when entity keys are modified.
- Key reuse scenarios ā if a key changes and another entity adopts the previously-used key, is this scenario correctly detected?
- Pagination and source data integrity ā does the source API guarantee snapshot isolation? Can entities be missed or duplicated if data changes mid-pagination? Use
min_absent_inventoriesdeferral on purges to tolerate non-snapshot pagination.
š Strategy Ground Rules
Headlines from strategies-development/080-strategy-ground-rules.md:
- Data sync guarantee ā DC's purpose is to guarantee entity state matches the source. When in doubt, choose correctness over speed.
- Single file, no code sharing ā a strategy is a single Python file. No imports from other strategies.
- No static or class-level state ā steps may run on different pods. All inter-step communication via DB-persisted channels (step inputs, NII, cache).
- Step input size limit ā 1KB serialized. Step inputs are coordinates into the work, not the work itself.
- Step memory footprint ā each step bounded regardless of total system size. One step = one connector call (or a small number of related calls). Pagination = next page as a new step.
- Fail fast ā never silently skip data, swallow errors, or continue with partial results. Prefer a failed inventory over a silently incomplete one.
- Call ordering within a step ā all engine methods must be called before
finish_step. Afterfinish_step, any engine call raises. - Lookup read isolation ā structure step dependencies so that data the strategy reads is fully committed before the reading step begins.
- NII resolver must be fast ā
resolve_next_inventory_input_conflict()has a 10-second timeout; pure data merge, no I/O. - Caching determinism ā config-method results are cached by DC; cached methods must return the same result for the same configuration.
š Where to go next
Day 27 covers the DC-UI in depth ā how all of these concepts surface in the user interface. Day 28 covers debugging and advanced features ā testing patterns, the debug API, troubleshooting visibility cascades and throttle-group behavior.