TCOSMM-37: Key trigger data cache by trigger class - #9
Conversation
Core PR: https://lab.civicrm.org/extensions/civirules/-/merge_requests/376 post() cached one trigger's data and handed it to every later trigger for the same event, keyed by nothing. triggerTrigger() only builds its own data when none is set, so an injected object suppresses getTriggerDataFromPost() in subclasses, which is the only place they attach their extra entities. CaseActivity therefore loses Case, and rules with case_type or case_status conditions evaluate against an empty array and silently never run. Cache per class instead, preserving the speedup MR !284 targeted for multiple rules sharing a trigger.
There was a problem hiding this comment.
Code Review
This pull request refactors the trigger data caching mechanism in CRM_Civirules_Trigger_Post to cache data per trigger class and introduces a stack ($triggerDataCacheStack) to handle nested post events safely. A potential runtime issue was identified in the finally block, where array_pop() could return null if the stack is empty, leading to a TypeError when assigned to the strictly typed array property self::$triggerDataCache. It is recommended to use the null coalescing operator to default to an empty array.
Core PR: https://lab.civicrm.org/extensions/civirules/-/merge_requests/376 A rule action can modify an entity, which fires another post hook and re-enters post(). The nested event cleared the static cache and repopulated it with its own trigger data, so the event still running underneath resumed with either an empty cache or, worse, data belonging to a different record. Park the caller's cache on a stack and restore it in a finally block. Observed on a real case rule chain: nesting reaches depth 2 and the outer event now resumes with its cache intact.
1230bc3 to
811e4f1
Compare
|
Upstream MR raised: https://lab.civicrm.org/extensions/civirules/-/merge_requests/376
Companion PR for the 3.17.1 line: #8. |
Overview
CiviRules silently stops running case rules. When a rule creates an activity on a case, the rules that should react to that activity never run: nothing is created, nothing is sent, and nothing appears in the rule log. From the outside it looks as though those rules were never configured at all.
Any site with case rules alongside at least one plain activity rule is affected.
Where it was introduced
4b571b2d"cache trigger data", Jon Goldberg, 2025-02-28masterThat commit added 9 lines and nothing else. Its stated premise is the defect:
It does change, whenever the rules use different trigger classes, because each subclass attaches different entities. The MR's scope was "multiple rules with the same trigger", but the cache was keyed by nothing at all, so it also leaked across unrelated classes. This PR narrows it to that stated intent.
Upgrading does not fix this. The faulty cache is byte-identical in 3.32, 3.41.0 and
master. 3.18+ also needs CiviCRM 5.82+, so it is not an option on the 4.x line (CiviCRM 5.75) anyway.Before
Two probe rules, both enabled, both triggered by the same event. Neither has any actions, so the only thing measured is whether each rule runs at all.
case_typeconditionBlue marks the rule under test. Red marks the Last Triggered column: populated for ID 2, empty for ID 3, even though one event fired both.
After
Same two rules, same event, on a build carrying this fix.
Last Triggeredis now populated for both.Both screenshots come from validating the identical change on the 3.17.1 line (see the companion PR below). The patched
post()is byte-identical on both branches, so they demonstrate this change too, but they were not captured on a 3.32 build. See Testing.Technical Details
What goes wrong
post()gathers every rule whose trigger matchesActivity+create. That set mixes plainActivityrules andCaseActivityrules.post()stores that object in one static slot.triggerTrigger()only builds its own data when none is set. A handed-down object therefore skipsgetTriggerDataFromPost()— the only placeCaseActivityattaches theCaseentity.getEntityData('Case')returns[], because there is no database fallback forCase.case_typeandcase_statuscompare against nothing and fail, so the rule does not run. A rule that fails its conditions writes no log row, which is why the failure is completely silent.The fix
post(). The cache is pushed onto a stack and restored in afinally, so a nested event cannot clear or overwrite the cache of the event still running underneath it. Raised in review; nesting was then observed reaching depth 2 on a real chain.A single slot plus a class name is not sufficient:
??=assigns once, so whichever class runs first claims the slot and every other class rebuilds, silently losing the optimisation. Hence a per-class map.What is and is not affected
Two conditions must both hold for a trigger class to be affected:
object_name+oppost()filters on these, so a class that is the sole handler for its object can never be handed another class's datahasTriggerData()guardCivirulesPostTrigger/Activity.phpandCivirules/Trigger/Post.phpconsult itNot affected, despite overriding
getTriggerDataFromPost():ContributionSoft,Participant,Event,ActionLog,Relationship— each is the only class for its object.Immune, because they override
triggerTrigger()and rebuild unconditionally:ContactRestored,ContactTrashed,MembershipRenewed,RelatedParticipantWhenActivityChanged,RelatedParticipantWhenActivityIsTagged.So in the standard trigger set the only affected class is
CaseActivity, onActivity/createandActivity/edit. It overridestriggerTrigger()but delegates toActivity::triggerTrigger(), which carries the guard.Note
findRulesByObjectNameAndOp()has noORDER BY, so which class runs first is not deterministic. Two sites with identical configuration can behave differently, and the same site can differ between runs. Both orderings were seen while testing.Risk to check when reviewing
$triggerDataCachechanges type from?CRM_Civirules_TriggerData_TriggerDatatoarray. It ispublic static, so this is technically an API change, though it is an implementation detail added in 3.17.0. A search across ~90 CiviCRM extensions, custom modules and core found no external consumers, but downstream custom code cannot be ruled out.Testing
The runtime validation for this change was done on the 3.17.1 line, where the environments available run CiviCRM 5.75. Summarised there:
Caseempty on everyCaseActivitytrigger before the fix, populated after, downstream rules firing.spl_object_id.Activity/createandActivity/edit, both class orderings.What has and has not been verified on this branch specifically:
post()byte-identical to the validated 3.17.1 branchhasTriggerData()guard sites, sameActivity::triggerTrigger()else-branch,CaseActivitystill attachesCasephp -lComments
master. Referenced asCore PR:in the commits. Landing it upstream is what stops a future version bump re-introducing this.post()is byte-identical on both branches; they should land together.info.xmlchange and no tag here: version metadata is left to whoever cuts the release.