ADR-0004: Diamond Problem in Provider Topology¶
Status¶
Accepted
Context¶
The faker module’s Provider topology forms a directed acyclic graph (DAG)
where Aggregate providers are connected via reference providers (foreign
keys). A diamond problem arises when the same AggregateProvider is
reachable from multiple paths.
Topology¶
Consider this topology:
::
ThirdModelFaker +– id (ThirdModelPkFaker) | +– first_model_id –> FirstModelFaker <– path 1 +– second_model_id –> SecondModelFaker | +– id (SecondModelPkFaker) | +– first_model_id –> FirstModelFaker <– path 2 (same instance) +– parent_id –> ThirdModelFaker (self-reference, nullable)
FirstModelFaker is the same instance reached from two paths: through
ThirdModelPkFaker.first_model_id (path 1) and through
SecondModelPkFaker.first_model_id (path 2).
Step-by-step reproduction¶
ThirdModelPkFaker.first_model_idpopulate triggersCursor->FirstModelFaker.create()-> generatesUUID_A->id_provider.require(UUID_A)->_output = aggregate. Note:create()sets_criteriaonly onid_provider, not onFirstModelFakeritself. SoFirstModelFaker._criteriaremainsNone.ThirdModelFaker.do_populate()propagates constraints:self.second_model_id.require({'$rel': {'id': {'first_model_id': UUID_A}}})->SecondModelFaker.require(...)->SecondModelPkFaker.first_model_id.require(UUID_A)->FirstModelFaker.require({'id': UUID_A}).Inside
BaseCompositeProvider.require():if self._criteria != old_criteria: self._output = empty # <-- RESETS already-created output!FirstModelFaker._criteriawasNone, new criteria isCompositeQuery(...), soNone != CompositeQuery(...)->True->_output = empty.SecondModelPkFaker.first_model_idpopulate triggersCursor->FirstModelFaker.create()->_output is empty-> creates a new aggregate ->UUID_B->id_provider.require(UUID_B)-> conflict withUUID_A.
Root cause¶
create() does not synchronize self._criteria with the actually created
state. A subsequent require() from path 2 with the same value treats it as
a new criterion and resets _output.
The problem manifests when require() arrives later, from a different path in
the graph.
Two sub-problems were identified:
Conflicting
require()calls on an already-created aggregate. AfterFirstModelFakerhas already created its output (via path 1), path 2 callsrequire()again. The defaultBaseCompositeProvider.require()resets_outputtoemptyand redistributes criteria to nested providers, breaking the already-created state.Null FK propagation. The nullable
parent_idself-reference producesEqOperator(None)which gets wrapped intoRelOperatorand propagated through the distribution chain to aCompositeValueProvider(the composite PK provider), whereEqOperator(None) + CompositeQuery(...)raisesTypeErrorbecauseEqOperator.__add__only acceptsEqOperator.
Decision¶
Fix 1: Validate instead of reset (AggregateProvider.require())
When AggregateProvider._output is already set (aggregate is created),
require() validates the new criteria against the existing state using
EvaluateWalker.evaluate_sync() instead of resetting and redistributing:
def require(self, criteria):
new_criteria = parse_query(criteria)
if self._output is not empty:
# Already created - validate state instead of resetting
state = self.state()
walker = EvaluateWalker()
if not walker.evaluate_sync(new_criteria, state):
raise DiamondUpdateConflict(
state, query_to_dict(new_criteria), self.provider_name
)
# State matches - merge criteria for bookkeeping
# Don't distribute - nested providers already have their state
return
super().require(criteria)
Fix 2: Null FK early return (ReferenceProvider.require())
When a null FK (EqOperator(None)) is received, ReferenceProvider sets
_input = None, _output = None and returns immediately without wrapping
into RelOperator or propagating to the aggregate:
def require(self, criteria):
new_criteria = parse_query(criteria)
# Null FK - no reference. Don't propagate to aggregate.
if isinstance(new_criteria, EqOperator) and new_criteria.value is None:
self._input = None
self._output = None
return
# ... normal flow
Consequences¶
Diamond topologies in provider graphs now work correctly: the first path creates the aggregate, and subsequent paths validate compatibility
Null FK references are handled gracefully without propagation cascades
EvaluateWalkerprovides sync evaluation without async overhead (each provider validates itself independently, no relation traversal needed)DiamondUpdateConflictis raised if a diamond produces genuinely incompatible constraints, catching topology bugs earlyThe fix is backward-compatible: non-diamond topologies are unaffected
Related Files¶
ascetic_ddd/faker/domain/providers/aggregate_provider.py—AggregateProvider.require()overrideascetic_ddd/faker/domain/providers/reference_provider.py—ReferenceProvider.require()null FK handlingascetic_ddd/faker/domain/query/evaluate_visitor.py—EvaluateWalker.evaluate_sync()ascetic_ddd/faker/domain/providers/exceptions.py—DiamondUpdateConflict