[medium] perf: batch per-attribute sightings lookup in DecayingModelBase::computeCurrentScore - #11075
Open
elhoim wants to merge 1 commit into
Open
[medium] perf: batch per-attribute sightings lookup in DecayingModelBase::computeCurrentScore#11075elhoim wants to merge 1 commit into
elhoim wants to merge 1 commit into
Conversation
DecayingModelBase::computeCurrentScore (app/Model/DecayingModelsFormulas/
Base.php:133) declares a $last_sighting_timestamp parameter that no caller
ever passes: DecayingModel::getScore (DecayingModel.php:723) calls it with
three arguments, so the guard at Base.php:139 always fires and
Sighting::getLastSightingForAttribute issues a find('first') with
ORDER BY Sighting.date_sighting DESC (Sighting.php:1064) once per attribute
per associated decaying model. That block sits inside the per-attribute
loops of MispAttribute::fetchAttributes (MispAttribute.php:2401-2411, batch
limit 50000 at :2293) and Event::fetchEvent (Event.php:3435), so a decay-
scored export costs up to 50000 * M round trips per batch page.
Mechanism: a new Sighting::getLastSightingsForAttributes($user, $ids) runs
one grouped query per batch -- SELECT attribute_id, MAX(date_sighting)
... WHERE attribute_id IN (batch) AND type = 0 [AND org_id IN (...)]
GROUP BY attribute_id -- reproducing the EVENT_OWNER/HOST_ORG org_id
conditions of getLastSightingForAttribute exactly. The resulting map is
threaded down through the already-declared parameter:
attachScoresToAttribute and getScore gained a trailing optional
$last_sighting_timestamp. Query count for the decay block drops from
50000 * M to 1 per batch page. ClassRegistry::init('DecayingModel') is
hoisted out of the per-attribute loop (a registry hit, not part of the
speedup claim).
Speedup: MEASURED 14.73x on a standalone harness that loads the real
pristine and patched Base.php + Polynomial.php under two namespaces with a
frozen time(), against a seeded 23268-sighting / 5000-attribute fixture
(6.1401 s -> 0.4168 s; an independent rerun measured 18.93x). The "before"
side is a re-executed PDO prepared statement rather than a full CakePHP
find('first') with query building, hydration and afterFind, so the ratio
understates the production one. DERIVED floor from the finding's Q+A
arithmetic: 5x on the named path (2.0x under the absurd assumption that
the pure-PHP score arithmetic costs as much as a full find()).
Why existing caching does not cover it: DecayingModel::$modelCacheForType
(:20), DecayingModelMapping::$modelCache (:28) and
DecayingModel::$__registered_model_classes (:22) all memoise model and
mapping metadata, never the sighting. computeCurrentScore has no memo of
any kind, getLastSightingForAttribute consults no Redis/Cache/file cache,
the sightings already attached by includeSightings are ignored because the
lookup re-queries by attribute_id, and the sightings table carries only
KEY attribute_id (INSTALL/MYSQL.sql:1413), so the ORDER BY ... LIMIT 1 is
a sort over the matched rows.
Behaviour preservation: $last_sighting_timestamp === false still means
"not prefetched, query it", so every pre-existing caller takes the
original path bit-identically. The only new value is null, produced solely
by the two batching call sites, meaning "known: this attribute has no
visible sighting" -- it routes into the same last_seen/timestamp fallback
the empty-result branch used before. null rather than 0 is deliberate: a
legitimate sighting with date_sighting = 0 is non-empty in the original
and must not fall through to last_seen. SIGHTING_POLICY_SIGHTING_REPORTER
returns null from the batch helper and every call site then passes false,
leaving that policy on the untouched per-attribute path. date_sighting is
bigint(20) NOT NULL, so MAX() is equivalent to ORDER BY ... DESC LIMIT 1
over the same matched rows. Field selection, return shapes and ACL
conditions are unchanged. Correctness assertion over all 5000 fixture
attributes (batched map vs per-attribute query vs independently computed
expected maximum, plus strict !== on the full computeCurrentScore return
array): 0 mismatches, identical score sums.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpjGT8zb2wRNqCjQwFugLC
elhoim
marked this pull request as ready for review
September 3, 2026 08:20
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
BLUF — Decay scoring issues one sightings query per attribute; this PR fetches the timestamps per batch.
MispAttribute::fetchAttributesandEvent::fetchEventissue one sightingsSELECTper attribute per associated decaying model just to find that attribute's most recent sighting — one query per attribute across a 50000-row batch page.Sightingso last-sighting timestamps are fetched once per batch page or per event, and threads them into the$last_sighting_timestampparameter thatDecayingModelBase::computeCurrentScorealready declares but no caller ever passed.includeDecayScoreinrestSearch, event exports or CSV export see the sightings query count collapse from per-attribute to per-page, with identical scores.What
When a decay score is requested, the per-attribute pipelines of
MispAttribute::fetchAttributesandEvent::fetchEventissue onesightingsSELECTper attribute per associated decaying model, purely to find that attribute's most recent sighting. This PR replaces those with one grouped query per batch page (or per event) and threads the result down through the$last_sighting_timestampparameter thatDecayingModelBase::computeCurrentScorealready declares but that no caller has ever passed.Why it is hot
Tier: T1-conditional, and I want to state the gate plainly rather than sell it. The path only runs when
includeDecayScoreis set and at least one enabled decaying model is associated with the attribute's type. Both halves are opt-in:includeDecayScoredefaults to0(app/Controller/EventsController.php:49) anddecaying_models.enableddefaults to0(INSTALL/MYSQL.sql:364). With no associated models,$modelsis empty (app/Model/DecayingModel.php:628-639) and the loop at:640never runs, so no query is issued at all.It is nevertheless a caller-requested per-request export option in the same class as
includeCorrelations, reachable fromrestSearch/index(EventsController.php:1362-1364,:1822-1823), from the CSV export viaenable_decaying(app/Lib/Export/CsvExport.php:41), fromEvent::fetchEvent(app/Model/Event.php:3431) and fromDecayingModelController.php:811. When it is requested, the block executes once per attribute across a 50000-row batch page (app/Model/MispAttribute.php:2293sets$params['limit'] = 50000; the per-attributeforeachopens at:2348and the decay block sits at:2401-2411), and once per attribute per event inEvent::fetchEvent(Event.php:3435).Mechanism
DecayingModelBase::computeCurrentScoreis declared ascomputeCurrentScore($user, $model, $attribute, $base_score = false, $last_sighting_timestamp = false)(app/Model/DecayingModelsFormulas/Base.php:133), but its only production caller,DecayingModel::getScore, calls$this->Computation->computeCurrentScore($user, $model, $attribute)with three arguments (app/Model/DecayingModel.php:723). The fifth parameter is therefore alwaysfalse, the guard atBase.php:139always fires, andBase.php:140callsSighting::getLastSightingForAttribute($user, $attribute['id'])— afind('first')with'order' => ['Sighting.date_sighting DESC'](app/Model/Sighting.php:1064-1068). One MySQL round trip, per attribute, per model (DecayingModel.php:640loops the models,:643callsgetScore).Everything else the function does is pure PHP:
computeBasescore(Base.php:102-129) walks the attribute'sEventTag/AttributeTagarrays through__getPrioritisedTag(:67-100) and__getRatioScore(:56-63), andPolynomial::computeScore(app/Model/DecayingModelsFormulas/Polynomial.php:11-19) is a singlepow(). So the round trip is the whole cost of the block.The change:
Sighting::getLastSightingsForAttributes($user, $attributeIds)runs oneSELECT attribute_id, MAX(date_sighting) AS last_sighting ... WHERE attribute_id IN (batch) AND type = 0 [AND org_id IN (...)] GROUP BY attribute_id, reproducing the org_id conditions ofgetLastSightingForAttribute(Sighting.php:1055-1058) exactly forSIGHTING_POLICY_EVENT_OWNER/HOST_ORG, and no org filter forSIGHTING_POLICY_EVERYONE— same as the original in both cases.MispAttribute::fetchAttributesbuilds the map once per 50000-row batch page andEvent::fetchEventonce per event.attachScoresToAttributeandDecayingModel::getScoregained a trailing optional$last_sighting_timestamp = false, forwarded ascomputeCurrentScore($user, $model, $attribute, false, $last_sighting_timestamp).ClassRegistry::init('DecayingModel')is hoisted out of the per-attribute loop (MispAttribute.php:2402). It is a registry hit, not a re-instantiation, so this is cleanliness only and is not part of the speedup claim.Query count for the decay block goes from
50000 * Mper batch page to1.Why existing caching does not already cover it
I checked the caching that exists on this path; none of it touches the sighting.
DecayingModel::$modelCacheForType(DecayingModel.php:20, populated:635/:639) caches the model definitions per attribute type, not the sightings.DecayingModelMapping::$modelCache(DecayingModelMapping.php:28, checked:80-82, stored:117) memoisesgetAssociatedModelsperattribute_type— so the mapping lookup atDecayingModel.php:628is genuinely not an N+1, but it says nothing about sightings.DecayingModel::$__registered_model_classes(:22,:461-468) memoises the formula class instance.ClassRegistry::init('DecayingModel')atMispAttribute.php:2402is a registry hit returning the existing instance (app/Lib/cakephp/lib/Cake/Utility/ClassRegistry.php:305-315, returned at:137-141), so the memos above do survive the loop — the per-attributeinitwas a no-op cost, not the defect.computeCurrentScoreitself has no memo of any kind betweenBase.php:133and:165.includeSightingsmay already have attached (MispAttribute.php:2371→$attr['Attribute']['Sighting']) are ignored:getLastSightingForAttributere-queries byattribute_id.Cache/file cache is consulted anywhere inBase.phpor ingetLastSightingForAttribute.sightingscarries onlyKEY attribute_id(INSTALL/MYSQL.sql:1413), no composite(attribute_id, type, date_sighting), so theORDER BY ... DESC LIMIT 1is a sort over the matched rows, not an index-order early stop.Speedup
MEASURED: 14.73x on the named path (an independent rerun of the same harness measured 18.93x; I report the lower figure).
Benchmark method —
bench.phploads the realapp/Model/DecayingModelsFormulas/Base.phptwice, the pristine 2.5 copy and the patched copy, plus the realPolynomial.php, each under its own PHP namespace (Bench\Orig,Bench\Patched) via class renaming, so the timed code is the actual production formula rather than a paraphrase. (The three source copies were byte-diffed against the pristine and worktree originals to confirm this.) Each namespace defines a localtime()returning a frozen timestamp and aDateTimesubclass so the two paths are byte-comparable rather than racing the clock, plus a localClassRegistryhanding back a PDO-backedSightingstub that mirrorsapp/Model/Sighting.phpexactly:getLastSightingForAttributeissues the per-attribute... WHERE attribute_id = ? AND type = 0 AND org_id IN (?,?) ORDER BY date_sighting DESC LIMIT 1;getLastSightingsForAttributesissues the batched groupedMAX.Fixture: a
bench_sightingstable with theINSTALL/MYSQL.sql:1405-1417column set and onlyKEY attribute_id(no composite, matching production), seeded with 23268 sightings across 5000 attributes — ~30% of attributes deliberately have no visible sighting, sightings spread over 4org_ids and bothtype0/1 so the org and type filters actually discriminate. Attributes carry randomisedAttributeTag/EventTagarrays and alast_seenthat is NULL 40% of the time, so all three branches of the fallback are exercised. Timing is the mean of 3 reps of the full 5000-attribute loop each way after a 200-attribute warmup, serialized behind a lock.Raw output:
Correctness assertion: 5000 inputs, 0 mismatches, over three independent comparisons — batched map vs per-attribute query result; both vs an independently computed expected maximum; and the full
computeCurrentScorereturn array (score+base_score) orig vs patched compared with strict!==. Score sums identical.Honest caveats on the number:
find('first')with query building, hydration and_filterResults/afterFind— so the ratio understates the production ratio rather than inflating it.include*options are enabled, since those add untouched per-attribute cost.DERIVED: >= 5x floor, independently of the measurement. Per attribute the block costs
Q + A, whereQ= one CakePHP round trip (Sighting.php:1064, timesM >= 1associated models) andA= the pure-PHP score computation. After the change,QbecomesQ/50000per batch page. Even under the absurd assumption thatAcosts as much as a fullfind('first')round trip: before =Q + A= 2 units, after =A + Q/50000~= 1 unit, i.e. 2.0x as an absolute floor. A single indexed MySQL round trip through CakePHP is realistically one to two orders of magnitude more expensive than onepow()plus a few array walks over an attribute's handful of tags, which puts the true figure nearM * 50x; 5x is the defensible floor with margin.Risk
Medium-low. The semantics hold only if the batched query reproduces the sightings-policy conditions of
Sighting.php:1055-1062, and it does:SIGHTING_POLICY_SIGHTING_REPORTERtakes a different path (listSightings) and returns a scalar rather than a row, so it must not be batched.getLastSightingsForAttributesreturnsnullunder that policy, both call sites then passfalse, andcomputeCurrentScoretakes the untouched original per-attribute branch. That policy is bit-for-bit unchanged — which also means it preserves a pre-existing bug:Sighting.php:1059-1062returns a scalar from a method declared: arraywhichBase.phpthen array-indexes, andlistSightingsthrowsMethodNotAllowedExceptionwhen the attribute has no sightings. I did not fix that; it is orthogonal and would be a behaviour change smuggled into a perf patch.EVENT_OWNER/HOST_ORGdepends on$user['org_id']andMISP.host_org_id, both constant for the whole request, so it batches cleanly.Base.php:141. The patch restructurescomputeCurrentScoresofalse= "not prefetched, query it" (original behaviour) andnull= "known: no sighting", routing into the existinglast_seen/timestampfallback.nullrather than0is deliberate and strictly more correct than a0-sentinel would be: a legitimate sightings row withdate_sighting = 0is non-empty in the original and must not fall through tolast_seen.MAX()vsORDER BY ... DESC LIMIT 1.date_sightingisbigint(20) NOT NULL(INSTALL/MYSQL.sql:1407), soMAX()-skips-NULL cannot diverge.Raised in review, all accepted as known and non-blocking:
includeDecayScoreset and zero enabled models associated with the batch's attribute types, the original issued zero sighting queries; the patch issues one grouped query per batch page / per event regardless. Perf micro-cost only, no output change, and a strict win whenever any model is associated.INlist. Up to 50000 ids in a singleIN (...)(~400 KB of SQL). Safe under the defaultmax_allowed_packet(16 MB), but there is no chunking guard.REQUIRES_SIGHTINGSformulas are untouched.Sightings.php→Base.php:158→Sighting::listSightings→ a nested per-attribute ACL-enforcedfetchAttributes(Sighting.php:960) is a larger win still, but it changeslistSightings' semantics and shape and is a separate, riskier change. The 14.73x does not depend on it.(attribute_id, type, date_sighting)onsightingsdeliberately not added. It needs adb_schema.json+Server.php/updateDatabasemigration with its own version bump and upgrade path — out of scope for a minimal code-path patch, and the batched query is one round trip either way.Behaviour-preservation argument in one line:
$last_sighting_timestamp === falsestill means "look it up", so every pre-existing caller — includingDecayingModel.php:591, which always passes an int, andDecayingModelController.php:811— is unaffected; the only new value the parameter can take isnull, produced solely by the two batching call sites. Field selection, return shapes, ordering and ACL conditions are untouched.Testing
php -lclean on all five changed files. The deprecation notices onDecayingModel.php:164andEvent.php:6351/:6682/:9977are pre-existing in the pristine tree, not introduced here.last_seenNULL / set,date_sighting = 0, a sighting older than the attribute timestamp, and theSIGHTING_POLICY_SIGHTING_REPORTERscalar return including the pathologicaltimestamp = 0case: original===patched-with-falseand original===patched-with-prefetch on every case.SightingnorAppModeldeclares a defaultpublic $order, so Cake will not inject anORDER BYon a non-grouped column into the new groupedfind— noONLY_FULL_GROUP_BYproblem. Neither model declaresbeforeFind/afterFindeither, so the partial grouped rows pass through no callback that could touch a missing field.'MAX(x) AS y'+'group'pattern has in-repo precedent (Log.php:373,MispAttribute.php:3015/:3020), and the aggregate is read with a$row[0]['last_sighting'] ?? $row['Sighting']['last_sighting']fallback for the row position.No live MISP instance was available, so this has had no end-to-end testing — no real export, restSearch or CSV run against a populated instance. All verification above is static analysis plus a standalone harness running the real formula code against a seeded MariaDB fixture. Review of the actual query against a production-shaped
sightingstable would be welcome.🤖 Generated with Claude Code