Relationship Closure Manager
The relationship closure manager (Drupal\crm\Service\ClosureInterface) maintains a derived transitive closure index for hierarchical relationship types. The crm_relationship entity remains the canonical source of truth; the closure manager keeps crm_relationship_closure and crm_relationship_closure_path in sync as direct relationships are saved, updated, or deleted.
Internally the facade (ClosureService) delegates table I/O to ClosureRepositoryService and Batch API callbacks to ClosureBatch.
Why a closure table
Direct relationships answer questions like "who is Alice's parent?" but not "who are all of Alice's ancestors?" Recursively walking the graph in PHP is expensive, and Drupal Entity Query has no portable recursive query support. The closure table stores every reachable descendant/ancestor pair plus a depth, so the question becomes a single indexed lookup. The same table is exposed to Views; see Views integration.
Tables
crm_relationship_closure
| Field | Type | Notes |
|---|---|---|
id |
serial | Primary key |
relationship_type |
varchar(128) | Bundle machine name |
descendant_id |
int unsigned | Lower contact ID |
ancestor_id |
int unsigned | Upper contact ID |
depth |
int unsigned | 1 = direct edge |
path_hash |
varchar(64) | sha-256 of relationship-id sequence |
created, changed |
int unsigned | Timestamps |
Unique key: (relationship_type, descendant_id, ancestor_id, path_hash).
crm_relationship_closure_path
Stores ordered steps for each closure row. Avoids storing relationship IDs as a delimited string, which would impose an arbitrary maximum depth.
| Field | Type | Notes |
|---|---|---|
closure_id |
int unsigned | Foreign reference to closure row |
step |
int unsigned | Zero-based step within the path |
relationship_id |
int unsigned | The crm_relationship ID for this hop |
from_contact_id |
int unsigned | Descendant side of the hop |
to_contact_id |
int unsigned | Ancestor side of the hop |
Primary key: (closure_id, step).
Service API
$manager = \Drupal::service(\Drupal\crm\Service\ClosureInterface::class);
// Maintenance.
$manager->applyForRelationship($relationship);
$manager->removeForRelationshipId($relationship_id);
$manager->enqueueForRelationship($relationship);
$manager->rebuildAll();
$manager->rebuildAll('parent');
// Queries.
$rows = $manager->getAncestors($contact_id, 'parent');
$rows = $manager->getDescendants($contact_id, 'parent', max_depth: 2);
$distance = $manager->getDistance($descendant_id, $ancestor_id, 'parent');
$path = $manager->getPath($closure_id);
// Estimation (used by hooks and the form confirmation flow).
$estimate = $manager->estimateAffectedRows($relationship, $original);
Update routing
Drupal\crm\Hook\ClosureHooks listens to relationship CRUD events. Routing is determined by:
closureChoicestamped on the entity by the relationship form (sync,queue, orbatch).- Otherwise the global config:
auto: synchronous when belowsync_threshold, otherwise queued.sync: always synchronous.queue: always queued.batch: queued for non-interactive saves; the form is responsible forbatch_set()on interactive saves.
Interactive large updates under auto mode are handled by Drupal\crm\Service\ClosureConfirmService from the relationship entity form.
The same hooks class also listens to crm_relationship_type update and delete. Changing transitive or transitive_direction truncates or rebuilds that type's closure rows synchronously so the index matches the saved config.
Queue worker
Drupal\crm\Plugin\QueueWorker\ClosureRebuildWorker (queue id crm_relationship_closure_rebuild) accepts:
{ op: 'apply', relationship_id }
{ op: 'remove', relationship_id }
{ op: 'rebuild_type', type }
{ op: 'rebuild_all' }
It runs on cron and via drush queue:run crm_relationship_closure_rebuild.
Batch API
Drupal\crm\Service\ClosureBatch owns the static Batch API callbacks (applyBatchOperation, truncateBatchOperation, rebuildBatchOperation, rebuildBatchFinished). Form and settings rebuild flows reference those callbacks when calling batch_set().
Drush
ddev drush crm:rebuild-relationship-closure
ddev drush crm:rebuild-relationship-closure --type=parent
Cycle prevention
The RelationshipNoCycle validation constraint queries the closure index to
refuse edges that would form a cycle (e.g. attempting to make Carol a child of
Alice when Alice is already Carol's descendant in the parent hierarchy). On
updates, paths that include the relationship being edited are excluded so
reversing or reassigning a direct edge is not mistaken for a cycle. Alternate
paths through other relationships still block the save.
Views
hook_views_data() registers crm_relationship_closure as a base table with
relationships to descendant and ancestor contacts, a transitive-type filter,
a numeric depth filter, and an optional shortest-path filter. Multiple paths
between the same contacts remain separate rows unless that filter is enabled.
Access requires view permission on both endpoints.
The path-step table is not exposed. For setup examples and the async-index staleness caveat, see Views integration.