Skip to content

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:

  1. closureChoice stamped on the entity by the relationship form (sync, queue, or batch).
  2. Otherwise the global config:
  3. auto: synchronous when below sync_threshold, otherwise queued.
  4. sync: always synchronous.
  5. queue: always queued.
  6. batch: queued for non-interactive saves; the form is responsible for batch_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.