fix(cost): close RequestCost instrumentation gaps and re-base the price table - #36978
Conversation
…ce table (#36977) Audited @RequestCost placement against Glowroot production profiles from six tenant instances. The instrumentation was concentrated on the Contentlet API while the traffic was elsewhere: a GraphQL-only customer could saturate a node and accrue almost nothing. Metering gaps closed: - findContentlets(List) - the bulk loader behind every search result (GraphQL, /api/content, /api/es/search, page render). Bypassed find(), so it was entirely uncosted. Now charged per contentlet, cache hit vs DB miss. - Background work no longer discarded. incrementCost returned before the totals when no request was on the thread, so reindexing, scheduled publishing, remote publishing and content indexing were priced correctly and then thrown away. Reported as windowJobTokens / lifetimeJobTokens. - /api/es/search bypassed ES_QUERY via enterprise ESSearchAPIImpl. - /api/vtl/* bypassed VELOCITY_MERGE by calling the engine directly. - #dotParse / #parseContainer were free, so a 30-container page priced as one. - Prerender used its own HttpClient, so HTTP_FETCH never fired. - CONTENT_INDEX, NAV_BUILD and GRAPHQL_QUERY added; CHECKIN/CHECKOUT/COPY/MOVE had prices in the enum that nothing charged. Price table re-based on resource-time, then the DB tier compressed: what is metered is capacity consumed on this node, not wall time. Cache-hit vs miss is priced because customers control caching; statements-per-miss is not, because they cannot. IMAGE_FILTER_TRANSFORM and FILE_METADATA_GENERATE were priced below a cache read and are now at the top of the scale. Reporting keeps its historical range via REQUEST_COST_DENOMINATOR 1 -> 10, and token totals stay integral so a collector parsing them as ints still works. Requires two new ingestor columns before background cost is billed.
|
Claude finished @wezell's task in 4m 28s —— View job Code Review
I reviewed the diff (
New Issues
Non-blocking
Nice touches worth keeping: the · branch |
…36977) Velocity rendering was not metered anywhere on the page path. All three mode handlers call Template.merge() directly rather than an annotated VelocityUtil method, so neither the page nor its nested directives charged a merge - the annotated VelocityUtil.merge/mergeTemplate/eval only ever fired for AI prompt evaluation and SAML relay state. Charged at the merge itself rather than on serve(): - LiveMode: on writePage(), which the two page-cache hit paths return before reaching - so a cached page correctly pays no merge. - Edit/Preview: immediately before the merge call. serve() also does permission checks and context building, and would have billed a merge that never ran. Nested #dotParse / #parseContainer continue to charge separately in DotDirective.render, past its own cache short-circuit.
…ntlets (#36977) The cache lookup already visits every inode and already knows which ones missed, but the misses were re-derived afterwards with CollectionUtils.subtract(inodes, conMap.keySet()) - which builds a HashBag of the hit keys, walks the inodes against it, and returns a list that was then copied into a second ArrayList. Two passes and three allocations to recompute something the first loop knew. Also fixes a latent duplicate-handling wart: subtract has multiset semantics, so a repeated inode that WAS in cache would fall out of the bag on its second occurrence and be queried from the database anyway. Filtering on the cache result cannot do that. Behaviour is otherwise identical for the normal case where an inode maps to a contentlet with the same inode.
Reverts the push URL default to null. isEnabled() gates on url AND token, so a default URL could not activate the publisher on its own - but it meant anyone who set only REQUEST_COST_PUSH_TOKEN would silently start posting usage data to a dotcms.dev endpoint they never named. The collector has to be opted into explicitly. The collection interval default stays at 300s (up from 60s): a five-minute window is enough resolution for billing and cuts the push volume fivefold.
…ocument stacking (#36977) Three findings from the automated review, all valid. 1. The four direct incrementCost(..) calls ran unguarded on page-render and content-load paths. The annotation path is protected - RequestCostAdvice.enter is @Advice.OnMethodEnter(suppress = Throwable.class) - so a failure in the cost API can never break the metered method there. The direct calls had no such protection: a transient locator or init failure would have propagated out of DotDirective.render and ESContentFactoryImpl.findContentlets and broken the page or the search result. Metering must never take down serving. Routed all four through RequestCostHandler, which already wraps exactly this for the advice and CDI paths, rather than scattering try/catch. 2. The x-dotrequest-cost header rounded to "0.00" for any request costing less than half the denominator - at the default of 10, every request reading a single warm contentlet. A request that did real work reported as free. Now floored at 1 whenever the raw cost is non-zero; only a genuinely zero-cost request reports 0.00. Window and lifetime totals are unaffected, they sum raw units and divide once. 3. CONTENT_MOVE and CONTENT_COPY are not terminals and stack with CHECKIN and INDEX. That is deliberate - a copy really does check in each version and a move really does reindex - and is not the double-charge trap, which is about one operation counted twice rather than two operations each counted once. Documented on both so a future reader does not "fix" it.
sfreudenthaler
left a comment
There was a problem hiding this comment.
@wezell i’m good with the changes. I’d like to see the comments ripped out or much less verbose in most cases, but not blocking a merge on it
Co-authored-by: Steve Freudenthaler <31257998+sfreudenthaler@users.noreply.github.com>
Deleting unneeded analysis
Deleting unneeded doc
) The report template renders the total as requestCost / REQUEST_COST_DENOMINATOR, whose default moved from 1.0 to 10.0 when the price table was re-based. Compute the expected value the same way the report does so the test holds under any configured denominator. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VTt9ymEwfmHV9DSKe1vPuJ
…otal (#36977) RequestCostApiImpl only reads REQUEST_COST_DENOMINATOR in @PostConstruct, so the test's hand-constructed instance keeps the 1.0 field default while the report divides by the CDI singleton's 10.0 — the expected and rendered totals diverged. Compute the expectation with the same instance the report uses. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VTt9ymEwfmHV9DSKe1vPuJ
…lets (#36977) The single-pass rewrite of the cache-miss collection marked any non-null cache entry as found, but the cache stores the CACHE_404_CONTENTLET sentinel under the requested inode after a failed single-item lookup. A sentinel hit skipped the DB fetch and the final keyed lookup then dropped the inode from the result entirely (ESContentFactoryImplTest.findContentlets: expected 991, got 988). Count a hit only when the cached inode equals the requested one, matching the old CollectionUtils.subtract semantics. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VTt9ymEwfmHV9DSKe1vPuJ
Fixes #36977
Why
@RequestCostscores request heaviness so usage can be metered and rate-limited. Auditing where the annotation actually sits against Glowroot main-thread profiles from six production instances found the instrumentation concentrated on the Contentlet API while the traffic was somewhere else — and one whole class of cost being discarded.addContentToIndex— ~10% of all samples, blockingNavTool.getNav11%CircuitBreakerUrloutbound HTTP ~50%A GraphQL-only customer could saturate a node and accrue almost nothing.
What changed
Metering gaps closed
ESContentFactoryImpl.findContentlets(List)/api/content,/api/es/search, page render. It bypassesfind(), so nothing metered it. Now per contentlet: base fee, plus a surcharge on rows read from the DBincrementCostbackground pathenterprise.priv.ESSearchAPIImpl.esSearchRaw/api/es/searchbypassed the existingES_QUERYcharge entirelyVTLResource.evalVelocity/api/vtl/*called the engine directly, bypassing the annotated mergeDotDirective.render#dotParse/#parseContainernor the top-level page merge, since all three mode handlers callTemplate.merge()directly rather than an annotatedVelocityUtilmethod. (What each container fetched was metered, viafind()andcachedIndexSearch; the work of assembling the page was not.) Charged past thegetFromCache()short-circuit, so cacheable containers stay cheapVelocityLiveMode/EditMode/PreviewModeserve()— LiveMode onwritePage(), which the two page-cache hit paths return before reaching, so a cached page pays no mergePreRenderSEOWebAPIImplHttpClient, soCircuitBreakerUrl's charge never firedContentletIndexAPIImpl,NavTool,DotGraphQLHttpServletCONTENT_INDEX,NAV_BUILD,GRAPHQL_QUERYESContentletAPIImplterminalsCONTENT_CHECKIN/CHECKOUT/COPY/MOVEexisted in the enum with nothing charging themPricing model. Meter customer-visible operations, with the line drawn at what the customer can influence:
Content is
CONTENT_FROM_CACHE(1) per contentlet asked for plusCONTENT_FROM_DB(10) on the ones read from Postgres — 1 warm, 11 cold, an 11× incentive to cache. The surcharge is per missed row, not per SQL statement; the 200-row batching is our detail. There is deliberately no genericDB_QUERY.Price table re-based on resource-time, then the DB tier compressed — what is metered is capacity consumed on this node, not wall time. A query parks the thread and burns cycles on Postgres. At
DB_QUERY25 the table said DB was 50% of a page render; the profile said template rendering dominated. At 10 it reads merges 59% / DB 31% / ES 10%, which matches.IMAGE_FILTER_TRANSFORMwas 2 andFILE_METADATA_GENERATEwas 3 — decoding an image or running Tika burns a core and a large buffer. Both now 50.Reporting.
REQUEST_COST_DENOMINATOR1 → 10 keeps reported tokens in their historical range. Header and snapshot totals are rounded — both have only ever carried whole numbers, and a collector parsing them as ints would break on a fraction. Per-request averages stay fractional, as always. Rate-limit defaults scaled with the table (still off by default).Collector change required
RequestCostSnapshotgainswindowJobTokensandlifetimeJobTokens(doubles), reported separately —windowTokensstays request-only, total consumption is the sum. Unknown fields are dropped silently by the ingestor, so this is safe to deploy first, but background cost is not billed until those two columns exist.Expect a step change in reported tokens at deploy: prices re-based, denominator moved. Roughly range-preserving for requests, but per-endpoint series shift by different amounts.
Testing
RequestCostSnapshotTestandRequestCostPublisherTestupdated for the two new fields, including the "exactly N fields on the wire" guard.Verified with
javacagainst the built classpath — error count identical to the baseline. The full Maven build fails indotcms-core-web's nx step, pre-existing and unrelated.Not validated against a running instance. Prices are order-of-magnitude estimates from sampled time-share; placements were verified by reading call chains, not by watching a request. The calibration loop — run a week, compare per-endpoint mean response time against mean cost, investigate outlier ratios — is what turns these into measured numbers.
Known gaps, deliberately out of scope
HTTP_FETCHfor its own push (~10 tokens/window).PermissionBitAPIImpl.getPermissionsByRolegoes throughnet.sf.hibernate.loader.Loader, neverDotConnect.Reviewer notes
Three traps worth knowing if you touch this:
ES_QUERY,ES_COUNT,WORKFLOW_ACTION_RUNandIMAGE_FILTER_TRANSFORMare charged with directincrementCost(...)calls, invisible to a@RequestCostgrep. Grep both forms.checkinhas 12 overloads chaining into each other; annotating two in one chain double-charges. Every placement here targets a verified terminal.@RequestCoston an interface default does nothing for an override — this had already leftCONTENT_FROM_DBdead on the common single-find path.Docs ship with the change:
docs/requestcost-session-summary.md(narrative) anddocs/requestcost-placement-analysis.md(evidence and backlog). Tenant identities are anonymised.Defects found during the audit, filed separately: #36970, #36971.
🤖 Generated with Claude Code
This PR fixes: #36977