diff --git a/chain_capabilities/evm/go.mod b/chain_capabilities/evm/go.mod index f53a39bb9..f3668b48d 100644 --- a/chain_capabilities/evm/go.mod +++ b/chain_capabilities/evm/go.mod @@ -5,14 +5,16 @@ go 1.26.2 require ( github.com/ethereum/go-ethereum v1.17.0 github.com/google/go-cmp v0.7.0 + github.com/jonboulle/clockwork v0.5.0 github.com/smartcontractkit/capabilities/chain_capabilities/common v0.0.0-20260615195421-fb87220e503f - github.com/smartcontractkit/capabilities/libs v0.0.0-20260604174211-7f26071a47e0 + github.com/smartcontractkit/capabilities/libs v0.0.0-20260609124022-2749e4a32bfb github.com/smartcontractkit/chain-selectors v1.0.106 - github.com/smartcontractkit/chainlink-common v0.11.2-0.20260714130758-475cb096a3e1 + github.com/smartcontractkit/chainlink-common v0.11.2-0.20260810181408-53d56f533c59 github.com/smartcontractkit/chainlink-evm v0.3.4-0.20260410162948-2dca02f24e98 github.com/smartcontractkit/chainlink-evm/gethwrappers v0.0.0-20251022073203-7d8ae8cf67c1 github.com/smartcontractkit/chainlink-framework/multinode v0.0.0-20260410144512-ca02ad6ed16a - github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260707195416-ca350beacd4b + github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260804191526-b7a850ae7648 + github.com/smartcontractkit/chainlink-protos/metering/go v0.0.0-20260729184203-90b4cdd48536 github.com/stretchr/testify v1.11.1 go.opentelemetry.io/otel v1.43.0 go.uber.org/zap v1.27.1 @@ -69,7 +71,6 @@ require ( github.com/jackc/pgx/v5 v5.9.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackpal/go-nat-pmp v1.0.2 // indirect - github.com/jonboulle/clockwork v0.5.0 // indirect github.com/klauspost/compress v1.18.2 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/kr/pretty v0.3.1 // indirect @@ -90,7 +91,7 @@ require ( github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/shirou/gopsutil v3.21.11+incompatible // indirect github.com/smartcontractkit/chainlink-common/keystore v1.1.1-0.20260529092756-a94bc8ce96d6 // indirect - github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.11-0.20260626151909-052e55e62e62 // indirect + github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.11-0.20260724142814-45996a1bcb72 // indirect github.com/smartcontractkit/chainlink-framework/chains v0.0.0-20260326122810-b657beadfb57 // indirect github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20260401162955-be2bc6b5264b // indirect github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b // indirect @@ -217,3 +218,5 @@ require ( ) replace github.com/fbsobreira/gotron-sdk => github.com/smartcontractkit/chainlink-tron/relayer/gotron-sdk v0.0.5-0.20250528121202-292529af39df + +replace github.com/smartcontractkit/capabilities/libs => ../../libs diff --git a/chain_capabilities/evm/go.sum b/chain_capabilities/evm/go.sum index 967b49587..b594de6e3 100644 --- a/chain_capabilities/evm/go.sum +++ b/chain_capabilities/evm/go.sum @@ -469,16 +469,14 @@ github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMB github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/smartcontractkit/capabilities/chain_capabilities/common v0.0.0-20260615195421-fb87220e503f h1:ovzaEXpe8k5Lx7MjQKrwxZZNV6uXGt9xPhtXr87k2Ow= github.com/smartcontractkit/capabilities/chain_capabilities/common v0.0.0-20260615195421-fb87220e503f/go.mod h1:gp/Xrw5nvPswONfr48WKWvAoTTg+Xv/tlk0SRrru8Qw= -github.com/smartcontractkit/capabilities/libs v0.0.0-20260604174211-7f26071a47e0 h1:pFicQMOnjHEc49Kf8hhD/nfqDLwFXu0NQx1rlm7ayVM= -github.com/smartcontractkit/capabilities/libs v0.0.0-20260604174211-7f26071a47e0/go.mod h1:LS7F8U2YZNc0Vt8f6SVWUUigGLxdxZMpyC7VCcUTagg= github.com/smartcontractkit/chain-selectors v1.0.106 h1:6Mz7FDooWJEcfvuOX3nH7oh5s44fqg/WqVDQS63Mjzs= github.com/smartcontractkit/chain-selectors v1.0.106/go.mod h1:qy7whtgG5g+7z0jt0nRyii9bLND9m15NZTzuQPkMZ5w= -github.com/smartcontractkit/chainlink-common v0.11.2-0.20260714130758-475cb096a3e1 h1:qxSP9Rlaq9hxyLsDUdGj21MdHLhiHF7HhEtwaHSvWcw= -github.com/smartcontractkit/chainlink-common v0.11.2-0.20260714130758-475cb096a3e1/go.mod h1:snfVBRRQTpC2x5O3bQHZe9SvJX5yv/SbG8oHkJTKLtE= +github.com/smartcontractkit/chainlink-common v0.11.2-0.20260810181408-53d56f533c59 h1:Kfn0SV1b3pA0MenAW87jmVQY931zoj0kvUn1g86oRn8= +github.com/smartcontractkit/chainlink-common v0.11.2-0.20260810181408-53d56f533c59/go.mod h1:P9kQKuadFvQJbZcracSvMzgnLt3IXda2FLl5NiPTbVM= github.com/smartcontractkit/chainlink-common/keystore v1.1.1-0.20260529092756-a94bc8ce96d6 h1:fWsYxxj35fp1/6YZngoTsOTMLqDie4N5X0osAOdhUTE= github.com/smartcontractkit/chainlink-common/keystore v1.1.1-0.20260529092756-a94bc8ce96d6/go.mod h1:6JexOOhPhknQ0QMuppFIlOpm6wCp54yZMxai+tWugwY= -github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.11-0.20260626151909-052e55e62e62 h1:o7vfwNQjQbMKQ9YsZFQOxvU7RMXD/wKnZsX5N9sDS3w= -github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.11-0.20260626151909-052e55e62e62/go.mod h1:HmUyH2oD9m+GRpKq7q3vuRnm1F2Uczf/Nd1v3ipMSK8= +github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.11-0.20260724142814-45996a1bcb72 h1:uWEwl7i2ryuRVoV4DmIKm6mqYevf1lH/8cQYhw/JXko= +github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.11-0.20260724142814-45996a1bcb72/go.mod h1:UYcRMb4dZcoaIPgZJ3hckCySTqtJc9K4Q+tOKErwTq0= github.com/smartcontractkit/chainlink-evm v0.3.4-0.20260410162948-2dca02f24e98 h1:h/L6wrXYLQalI/vHm6qg/KBv6d7kMb3geMHV5hCM1t4= github.com/smartcontractkit/chainlink-evm v0.3.4-0.20260410162948-2dca02f24e98/go.mod h1:6vCMfxz7cMW0wWseNKtct+b1JJbbRVJJhh/t6pQWN3M= github.com/smartcontractkit/chainlink-evm/gethwrappers v0.0.0-20251022073203-7d8ae8cf67c1 h1:NTODgwAil7BLoijS7y6KnEuNbQ9v60VUhIR9FcAzIhg= @@ -491,10 +489,12 @@ github.com/smartcontractkit/chainlink-framework/multinode v0.0.0-20260410144512- github.com/smartcontractkit/chainlink-framework/multinode v0.0.0-20260410144512-ca02ad6ed16a/go.mod h1:7ketk4ischPQW/JQgmyHz6zdzLUJv1VC29SiSgosydQ= github.com/smartcontractkit/chainlink-protos/billing/go v0.0.0-20251024234028-0988426d98f4 h1:GCzrxDWn3b7jFfEA+WiYRi8CKoegsayiDoJBCjYkneE= github.com/smartcontractkit/chainlink-protos/billing/go v0.0.0-20251024234028-0988426d98f4/go.mod h1:HHGeDUpAsPa0pmOx7wrByCitjQ0mbUxf0R9v+g67uCA= -github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260707195416-ca350beacd4b h1:NR5EdsI2wNUV9awkmZyVSWos3JAK+2uSgjkrWbSRWXo= -github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260707195416-ca350beacd4b/go.mod h1:/i8hjTPFdVWHiY+QjeSiVS2Z3GB3WAZznGgXHstC02E= +github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260804191526-b7a850ae7648 h1:WEUMkKQPAgcNMRgES6CBWrRUiII+HKEWQjulKQBSuMA= +github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260804191526-b7a850ae7648/go.mod h1:/i8hjTPFdVWHiY+QjeSiVS2Z3GB3WAZznGgXHstC02E= github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b h1:QuI6SmQFK/zyUlVWEf0GMkiUYBPY4lssn26nKSd/bOM= github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b/go.mod h1:qSTSwX3cBP3FKQwQacdjArqv0g6QnukjV4XuzO6UyoY= +github.com/smartcontractkit/chainlink-protos/metering/go v0.0.0-20260729184203-90b4cdd48536 h1:ecQYtdRA+NQLXf0aKYUMfcn1TRhcQ4RZCZzzadFsbSs= +github.com/smartcontractkit/chainlink-protos/metering/go v0.0.0-20260729184203-90b4cdd48536/go.mod h1:z7lx7wI3XZ4u9kmUtAVdwn1BCC9T8aieWSDcuDgPTdQ= github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260709145319-7782fb89eb16 h1:/vkKPJoweLkRd56V4YHGRAtTG4+/JAlgklGEfvH6l4c= github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260709145319-7782fb89eb16/go.mod h1:dkR2uYg9XYJuT1JASkPzWE51jjFkVb86P7a/yXe5/GM= github.com/smartcontractkit/chainlink-protos/workflows/go v0.0.0-20260528173149-f5b8336b19d9 h1:LQy2j2+TdKLSWsUTUYuqmQPn8kjqCLjGI3ZJYGtDc08= diff --git a/chain_capabilities/evm/main.go b/chain_capabilities/evm/main.go index 38faa09d5..b34462c12 100644 --- a/chain_capabilities/evm/main.go +++ b/chain_capabilities/evm/main.go @@ -33,6 +33,7 @@ import ( evmcapserver "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/evm/server" "github.com/smartcontractkit/chainlink-common/pkg/logger" "github.com/smartcontractkit/chainlink-common/pkg/loop" + "github.com/smartcontractkit/chainlink-common/pkg/resourcemanager" "github.com/smartcontractkit/chainlink-common/pkg/settings/limits" "github.com/smartcontractkit/chainlink-common/pkg/types" "github.com/smartcontractkit/chainlink-common/pkg/types/core" @@ -46,6 +47,10 @@ type capabilityGRPCService struct { capability lggr logger.Logger limitsFactory limits.Factory + // metering is the resolved metering Config (ResourceManagerConfig + + // DeploymentIdentity) produced by loop.Server.MeteringConfig at startup. + // The zero value is valid and leaves those dimensions empty/disabled. + metering resourcemanager.Config } type capability struct { @@ -62,7 +67,15 @@ var _ evmcapserver.ClientCapability = &capabilityGRPCService{} func main() { loopserver.ServeNew(CapabilityName, func(s *loop.Server) loop.StandardCapabilities { - return evmcapserver.NewClientServer(&capabilityGRPCService{lggr: s.Logger, limitsFactory: s.LimitsFactory}) + // Server.MeteringConfig is the single, canonical loop-env -> metering + // mapping (enable flags, snapshot interval, deployment identity); no + // per-main copy of that mapping, and no reaching for a process-global + // emitter (it injects the server's own durable emitter). + return evmcapserver.NewClientServer(&capabilityGRPCService{ + lggr: s.Logger, + limitsFactory: s.LimitsFactory, + metering: s.MeteringConfig(), + }) }, loop.WithOtelViews(append(consMetrics.MetricViews(), monitoring.MetricViews()...))) } @@ -127,10 +140,12 @@ func (c *capabilityGRPCService) Initialise(ctx context.Context, dependencies cor // - job-spec boot path: populated when unambiguous, otherwise 0 (e.g. a node // that belongs to multiple DONs running this capability, or a core node // that pre-dates CRE-4409). - // When it is 0 the trigger service falls back to the consumer workflow's DON - // ID (see trigger.NewLogTriggerService). We deliberately do NOT re-resolve it - // from the registry here: that lookup cannot disambiguate multi-DON nodes and - // would emit a guess instead of the safe workflow-DON fallback. See CRE-4409. + // When it is 0, event labels fall back to the consumer workflow's DON ID at + // their own call sites, while metering snapshots carry no DON dimension at + // all (the workflow DON is never substituted — see triggermeter.DonID). We + // deliberately do NOT re-resolve it from the registry here: that lookup + // cannot disambiguate multi-DON nodes and would emit a guess instead of the + // safe per-call-site degradation. See CRE-4409. capabilityDonID := dependencies.CapabilityDonID var scheduler ts.TransmissionScheduler @@ -158,11 +173,15 @@ func (c *capabilityGRPCService) Initialise(ctx context.Context, dependencies cor return fmt.Errorf("failed to init evm relayer for chainID %d from relayer: %w", cfg.ChainID, err) } - // TODO: add org resolver capabilityID := fmt.Sprintf("%s (%d)", c.id, cfg.ChainID) - c.triggerService, err = trigger.NewLogTriggerService(evmRelayer, trigger.NewLogTriggerStore(), c.lggr, capabilityID, capabilityDonID, processor, messageBuilder, + // The ResourceManager owns the snapshot tick; the LogTriggerService wraps it + // in a triggermeter (which owns its lifecycle, identity, and snapshot + // registration), so it must be configured with a snapshot interval here. + resourceManager := resourcemanager.NewResourceManager(c.lggr, c.metering.ResourceManagerConfig) + orgResolver := dependencies.OrgResolver + c.triggerService, err = trigger.NewLogTriggerService(evmRelayer, trigger.NewLogTriggerStore(), c.lggr, capabilityID, processor, messageBuilder, cfg.LogTriggerPollInterval, cfg.LogTriggerSendChannelBufferSize, cfg.LogTriggerLimitQueryLogSize, c.limitsFactory, - dependencies.OrgResolver, dependencies.TriggerEventStore) + orgResolver, dependencies.TriggerEventStore, resourceManager, c.metering.DeploymentIdentity, dependencies.CapabilityDonID, c.chainSelector) if err != nil { return fmt.Errorf("error when creating trigger: %w", err) } diff --git a/chain_capabilities/evm/trigger/physical_filter_id.go b/chain_capabilities/evm/trigger/physical_filter_id.go new file mode 100644 index 000000000..f86c19e15 --- /dev/null +++ b/chain_capabilities/evm/trigger/physical_filter_id.go @@ -0,0 +1,62 @@ +package trigger + +import ( + "crypto/sha256" + "encoding/hex" + "sort" + "strings" + + evmtypes "github.com/smartcontractkit/chainlink-common/pkg/types/chains/evm" +) + +// physicalFilterID returns the workflow-independent content identity of an EVM +// log filter: the lowercase hex SHA-256 over a canonical encoding of the +// filter's physical matching criteria (chain selector, addresses, event +// signatures, and positional topic slots). Two filters that match exactly the +// same on-chain logs hash to the same ID regardless of which workflow or +// trigger registered them, or of the order their addresses/sigs/topics were +// supplied. It is used as the snapshot resource_id and dedup key so identical +// filters share one billable physical resource (R4). +// +// Canonicalization rules (each rule defeats a source of non-determinism): +// - addresses and event sigs are lowercased 0x-prefixed hex and sorted +// ascending: the matching set is order-independent; +// - topic2/topic3/topic4 are POSITIONAL — a value in topic2 is a different +// filter than the same value in topic3 — so each slot is encoded under its +// own positional tag, and within a slot the values are sorted ascending; +// - the chain selector scopes the hash so identical filters on different +// chains stay distinct. +// +// The preimage uses "|" as a top-level separator and "," within a set; the +// per-element hex encodings are fixed-width and contain neither, so the +// encoding is unambiguous. +func physicalFilterID(chainSelector string, addresses []evmtypes.Address, eventSigs, topic2, topic3, topic4 []evmtypes.Hash) string { + sortedAddrs := make([]string, len(addresses)) + for i, a := range addresses { + sortedAddrs[i] = "0x" + hex.EncodeToString(a[:]) + } + sort.Strings(sortedAddrs) + + canonHashes := func(hs []evmtypes.Hash) string { + out := make([]string, len(hs)) + for i, h := range hs { + out[i] = "0x" + hex.EncodeToString(h[:]) + } + sort.Strings(out) + return strings.Join(out, ",") + } + + // Topic slots are encoded positionally so the same value in different slots + // produces a different identity. + preimage := strings.Join([]string{ + "cs=" + chainSelector, + "addrs=" + strings.Join(sortedAddrs, ","), + "sigs=" + canonHashes(eventSigs), + "t2=" + canonHashes(topic2), + "t3=" + canonHashes(topic3), + "t4=" + canonHashes(topic4), + }, "|") + + sum := sha256.Sum256([]byte(preimage)) + return hex.EncodeToString(sum[:]) +} diff --git a/chain_capabilities/evm/trigger/store.go b/chain_capabilities/evm/trigger/store.go index ee781ea63..7f060761d 100644 --- a/chain_capabilities/evm/trigger/store.go +++ b/chain_capabilities/evm/trigger/store.go @@ -12,7 +12,30 @@ import ( ) type filter struct { - filterID string + filterID string + // physicalFilterID is the workflow-independent content hash of the filter's + // physical matching criteria (chain selector + canonicalized addresses, + // event sigs, and positional topics). It is the metering ResourceID, so the + // snapshot path reuses it from here without the request input. Identical + // filters registered by different triggers share one physicalFilterID and + // are billed once: the snapshot path dedups on it (see snapshotRows). + physicalFilterID string + // reservedAddressCount is the number of filter addresses this filter bills: + // the physical filter's snapshot level carries this value. + // UnregisterLogTrigger ignores its request input, so the count is stashed + // here at registration. + reservedAddressCount int64 + // donID is the capability DON ID resolved at registration, stashed so the + // snapshot path reproduces the same identity without the original request. + // The consumer workflow's DON ID is never substituted for it; it is empty + // when the host did not inject a capability DON. + donID string + // workflowOwner is stored for attribution. + workflowOwner string + // orgID is the organization ID resolved from workflowOwner at registration + // time and stored alongside so that emit and snapshot paths can use it + // without a network call. + orgID string expressions []query.Expression confidence primitives.ConfidenceLevel } diff --git a/chain_capabilities/evm/trigger/trigger.go b/chain_capabilities/evm/trigger/trigger.go index 5f11cfcef..5cf89f244 100644 --- a/chain_capabilities/evm/trigger/trigger.go +++ b/chain_capabilities/evm/trigger/trigger.go @@ -7,13 +7,12 @@ import ( "math/big" "strconv" "strings" + "sync" "time" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/anypb" - capcommon "github.com/smartcontractkit/capabilities/chain_capabilities/common" - commoncfg "github.com/smartcontractkit/chainlink-common/pkg/config" "github.com/smartcontractkit/chainlink-common/pkg/beholder" @@ -23,6 +22,7 @@ import ( evmservice "github.com/smartcontractkit/chainlink-common/pkg/chains/evm" "github.com/smartcontractkit/chainlink-common/pkg/custmsg" "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink-common/pkg/resourcemanager" "github.com/smartcontractkit/chainlink-common/pkg/services" "github.com/smartcontractkit/chainlink-common/pkg/services/orgresolver" "github.com/smartcontractkit/chainlink-common/pkg/settings/cresettings" @@ -35,7 +35,9 @@ import ( "github.com/smartcontractkit/chainlink-common/pkg/workflows" "github.com/smartcontractkit/chainlink-common/pkg/workflows/events" + capcommon "github.com/smartcontractkit/capabilities/chain_capabilities/common" "github.com/smartcontractkit/capabilities/chain_capabilities/evm/monitoring" + "github.com/smartcontractkit/capabilities/libs/triggermeter" ) const ( @@ -44,6 +46,25 @@ const ( defaultLimitQueryLogSize = 1000 ) +// cleanupInterval is how often stale log-poller filters are swept AND the +// minimum age a filter must reach before it is eligible for that sweep. The +// min-age guard closes a race the cleanup previously assumed away: a filter can +// be live at the log poller for a brief window before its store entry is +// observable, and cleaning it in that window would bill-then-kill a live +// filter. +const cleanupInterval = 30 * time.Second + +// meteringConfig carries the EVM log trigger's metering identity constants +// (SHARED-2711): the stable service constant (it must not encode deployment +// environment or zone, which ride on the structured identity's coarse +// dimensions), the log-filter resource pool, and the billing unit carried on +// Utilization.resource_type. +var meteringConfig = triggermeter.Config{ + Service: "evm-log-trigger", + ResourcePool: "log_filters", + ResourceType: "log_filter_addresses", +} + type LogTriggerService struct { services.Service @@ -55,12 +76,14 @@ type LogTriggerService struct { lggr logger.Logger beholderProcessor beholder.ProtoProcessor messageBuilder *monitoring.MessageBuilder - - // capabilityDonID is the on-chain DON ID of this capability DON. - // Used to label emitted events with the sending DON ID, distinct from the - // consumer workflow's DON ID carried in RequestMetadata.WorkflowDonID. Zero - // means unknown; the labeler then falls back to WorkflowDonID. - capabilityDonID uint32 + // meter owns every metering concern: the ResourceManager lifecycle, the + // base identity (with the host-injected capability DON when available — + // the consumer workflow's DON ID is never substituted, see + // triggermeter.TriggerMeter.DonID), org resolution, and the snapshot + // registration over snapshotRows. Nil-receiver-safe; a meter with metering + // off is a no-op. + meter *triggermeter.TriggerMeter + chainSelector string // decimal chain selector, the chain label on meter snapshots triggers LogTriggerStore logTriggerPollInterval time.Duration @@ -72,17 +95,25 @@ type LogTriggerService struct { eventRateLimit limits.RateLimiter eventPayloadSizeLimiter limits.BoundLimiter[commoncfg.Size] orgResolver orgresolver.OrgResolver // Optional org resolver for fetching organization IDs + // filterRegisteredAt records, per log-poller filter name, the time it was + // registered at the log poller. cleanUpStaleFilters uses it to skip filters + // younger than one cleanup interval, closing the register-time window where + // a filter is live at the poller before its store entry is observable. + filterRegisteredAt sync.Map // filterID (string) -> time.Time } // NewLogTriggerService creates a new instance of logTriggerService. func NewLogTriggerService(evmService types.EVMService, store LogTriggerStore, lggr logger.Logger, capabilityID string, - capabilityDonID uint32, beholderProcessor beholder.ProtoProcessor, messageBuilder *monitoring.MessageBuilder, logTriggerPollInterval time.Duration, logTriggerSendChannelBufferSize uint64, logTriggerLimitQueryLogSize uint64, limitsFactory limits.Factory, orgResolver orgresolver.OrgResolver, - triggerEventStore capabilities.EventStore) (*LogTriggerService, error) { + triggerEventStore capabilities.EventStore, + resourceManager *resourcemanager.ResourceManager, + deployment resourcemanager.DeploymentIdentity, + capabilityDonID uint32, + chainSelector uint64) (*LogTriggerService, error) { if capabilityID == "" { return nil, fmt.Errorf("capabilityID must be non-empty") } @@ -112,7 +143,7 @@ func NewLogTriggerService(evmService types.EVMService, store LogTriggerStore, lg lggr: lggr, beholderProcessor: beholderProcessor, messageBuilder: messageBuilder, - capabilityDonID: capabilityDonID, + chainSelector: strconv.FormatUint(chainSelector, 10), triggers: store, logTriggerPollInterval: logTriggerPollInterval, logTriggerSendChannelBufferSize: currentSendChannelBufferSize, @@ -122,6 +153,10 @@ func NewLogTriggerService(evmService types.EVMService, store LogTriggerStore, lg if lts.orgResolver == nil { lts.lggr.Warn("OrgResolver is nil, EVM log trigger capability will not be able to fetch organization ID") } + // The meter owns the ResourceManager lifecycle, the base metering identity, + // org resolution, and the snapshot registration over snapshotRows. A nil + // resourceManager means metering is off (the meter no-ops). + lts.meter = triggermeter.New(lggr, resourceManager, deployment, capabilityDonID, meteringConfig, orgResolver, lts.snapshotRows) if err := lts.initLimiters(limitsFactory); err != nil { return nil, err } @@ -169,16 +204,26 @@ func (lts *LogTriggerService) start(ctx context.Context) error { if err != nil { return err } - duration := 30 * time.Second - ticker := services.NewTicker(duration) - lts.lggr.Infof("Starting clean up of failed log poller filters every %s seconds", duration) + ticker := services.NewTicker(cleanupInterval) + lts.lggr.Infof("Starting clean up of failed log poller filters every %s", cleanupInterval) lts.srvcEng.GoTick(ticker, lts.cleanUpStaleFilters) - return nil + + // Start the meter: it owns the ResourceManager lifecycle (the RM owns the + // snapshot tick) and registers snapshotRows for polling. Fail-open — a + // metering failure never gates the trigger service. + return lts.meter.Start(ctx) } +// close performs an orderly shutdown. There are NO process-lifecycle metering +// emissions: a graceful stop emits nothing, and billing releases each +// still-active filter by its absence from the next snapshot. The meter is +// closed FIRST (deregistering its snapshot Meterable so no tick can run +// against a half-torn-down service, then closing the ResourceManager iff it +// started it), then the base trigger is stopped. func (lts *LogTriggerService) close() error { + err := lts.meter.Close() lts.baseTrigger.Stop() - return nil + return err } func (lts *LogTriggerService) cleanUpStaleFilters(ctx context.Context) { @@ -211,11 +256,31 @@ func (lts *LogTriggerService) cleanUpStaleFilters(ctx context.Context) { lts.lggr.Debugf("Found %d filters to clean up that are not live", len(toCleanUp)) for filterID := range toCleanUp { + // Min-age guard: a filter that was registered at the log poller less + // than one cleanup interval ago may simply not have its store entry + // observable yet (the register path writes the store right after the + // RegisterLogTracking RPC returns). Skip it this round so we never + // bill-then-kill a filter that is actually live. Filters with no + // recorded registration time (e.g. orphaned from a previous process) + // are always eligible. + if registeredAt, ok := lts.filterRegisteredAt.Load(filterID); ok { + if time.Since(registeredAt.(time.Time)) < cleanupInterval { + lts.lggr.Debugf("Skipping filter %s: younger than one cleanup interval", filterID) + continue + } + } lts.lggr.Debugf("Cleaning up filter %s", filterID) if err := lts.EVMService.UnregisterLogTracking(ctx, filterID); err != nil { summary := fmt.Sprintf("failed to unregister log-tracking from the clean up thread: '%v' source triggerID: %s", err, filterID) monitoring.LogAndEmitError(ctx, lts.lggr, lts.beholderProcessor, lts.messageBuilder.BuildLogTriggerCleanUpError(telemetryContext, summary, err.Error())) + continue } + lts.filterRegisteredAt.Delete(filterID) + // This is log-poller filter hygiene only; it emits no MeterRecord. An + // orphaned filter has no trigger state, so it is already absent from + // GetUtilization and therefore from subsequent Snapshots. Billing + // reconciles the lost level by that absence (the snapshot liveness + // mechanism), not by a synthetic cleanup emission. } } @@ -295,6 +360,33 @@ func (lts *LogTriggerService) RegisterLogTrigger(ctx context.Context, triggerID Topic4: t4, } + expressions, confidence := lts.createLogRequest(ctx, addresses, sigs, t2, t3, t4, input.GetConfidence()) + + // Build the filter's metering identity once from the already-converted + // inputs: a workflow-independent content hash and the resolved DON ID. It is + // stashed on the trigger state so every later path (unregister, cleanup, + // snapshot) reproduces the same identity without the request input. The orgID + // is resolved at registration and stored so the snapshot path avoids network. + orgID := lts.meter.ResolveOrg(ctx, meta.WorkflowOwner) + // Stamp the capability DON once at registration. If it is not (yet) + // available the filter's snapshots are still billed — level integrity beats + // dimension completeness — but with the DON dimension absent rather than + // the consumer workflow's DON ID substituted. + capDonID, donErr := lts.meter.DonID() + if donErr != nil { + lts.lggr.Errorw("registering log filter without DON ID for metering", "err", donErr, "triggerID", triggerID) + } + loggedFilter := filter{ + filterID: filterID, + physicalFilterID: physicalFilterID(lts.chainSelector, addresses, sigs, t2, t3, t4), + reservedAddressCount: int64(len(addresses)), + donID: capDonID, + workflowOwner: meta.WorkflowOwner, + orgID: orgID, + expressions: expressions, + confidence: confidence, + } + if err = lts.EVMService.RegisterLogTracking(ctx, filterQuery); err != nil { registerError := fmt.Errorf("failed to register log-tracking: '%w' for triggerID: %s, addresses: %v, eventSig: %v, topic2: %v, topic3: %v, topic4: %v", err, triggerID, filterQuery.Addresses, filterQuery.EventSigs, filterQuery.Topic2, filterQuery.Topic3, filterQuery.Topic4) @@ -309,7 +401,26 @@ func (lts *LogTriggerService) RegisterLogTrigger(ctx context.Context, triggerID monitoring.LogAndEmitError(ctx, lts.lggr, lts.beholderProcessor, lts.messageBuilder.BuildLogTriggerError(telemetryContext, triggerID, summary, err.Error())) return nil, caperrors.NewPublicSystemError(registerError, caperrors.Unavailable) } - expressions, confidence := lts.createLogRequest(ctx, addresses, sigs, t2, t3, t4, input.GetConfidence()) + // The filter is now live at the log poller. Record when so the stale-filter + // cleanup skips it until it is at least one interval old (see + // cleanUpStaleFilters). + lts.filterRegisteredAt.Store(filterID, time.Now()) + + // Create the polling context up front (cancelled on unregister or service + // stop) so the store write is synchronous and carries a working cancelFunc. + pollCtx, cancel := lts.srvcEng.NewCtx() + + // Write the trigger state SYNCHRONOUSLY so the orphan-cleanup thread can + // never observe the live log-poller filter without its store entry and + // kill a live filter. No MeterRecord delta is emitted: billing observes the + // filter's level (per physical filter, in snapshotRows) in the next + // snapshot. + lts.triggers.Write(triggerID, logTriggerState{ + cancelFunc: cancel, + lastBlock: fromBlock, + unfinalizedSentEventIDs: make(map[string]*big.Int), + filter: loggedFilter, + }) monitoring.EmitInitiated(ctx, lts.lggr, lts.beholderProcessor, lts.messageBuilder.BuildLogTriggerInitiated(telemetryContext, input)) @@ -317,18 +428,7 @@ func (lts *LogTriggerService) RegisterLogTrigger(ctx context.Context, triggerID lts.baseTrigger.RegisterTrigger(triggerID, logCh) - lts.srvcEng.Go(func(ctx context.Context) { - ctx, cancel := context.WithCancel(ctx) - lts.triggers.Write(triggerID, logTriggerState{ - cancelFunc: cancel, - lastBlock: fromBlock, - unfinalizedSentEventIDs: make(map[string]*big.Int), - filter: filter{ - filterID: filterID, - expressions: expressions, - confidence: confidence, - }, - }) + lts.srvcEng.GoCtx(pollCtx, func(ctx context.Context) { ctx = meta.ContextWithCRE(ctx) lts.startPolling(ctx, telemetryContext, triggerID, input, logCh) }) @@ -378,6 +478,55 @@ func (lts *LogTriggerService) generateFilterID(triggerID string) string { return triggerID + SuffixLogTriggerFilterID } +// snapshotRows reports the current level of active log filters for the +// meter's snapshot tick: one row per distinct physical filter (NOT one per +// trigger registration), since identical filters registered by many triggers +// share one billable physical resource. The value is the shared filter's +// address count. Org attribution for a shared filter uses the deterministic +// "lowest triggerID's owner" rule so all nodes agree on the same org without +// coordination. It is a cheap in-memory read — triggers.ReadAll already +// returns a copy — with the org served from the value stored at registration, +// as the snapshot contract requires. The EVM trigger emits NO MeterRecord +// deltas: billing follows this snapshot level, and a filter is released by +// its absence from the next snapshot. +func (lts *LogTriggerService) snapshotRows(context.Context) []triggermeter.SnapshotRow { + triggers := lts.triggers.ReadAll() + + // Dedup by physicalFilterID, keeping the filter owned by the lowest + // triggerID for deterministic org attribution of a shared filter. + type physicalAgg struct { + f filter + lowestTriggerID string + } + byPhysical := make(map[string]*physicalAgg, len(triggers)) + for triggerID, state := range triggers { + agg, ok := byPhysical[state.physicalFilterID] + if !ok { + byPhysical[state.physicalFilterID] = &physicalAgg{f: state.filter, lowestTriggerID: triggerID} + continue + } + if triggerID < agg.lowestTriggerID { + agg.lowestTriggerID = triggerID + agg.f = state.filter + } + } + + rows := make([]triggermeter.SnapshotRow, 0, len(byPhysical)) + for _, agg := range byPhysical { + f := agg.f + rows = append(rows, triggermeter.SnapshotRow{ + Value: f.reservedAddressCount, + ResourceID: f.physicalFilterID, + OrgID: f.orgID, + // Per-filter DON re-stamp: the DON resolved at this filter's + // registration, which may differ from the meter's base when the + // capability DON arrived late. + DonID: f.donID, + }) + } + return rows +} + func (lts *LogTriggerService) startPolling(ctx context.Context, telemetryContext monitoring.TelemetryContext, triggerID string, input *evmcappb.FilterLogTriggerRequest, logCh chan capabilities.TriggerAndId[*evmcappb.Log]) { lts.lggr.Infof("Starting polling for triggerID: %s, interval: %d", triggerID, lts.logTriggerPollInterval) ticker := defaultTickerFactory.NewTicker(lts.logTriggerPollInterval) @@ -496,19 +645,17 @@ func (lts *LogTriggerService) sendLogsToWorkflows(ctx context.Context, telemetry events.KeyWorkflowName, displayWorkflowName, ) - // Emit the *sending* capability DON ID. The trigger plugin runs on a capability - // DON (e.g. chain_capabilities_zone-a), separate from the consumer workflow's - // DON carried in RequestMetadata.WorkflowDonID. The workflow service needs the - // sender's DON to resolve on-chain quorum params (N, F). See CRE-4409. - // capabilityDonID is 0 when the host could not resolve it authoritatively - // (a multi-DON job-spec node, or a core node that pre-dates CRE-4409); in - // that case we fall back to WorkflowDonID. This fallback is permanent, not - // transitional, since the job-spec boot path is still supported. - switch { - case lts.capabilityDonID != 0: - labeler = labeler.With(events.KeyDonID, strconv.Itoa(int(lts.capabilityDonID))) - case telemetryContext.WorkflowDonID != 0: - labeler = labeler.With(events.KeyDonID, strconv.Itoa(int(telemetryContext.WorkflowDonID))) + // CRE-4409: event labels prefer the capability DON ID but fall back to + // the consumer workflow's DON ID when it is not (yet) initialised — a + // best-effort label beats an absent one. Metering deliberately does NOT + // share this fallback (see triggermeter.TriggerMeter.DonID): snapshots + // carry the capability DON stamped at registration or nothing. + donIDLabel, donIDErr := lts.meter.DonID() + if donIDErr != nil && telemetryContext.WorkflowDonID != 0 { + donIDLabel = strconv.FormatUint(uint64(telemetryContext.WorkflowDonID), 10) + } + if donIDLabel != "" { + labeler = labeler.With(events.KeyDonID, donIDLabel) } if telemetryContext.WorkflowDonConfigVersion != 0 { labeler = labeler.With(events.KeyDonVersion, strconv.Itoa(int(telemetryContext.WorkflowDonConfigVersion))) @@ -741,7 +888,11 @@ func (lts *LogTriggerService) UnregisterLogTrigger(ctx context.Context, triggerI } lts.lggr.Infof("UnregisterLogTrigger triggerID: %s", triggerID) trigger.cancelFunc() + // No MeterRecord delta: when the last trigger holding a physical filter is + // removed, the filter drops out of snapshotRows and billing releases the + // level by its absence from the next snapshot. lts.triggers.Delete(triggerID) + lts.filterRegisteredAt.Delete(lts.generateFilterID(triggerID)) lts.baseTrigger.UnregisterTrigger(triggerID) err := lts.EVMService.UnregisterLogTracking(ctx, lts.generateFilterID(triggerID)) diff --git a/chain_capabilities/evm/trigger/trigger_metering_test.go b/chain_capabilities/evm/trigger/trigger_metering_test.go new file mode 100644 index 000000000..6677e05e8 --- /dev/null +++ b/chain_capabilities/evm/trigger/trigger_metering_test.go @@ -0,0 +1,531 @@ +package trigger + +import ( + "bytes" + "context" + "errors" + "testing" + "time" + + "github.com/jonboulle/clockwork" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + + "github.com/smartcontractkit/chainlink-common/pkg/beholder" + "github.com/smartcontractkit/chainlink-common/pkg/capabilities" + evmcappb "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/evm" + evmservice "github.com/smartcontractkit/chainlink-common/pkg/chains/evm" + "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink-common/pkg/resourcemanager" + evmtypes "github.com/smartcontractkit/chainlink-common/pkg/types/chains/evm" + evmmock "github.com/smartcontractkit/chainlink-common/pkg/types/mocks" + meteringpb "github.com/smartcontractkit/chainlink-protos/metering/go" + + "github.com/smartcontractkit/capabilities/libs/triggermeter" +) + +const testChainSelector = "5009297550715157269" + +// testDeployment is the deployment/node identity the metering tests build +// their meter with (production sources it from loop.EnvConfig). It carries +// every coarse dimension so the tests can assert each one is populated on the +// emitted snapshots. +var testDeployment = resourcemanager.DeploymentIdentity{ + Product: "cre", + Tenant: "mainline", + NumericTenantID: "42", + Environment: "staging", + Zone: "wf-zone-a", + NodeID: "csa-pubkey-hex", +} + +// fakeMeterEmitter captures MeterRecords and MeterSnapshots emitted through the +// ResourceManager. The two message types are distinguished by the entity +// attribute the emitter is called with. The manager emits one MeterSnapshot +// per active resource, so snapshots accumulates one message per resource. +type fakeMeterEmitter struct { + err error + emitCalls int + records []*meteringpb.MeterRecord + recordDomains []string + snapshots []*meteringpb.MeterSnapshot +} + +func (f *fakeMeterEmitter) Emit(_ context.Context, body []byte, attrKVs ...any) error { + f.emitCalls++ + if f.err != nil { + return f.err + } + if isSnapshotEmit(attrKVs) { + var snapshot meteringpb.MeterSnapshot + if err := proto.Unmarshal(body, &snapshot); err != nil { + return err + } + f.snapshots = append(f.snapshots, &snapshot) + return nil + } + var record meteringpb.MeterRecord + if err := proto.Unmarshal(body, &record); err != nil { + return err + } + f.records = append(f.records, &record) + f.recordDomains = append(f.recordDomains, attrString(attrKVs, beholder.AttrKeyDomain)) + return nil +} + +// attrString returns the string value for key in the alternating key/value +// attrs the ResourceManager passes to Emit, or "" if absent. +func attrString(attrKVs []any, key string) string { + for i := 0; i+1 < len(attrKVs); i += 2 { + if attrKVs[i] == key { + if v, ok := attrKVs[i+1].(string); ok { + return v + } + } + } + return "" +} + +// isSnapshotEmit reports whether the emitter attributes name the MeterSnapshot +// entity, so the fake can demux the two message types off the same Emit method. +// The key is beholder.AttrKeyEntity ("beholder_entity") and the value is the +// snapshot entity constant the ResourceManager emits with. +func isSnapshotEmit(attrKVs []any) bool { + for i := 0; i+1 < len(attrKVs); i += 2 { + if attrKVs[i] == beholder.AttrKeyEntity && attrKVs[i+1] == "metering.v1.MeterSnapshot" { + return true + } + } + return false +} + +// newMeteredTriggerObject builds a LogTriggerService whose meter wraps an +// enabled ResourceManager wired to a fake emitter. The poll interval is +// stretched so the polling goroutine stays quiet; metering happens on the +// snapshot tick only (the EVM trigger emits no MeterRecord deltas). +func newMeteredTriggerObject(t *testing.T, mockEVM *evmmock.EVMService, store LogTriggerStore) (*LogTriggerService, *fakeMeterEmitter, *clockwork.FakeClock) { + t.Helper() + lts := createTriggerObject(t, mockEVM, store) + lts.logTriggerPollInterval = time.Hour + emitter := &fakeMeterEmitter{} + clock := clockwork.NewFakeClockAt(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)) + rm := resourcemanager.NewResourceManager(logger.Test(t), + resourcemanager.ResourceManagerConfig{ + MeterRecordsEnabled: true, + MeterSnapshotsEnabled: true, + Emitter: emitter, + SnapshotInterval: time.Minute, + Clock: clock, + }) + lts.meter = triggermeter.New(logger.Test(t), rm, testDeployment, 42, meteringConfig, nil, lts.snapshotRows) + lts.chainSelector = testChainSelector + return lts, emitter, clock +} + +// startMeter starts the meter (RM + snapshot registration) and tears it down +// on test cleanup, for tests that drive snapshot ticks directly without the +// full service lifecycle. +func startMeter(t *testing.T, lts *LogTriggerService) { + t.Helper() + require.NoError(t, lts.meter.Start(t.Context())) + t.Cleanup(func() { require.NoError(t, lts.meter.Close()) }) +} + +// meteringTestInput is a registration request with two filter addresses, so +// tests can tell an address count apart from a hardcoded 1. +func meteringTestInput() *evmcappb.FilterLogTriggerRequest { + return &evmcappb.FilterLogTriggerRequest{ + Addresses: [][]byte{expectedAddress, bytes.Repeat([]byte{0x42}, evmtypes.AddressLength)}, + Topics: topicsWithEventSig0, + } +} + +// assertBaseIdentity checks the six coarse dimensions + service/resource_pool +// on the emitted snapshot identity, proving the host-injected identity is +// carried. +func assertBaseIdentity(t *testing.T, id *meteringpb.ResourceIdentity) { + t.Helper() + require.NotNil(t, id) + require.Equal(t, "cre", id.GetProduct()) + require.Equal(t, "mainline", id.GetTenant()) + require.Equal(t, "42", id.GetNumericTenantId()) + require.Equal(t, "staging", id.GetEnvironment()) + require.Equal(t, "wf-zone-a", id.GetZone()) + require.Equal(t, "42", id.GetDon().GetDonId()) + require.Equal(t, "csa-pubkey-hex", id.GetDon().GetNodeId()) + require.Equal(t, meteringConfig.Service, id.GetService()) + require.Equal(t, meteringConfig.ResourcePool, id.GetResourcePool()) +} + +// expectedPhysicalFilterID recomputes the physical filter id for the metering +// test input via the production helper, so the tests assert against the real +// canonicalization rather than a frozen literal. +func expectedPhysicalFilterID(t *testing.T, input *evmcappb.FilterLogTriggerRequest) string { + t.Helper() + svc := &LogTriggerService{} + eventSigs, t2, t3, t4 := svc.getTopics(input) + addrs, err := evmservice.ConvertAddressesFromProto(input.GetAddresses()) + require.NoError(t, err) + sigs, err := evmservice.ConvertHashesFromProto(eventSigs) + require.NoError(t, err) + h2, err := evmservice.ConvertHashesFromProto(t2) + require.NoError(t, err) + h3, err := evmservice.ConvertHashesFromProto(t3) + require.NoError(t, err) + h4, err := evmservice.ConvertHashesFromProto(t4) + require.NoError(t, err) + return physicalFilterID(testChainSelector, addrs, sigs, h2, h3, h4) +} + +// TestLogTriggerMetering_NoRecords is the core snapshot-only invariant: the +// full registration lifecycle — register, shared-filter register, unregister, +// re-register (the restart shape), failed paths — emits ZERO MeterRecords. +// EVM log filters bill exclusively through snapshots: the level (addressCount +// per physical filter) rises when the filter appears in the next snapshot and +// is released by its absence. +func TestLogTriggerMetering_NoRecords(t *testing.T) { + evmService := initMocks(t) + evmService.EXPECT().GetLatestLPBlock(mock.Anything).Return(&finalizedExpBlock, nil).Times(3) + evmService.On("RegisterLogTracking", mock.Anything, mock.Anything).Return(nil).Times(3) + evmService.On("UnregisterLogTracking", mock.Anything, mock.Anything).Return(nil) + service, emitter, _ := newMeteredTriggerObject(t, evmService, NewLogTriggerStore()) + + meta := capabilities.RequestMetadata{WorkflowID: "wf-id", WorkflowOwner: "0xOwner"} + _, err := service.RegisterLogTrigger(t.Context(), "trigger-A", meta, meteringTestInput()) + require.NoError(t, err) + require.Empty(t, emitter.records, "registration must not emit meter records") + + // A second trigger sharing the identical physical filter. + _, err = service.RegisterLogTrigger(t.Context(), "trigger-B", + capabilities.RequestMetadata{WorkflowID: "wf-2", WorkflowOwner: "0xOther"}, meteringTestInput()) + require.NoError(t, err) + require.Empty(t, emitter.records) + + // Unregister then re-register — the shape of an engine restart. + require.NoError(t, service.UnregisterLogTrigger(t.Context(), "trigger-A", meta, &evmcappb.FilterLogTriggerRequest{})) + _, err = service.RegisterLogTrigger(t.Context(), "trigger-A", meta, meteringTestInput()) + require.NoError(t, err) + require.Empty(t, emitter.records, "restart-shaped re-registration must not emit meter records") + + require.Zero(t, emitter.emitCalls, "no metering emission of any kind outside the snapshot tick") +} + +func TestLogTriggerMetering_NoEmitOnRegisterFailure(t *testing.T) { + evmService := initMocks(t) + evmService.EXPECT().GetLatestLPBlock(mock.Anything).Return(&finalizedExpBlock, nil).Once() + evmService.On("RegisterLogTracking", mock.Anything, mock.Anything).Return(errors.New("mocked register failure")).Once() + service, emitter, _ := newMeteredTriggerObject(t, evmService, NewLogTriggerStore()) + + _, err := service.RegisterLogTrigger(t.Context(), triggerID, capabilities.RequestMetadata{WorkflowID: "wf-id"}, meteringTestInput()) + require.Error(t, err) + require.Zero(t, emitter.emitCalls, "nothing may be emitted for a failed registration") +} + +// TestLogTriggerMetering_DonIDNotInitialised asserts that when the host has +// not injected a capability DON ID, snapshots are still emitted but with the +// DON dimension carrying only the node ID — the consumer workflow's DON ID is +// never substituted — and the meter's DonID surfaces ErrDonIDNotInitialised +// for callers that degrade explicitly (event labels, CRE-4409). +func TestLogTriggerMetering_DonIDNotInitialised(t *testing.T) { + evmService := initMocks(t) + evmService.EXPECT().GetLatestLPBlock(mock.Anything).Return(&finalizedExpBlock, nil).Once() + evmService.On("RegisterLogTracking", mock.Anything, mock.Anything).Return(nil).Once() + service, emitter, clock := newMeteredTriggerObject(t, evmService, NewLogTriggerStore()) + // Host did not inject a capability DON (0). + service.meter = triggermeter.New(logger.Test(t), resourcemanager.NewResourceManager(logger.Test(t), + resourcemanager.ResourceManagerConfig{ + MeterRecordsEnabled: true, + MeterSnapshotsEnabled: true, + Emitter: emitter, + SnapshotInterval: time.Minute, + Clock: clock, + }), testDeployment, 0, meteringConfig, nil, service.snapshotRows) + + _, donErr := service.meter.DonID() + require.ErrorIs(t, donErr, triggermeter.ErrDonIDNotInitialised) + + meta := capabilities.RequestMetadata{WorkflowID: "wf-id", WorkflowOwner: "0xOwner", WorkflowDonID: 7} + _, err := service.RegisterLogTrigger(t.Context(), triggerID, meta, meteringTestInput()) + require.NoError(t, err) + + startMeter(t, service) + require.NoError(t, clock.BlockUntilContext(t.Context(), 1)) + clock.Advance(time.Minute) + require.Eventually(t, func() bool { return len(emitter.snapshots) == 1 }, time.Second, time.Millisecond) + + require.Empty(t, emitter.snapshots[0].GetIdentity().GetDon().GetDonId(), + "the consumer workflow's DON ID must never be substituted for the capability DON") + require.Equal(t, "csa-pubkey-hex", emitter.snapshots[0].GetIdentity().GetDon().GetNodeId(), + "the node dimension is preserved even without a DON ID") +} + +func TestLogTriggerMetering_OrphanCleanupEmitsNothing(t *testing.T) { + // Orphan cleanup is log-poller filter hygiene, never a metering event. A + // lost reservation is reconciled by the resource's absence from subsequent + // Snapshots (the liveness mechanism), not by a synthetic cleanup emission. + t.Run("stale filter cleanup emits no metering", func(t *testing.T) { + mockEVM := evmmock.NewEVMService(t) + store := NewLogTriggerStore() + service, emitter, _ := newMeteredTriggerObject(t, mockEVM, store) + + liveFilterID := service.generateFilterID("live-trigger") + staleFilterID := service.generateFilterID("stale-trigger") + mockEVM.On("GetFiltersNames", mock.Anything).Return([]string{liveFilterID, staleFilterID}, nil).Once() + mockEVM.On("UnregisterLogTracking", mock.Anything, staleFilterID).Return(nil).Once() + // mimicking there's a live trigger with the filter registered to log poller + store.Write("live-trigger", logTriggerState{filter: filter{filterID: liveFilterID}}) + + service.cleanUpStaleFilters(t.Context()) + + require.Zero(t, emitter.emitCalls, "orphan cleanup must not emit any metering") + }) + + t.Run("emits nothing when cleanup unregister fails", func(t *testing.T) { + mockEVM := evmmock.NewEVMService(t) + service, emitter, _ := newMeteredTriggerObject(t, mockEVM, NewLogTriggerStore()) + + staleFilterID := service.generateFilterID("stale-trigger") + mockEVM.On("GetFiltersNames", mock.Anything).Return([]string{staleFilterID}, nil).Once() + mockEVM.On("UnregisterLogTracking", mock.Anything, staleFilterID).Return(errors.New("mocked cleanup failure")).Once() + + service.cleanUpStaleFilters(t.Context()) + require.Zero(t, emitter.emitCalls, "orphan cleanup never emits metering") + }) +} + +// TestLogTriggerMetering_FailOpen asserts registration succeeds and snapshot +// emission failures are swallowed when the emitter errors on every call. +func TestLogTriggerMetering_FailOpen(t *testing.T) { + evmService := initMocks(t) + evmService.EXPECT().GetLatestLPBlock(mock.Anything).Return(&finalizedExpBlock, nil).Once() + evmService.On("RegisterLogTracking", mock.Anything, mock.Anything).Return(nil).Once() + service, emitter, clock := newMeteredTriggerObject(t, evmService, NewLogTriggerStore()) + emitter.err = errors.New("mocked emitter failure") + + _, err := service.RegisterLogTrigger(t.Context(), triggerID, capabilities.RequestMetadata{WorkflowID: "wf-id"}, meteringTestInput()) + require.NoError(t, err, "a metering failure must never fail registration") + + // A snapshot tick attempts the emit and swallows the failure. + startMeter(t, service) + require.NoError(t, clock.BlockUntilContext(t.Context(), 1)) + clock.Advance(time.Minute) + require.Eventually(t, func() bool { return emitter.emitCalls >= 1 }, time.Second, time.Millisecond) + require.Empty(t, emitter.snapshots, "failed emissions record nothing") +} + +// TestPhysicalFilterID_Canonicalization proves the content hash is independent +// of the order addresses / event sigs / per-slot topic values are supplied, and +// independent of which workflow or trigger registered the filter, while staying +// sensitive to the positional topic slot. +func TestPhysicalFilterID_Canonicalization(t *testing.T) { + addrA := evmtypes.Address(expectedAddress) + addrB := evmtypes.Address(bytes.Repeat([]byte{0x42}, evmtypes.AddressLength)) + sig1 := evmtypes.Hash(eventSig0Example) + sig2 := evmtypes.Hash(bytes.Repeat([]byte{0x11}, evmtypes.HashLength)) + none := []evmtypes.Hash{} + + t.Run("address order does not change the id", func(t *testing.T) { + id1 := physicalFilterID(testChainSelector, []evmtypes.Address{addrA, addrB}, []evmtypes.Hash{sig1}, none, none, none) + id2 := physicalFilterID(testChainSelector, []evmtypes.Address{addrB, addrA}, []evmtypes.Hash{sig1}, none, none, none) + require.Equal(t, id1, id2) + }) + + t.Run("event sig order does not change the id", func(t *testing.T) { + id1 := physicalFilterID(testChainSelector, []evmtypes.Address{addrA}, []evmtypes.Hash{sig1, sig2}, none, none, none) + id2 := physicalFilterID(testChainSelector, []evmtypes.Address{addrA}, []evmtypes.Hash{sig2, sig1}, none, none, none) + require.Equal(t, id1, id2) + }) + + t.Run("topic values within a slot are order-independent", func(t *testing.T) { + id1 := physicalFilterID(testChainSelector, []evmtypes.Address{addrA}, []evmtypes.Hash{sig1}, []evmtypes.Hash{sig1, sig2}, none, none) + id2 := physicalFilterID(testChainSelector, []evmtypes.Address{addrA}, []evmtypes.Hash{sig1}, []evmtypes.Hash{sig2, sig1}, none, none) + require.Equal(t, id1, id2) + }) + + t.Run("topic slots are positional", func(t *testing.T) { + inSlot2 := physicalFilterID(testChainSelector, []evmtypes.Address{addrA}, []evmtypes.Hash{sig1}, []evmtypes.Hash{sig2}, none, none) + inSlot3 := physicalFilterID(testChainSelector, []evmtypes.Address{addrA}, []evmtypes.Hash{sig1}, none, []evmtypes.Hash{sig2}, none) + require.NotEqual(t, inSlot2, inSlot3, "the same value in topic2 vs topic3 is a different filter") + }) + + t.Run("different chain selector changes the id", func(t *testing.T) { + id1 := physicalFilterID(testChainSelector, []evmtypes.Address{addrA}, []evmtypes.Hash{sig1}, none, none, none) + id2 := physicalFilterID("999", []evmtypes.Address{addrA}, []evmtypes.Hash{sig1}, none, none, none) + require.NotEqual(t, id1, id2) + }) + + t.Run("identical filters from different workflows/triggers share one billed resource", func(t *testing.T) { + // physicalFilterID takes only physical criteria; workflow/trigger are not + // inputs. Two registrations with identical criteria collide by + // construction, so the snapshot path dedups them into ONE billed row. + evmService := initMocks(t) + evmService.EXPECT().GetLatestLPBlock(mock.Anything).Return(&finalizedExpBlock, nil).Twice() + evmService.On("RegisterLogTracking", mock.Anything, mock.Anything).Return(nil).Twice() + service, emitter, _ := newMeteredTriggerObject(t, evmService, NewLogTriggerStore()) + + _, err := service.RegisterLogTrigger(t.Context(), "trigger-A", + capabilities.RequestMetadata{WorkflowID: "wf-1", WorkflowOwner: "0xOwner"}, meteringTestInput()) + require.NoError(t, err) + _, err = service.RegisterLogTrigger(t.Context(), "trigger-B", + capabilities.RequestMetadata{WorkflowID: "wf-2", WorkflowOwner: "0xOther"}, meteringTestInput()) + require.NoError(t, err) + + require.Empty(t, emitter.records, "no deltas, ever") + rows := service.snapshotRows(t.Context()) + require.Len(t, rows, 1, "the shared physical filter is billed as one snapshot resource") + require.Equal(t, expectedPhysicalFilterID(t, meteringTestInput()), rows[0].ResourceID) + }) +} + +// TestLogTriggerMetering_SharedFilterLevel asserts the snapshot level of a +// physical filter shared by two triggers: one row at +addressCount while any +// holder remains, and absence once the last holder unregisters. +func TestLogTriggerMetering_SharedFilterLevel(t *testing.T) { + evmService := initMocks(t) + evmService.EXPECT().GetLatestLPBlock(mock.Anything).Return(&finalizedExpBlock, nil).Twice() + evmService.On("RegisterLogTracking", mock.Anything, mock.Anything).Return(nil).Twice() + evmService.On("UnregisterLogTracking", mock.Anything, mock.Anything).Return(nil) + service, emitter, _ := newMeteredTriggerObject(t, evmService, NewLogTriggerStore()) + + physID := expectedPhysicalFilterID(t, meteringTestInput()) + + _, err := service.RegisterLogTrigger(t.Context(), "trigger-A", + capabilities.RequestMetadata{WorkflowID: "wf-1", WorkflowOwner: "0xOwner"}, meteringTestInput()) + require.NoError(t, err) + _, err = service.RegisterLogTrigger(t.Context(), "trigger-B", + capabilities.RequestMetadata{WorkflowID: "wf-2", WorkflowOwner: "0xOther"}, meteringTestInput()) + require.NoError(t, err) + + rows := service.snapshotRows(t.Context()) + require.Len(t, rows, 1, "two holders of one physical filter snapshot as one resource") + require.Equal(t, physID, rows[0].ResourceID) + require.Equal(t, int64(2), rows[0].Value, "the level is the filter's address count, not the holder count") + + // Releasing one of two holders keeps the level. + require.NoError(t, service.UnregisterLogTrigger(t.Context(), "trigger-A", capabilities.RequestMetadata{}, &evmcappb.FilterLogTriggerRequest{})) + rows = service.snapshotRows(t.Context()) + require.Len(t, rows, 1) + require.Equal(t, int64(2), rows[0].Value) + + // Releasing the last holder drops the resource: release-by-absence. + require.NoError(t, service.UnregisterLogTrigger(t.Context(), "trigger-B", capabilities.RequestMetadata{}, &evmcappb.FilterLogTriggerRequest{})) + require.Empty(t, service.snapshotRows(t.Context()), "the last unregister releases the level by absence from the next snapshot") + + require.Empty(t, emitter.records, "no deltas at any point in the shared-filter lifecycle") +} + +// TestLogTriggerMetering_Snapshot drives one snapshot tick and asserts one +// MeterSnapshot per active filter, each fully identified by its +// ResourceIdentity (physical resource_id) with the right value. The manager +// emits one MeterSnapshot message per resource; there is no label metadata, so +// snapshots are keyed by their physical resource_id. +func TestLogTriggerMetering_Snapshot(t *testing.T) { + mockEVM := evmmock.NewEVMService(t) + store := NewLogTriggerStore() + service, emitter, clock := newMeteredTriggerObject(t, mockEVM, store) + + physA := expectedPhysicalFilterID(t, meteringTestInput()) + store.Write("trigger-A", logTriggerState{filter: filter{ + filterID: service.generateFilterID("trigger-A"), + physicalFilterID: physA, + reservedAddressCount: 2, + donID: "42", + }}) + store.Write("trigger-B", logTriggerState{filter: filter{ + filterID: service.generateFilterID("trigger-B"), + physicalFilterID: "physB", + reservedAddressCount: 5, + donID: "42", + }}) + + startMeter(t, service) + require.NoError(t, clock.BlockUntilContext(t.Context(), 1)) + clock.Advance(time.Minute) + + require.Eventually(t, func() bool { + return len(emitter.snapshots) == 2 + }, time.Second, time.Millisecond) + + require.Len(t, emitter.snapshots, 2, "one MeterSnapshot per active filter") + + byResourceID := map[string]*meteringpb.MeterSnapshot{} + for _, s := range emitter.snapshots { + assertBaseIdentity(t, s.GetIdentity()) + byResourceID[s.GetUtilization()[0].GetResourceId()] = s + } + + a := byResourceID[physA] + require.NotNil(t, a) + require.Equal(t, "2", a.GetUtilization()[0].GetValue()) + require.Equal(t, meteringConfig.ResourceType, a.GetUtilization()[0].GetResourceType()) + + b := byResourceID["physB"] + require.NotNil(t, b) + require.Equal(t, "5", b.GetUtilization()[0].GetValue()) + + // The snapshot stream is the only metering surface: no records, ever. + require.Empty(t, emitter.records) +} + +// TestLogTriggerMetering_Snapshot_NothingActive asserts an empty store emits no +// snapshots: billing zeroes a resource out by its absence from later snapshots. +func TestLogTriggerMetering_Snapshot_NothingActive(t *testing.T) { + mockEVM := evmmock.NewEVMService(t) + service, emitter, clock := newMeteredTriggerObject(t, mockEVM, NewLogTriggerStore()) + + startMeter(t, service) + require.NoError(t, clock.BlockUntilContext(t.Context(), 1)) + clock.Advance(time.Minute) + + require.Empty(t, emitter.snapshots, "an empty store emits no MeterSnapshot") +} + +// TestLogTriggerMetering_NoShutdownEmissions asserts that a graceful Close +// emits NO metering at all. Process-lifecycle emissions are deleted by design: +// an active filter is released by its absence from the next snapshot, not by a +// close-time drain. +func TestLogTriggerMetering_NoShutdownEmissions(t *testing.T) { + evmService := initMocks(t) + evmService.EXPECT().GetLatestLPBlock(mock.Anything).Return(&finalizedExpBlock, nil).Once() + evmService.On("RegisterLogTracking", mock.Anything, mock.Anything).Return(nil).Once() + evmService.EXPECT().GetFiltersNames(mock.Anything).Return([]string{}, nil).Maybe() + service, emitter, _ := newMeteredTriggerObject(t, evmService, NewLogTriggerStore()) + require.NoError(t, service.Start(t.Context())) + + _, err := service.RegisterLogTrigger(t.Context(), triggerID, + capabilities.RequestMetadata{WorkflowID: "wf", WorkflowOwner: "0xOwner"}, meteringTestInput()) + require.NoError(t, err) + + require.NoError(t, service.Close()) + require.Zero(t, emitter.emitCalls, "graceful close must emit no metering") +} + +// TestLogTriggerMetering_SnapshotDedup asserts the snapshot source emits one +// entry per DISTINCT physical filter (not per trigger registration): two +// triggers sharing one physicalFilterID snapshot as a single resource. +func TestLogTriggerMetering_SnapshotDedup(t *testing.T) { + mockEVM := evmmock.NewEVMService(t) + store := NewLogTriggerStore() + service, emitter, clock := newMeteredTriggerObject(t, mockEVM, store) + + physShared := expectedPhysicalFilterID(t, meteringTestInput()) + // Two triggers share one physical filter. + store.Write("trigger-A", logTriggerState{filter: filter{ + filterID: service.generateFilterID("trigger-A"), physicalFilterID: physShared, reservedAddressCount: 2, donID: "42", + }}) + store.Write("trigger-B", logTriggerState{filter: filter{ + filterID: service.generateFilterID("trigger-B"), physicalFilterID: physShared, reservedAddressCount: 2, donID: "42", + }}) + + startMeter(t, service) + require.NoError(t, clock.BlockUntilContext(t.Context(), 1)) + clock.Advance(time.Minute) + + require.Eventually(t, func() bool { + return len(emitter.snapshots) == 1 + }, time.Second, time.Millisecond) + require.Len(t, emitter.snapshots, 1, "two triggers sharing one physical filter snapshot once") + require.Equal(t, physShared, emitter.snapshots[0].GetUtilization()[0].GetResourceId()) + require.Equal(t, "2", emitter.snapshots[0].GetUtilization()[0].GetValue()) +} diff --git a/chain_capabilities/evm/trigger/trigger_test.go b/chain_capabilities/evm/trigger/trigger_test.go index 9cccae9a0..8507edcf7 100644 --- a/chain_capabilities/evm/trigger/trigger_test.go +++ b/chain_capabilities/evm/trigger/trigger_test.go @@ -30,6 +30,7 @@ import ( "github.com/smartcontractkit/chainlink-common/pkg/capabilities" evmcappb "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/evm" "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink-common/pkg/resourcemanager" "github.com/smartcontractkit/chainlink-common/pkg/services" evmtypes "github.com/smartcontractkit/chainlink-common/pkg/types/chains/evm" evmmock "github.com/smartcontractkit/chainlink-common/pkg/types/mocks" @@ -1261,33 +1262,33 @@ func TestNewLogTriggerService(t *testing.T) { t.Run("empty capability id", func(t *testing.T) { lggr := logger.Test(t) - _, err := NewLogTriggerService(evmService, store, lggr, "", 0, beholderProcessor, messageBuilder, time.Second, 0, 0, limits.Factory{Logger: lggr}, nil, capabilities.NewMemEventStore()) + _, err := NewLogTriggerService(evmService, store, lggr, "", beholderProcessor, messageBuilder, time.Second, 0, 0, limits.Factory{Logger: lggr}, nil, capabilities.NewMemEventStore(), nil, resourcemanager.DeploymentIdentity{}, 0, 0) require.Error(t, err) require.Contains(t, err.Error(), "capabilityID must be non-empty") }) t.Run("ok initialize interval", func(t *testing.T) { - trigger, err := NewLogTriggerService(evmService, store, logger.Test(t), testLogTriggerCapabilityID, 0, beholderProcessor, messageBuilder, 10*time.Second, 0, 0, testLimitsFactory(t), nil, capabilities.NewMemEventStore()) + trigger, err := NewLogTriggerService(evmService, store, logger.Test(t), testLogTriggerCapabilityID, beholderProcessor, messageBuilder, 10*time.Second, 0, 0, testLimitsFactory(t), nil, capabilities.NewMemEventStore(), nil, resourcemanager.DeploymentIdentity{}, 0, 0) require.NoError(t, err) require.Equal(t, 10*time.Second, trigger.logTriggerPollInterval) require.Equal(t, uint64(1000), trigger.logTriggerSendChannelBufferSize) require.Equal(t, uint64(1000), trigger.limitAndSort.Limit.Count) }) t.Run("ok initialize all params", func(t *testing.T) { - trigger, err := NewLogTriggerService(evmService, store, logger.Test(t), testLogTriggerCapabilityID, 0, beholderProcessor, messageBuilder, 10*time.Second, 100, 50, testLimitsFactory(t), nil, capabilities.NewMemEventStore()) + trigger, err := NewLogTriggerService(evmService, store, logger.Test(t), testLogTriggerCapabilityID, beholderProcessor, messageBuilder, 10*time.Second, 100, 50, testLimitsFactory(t), nil, capabilities.NewMemEventStore(), nil, resourcemanager.DeploymentIdentity{}, 0, 0) require.NoError(t, err) require.Equal(t, 10*time.Second, trigger.logTriggerPollInterval) require.Equal(t, uint64(100), trigger.logTriggerSendChannelBufferSize) require.Equal(t, uint64(50), trigger.limitAndSort.Limit.Count) }) t.Run("ok initialize buffer only", func(t *testing.T) { - trigger, err := NewLogTriggerService(evmService, store, logger.Test(t), testLogTriggerCapabilityID, 0, beholderProcessor, messageBuilder, 10*time.Second, 10000, 0, testLimitsFactory(t), nil, capabilities.NewMemEventStore()) + trigger, err := NewLogTriggerService(evmService, store, logger.Test(t), testLogTriggerCapabilityID, beholderProcessor, messageBuilder, 10*time.Second, 10000, 0, testLimitsFactory(t), nil, capabilities.NewMemEventStore(), nil, resourcemanager.DeploymentIdentity{}, 0, 0) require.NoError(t, err) require.Equal(t, 10*time.Second, trigger.logTriggerPollInterval) require.Equal(t, uint64(10000), trigger.logTriggerSendChannelBufferSize) require.Equal(t, uint64(defaultLimitQueryLogSize), trigger.limitAndSort.Limit.Count) //default value for limit as 0 was provided }) t.Run("ok initialize query limit only", func(t *testing.T) { - trigger, err := NewLogTriggerService(evmService, store, logger.Test(t), testLogTriggerCapabilityID, 0, beholderProcessor, messageBuilder, 10*time.Second, 0, 100, testLimitsFactory(t), nil, capabilities.NewMemEventStore()) + trigger, err := NewLogTriggerService(evmService, store, logger.Test(t), testLogTriggerCapabilityID, beholderProcessor, messageBuilder, 10*time.Second, 0, 100, testLimitsFactory(t), nil, capabilities.NewMemEventStore(), nil, resourcemanager.DeploymentIdentity{}, 0, 0) require.NoError(t, err) require.Equal(t, 10*time.Second, trigger.logTriggerPollInterval) require.Equal(t, uint64(defaultSendChannelBufferSize), trigger.logTriggerSendChannelBufferSize) //default value for buffer size as 0 was provided @@ -1296,25 +1297,25 @@ func TestNewLogTriggerService(t *testing.T) { // negative tests t.Run("negative poll interval", func(t *testing.T) { lggr := logger.Test(t) - _, err := NewLogTriggerService(evmService, store, lggr, testLogTriggerCapabilityID, 0, beholderProcessor, messageBuilder, -1*time.Second, 0, 0, limits.Factory{Logger: lggr}, nil, nil) + _, err := NewLogTriggerService(evmService, store, lggr, testLogTriggerCapabilityID, beholderProcessor, messageBuilder, -1*time.Second, 0, 0, limits.Factory{Logger: lggr}, nil, nil, nil, resourcemanager.DeploymentIdentity{}, 0, 0) require.Error(t, err) require.Contains(t, err.Error(), "logTriggerPollInterval must be positive, got: -1s") }) t.Run("limit query log size >= send channel buffer size", func(t *testing.T) { lggr := logger.Test(t) - _, err := NewLogTriggerService(evmService, store, lggr, testLogTriggerCapabilityID, 0, beholderProcessor, messageBuilder, time.Second, 5, 10, limits.Factory{Logger: lggr}, nil, nil) + _, err := NewLogTriggerService(evmService, store, lggr, testLogTriggerCapabilityID, beholderProcessor, messageBuilder, time.Second, 5, 10, limits.Factory{Logger: lggr}, nil, nil, nil, resourcemanager.DeploymentIdentity{}, 0, 0) require.Error(t, err) require.Contains(t, err.Error(), "logTriggerLimitQueryLogSize (10) must be less than logTriggerSendChannelBufferSize (5)") }) t.Run("limit query log size >= default send channel buffer size", func(t *testing.T) { lggr := logger.Test(t) - _, err := NewLogTriggerService(evmService, store, lggr, testLogTriggerCapabilityID, 0, beholderProcessor, messageBuilder, time.Second, 0, defaultSendChannelBufferSize+1, limits.Factory{Logger: lggr}, nil, nil) + _, err := NewLogTriggerService(evmService, store, lggr, testLogTriggerCapabilityID, beholderProcessor, messageBuilder, time.Second, 0, defaultSendChannelBufferSize+1, limits.Factory{Logger: lggr}, nil, nil, nil, resourcemanager.DeploymentIdentity{}, 0, 0) require.Error(t, err) require.Contains(t, err.Error(), "logTriggerLimitQueryLogSize (1001) must be less than logTriggerSendChannelBufferSize (1000)") }) t.Run("nil trigger event store", func(t *testing.T) { lggr := logger.Test(t) - _, err := NewLogTriggerService(evmService, store, lggr, testLogTriggerCapabilityID, 0, beholderProcessor, messageBuilder, time.Second, 0, 0, limits.Factory{Logger: lggr}, nil, nil) + _, err := NewLogTriggerService(evmService, store, lggr, testLogTriggerCapabilityID, beholderProcessor, messageBuilder, time.Second, 0, 0, limits.Factory{Logger: lggr}, nil, nil, nil, resourcemanager.DeploymentIdentity{}, 0, 0) require.Error(t, err) require.Contains(t, err.Error(), "no trigger event store provided") }) diff --git a/cron/go.mod b/cron/go.mod index ea23da7c9..0b30c2452 100644 --- a/cron/go.mod +++ b/cron/go.mod @@ -7,8 +7,9 @@ require ( github.com/google/uuid v1.6.0 github.com/jonboulle/clockwork v0.5.0 github.com/smartcontractkit/capabilities/libs v0.0.0-20260210010829-97eb42ca2924 - github.com/smartcontractkit/chainlink-common v0.11.2-0.20260529092756-a94bc8ce96d6 - github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260526195338-adcf8013a1b7 + github.com/smartcontractkit/chainlink-common v0.11.2-0.20260810181408-53d56f533c59 + github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260804191526-b7a850ae7648 + github.com/smartcontractkit/chainlink-protos/metering/go v0.0.0-20260729184203-90b4cdd48536 github.com/stretchr/testify v1.11.1 go.opentelemetry.io/otel v1.43.0 go.opentelemetry.io/otel/metric v1.43.0 @@ -81,11 +82,11 @@ require ( github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 // indirect github.com/scylladb/go-reflectx v1.0.1 // indirect github.com/shopspring/decimal v1.4.0 // indirect - github.com/smartcontractkit/chain-selectors v1.0.100 // indirect - github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.11-0.20260529092756-a94bc8ce96d6 // indirect + github.com/smartcontractkit/chain-selectors v1.0.104 // indirect + github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.11-0.20260724142814-45996a1bcb72 // indirect github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b // indirect - github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260205130626-db2a2aab956b // indirect - github.com/smartcontractkit/chainlink-protos/workflows/go v0.0.0-20260323124644-faea187e6997 // indirect + github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260709145319-7782fb89eb16 // indirect + github.com/smartcontractkit/chainlink-protos/workflows/go v0.0.0-20260528173149-f5b8336b19d9 // indirect github.com/smartcontractkit/freeport v0.1.3-0.20250716200817-cb5dfd0e369e // indirect github.com/smartcontractkit/grpc-proxy v0.0.0-20240830132753-a7e17fec5ab7 // indirect github.com/smartcontractkit/libocr v0.0.0-20250912173940-f3ab0246e23d // indirect @@ -126,3 +127,5 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect sigs.k8s.io/yaml v1.4.0 // indirect ) + +replace github.com/smartcontractkit/capabilities/libs => ../libs diff --git a/cron/go.sum b/cron/go.sum index aa0ba9372..ac7c890db 100644 --- a/cron/go.sum +++ b/cron/go.sum @@ -209,22 +209,22 @@ github.com/scylladb/go-reflectx v1.0.1 h1:b917wZM7189pZdlND9PbIJ6NQxfDPfBvUaQ7cj github.com/scylladb/go-reflectx v1.0.1/go.mod h1:rWnOfDIRWBGN0miMLIcoPt/Dhi2doCMZqwMCJ3KupFc= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= -github.com/smartcontractkit/capabilities/libs v0.0.0-20260210010829-97eb42ca2924 h1:6wqsOpDXA0ZMEswN7f8hX04Y3+gXva7p5emXThtJVlI= -github.com/smartcontractkit/capabilities/libs v0.0.0-20260210010829-97eb42ca2924/go.mod h1:v0O0Au8RE00Z89QxBE6I2q9bR9r3+RO1gLD3oaO2WB0= -github.com/smartcontractkit/chain-selectors v1.0.100 h1:wpiSpmI/eFjY+wx/nPr5VuNF4hki0prIBMKEaQWn3g4= -github.com/smartcontractkit/chain-selectors v1.0.100/go.mod h1:qy7whtgG5g+7z0jt0nRyii9bLND9m15NZTzuQPkMZ5w= -github.com/smartcontractkit/chainlink-common v0.11.2-0.20260529092756-a94bc8ce96d6 h1:hms02zQQ0BPcp9CBwh/xda5KwJWdU0IIA/yjtwyRoA4= -github.com/smartcontractkit/chainlink-common v0.11.2-0.20260529092756-a94bc8ce96d6/go.mod h1:jueIfDkkRexwGgLbVB7vGCZlNtd383zuwi4uHHwcbqc= -github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.11-0.20260529092756-a94bc8ce96d6 h1:ucHu2bPDT/58AzSgnPDyp4IjnjVbrVWYD3bG5jCbXMY= -github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.11-0.20260529092756-a94bc8ce96d6/go.mod h1:HmUyH2oD9m+GRpKq7q3vuRnm1F2Uczf/Nd1v3ipMSK8= -github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260526195338-adcf8013a1b7 h1:iljEJss3WOwcsMkWy72Yn2zvjw7Gyxc+RXL7r8YKM6g= -github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260526195338-adcf8013a1b7/go.mod h1:vTFHTCbLui4Vn8fTmAadfE3rdnvfrDwOmMujmW857D0= +github.com/smartcontractkit/chain-selectors v1.0.104 h1:/n9pPGM5W/+r1eHoWZv4VwX9LNS1af4+ICyhM8zKRNM= +github.com/smartcontractkit/chain-selectors v1.0.104/go.mod h1:qy7whtgG5g+7z0jt0nRyii9bLND9m15NZTzuQPkMZ5w= +github.com/smartcontractkit/chainlink-common v0.11.2-0.20260810181408-53d56f533c59 h1:Kfn0SV1b3pA0MenAW87jmVQY931zoj0kvUn1g86oRn8= +github.com/smartcontractkit/chainlink-common v0.11.2-0.20260810181408-53d56f533c59/go.mod h1:P9kQKuadFvQJbZcracSvMzgnLt3IXda2FLl5NiPTbVM= +github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.11-0.20260724142814-45996a1bcb72 h1:uWEwl7i2ryuRVoV4DmIKm6mqYevf1lH/8cQYhw/JXko= +github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.11-0.20260724142814-45996a1bcb72/go.mod h1:UYcRMb4dZcoaIPgZJ3hckCySTqtJc9K4Q+tOKErwTq0= +github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260804191526-b7a850ae7648 h1:WEUMkKQPAgcNMRgES6CBWrRUiII+HKEWQjulKQBSuMA= +github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260804191526-b7a850ae7648/go.mod h1:/i8hjTPFdVWHiY+QjeSiVS2Z3GB3WAZznGgXHstC02E= github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b h1:QuI6SmQFK/zyUlVWEf0GMkiUYBPY4lssn26nKSd/bOM= github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b/go.mod h1:qSTSwX3cBP3FKQwQacdjArqv0g6QnukjV4XuzO6UyoY= -github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260205130626-db2a2aab956b h1:36knUpKHHAZ86K4FGWXtx8i/EQftGdk2bqCoEu/Cha8= -github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260205130626-db2a2aab956b/go.mod h1:dkR2uYg9XYJuT1JASkPzWE51jjFkVb86P7a/yXe5/GM= -github.com/smartcontractkit/chainlink-protos/workflows/go v0.0.0-20260323124644-faea187e6997 h1:W0HKHO8eE8BckTRnhSdqjHKbJcnk068nEWYnWRu6tJY= -github.com/smartcontractkit/chainlink-protos/workflows/go v0.0.0-20260323124644-faea187e6997/go.mod h1:GTpDgyK0OObf7jpch6p8N281KxN92wbB8serZhU9yRc= +github.com/smartcontractkit/chainlink-protos/metering/go v0.0.0-20260729184203-90b4cdd48536 h1:ecQYtdRA+NQLXf0aKYUMfcn1TRhcQ4RZCZzzadFsbSs= +github.com/smartcontractkit/chainlink-protos/metering/go v0.0.0-20260729184203-90b4cdd48536/go.mod h1:z7lx7wI3XZ4u9kmUtAVdwn1BCC9T8aieWSDcuDgPTdQ= +github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260709145319-7782fb89eb16 h1:/vkKPJoweLkRd56V4YHGRAtTG4+/JAlgklGEfvH6l4c= +github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260709145319-7782fb89eb16/go.mod h1:dkR2uYg9XYJuT1JASkPzWE51jjFkVb86P7a/yXe5/GM= +github.com/smartcontractkit/chainlink-protos/workflows/go v0.0.0-20260528173149-f5b8336b19d9 h1:LQy2j2+TdKLSWsUTUYuqmQPn8kjqCLjGI3ZJYGtDc08= +github.com/smartcontractkit/chainlink-protos/workflows/go v0.0.0-20260528173149-f5b8336b19d9/go.mod h1:GTpDgyK0OObf7jpch6p8N281KxN92wbB8serZhU9yRc= github.com/smartcontractkit/freeport v0.1.3-0.20250716200817-cb5dfd0e369e h1:Hv9Mww35LrufCdM9wtS9yVi/rEWGI1UnjHbcKKU0nVY= github.com/smartcontractkit/freeport v0.1.3-0.20250716200817-cb5dfd0e369e/go.mod h1:T4zH9R8R8lVWKfU7tUvYz2o2jMv1OpGCdpY2j2QZXzU= github.com/smartcontractkit/grpc-proxy v0.0.0-20240830132753-a7e17fec5ab7 h1:12ijqMM9tvYVEm+nR826WsrNi6zCKpwBhuApq127wHs= diff --git a/cron/main.go b/cron/main.go index a085c0391..38466fba9 100644 --- a/cron/main.go +++ b/cron/main.go @@ -6,14 +6,23 @@ import ( "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/triggers/cron/server" "github.com/smartcontractkit/chainlink-common/pkg/loop" + "github.com/smartcontractkit/chainlink-common/pkg/resourcemanager" ) func main() { loopserver.ServeNew(trigger.ServiceName, func(s *loop.Server) loop.StandardCapabilities { - triggerService, err := trigger.NewTriggerService(s.Logger, nil, s.LimitsFactory) + // Server.MeteringConfig is the single, canonical loop-env -> metering + // mapping (enable flags, snapshot interval, deployment identity); no + // per-main copy of that mapping, and no reaching for a process-global + // emitter (it injects the server's own durable emitter). + metering := s.MeteringConfig() + meters := resourcemanager.NewResourceManager(s.Logger, metering.ResourceManagerConfig) + + triggerService, err := trigger.NewTriggerService(s.Logger, nil, s.LimitsFactory, meters) if err != nil { s.Logger.Fatalw("Failed to create cron trigger service", "error", err) } + triggerService.Deployment = metering.DeploymentIdentity return server.NewCronServer(triggerService) }, loop.WithOtelViews(trigger.MetricViews())) diff --git a/cron/trigger/metering_test.go b/cron/trigger/metering_test.go new file mode 100644 index 000000000..ac119a351 --- /dev/null +++ b/cron/trigger/metering_test.go @@ -0,0 +1,391 @@ +package trigger + +import ( + "context" + "encoding/json" + "errors" + "sync" + "testing" + "time" + + "github.com/jonboulle/clockwork" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + + "github.com/smartcontractkit/chainlink-common/pkg/capabilities" + crontypedapi "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/triggers/cron" + "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink-common/pkg/resourcemanager" + "github.com/smartcontractkit/chainlink-common/pkg/settings/limits" + "github.com/smartcontractkit/chainlink-common/pkg/types/core" + meteringpb "github.com/smartcontractkit/chainlink-protos/metering/go" + + "github.com/smartcontractkit/capabilities/libs/triggermeter" +) + +// fakeMeterEmitter captures metering emissions delivered through a real +// ResourceManager, so tests assert on exactly the bytes production would emit. +// It demultiplexes MeterRecord and MeterSnapshot messages by their beholder +// entity attribute. A non-nil err simulates delivery failure: nothing is +// recorded. +type fakeMeterEmitter struct { + mu sync.Mutex + err error + records []*meteringpb.MeterRecord + recordDomains []string + snapshots []*meteringpb.MeterSnapshot +} + +func (f *fakeMeterEmitter) Emit(_ context.Context, body []byte, attrKVs ...any) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.err != nil { + return f.err + } + if attrValue(attrKVs, "beholder_entity") == "metering.v1.MeterSnapshot" { + snapshot := &meteringpb.MeterSnapshot{} + if err := proto.Unmarshal(body, snapshot); err != nil { + return err + } + f.snapshots = append(f.snapshots, snapshot) + return nil + } + record := &meteringpb.MeterRecord{} + if err := proto.Unmarshal(body, record); err != nil { + return err + } + f.records = append(f.records, record) + f.recordDomains = append(f.recordDomains, attrValue(attrKVs, "beholder_domain")) + return nil +} + +// attrValue extracts a beholder attribute value by key from the variadic +// key/value attrs the ResourceManager passes to Emit. +func attrValue(attrKVs []any, key string) string { + for i := 0; i+1 < len(attrKVs); i += 2 { + if k, ok := attrKVs[i].(string); ok && k == key { + if v, ok := attrKVs[i+1].(string); ok { + return v + } + } + } + return "" +} + +func (f *fakeMeterEmitter) Records() []*meteringpb.MeterRecord { + f.mu.Lock() + defer f.mu.Unlock() + return append([]*meteringpb.MeterRecord(nil), f.records...) +} + +func (f *fakeMeterEmitter) Snapshots() []*meteringpb.MeterSnapshot { + f.mu.Lock() + defer f.mu.Unlock() + return append([]*meteringpb.MeterSnapshot(nil), f.snapshots...) +} + +// meteredTestDeps are the host-injected dependencies used by metering tests. +// The DON dimension still arrives via the Initialise channel; the +// deployment/node dimensions now arrive via loop.EnvConfig (meteredTestDeployment). +var meteredTestDeps = core.StandardCapabilitiesDependencies{ + CapabilityDonID: 7, +} + +// meteredTestDeployment is the deployment/node identity that main would source +// from loop.EnvConfig and set on the service before Initialise. +var meteredTestDeployment = resourcemanager.DeploymentIdentity{ + Product: "cre-mainline", + Tenant: "mainline", + NumericTenantID: "42", + Environment: "staging", + Zone: "wf-zone-a", + NodeID: "clp-cre-wf-zone-a-1", +} + +// newMeteredTriggerService builds an initialised trigger service whose +// ResourceManager is enabled and wired to emitter, with identity sourced from +// meteredTestDeps. Snapshots use a fake clock so tests advance the tick +// deterministically. +func newMeteredTriggerService(t *testing.T, clock clockwork.Clock, emitter resourcemanager.Emitter) (*Service, *resourcemanager.ResourceManager, *clockwork.FakeClock) { + t.Helper() + + fakeClock, ok := clock.(*clockwork.FakeClock) + if !ok { + fakeClock = clockwork.NewFakeClockAt(clock.Now()) + clock = fakeClock + } + + meters := resourcemanager.NewResourceManager(logger.Nop(), resourcemanager.ResourceManagerConfig{ + MeterRecordsEnabled: true, + MeterSnapshotsEnabled: true, + Emitter: emitter, + SnapshotInterval: time.Minute, + Clock: clock, + }) + ts, err := NewTriggerService(logger.Nop(), clock, limits.Factory{}, meters) + require.NoError(t, err) + ts.Deployment = meteredTestDeployment + + config, err := json.Marshal(Config{FastestScheduleIntervalSeconds: 1}) + require.NoError(t, err) + + deps := meteredTestDeps + deps.Config = string(config) + require.NoError(t, ts.Initialise(t.Context(), deps)) + + return ts, meters, fakeClock +} + +// TestCronTrigger_Metering_NoRecords is the core snapshot-only invariant: a +// full registration lifecycle — register, tick callbacks, failed paths, +// unregister, re-register (the restart shape) — emits ZERO MeterRecords. +// Trigger capabilities bill exclusively through snapshots: the level rises +// when a registration appears in the next snapshot and is released by its +// absence, so there are no deltas to re-emit on restart and nothing for the +// billing consumer to dedup. +func TestCronTrigger_Metering_NoRecords(t *testing.T) { + t.Parallel() + + fakeClock := clockwork.NewFakeClockAt(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)) + emitter := &fakeMeterEmitter{} + ts, _, _ := newMeteredTriggerService(t, fakeClock, emitter) + + metadata := capabilities.RequestMetadata{ + WorkflowID: workflowID1, + WorkflowOwner: "0xOwner-1", + } + ch, capErr := ts.RegisterTrigger(t.Context(), triggerID1, metadata, &crontypedapi.Config{Schedule: everySecond}) + require.Nil(t, capErr) + require.Empty(t, emitter.Records(), "registration must not emit meter records") + + // Failed paths emit nothing either. + _, capErr = ts.RegisterTrigger(t.Context(), "bad-schedule", metadata, &crontypedapi.Config{Schedule: "not-a-schedule"}) + require.NotNil(t, capErr) + _, capErr = ts.RegisterTrigger(t.Context(), triggerID1, metadata, &crontypedapi.Config{Schedule: everySecond}) + require.NotNil(t, capErr, "duplicate registration fails") + require.Nil(t, ts.UnregisterTrigger(t.Context(), "missing", metadata, &crontypedapi.Config{Schedule: everySecond})) + require.Empty(t, emitter.Records()) + + // Each cron tick re-Writes the trigger to reschedule it; the Write happens + // before the channel send, so after receiving the event the callback path + // has fully run. It must not emit. + for range 3 { + fakeClock.Advance(time.Second) + <-ch + } + require.Empty(t, emitter.Records(), "cron tick callbacks must not emit meter records") + + // Unregister then re-register the same trigger — the shape of an engine + // restart re-registering its triggers. Still nothing on the record stream. + require.Nil(t, ts.UnregisterTrigger(t.Context(), triggerID1, metadata, &crontypedapi.Config{Schedule: everySecond})) + _, capErr = ts.RegisterTrigger(t.Context(), triggerID1, metadata, &crontypedapi.Config{Schedule: everySecond}) + require.Nil(t, capErr) + require.Empty(t, emitter.Records(), "restart-shaped re-registration must not emit meter records") + + require.NoError(t, ts.Close()) + require.Empty(t, emitter.Records()) +} + +func TestCronTrigger_Metering_FailOpen(t *testing.T) { + t.Parallel() + + fakeClock := clockwork.NewFakeClock() + emitter := &fakeMeterEmitter{err: errors.New("collector unavailable")} + ts, _, _ := newMeteredTriggerService(t, fakeClock, emitter) + + metadata := capabilities.RequestMetadata{WorkflowID: workflowID1, WorkflowOwner: "owner-1"} + + // Registration and unregistration succeed even though every emission fails. + ch, capErr := ts.RegisterTrigger(t.Context(), triggerID1, metadata, &crontypedapi.Config{Schedule: everySecond}) + require.Nil(t, capErr) + + fakeClock.Advance(time.Second) + <-ch // trigger still fires + + require.Nil(t, ts.UnregisterTrigger(t.Context(), triggerID1, metadata, &crontypedapi.Config{Schedule: everySecond})) + require.Empty(t, emitter.Records()) + + require.NoError(t, ts.Close()) +} + +// TestCronTrigger_Metering_NilMeterEquivalence asserts the fail-open +// equivalence contract: with metering off entirely (nil ResourceManager), the +// register/fire/unregister lifecycle behaves identically to the metered path. +func TestCronTrigger_Metering_NilMeterEquivalence(t *testing.T) { + t.Parallel() + + fakeClock := clockwork.NewFakeClock() + ts, err := NewTriggerService(logger.Nop(), fakeClock, limits.Factory{}, nil) + require.NoError(t, err) + + config, err := json.Marshal(Config{FastestScheduleIntervalSeconds: 1}) + require.NoError(t, err) + require.NoError(t, ts.Initialise(t.Context(), core.StandardCapabilitiesDependencies{Config: string(config)})) + + metadata := capabilities.RequestMetadata{WorkflowID: workflowID1, WorkflowOwner: "owner-1"} + ch, capErr := ts.RegisterTrigger(t.Context(), triggerID1, metadata, &crontypedapi.Config{Schedule: everySecond}) + require.Nil(t, capErr) + + fakeClock.Advance(time.Second) + <-ch + + require.Nil(t, ts.UnregisterTrigger(t.Context(), triggerID1, metadata, &crontypedapi.Config{Schedule: everySecond})) + require.NoError(t, ts.Close()) +} + +// TestCronTrigger_Metering_Snapshot asserts the snapshot surface end to end: a +// tick emits one MeterSnapshot per active trigger carrying the full identity +// and per-trigger utilization, and an unregistered trigger is released by its +// absence from the following tick. +func TestCronTrigger_Metering_Snapshot(t *testing.T) { + t.Parallel() + + fakeClock := clockwork.NewFakeClockAt(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)) + emitter := &fakeMeterEmitter{} + ts, _, clock := newMeteredTriggerService(t, fakeClock, emitter) + + metadata1 := capabilities.RequestMetadata{WorkflowID: workflowID1, WorkflowOwner: "0xOwner-1"} + _, capErr := ts.RegisterTrigger(t.Context(), triggerID1, metadata1, &crontypedapi.Config{Schedule: everySecond}) + require.Nil(t, capErr) + + metadata2 := capabilities.RequestMetadata{WorkflowID: "workflow-id-2", WorkflowOwner: "owner-2"} + const triggerID2 = "test-id-2" + _, capErr = ts.RegisterTrigger(t.Context(), triggerID2, metadata2, &crontypedapi.Config{Schedule: everySecond}) + require.Nil(t, capErr) + + require.NoError(t, clock.BlockUntilContext(t.Context(), 1)) + clock.Advance(time.Minute) + + // One MeterSnapshot per active trigger, value 1, full per-resource identity. + require.Eventually(t, func() bool { + return len(emitter.Snapshots()) == 2 + }, time.Second, time.Millisecond) + snapshots := emitter.Snapshots() + require.Len(t, snapshots, 2, "one MeterSnapshot per active trigger per tick") + + byTrigger := map[string]*meteringpb.MeterSnapshot{} + for _, s := range snapshots { + byTrigger[s.GetUtilization()[0].GetResourceId()] = s + } + + s1 := byTrigger[triggerID1] + require.NotNil(t, s1) + assert.Equal(t, "1", s1.GetUtilization()[0].GetValue()) + assert.Equal(t, "operations", s1.GetUtilization()[0].GetResourceType()) + + // The snapshot identity carries the deployment dimensions from + // loop.EnvConfig and the host-injected capability DON. + id := s1.GetIdentity() + require.NotNil(t, id) + assert.Equal(t, "cre-mainline", id.GetProduct()) + assert.Equal(t, "mainline", id.GetTenant()) + assert.Equal(t, "42", id.GetNumericTenantId()) + assert.Equal(t, "staging", id.GetEnvironment()) + assert.Equal(t, "wf-zone-a", id.GetZone()) + assert.Equal(t, "7", id.GetDon().GetDonId()) + assert.Equal(t, "clp-cre-wf-zone-a-1", id.GetDon().GetNodeId()) + assert.Equal(t, "cron-trigger", id.GetService()) + assert.Equal(t, "trigger_registrations", id.GetResourcePool()) + capDonID, donErr := ts.meter.DonID() + require.NoError(t, donErr) + assert.Equal(t, capDonID, id.GetDon().GetDonId()) + + s2 := byTrigger[triggerID2] + require.NotNil(t, s2) + assert.Equal(t, "1", s2.GetUtilization()[0].GetValue()) + assert.Equal(t, triggerID2, s2.GetUtilization()[0].GetResourceId()) + + // Release-by-absence: after unregistering trigger 2, the next tick + // snapshots only trigger 1. + require.Nil(t, ts.UnregisterTrigger(t.Context(), triggerID2, metadata2, &crontypedapi.Config{Schedule: everySecond})) + require.NoError(t, clock.BlockUntilContext(t.Context(), 1)) + clock.Advance(time.Minute) + require.Eventually(t, func() bool { + return len(emitter.Snapshots()) == 3 + }, time.Second, time.Millisecond) + last := emitter.Snapshots()[2] + assert.Equal(t, triggerID1, last.GetUtilization()[0].GetResourceId(), + "an unregistered trigger is released by its absence from the next snapshot") + + // The snapshot stream is the only metering surface: no records, ever. + require.Empty(t, emitter.Records()) + + require.NoError(t, ts.Close()) +} + +// TestCronTrigger_Metering_NoShutdownEmissions asserts that a graceful Close +// emits NO metering at all. Process-lifecycle emissions are deleted by design: +// billing releases each still-active registration by its absence from the next +// snapshot, not by a shutdown drain. +func TestCronTrigger_Metering_NoShutdownEmissions(t *testing.T) { + t.Parallel() + + fakeClock := clockwork.NewFakeClockAt(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)) + emitter := &fakeMeterEmitter{} + ts, _, _ := newMeteredTriggerService(t, fakeClock, emitter) + + metadata1 := capabilities.RequestMetadata{WorkflowID: workflowID1, WorkflowOwner: "0xOwner-1"} + _, capErr := ts.RegisterTrigger(t.Context(), triggerID1, metadata1, &crontypedapi.Config{Schedule: everySecond}) + require.Nil(t, capErr) + + metadata2 := capabilities.RequestMetadata{WorkflowID: "workflow-id-2", WorkflowOwner: "owner-2"} + const triggerID2 = "test-id-2" + _, capErr = ts.RegisterTrigger(t.Context(), triggerID2, metadata2, &crontypedapi.Config{Schedule: everySecond}) + require.Nil(t, capErr) + + require.NoError(t, ts.Close()) + + require.Empty(t, emitter.Records(), "graceful close must emit no meter records") + require.Empty(t, emitter.Snapshots(), "no snapshot tick ran; close must not force one") +} + +// TestCronTrigger_Metering_DonIDNotInitialised asserts that when the host has +// not injected a capability DON ID, snapshots are still emitted but with the +// DON dimension absent — the consumer workflow's DON ID is never substituted — +// and the meter's DonID returns ErrDonIDNotInitialised so callers that want a +// best-effort value (event labels, CRE-4409) degrade explicitly themselves. +func TestCronTrigger_Metering_DonIDNotInitialised(t *testing.T) { + t.Parallel() + + fakeClock := clockwork.NewFakeClockAt(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)) + emitter := &fakeMeterEmitter{} + + meters := resourcemanager.NewResourceManager(logger.Nop(), resourcemanager.ResourceManagerConfig{ + MeterRecordsEnabled: true, + MeterSnapshotsEnabled: true, + Emitter: emitter, + SnapshotInterval: time.Minute, + Clock: fakeClock, + }) + ts, err := NewTriggerService(logger.Nop(), fakeClock, limits.Factory{}, meters) + require.NoError(t, err) + + config, err := json.Marshal(Config{FastestScheduleIntervalSeconds: 1}) + require.NoError(t, err) + + // No CapabilityDonID injected (zero) → the DON dimension stays absent. + require.NoError(t, ts.Initialise(t.Context(), core.StandardCapabilitiesDependencies{Config: string(config)})) + + _, donErr := ts.meter.DonID() + require.ErrorIs(t, donErr, triggermeter.ErrDonIDNotInitialised) + + metadata := capabilities.RequestMetadata{WorkflowID: workflowID1, WorkflowOwner: "owner-1", WorkflowDonID: 42} + _, capErr := ts.RegisterTrigger(t.Context(), triggerID1, metadata, &crontypedapi.Config{Schedule: everySecond}) + require.Nil(t, capErr) + + require.NoError(t, fakeClock.BlockUntilContext(t.Context(), 1)) + fakeClock.Advance(time.Minute) + require.Eventually(t, func() bool { + return len(emitter.Snapshots()) == 1 + }, time.Second, time.Millisecond) + + snapshot := emitter.Snapshots()[0] + assert.Empty(t, snapshot.GetIdentity().GetDon().GetDonId(), + "the consumer workflow's DON ID must never be substituted for the capability DON") + // Product falls back to the shared trigger default when the host injects none. + assert.Equal(t, "cre", snapshot.GetIdentity().GetProduct()) + + require.Nil(t, ts.UnregisterTrigger(t.Context(), triggerID1, metadata, &crontypedapi.Config{Schedule: everySecond})) + require.NoError(t, ts.Close()) +} diff --git a/cron/trigger/store.go b/cron/trigger/store.go index 9484c2970..ee9ed1a90 100644 --- a/cron/trigger/store.go +++ b/cron/trigger/store.go @@ -14,6 +14,7 @@ type CronStore interface { Read(triggerID string) (value cronTrigger, ok bool) ReadAll() (values map[string]cronTrigger) Write(triggerID string, value cronTrigger) + WriteIfPresent(triggerID string, value cronTrigger) (written bool) Delete(triggerID string) } @@ -46,6 +47,23 @@ func (cs *cronStore) Write(triggerID string, value cronTrigger) { cs.triggers[triggerID] = value } +// WriteIfPresent updates triggerID only when it currently exists, performing +// the existence check and the write atomically under the store lock. It returns +// false without writing when the trigger has already been deleted (e.g. by a +// concurrent UnregisterTrigger). The cron task callback uses this so a tick that +// began before an unregister cannot re-insert ("resurrect") a trigger that was +// just removed: snapshot absence is the release signal, so a resurrected entry +// would keep the resource billed after the caller stopped it. +func (cs *cronStore) WriteIfPresent(triggerID string, value cronTrigger) (written bool) { + cs.mu.Lock() + defer cs.mu.Unlock() + if _, ok := cs.triggers[triggerID]; !ok { + return false + } + cs.triggers[triggerID] = value + return true +} + func (cs *cronStore) Delete(triggerID string) { cs.mu.Lock() defer cs.mu.Unlock() diff --git a/cron/trigger/trigger.go b/cron/trigger/trigger.go index 3afd24602..67a16b350 100644 --- a/cron/trigger/trigger.go +++ b/cron/trigger/trigger.go @@ -16,18 +16,19 @@ import ( "github.com/smartcontractkit/chainlink-common/pkg/capabilities" caperrors "github.com/smartcontractkit/chainlink-common/pkg/capabilities/errors" - "github.com/smartcontractkit/chainlink-common/pkg/capabilities/triggers/cron" crontypedapi "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/triggers/cron" "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/triggers/cron/server" "github.com/smartcontractkit/chainlink-common/pkg/custmsg" "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink-common/pkg/resourcemanager" "github.com/smartcontractkit/chainlink-common/pkg/services" - "github.com/smartcontractkit/chainlink-common/pkg/services/orgresolver" "github.com/smartcontractkit/chainlink-common/pkg/settings/cresettings" "github.com/smartcontractkit/chainlink-common/pkg/settings/limits" "github.com/smartcontractkit/chainlink-common/pkg/types/core" "github.com/smartcontractkit/chainlink-common/pkg/workflows" "github.com/smartcontractkit/chainlink-common/pkg/workflows/events" + + "github.com/smartcontractkit/capabilities/libs/triggermeter" ) const ServiceName = "CronCapabilities" @@ -40,23 +41,43 @@ var cronTriggerInfo = capabilities.MustNewCapabilityInfo( "A trigger that uses a cron schedule to run periodically at fixed times, dates, or intervals.", ) +// meteringConfig carries the cron trigger's metering identity constants: +// the stable service constant (it must not encode deployment environment or +// zone — those are discrete identity dimensions delivered via loop.EnvConfig, +// see Service.Deployment), the resource pool snapshots apply to, and the +// billing unit per registration. +var meteringConfig = triggermeter.Config{ + Service: "cron-trigger", + ResourcePool: "trigger_registrations", + ResourceType: "operations", +} + type Config struct { FastestScheduleIntervalSeconds int `json:"fastestScheduleIntervalSeconds"` } +type Payload struct { + ScheduledExecutionTime string `json:"ScheduledExecutionTime" yaml:"ScheduledExecutionTime" mapstructure:"ScheduledExecutionTime"` +} + type Response struct { capabilities.TriggerEvent - Payload cron.Payload + Payload Payload } type cronTrigger struct { - job gocron.Job - nextRun time.Time - workflowID string - close func() + job gocron.Job + nextRun time.Time + workflowID string + workflowOwner string + orgID string + close func() } type Service struct { + services.Service + srvcEng *services.Engine + capabilities.CapabilityInfo limitsFactory limits.Factory fastestScheduleInterval limits.TimeLimiter @@ -66,7 +87,20 @@ type Service struct { triggers *cronStore labeler custmsg.MessageEmitter metrics *Metrics - orgResolver orgresolver.OrgResolver + // rm is the ResourceManager handed to NewTriggerService (possibly nil: + // metering off); Initialise wraps it in the meter, which owns its + // lifecycle from then on. + rm *resourcemanager.ResourceManager + // meter owns every metering concern (RM lifecycle, identity, org + // resolution, snapshot registration). Nil-receiver-safe: it is nil until + // Initialise and a no-op when metering is off. + meter *triggermeter.TriggerMeter + // Deployment carries the static deployment/node identity dimensions + // delivered to the plugin process via loop.EnvConfig. It is set once at + // startup (by main, before Initialise) and read when building the base + // metering identity. The zero value is valid and leaves those dimensions + // empty. + Deployment resourcemanager.DeploymentIdentity } func (s *Service) RegisterLegacyTrigger(ctx context.Context, triggerID string, metadata capabilities.RequestMetadata, input *crontypedapi.Config) (<-chan capabilities.TriggerAndId[*crontypedapi.LegacyPayload], caperrors.Error) { //nolint:staticcheck @@ -104,8 +138,9 @@ func (s *Service) UnregisterLegacyTrigger(ctx context.Context, triggerID string, var _ services.Service = &Service{} // NewTriggerService creates a new trigger service. Optionally, a clock can be passed in for testing, if nil -// the system clock will be used. The orgResolver is optional and can be nil, but should be set in live environments. -func NewTriggerService(parentLggr logger.Logger, clock clockwork.Clock, limitsFactory limits.Factory) (*Service, error) { +// the system clock will be used. +// meters reports trigger registrations for billing; nil means metering is off. +func NewTriggerService(parentLggr logger.Logger, clock clockwork.Clock, limitsFactory limits.Factory, meters *resourcemanager.ResourceManager) (*Service, error) { lggr := logger.Named(parentLggr, "CRONTrigger") metrics, err := NewMetrics() @@ -131,7 +166,7 @@ func NewTriggerService(parentLggr logger.Logger, clock clockwork.Clock, limitsFa return nil, fmt.Errorf("error creating scheduler: %w", err) } - return &Service{ + s := &Service{ lggr: lggr, CapabilityInfo: cronTriggerInfo, limitsFactory: limitsFactory, @@ -144,7 +179,38 @@ func NewTriggerService(parentLggr logger.Logger, clock clockwork.Clock, limitsFa "capabilityName", cronTriggerInfo.ID, ), metrics: metrics, - }, nil + rm: meters, + } + + // The scheduler is started/stopped in s.start / s.close; the meter (built + // at Initialise) owns the ResourceManager lifecycle from those same hooks. + s.Service, s.srvcEng = services.Config{ + Name: "CronTrigger", + Start: s.start, + Close: s.close, + }.NewServiceEngine(lggr) + + return s, nil +} + +// snapshotRows reports the absolute state of every currently active cron +// registration for the meter's snapshot tick: one row per trigger, each at +// value 1 (a registration is a single reserved unit), with the org that was +// resolved and stored at registration time. It is a cheap in-memory read of +// the store snapshot. Cron emits NO MeterRecord deltas: billing follows the +// snapshot level, and a registration is released by its absence from the next +// snapshot. +func (s *Service) snapshotRows(context.Context) []triggermeter.SnapshotRow { + triggers := s.triggers.ReadAll() + rows := make([]triggermeter.SnapshotRow, 0, len(triggers)) + for triggerID, trigger := range triggers { + rows = append(rows, triggermeter.SnapshotRow{ + Value: 1, + ResourceID: triggerID, + OrgID: trigger.orgID, + }) + } + return rows } func (s *Service) Initialise(ctx context.Context, dependencies core.StandardCapabilitiesDependencies) error { @@ -168,11 +234,16 @@ func (s *Service) Initialise(ctx context.Context, dependencies core.StandardCapa } s.fastestScheduleInterval = limiter - s.orgResolver = dependencies.OrgResolver - if s.orgResolver == nil { + if dependencies.OrgResolver == nil { s.lggr.Warn("OrgResolver is nil, cron capability will not be able to fetch organization ID") } + // Build the meter: it owns the ResourceManager lifecycle, the base metering + // identity (deployment/node dimensions from s.Deployment via loop.EnvConfig, + // DON dimension from the host-injected CapabilityDonID), org resolution, and + // the snapshot registration over snapshotRows. + s.meter = triggermeter.New(s.lggr, s.rm, s.Deployment, dependencies.CapabilityDonID, meteringConfig, dependencies.OrgResolver, s.snapshotRows) + err = s.Start(ctx) if err != nil { return fmt.Errorf("error when starting trigger service: %w", err) @@ -254,22 +325,17 @@ func (s *Service) RegisterTrigger(ctx context.Context, triggerID string, metadat s.lggr.Errorw("failed to generate execution ID", "err", execIDErr, "triggerID", triggerID, "workflowID", trigger.workflowID, "triggerEventID", response.Id) // Continue with execution even if we can't generate ID or emit event } else { - // Try to fetch organization ID if org resolver is available - var orgID string - if s.orgResolver != nil && metadata.WorkflowOwner != "" { - func() { - defer func() { - if r := recover(); r != nil { - s.lggr.Warnw("Panic while fetching organization ID from org resolver", "workflowOwner", metadata.WorkflowOwner, "panic", r) - } - }() - if fetchedOrgID, orgErr := s.orgResolver.Get(ctx, metadata.WorkflowOwner); orgErr != nil { - s.lggr.Warnw("Failed to fetch organization ID from org resolver", "workflowOwner", metadata.WorkflowOwner, "error", orgErr) - } else if fetchedOrgID != "" { - orgID = fetchedOrgID - s.lggr.Debugw("Successfully fetched organization ID", "workflowOwner", metadata.WorkflowOwner, "orgID", orgID) - } - }() + // Try to fetch organization ID (fail-open, panic-safe in the meter). + orgID := s.meter.ResolveOrg(ctx, metadata.WorkflowOwner) + + // CRE-4409: event labels prefer the capability DON ID but fall + // back to the consumer workflow's DON ID when it is not (yet) + // initialised — a best-effort label beats an absent one. + // Metering deliberately does NOT share this fallback (see + // triggermeter.TriggerMeter.DonID). + donIDLabel, donIDErr := s.meter.DonID() + if donIDErr != nil && metadata.WorkflowDonID != 0 { + donIDLabel = strconv.FormatUint(uint64(metadata.WorkflowDonID), 10) } // Emit TriggerExecutionStarted event @@ -279,7 +345,7 @@ func (s *Service) RegisterTrigger(ctx context.Context, triggerID string, metadat events.KeyWorkflowExecutionID, workflowExecutionID, events.KeyWorkflowOwner, metadata.WorkflowOwner, events.KeyWorkflowName, displayWorkflowName, - events.KeyDonID, strconv.Itoa(int(metadata.WorkflowDonID)), + events.KeyDonID, donIDLabel, events.KeyDonVersion, strconv.Itoa(int(metadata.WorkflowDonConfigVersion)), events.KeyOrganizationID, orgID, events.KeyWorkflowRegistryChainSelector, metadata.WorkflowRegistryChainSelector, @@ -306,12 +372,21 @@ func (s *Service) RegisterTrigger(ctx context.Context, triggerID string, metadat if callbackCh == nil { return // unregistered already } - s.triggers.Write(triggerID, cronTrigger{ - job: job, - nextRun: nextExecutionTime, - workflowID: metadata.WorkflowID, - close: closeCh, - }) + // Re-check existence atomically with the write: an unregister that + // ran during this callback (after the Read above) deletes the + // trigger, and resurrecting it here would keep the resource billed + // via snapshots after the caller stopped it. WriteIfPresent skips + // the write when the trigger is already gone. + if written := s.triggers.WriteIfPresent(triggerID, cronTrigger{ + job: job, + nextRun: nextExecutionTime, + workflowID: metadata.WorkflowID, + workflowOwner: metadata.WorkflowOwner, + orgID: trigger.orgID, + close: closeCh, + }); !written { + return // unregistered concurrently; do not resurrect or send + } select { case callbackCh <- response: @@ -349,13 +424,24 @@ func (s *Service) RegisterTrigger(ctx context.Context, triggerID string, metadat return nil, caperrors.NewPublicSystemError(fmt.Errorf("RegisterTrigger failed to remove job: %s", err), caperrors.Internal) } + // Resolve the org once at registration and store it: the snapshot path + // (snapshotRows) must be network-free, so it reads this stored value. + orgID := s.meter.ResolveOrg(ctx, metadata.WorkflowOwner) + s.triggers.Write(triggerID, cronTrigger{ - job: job, - nextRun: firstRunTime, - workflowID: metadata.WorkflowID, - close: closeCh, + job: job, + nextRun: firstRunTime, + workflowID: metadata.WorkflowID, + workflowOwner: metadata.WorkflowOwner, + orgID: orgID, + close: closeCh, }) + // No MeterRecord delta: billing observes the new registration in the next + // snapshot (snapshotRows). Trigger capabilities are snapshot-only producers + // — see the triggermeter package doc for why deltas are structurally + // unsound here. + s.lggr.Debugw("Trigger registered", "workflowId", metadata.WorkflowID, "triggerId", triggerID, "jobId", job.ID()) s.metrics.IncActiveTriggersGauge(ctx) return callbackCh, nil @@ -404,7 +490,8 @@ func (s *Service) UnregisterTrigger(ctx context.Context, triggerID string, metad // Close callback channel trigger.close() - // Remove from triggers context + // Remove from triggers context. No MeterRecord delta: billing releases the + // registration by its absence from the next snapshot (snapshotRows). s.triggers.Delete(triggerID) s.lggr.Debugw("UnregisterTrigger", "triggerId", triggerID, "jobId", jobID) @@ -412,66 +499,68 @@ func (s *Service) UnregisterTrigger(ctx context.Context, triggerID string, metad return nil } -// Start the service. -func (s *Service) Start(ctx context.Context) error { +// start is the services.Engine start hook. It starts the meter (which owns +// the ResourceManager lifecycle and snapshot registration; fail-open, so a +// metering failure never gates the trigger) and the scheduler, refreshing +// next-run times for any registrations that survived a restart. +func (s *Service) start(ctx context.Context) error { if s.scheduler == nil { return errors.New("service has shutdown, it must be built again to restart") } + if err := s.meter.Start(ctx); err != nil { + return err + } + s.scheduler.Start() for triggerID, trigger := range s.triggers.ReadAll() { nextExecutionTime, err := trigger.job.NextRun() s.triggers.Write(triggerID, cronTrigger{ - job: trigger.job, - nextRun: nextExecutionTime, - workflowID: trigger.workflowID, - close: trigger.close, + job: trigger.job, + nextRun: nextExecutionTime, + workflowID: trigger.workflowID, + workflowOwner: trigger.workflowOwner, + orgID: trigger.orgID, + close: trigger.close, }) if err != nil { s.lggr.Errorw("Unable to get next run time", "err", err, "triggerID", triggerID) } } - s.lggr.Info(s.Name() + " started") - return nil } -// Close stops the Service. -// After this call the Service cannot be started again, -// The service will need to be re-built to start scheduling again. -func (s *Service) Close() error { +// close is the services.Engine close hook. After this the Service cannot be +// started again; it must be re-built to schedule again. There are NO +// process-lifecycle metering emissions: a graceful shutdown emits nothing, and +// billing releases each still-active registration by its absence from the next +// snapshot. close closes the meter FIRST (deregistering the snapshot Meterable +// so no tick can observe a half-torn-down service, then closing the +// ResourceManager), then shuts the scheduler down. +func (s *Service) close() error { if s.scheduler == nil { return errors.New("service has shutdown, it must be built again to restart") } + meterErr := s.meter.Close() + err := s.scheduler.Shutdown() if err != nil { - return fmt.Errorf("scheduler shutdown encountered a problem: %s", err) + return errors.Join(meterErr, fmt.Errorf("scheduler shutdown encountered a problem: %s", err)) + } + if meterErr != nil { + return meterErr } // After .Shutdown() the scheduler cannot be started again, // but calling .Start() on it will not error. Set to nil to mark closed. s.scheduler = nil - s.lggr.Info(s.Name() + " closed") - return nil } -func (s *Service) Ready() error { - return nil -} - -func (s *Service) HealthReport() map[string]error { - return map[string]error{s.Name(): nil} -} - -func (s *Service) Name() string { - return s.lggr.Name() -} - func (s *Service) Description() string { return "Cron Trigger Capability" } diff --git a/cron/trigger/trigger_test.go b/cron/trigger/trigger_test.go index 3eca57abd..b150d4856 100644 --- a/cron/trigger/trigger_test.go +++ b/cron/trigger/trigger_test.go @@ -20,7 +20,6 @@ import ( "github.com/smartcontractkit/chainlink-common/pkg/capabilities" caperrors "github.com/smartcontractkit/chainlink-common/pkg/capabilities/errors" - "github.com/smartcontractkit/chainlink-common/pkg/capabilities/triggers/cron" crontypedapi "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/triggers/cron" "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/triggers/cron/server" "github.com/smartcontractkit/chainlink-common/pkg/logger" @@ -115,7 +114,7 @@ func upwrapCronTriggerEvent(t *testing.T, event capabilities.TriggerEvent, payload := &crontypedapi.LegacyPayload{} //nolint:staticcheck err := event.Payload.UnmarshalTo(payload) require.NoError(t, err) - response.Payload = cron.Payload{ScheduledExecutionTime: payload.ScheduledExecutionTime} + response.Payload = Payload{ScheduledExecutionTime: payload.ScheduledExecutionTime} return response } @@ -252,7 +251,7 @@ func successWithStandardCronIntervals(t *testing.T, useTypedAPI bool) { config, err := json.Marshal(Config{FastestScheduleIntervalSeconds: 1}) require.NoError(t, err) - ts, err := NewTriggerService(logger.Nop(), fakeClock, limits.Factory{}) + ts, err := NewTriggerService(logger.Nop(), fakeClock, limits.Factory{}, nil) require.NoError(t, err) err = ts.Initialise(t.Context(), core.StandardCapabilitiesDependencies{ Config: string(config), @@ -333,7 +332,7 @@ func TestCronTrigger_Load(t *testing.T) { config, err := json.Marshal(Config{FastestScheduleIntervalSeconds: 1}) require.NoError(t, err) - ts, err := NewTriggerService(logger.Nop(), fakeClock, limits.Factory{}) + ts, err := NewTriggerService(logger.Nop(), fakeClock, limits.Factory{}, nil) require.NoError(t, err) err = ts.Initialise(t.Context(), core.StandardCapabilitiesDependencies{ Config: string(config), @@ -480,7 +479,7 @@ func testCronTriggerRegisterTriggerBeforeStart(t *testing.T, useTypedAPI bool) { fakeClock := clockwork.NewRealClock() config, err := json.Marshal(Config{FastestScheduleIntervalSeconds: 1}) require.NoError(t, err) - ts, err := NewTriggerService(logger.Nop(), fakeClock, limits.Factory{}) + ts, err := NewTriggerService(logger.Nop(), fakeClock, limits.Factory{}, nil) require.NoError(t, err) err = ts.Initialise(t.Context(), core.StandardCapabilitiesDependencies{ Config: string(config), @@ -553,7 +552,7 @@ func testCronTriggerTimeWindows(t *testing.T, useTypedAPI bool) { config, err := json.Marshal(Config{FastestScheduleIntervalSeconds: 1}) require.NoError(t, err) - ts, err := NewTriggerService(logger.Nop(), fakeClock, limits.Factory{}) + ts, err := NewTriggerService(logger.Nop(), fakeClock, limits.Factory{}, nil) require.NoError(t, err) err = ts.Initialise(t.Context(), core.StandardCapabilitiesDependencies{ Config: string(config), @@ -629,7 +628,7 @@ func testCronTriggerMultipleDifferentSchedules(t *testing.T, useTypedAPI bool) { } config, err := json.Marshal(Config{FastestScheduleIntervalSeconds: 1}) require.NoError(t, err) - ts, err := NewTriggerService(logger.Nop(), fakeClock, limits.Factory{}) + ts, err := NewTriggerService(logger.Nop(), fakeClock, limits.Factory{}, nil) require.NoError(t, err) err = ts.Initialise(t.Context(), core.StandardCapabilitiesDependencies{ Config: string(config), @@ -752,7 +751,7 @@ func testCronTriggerTimeZone(t *testing.T, useTypedAPI bool) { config, err := json.Marshal(Config{FastestScheduleIntervalSeconds: 1}) require.NoError(t, err) - ts, err := NewTriggerService(logger.Nop(), fakeClock, limits.Factory{}) + ts, err := NewTriggerService(logger.Nop(), fakeClock, limits.Factory{}, nil) require.NoError(t, err) err = ts.Initialise(t.Context(), core.StandardCapabilitiesDependencies{ Config: string(config), @@ -865,7 +864,7 @@ func testCronTriggerRegisterTrigger(t *testing.T, useTypedAPI bool) { for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { fakeClock := clockwork.NewRealClock() - ts, err := NewTriggerService(logger.Nop(), fakeClock, limits.Factory{}) + ts, err := NewTriggerService(logger.Nop(), fakeClock, limits.Factory{}, nil) require.NoError(t, err) err = ts.Initialise(t.Context(), core.StandardCapabilitiesDependencies{}) require.NoError(t, err) @@ -904,7 +903,7 @@ func TestCronTrigger_RegisterTriggerDuplicateError(t *testing.T) { triggerConfig, err := json.Marshal(Config{FastestScheduleIntervalSeconds: 1}) require.NoError(t, err) fakeClock := clockwork.NewRealClock() - ts, err := NewTriggerService(logger.Nop(), fakeClock, limits.Factory{}) + ts, err := NewTriggerService(logger.Nop(), fakeClock, limits.Factory{}, nil) require.NoError(t, err) err = ts.Initialise(t.Context(), core.StandardCapabilitiesDependencies{ Config: string(triggerConfig), @@ -939,7 +938,7 @@ func TestCronTrigger_UnregisterTriggerError(t *testing.T) { triggerConfig, err := json.Marshal(Config{FastestScheduleIntervalSeconds: 1}) require.NoError(t, err) fakeClock := clockwork.NewRealClock() - ts, err := NewTriggerService(logger.Nop(), fakeClock, limits.Factory{}) + ts, err := NewTriggerService(logger.Nop(), fakeClock, limits.Factory{}, nil) require.NoError(t, err) err = ts.Initialise(t.Context(), core.StandardCapabilitiesDependencies{ Config: string(triggerConfig), @@ -1018,7 +1017,7 @@ func TestCronTrigger_UnregisterTriggerError(t *testing.T) { }) t.Run("NOK fails to unregister if closed", func(t *testing.T) { - ts, err := NewTriggerService(logger.Nop(), fakeClock, limits.Factory{}) + ts, err := NewTriggerService(logger.Nop(), fakeClock, limits.Factory{}, nil) require.NoError(t, err) err = ts.Initialise(t.Context(), core.StandardCapabilitiesDependencies{ Config: string(triggerConfig), @@ -1056,7 +1055,7 @@ func TestCronTrigger_UnregisterTriggerError(t *testing.T) { func TestCronTrigger_CloseStartErrors(t *testing.T) { fakeClock := clockwork.NewRealClock() - ts, err := NewTriggerService(logger.Nop(), fakeClock, limits.Factory{}) + ts, err := NewTriggerService(logger.Nop(), fakeClock, limits.Factory{}, nil) require.NoError(t, err) ctx := t.Context() @@ -1082,7 +1081,7 @@ func TestGocronNewTaskPanic(t *testing.T) { config, err := json.Marshal(Config{FastestScheduleIntervalSeconds: 1}) require.NoError(t, err) logger, observedLogs := logger.TestObserved(t, zap.ErrorLevel) - ts, err := NewTriggerService(logger, fakeClock, limits.Factory{}) + ts, err := NewTriggerService(logger, fakeClock, limits.Factory{}, nil) require.NoError(t, err) err = ts.Initialise(t.Context(), core.StandardCapabilitiesDependencies{ Config: string(config), @@ -1176,7 +1175,7 @@ func TestCronTrigger_ExecutionIDWithTriggerIndex(t *testing.T) { triggerConfig, err := json.Marshal(Config{FastestScheduleIntervalSeconds: 1}) require.NoError(t, err) - ts, err := NewTriggerService(lggr, fakeClock, limits.Factory{}) + ts, err := NewTriggerService(lggr, fakeClock, limits.Factory{}, nil) require.NoError(t, err) err = ts.Initialise(t.Context(), core.StandardCapabilitiesDependencies{Config: string(triggerConfig)}) require.NoError(t, err) diff --git a/http_trigger/go.mod b/http_trigger/go.mod index c305e09cc..f75929f43 100644 --- a/http_trigger/go.mod +++ b/http_trigger/go.mod @@ -3,8 +3,9 @@ module github.com/smartcontractkit/capabilities/http_trigger go 1.26.2 require ( + github.com/jonboulle/clockwork v0.5.0 github.com/smartcontractkit/capabilities/libs v0.0.0-20260210010829-97eb42ca2924 - github.com/smartcontractkit/chainlink-common v0.11.2-0.20260722132052-c7af471801f2 + github.com/smartcontractkit/chainlink-common v0.11.2-0.20260810181408-53d56f533c59 github.com/stretchr/testify v1.11.1 go.opentelemetry.io/otel v1.43.0 go.opentelemetry.io/otel/metric v1.43.0 @@ -50,7 +51,6 @@ require ( github.com/jackc/pgx/v5 v5.9.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jmoiron/sqlx v1.4.0 // indirect - github.com/jonboulle/clockwork v0.5.0 // indirect github.com/jpillora/backoff v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.18.0 // indirect @@ -75,11 +75,11 @@ require ( github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 // indirect github.com/scylladb/go-reflectx v1.0.1 // indirect github.com/shopspring/decimal v1.4.0 // indirect - github.com/smartcontractkit/chain-selectors v1.0.100 // indirect - github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.11-0.20260716165322-7f2edff6e954 // indirect - github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260622152157-c8e129347b8b // indirect + github.com/smartcontractkit/chain-selectors v1.0.104 // indirect + github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.11-0.20260724142814-45996a1bcb72 // indirect + github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260804191526-b7a850ae7648 // indirect github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b // indirect - github.com/smartcontractkit/chainlink-protos/metering/go v0.0.0-20260710151514-27b5a126dabe // indirect + github.com/smartcontractkit/chainlink-protos/metering/go v0.0.0-20260729184203-90b4cdd48536 github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260709145319-7782fb89eb16 // indirect github.com/smartcontractkit/chainlink-protos/workflows/go v0.0.0-20260528173149-f5b8336b19d9 // indirect github.com/smartcontractkit/freeport v0.1.3-0.20250716200817-cb5dfd0e369e // indirect @@ -121,7 +121,9 @@ require ( google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect google.golang.org/grpc v1.82.1 // indirect - google.golang.org/protobuf v1.36.11 // indirect + google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 // indirect sigs.k8s.io/yaml v1.4.0 // indirect ) + +replace github.com/smartcontractkit/capabilities/libs => ../libs diff --git a/http_trigger/go.sum b/http_trigger/go.sum index 807450807..a65609bbe 100644 --- a/http_trigger/go.sum +++ b/http_trigger/go.sum @@ -205,20 +205,18 @@ github.com/scylladb/go-reflectx v1.0.1 h1:b917wZM7189pZdlND9PbIJ6NQxfDPfBvUaQ7cj github.com/scylladb/go-reflectx v1.0.1/go.mod h1:rWnOfDIRWBGN0miMLIcoPt/Dhi2doCMZqwMCJ3KupFc= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= -github.com/smartcontractkit/capabilities/libs v0.0.0-20260210010829-97eb42ca2924 h1:6wqsOpDXA0ZMEswN7f8hX04Y3+gXva7p5emXThtJVlI= -github.com/smartcontractkit/capabilities/libs v0.0.0-20260210010829-97eb42ca2924/go.mod h1:v0O0Au8RE00Z89QxBE6I2q9bR9r3+RO1gLD3oaO2WB0= -github.com/smartcontractkit/chain-selectors v1.0.100 h1:wpiSpmI/eFjY+wx/nPr5VuNF4hki0prIBMKEaQWn3g4= -github.com/smartcontractkit/chain-selectors v1.0.100/go.mod h1:qy7whtgG5g+7z0jt0nRyii9bLND9m15NZTzuQPkMZ5w= -github.com/smartcontractkit/chainlink-common v0.11.2-0.20260722132052-c7af471801f2 h1:JZLdNP/jAB2bfjD2u9pC6zST7dyG3Ch3PDi/ctEPDHQ= -github.com/smartcontractkit/chainlink-common v0.11.2-0.20260722132052-c7af471801f2/go.mod h1:DGvW2Opi/qcOWNkOq18xTI5ZqByoxfPTL9H5M1bC4Ls= -github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.11-0.20260716165322-7f2edff6e954 h1:QhTMiEn3s+AB4xBoScuQglsqHGJYxheYrgpxdIdqNAI= -github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.11-0.20260716165322-7f2edff6e954/go.mod h1:UYcRMb4dZcoaIPgZJ3hckCySTqtJc9K4Q+tOKErwTq0= -github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260622152157-c8e129347b8b h1:VDgJWDipihV9f7M5+d21d1RzSsg5rEv+iI12oN1VQbo= -github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260622152157-c8e129347b8b/go.mod h1:vTFHTCbLui4Vn8fTmAadfE3rdnvfrDwOmMujmW857D0= +github.com/smartcontractkit/chain-selectors v1.0.104 h1:/n9pPGM5W/+r1eHoWZv4VwX9LNS1af4+ICyhM8zKRNM= +github.com/smartcontractkit/chain-selectors v1.0.104/go.mod h1:qy7whtgG5g+7z0jt0nRyii9bLND9m15NZTzuQPkMZ5w= +github.com/smartcontractkit/chainlink-common v0.11.2-0.20260810181408-53d56f533c59 h1:Kfn0SV1b3pA0MenAW87jmVQY931zoj0kvUn1g86oRn8= +github.com/smartcontractkit/chainlink-common v0.11.2-0.20260810181408-53d56f533c59/go.mod h1:P9kQKuadFvQJbZcracSvMzgnLt3IXda2FLl5NiPTbVM= +github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.11-0.20260724142814-45996a1bcb72 h1:uWEwl7i2ryuRVoV4DmIKm6mqYevf1lH/8cQYhw/JXko= +github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.11-0.20260724142814-45996a1bcb72/go.mod h1:UYcRMb4dZcoaIPgZJ3hckCySTqtJc9K4Q+tOKErwTq0= +github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260804191526-b7a850ae7648 h1:WEUMkKQPAgcNMRgES6CBWrRUiII+HKEWQjulKQBSuMA= +github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260804191526-b7a850ae7648/go.mod h1:/i8hjTPFdVWHiY+QjeSiVS2Z3GB3WAZznGgXHstC02E= github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b h1:QuI6SmQFK/zyUlVWEf0GMkiUYBPY4lssn26nKSd/bOM= github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b/go.mod h1:qSTSwX3cBP3FKQwQacdjArqv0g6QnukjV4XuzO6UyoY= -github.com/smartcontractkit/chainlink-protos/metering/go v0.0.0-20260710151514-27b5a126dabe h1:MDnY5wQbWTpFdDnMRicEnoMfSP5nM/KncARr4skP1ug= -github.com/smartcontractkit/chainlink-protos/metering/go v0.0.0-20260710151514-27b5a126dabe/go.mod h1:z7lx7wI3XZ4u9kmUtAVdwn1BCC9T8aieWSDcuDgPTdQ= +github.com/smartcontractkit/chainlink-protos/metering/go v0.0.0-20260729184203-90b4cdd48536 h1:ecQYtdRA+NQLXf0aKYUMfcn1TRhcQ4RZCZzzadFsbSs= +github.com/smartcontractkit/chainlink-protos/metering/go v0.0.0-20260729184203-90b4cdd48536/go.mod h1:z7lx7wI3XZ4u9kmUtAVdwn1BCC9T8aieWSDcuDgPTdQ= github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260709145319-7782fb89eb16 h1:/vkKPJoweLkRd56V4YHGRAtTG4+/JAlgklGEfvH6l4c= github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260709145319-7782fb89eb16/go.mod h1:dkR2uYg9XYJuT1JASkPzWE51jjFkVb86P7a/yXe5/GM= github.com/smartcontractkit/chainlink-protos/workflows/go v0.0.0-20260528173149-f5b8336b19d9 h1:LQy2j2+TdKLSWsUTUYuqmQPn8kjqCLjGI3ZJYGtDc08= diff --git a/http_trigger/main.go b/http_trigger/main.go index 337e478c3..e6eff0d24 100644 --- a/http_trigger/main.go +++ b/http_trigger/main.go @@ -10,6 +10,11 @@ import ( func main() { loopserver.ServeNew(trigger.ServiceName, func(s *loop.Server) loop.StandardCapabilities { - return server.NewHTTPServer(trigger.NewService(s.Logger, s.LimitsFactory)) + // Server.MeteringConfig is the single, canonical loop-env -> metering + // mapping (enable flags, snapshot interval, deployment identity); no + // per-main copy of that mapping, and no reaching for a process-global + // emitter (it injects the server's own durable emitter). + svc := trigger.NewService(s.Logger, s.LimitsFactory, s.MeteringConfig()) + return server.NewHTTPServer(svc) }) } diff --git a/http_trigger/trigger/connector_handler.go b/http_trigger/trigger/connector_handler.go index a6807ed15..4b478a6a4 100644 --- a/http_trigger/trigger/connector_handler.go +++ b/http_trigger/trigger/connector_handler.go @@ -26,6 +26,8 @@ import ( gateway_common "github.com/smartcontractkit/chainlink-common/pkg/types/gateway" "github.com/smartcontractkit/chainlink-common/pkg/workflows" "github.com/smartcontractkit/chainlink-common/pkg/workflows/events" + + "github.com/smartcontractkit/capabilities/libs/triggermeter" ) const ( @@ -40,7 +42,6 @@ type connectorHandler struct { lggr logger.Logger gatewayConnector core.GatewayConnector config ServiceConfig - capabilityDonID uint32 // authoritative sending DON ID; 0 = unknown, falls back to WorkflowDONID requestCache *requestCache workflowStore *workflowStore gatewayMetadataPublisher GatewayMetadataPublisher @@ -49,29 +50,34 @@ type connectorHandler struct { stopChan services.StopChan orgResolver orgresolver.OrgResolver // Optional org resolver for fetching organization IDs multiTriggerFlag limits.RangeLimiter[config.Timestamp] + // meter owns every metering concern: the ResourceManager lifecycle, the + // base identity, org resolution, and the snapshot registration over the + // workflow store. Nil-receiver-safe; a meter with metering off is a no-op. + meter *triggermeter.TriggerMeter } -func NewConnectorHandler(lggr logger.Logger, gc core.GatewayConnector, config ServiceConfig, capabilityDonID uint32, +func NewConnectorHandler(lggr logger.Logger, gc core.GatewayConnector, config ServiceConfig, workflowStore *workflowStore, gatewayMetadataPublisher GatewayMetadataPublisher, requestCache *requestCache, metrics *Metrics, orgResolver orgresolver.OrgResolver, limitsFactory limits.Factory, -) (*connectorHandler, error) { + meter *triggermeter.TriggerMeter) (*connectorHandler, error) { multiTriggerFlag, err := limits.MakeRangeLimiter(limitsFactory, cresettings.Default.PerWorkflow.FeatureHTTPTriggerNewExecutionIDsActivePeriod) if err != nil { return nil, fmt.Errorf("failed to create multi-trigger execution ID flag: %w", err) } - return &connectorHandler{ + h := &connectorHandler{ lggr: logger.Named(lggr, HandlerName), gatewayConnector: gc, config: config, - capabilityDonID: capabilityDonID, workflowStore: workflowStore, gatewayMetadataPublisher: gatewayMetadataPublisher, requestCache: requestCache, metrics: metrics, stopChan: make(chan struct{}), orgResolver: orgResolver, + meter: meter, multiTriggerFlag: multiTriggerFlag, - }, nil + } + return h, nil } func (h *connectorHandler) Start(ctx context.Context) error { @@ -79,6 +85,11 @@ func (h *connectorHandler) Start(ctx context.Context) error { h.wg.Add(1) go h.startRequestCacheCleanup(ctx) return h.StartOnce(HandlerName, func() error { + // Start the meter (fail-open: it owns the ResourceManager lifecycle and + // snapshot registration, and a metering failure never gates the handler). + if err := h.meter.Start(ctx); err != nil { + return err + } return h.gatewayConnector.AddHandler(ctx, []string{ gateway_common.MethodWorkflowExecute, gateway_common.MethodPullWorkflowMetadata, @@ -113,7 +124,11 @@ func (h *connectorHandler) Close() error { return h.StopOnce(HandlerName, func() error { close(h.stopChan) h.wg.Wait() - return nil + // No process-lifecycle metering emissions: a graceful shutdown emits + // nothing, and billing releases each still-active workflow by its + // absence from the next snapshot. The meter deregisters its snapshot + // Meterable first, then closes the ResourceManager iff it started it. + return h.meter.Close() }) } @@ -156,7 +171,17 @@ func (h *connectorHandler) RegisterWorkflow(ctx context.Context, input WorkflowR h.metrics.RecordBroadcastMetadataLatency(ctx, latencyMs, h.lggr) workflow := newWorkflowWithMetadata(input.WorkflowSelector, authorizedKeys, sendCh, input.Metadata) - if err := h.workflowStore.upsertWorkflow(workflow); err != nil { + // Resolve the org once at registration and store it on the workflow entry: + // the snapshot path (workflowStore.snapshotRows) must be network-free, so + // it reads this stored value on every tick. + workflow.orgID = h.meter.ResolveOrg(ctx, input.WorkflowSelector.WorkflowOwner) + // upsertWorkflow evicts any workflow previously holding this owner/name/tag + // reference atomically under the store lock. No MeterRecord deltas are + // emitted for registration, version eviction, or unregistration: billing + // follows the snapshot level, so a new workflow starts billing when it + // appears in the next snapshot and an evicted one is released by its + // absence. + if _, err := h.workflowStore.upsertWorkflow(workflow); err != nil { return fmt.Errorf("failed to register workflow (ID: %s, Owner: %s, Name: %s): %w", input.WorkflowSelector.WorkflowID, input.WorkflowSelector.WorkflowOwner, input.WorkflowSelector.WorkflowName, err) } @@ -191,6 +216,8 @@ func (h *connectorHandler) validateAuthorizedKeys(inputKeys []*http.AuthorizedKe } func (h *connectorHandler) UnregisterWorkflow(ctx context.Context, workflowID string) error { + // No MeterRecord delta: billing releases the workflow by its absence from + // the next snapshot (workflowStore.snapshotRows). err := h.workflowStore.removeWorkflow(workflowID) if err != nil { return fmt.Errorf("failed to unregister workflow %s: %w", workflowID, err) @@ -323,16 +350,14 @@ func (h *connectorHandler) processTrigger(ctx context.Context, gatewayID string, displayWorkflowName = workflowMetadata.WorkflowName } - // Emit the *sending* capability DON ID. The HTTP trigger plugin runs on a - // capability DON, separate from the consumer workflow's DON. The workflow - // service needs the sender's DON to resolve on-chain quorum params (N, F). - // See CRE-4409. capabilityDonID is 0 when the host could not resolve it - // authoritatively (a multi-DON job-spec node, or a core node that pre-dates - // CRE-4409); in that case we fall back to WorkflowDONID. This fallback is - // permanent, not transitional, since the job-spec boot path is still supported. - donIDForEvent := h.capabilityDonID - if donIDForEvent == 0 { - donIDForEvent = workflowMetadata.WorkflowDONID + // CRE-4409: event labels prefer the capability DON ID but fall back to the + // consumer workflow's DON ID when it is not (yet) initialised — a + // best-effort label beats an absent one. Metering deliberately does NOT + // share this fallback (see triggermeter.TriggerMeter.DonID): snapshots + // carry the capability DON or nothing. + donIDForEvent, donIDErr := h.meter.DonID() + if donIDErr != nil && workflowMetadata.WorkflowDONID != 0 { + donIDForEvent = strconv.FormatUint(uint64(workflowMetadata.WorkflowDONID), 10) } labeler := custmsg.NewLabeler().With( @@ -344,7 +369,7 @@ func (h *connectorHandler) processTrigger(ctx context.Context, gatewayID string, events.KeyWorkflowRegistryChainSelector, workflowMetadata.WorkflowRegistryChainSelector, events.KeyWorkflowRegistryAddress, workflowMetadata.WorkflowRegistryAddress, events.KeyEngineVersion, workflowMetadata.EngineVersion, - events.KeyDonID, strconv.Itoa(int(donIDForEvent)), + events.KeyDonID, donIDForEvent, ) if orgID != "" { labeler = labeler.With(events.KeyOrganizationID, orgID) diff --git a/http_trigger/trigger/connector_handler_test.go b/http_trigger/trigger/connector_handler_test.go index 0531e82fb..e1d807d6e 100644 --- a/http_trigger/trigger/connector_handler_test.go +++ b/http_trigger/trigger/connector_handler_test.go @@ -4,21 +4,31 @@ import ( "context" "database/sql" "encoding/json" + "errors" "strings" "sync" + "sync/atomic" "testing" "time" + "github.com/jonboulle/clockwork" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + "github.com/smartcontractkit/chainlink-common/pkg/beholder" "github.com/smartcontractkit/chainlink-common/pkg/capabilities" "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/triggers/http" jsonrpc "github.com/smartcontractkit/chainlink-common/pkg/jsonrpc2" "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink-common/pkg/resourcemanager" + "github.com/smartcontractkit/chainlink-common/pkg/services/orgresolver" "github.com/smartcontractkit/chainlink-common/pkg/settings/limits" "github.com/smartcontractkit/chainlink-common/pkg/types/core" gateway_common "github.com/smartcontractkit/chainlink-common/pkg/types/gateway" "github.com/smartcontractkit/chainlink-common/pkg/workflows" + meteringpb "github.com/smartcontractkit/chainlink-protos/metering/go" + + "github.com/smartcontractkit/capabilities/libs/triggermeter" ) const ( @@ -190,13 +200,13 @@ func setupWithTriggerChannelBuffer(t *testing.T, lggr logger.Logger, triggerChBu lggr, mockConnector, cfg, - 0, store, metadataPublisher, requestCache, newMetrics(t), nil, limits.Factory{}, + nil, ) require.NoError(t, err) sdkCfg := &http.Config{ @@ -596,13 +606,13 @@ func TestRegisterWorkflow_TooManyAuthorizedKeys(t *testing.T) { lggr, mockConnector, cfg, - 0, store, metadataPublisher, requestCache, newMetrics(t), nil, limits.Factory{}, + nil, ) require.NoError(t, err) @@ -715,13 +725,13 @@ func TestConnectorHandler_Start_HealthReport_Ready_Name_Close(t *testing.T) { lggr, mockConnector, cfg, - 0, store, metadataPublisher, requestCache, newMetrics(t), nil, limits.Factory{}, + nil, ) require.NoError(t, err) @@ -876,13 +886,13 @@ func TestHandleGatewayMessage_PullAuthMetadata_EmptyWorkflows(t *testing.T) { lggr, mockConnector, cfg, - 0, store, metadataPublisher, requestCache, newMetrics(t), nil, limits.Factory{}, + nil, ) require.NoError(t, err) @@ -1054,13 +1064,13 @@ func TestConnectorHandler_StartRequestCacheCleanup(t *testing.T) { lggr, mockConnector, cfg, - 0, store, metadataPublisher, requestCache, newMetrics(t), nil, limits.Factory{}, + nil, ) require.NoError(t, err) @@ -1122,6 +1132,399 @@ func TestHandleGatewayMessage_NilRequest(t *testing.T) { require.Contains(t, err.Error(), "request cannot be nil") } +// fakeMeterEmitter decodes and records the MeterRecords and MeterSnapshots +// passed to Emit and can be configured to fail, for asserting fail-open +// behavior. It dispatches on the beholder entity attribute so MeterRecord and +// MeterSnapshot bodies are decoded with the correct type. Each MeterSnapshot +// covers exactly one resource. +type fakeMeterEmitter struct { + err error + records []*meteringpb.MeterRecord + recordDomains []string + snapshots []*meteringpb.MeterSnapshot +} + +func (f *fakeMeterEmitter) Emit(ctx context.Context, body []byte, attrKVs ...any) error { + if f.attr(attrKVs, beholder.AttrKeyEntity) == "metering.v1.MeterSnapshot" { + var snapshot meteringpb.MeterSnapshot + if err := proto.Unmarshal(body, &snapshot); err != nil { + return err + } + f.snapshots = append(f.snapshots, &snapshot) + return f.err + } + var record meteringpb.MeterRecord + if err := proto.Unmarshal(body, &record); err != nil { + return err + } + f.records = append(f.records, &record) + f.recordDomains = append(f.recordDomains, f.attr(attrKVs, beholder.AttrKeyDomain)) + return f.err +} + +// attr returns the value of a beholder attribute by key from the alternating +// key/value attrKVs slice, or "" if absent. +func (f *fakeMeterEmitter) attr(attrKVs []any, key string) string { + for i := 0; i+1 < len(attrKVs); i += 2 { + if k, ok := attrKVs[i].(string); ok && k == key { + if v, ok := attrKVs[i+1].(string); ok { + return v + } + } + } + return "" +} + +func (f *fakeMeterEmitter) actions() []meteringpb.MeterAction { + actions := make([]meteringpb.MeterAction, len(f.records)) + for i, r := range f.records { + actions[i] = r.GetAction() + } + return actions +} + +// testDeployment is the deployment/node identity metering tests supply to the +// meter (production sources it from loop.EnvConfig). +var testDeployment = resourcemanager.DeploymentIdentity{ + Product: "cre-test", + Tenant: "mainline", + NumericTenantID: "42", + Environment: "staging", + Zone: "wf-zone-a", + NodeID: "node-csa-pubkey", +} + +// fakeOrgResolver is an orgresolver.OrgResolver that resolves "org-"+owner and +// counts its Get calls, so tests can assert org resolution happens once at +// registration and never on the snapshot tick. +type fakeOrgResolver struct { + calls atomic.Int32 +} + +func (f *fakeOrgResolver) Get(_ context.Context, owner string) (string, error) { + f.calls.Add(1) + return "org-" + owner, nil +} +func (f *fakeOrgResolver) Start(context.Context) error { return nil } +func (f *fakeOrgResolver) Close() error { return nil } +func (f *fakeOrgResolver) Ready() error { return nil } +func (f *fakeOrgResolver) HealthReport() map[string]error { return nil } +func (f *fakeOrgResolver) Name() string { return "fakeOrgResolver" } + +// newTestMeter builds a metering-enabled TriggerMeter over store's snapshot +// rows, wired to emitter. capabilityDonID 7 matches the identity assertions. +func newTestMeter(lggr logger.Logger, store *workflowStore, emitter resourcemanager.Emitter, orgResolver orgresolver.OrgResolver, clock clockwork.Clock) *triggermeter.TriggerMeter { + rm := resourcemanager.NewResourceManager(lggr, resourcemanager.ResourceManagerConfig{ + MeterRecordsEnabled: true, + MeterSnapshotsEnabled: true, + Emitter: emitter, + SnapshotInterval: time.Minute, + Clock: clock, + }) + return triggermeter.New(lggr, rm, testDeployment, 7, meteringConfig, orgResolver, store.snapshotRows) +} + +// setupWithMeterEmitter builds a handler with metering enabled and a fake +// emitter capturing everything the meter emits. The handler (and through it +// the meter and ResourceManager) is started and torn down on test cleanup. No +// workflows are registered. +func setupWithMeterEmitter(t *testing.T, lggr logger.Logger, emitErr error) (*connectorHandler, *fakeMeterEmitter) { + t.Helper() + emitter := &fakeMeterEmitter{err: emitErr} + cfg := ServiceConfig{ + MetadataBatchSize: 10, + MaxAuthorizedKeysPerWorkflow: 3, + } + store := newWorkflowStore(lggr) + metadataPublisher := NewGatewayMetadataPublisher(lggr, &mockGatewayConnector{}, store, cfg, newMetrics(t)) + requestCache := newRequestCache(logger.Sugared(lggr), newTestKVStore(), time.Hour) + handler, err := NewConnectorHandler( + lggr, + &mockGatewayConnector{}, + cfg, + store, + metadataPublisher, + requestCache, + newMetrics(t), + nil, + limits.Factory{}, + newTestMeter(lggr, store, emitter, nil, nil), + ) + require.NoError(t, err) + require.NoError(t, handler.Start(t.Context())) + t.Cleanup(func() { require.NoError(t, handler.Close()) }) + return handler, emitter +} + +func meterTestRegistrationInput() WorkflowRegistrationInput { + return WorkflowRegistrationInput{ + WorkflowSelector: gateway_common.WorkflowSelector{ + WorkflowID: testWorkflowID, + WorkflowOwner: testWorkflowOwner, + WorkflowName: testWorkflowName, + WorkflowTag: testWorkflowTag, + }, + Config: &http.Config{ + AuthorizedKeys: []*http.AuthorizedKey{ + { + PublicKey: publicKey, + Type: http.KeyType_KEY_TYPE_ECDSA_EVM, + }, + }, + }, + Metadata: WorkflowRegistrationMetadata{}, + } +} + +// TestRegisterUnregister_NoRecords is the core snapshot-only invariant: the +// full registration lifecycle — register, same-ID re-register (the restart +// shape), version update, unregister, failed unregister — emits ZERO +// MeterRecords. HTTP workflow registrations bill exclusively through +// snapshots: the level rises when a workflow appears in the next snapshot and +// is released by its absence. +func TestRegisterUnregister_NoRecords(t *testing.T) { + lggr := logger.Test(t) + handler, emitter := setupWithMeterEmitter(t, lggr, nil) + input := meterTestRegistrationInput() + + sendCh := make(chan capabilities.TriggerAndId[*http.Payload], 1) + require.NoError(t, handler.RegisterWorkflow(t.Context(), input, sendCh)) + require.Empty(t, emitter.records, "registration must not emit meter records") + + // Same-ID re-register (the shape of an engine restart re-registering). + sendCh2 := make(chan capabilities.TriggerAndId[*http.Payload], 1) + require.NoError(t, handler.RegisterWorkflow(t.Context(), input, sendCh2)) + require.Empty(t, emitter.records, "restart-shaped re-registration must not emit meter records") + + // Version update: same owner/name/tag reference, new workflow ID. The + // eviction happens in the store; billing follows via snapshot absence of + // the old ID and presence of the new, not via deltas. + inputB := meterTestRegistrationInput() + inputB.WorkflowSelector.WorkflowID = testWorkflowID2 + sendChB := make(chan capabilities.TriggerAndId[*http.Payload], 1) + require.NoError(t, handler.RegisterWorkflow(t.Context(), inputB, sendChB)) + require.Empty(t, emitter.records, "version eviction must not emit meter records") + _, oldStillThere := handler.workflowStore.getWorkflowByID(testWorkflowID) + require.False(t, oldStillThere, "version update evicts the previous workflow from the store") + + require.NoError(t, handler.UnregisterWorkflow(t.Context(), testWorkflowID2)) + require.Empty(t, emitter.records, "unregistration must not emit meter records") + + // Unregistering an absent workflow fails; still nothing on the record stream. + require.Error(t, handler.UnregisterWorkflow(t.Context(), testWorkflowID2)) + require.Empty(t, emitter.records) +} + +// TestRegisterWorkflow_OrgResolvedOnceAtRegistration asserts the org is +// resolved exactly once per registration and stored, and that snapshot ticks +// read the stored value without any resolver call (the Meterable no-network +// contract). +func TestRegisterWorkflow_OrgResolvedOnceAtRegistration(t *testing.T) { + lggr := logger.Test(t) + emitter := &fakeMeterEmitter{} + resolver := &fakeOrgResolver{} + clock := clockwork.NewFakeClockAt(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)) + cfg := ServiceConfig{MetadataBatchSize: 10, MaxAuthorizedKeysPerWorkflow: 3} + store := newWorkflowStore(lggr) + metadataPublisher := NewGatewayMetadataPublisher(lggr, &mockGatewayConnector{}, store, cfg, newMetrics(t)) + requestCache := newRequestCache(logger.Sugared(lggr), newTestKVStore(), time.Hour) + handler, err := NewConnectorHandler(lggr, &mockGatewayConnector{}, cfg, store, metadataPublisher, requestCache, newMetrics(t), resolver, limits.Factory{}, newTestMeter(lggr, store, emitter, resolver, clock)) + require.NoError(t, err) + require.NoError(t, handler.Start(t.Context())) + t.Cleanup(func() { require.NoError(t, handler.Close()) }) + + sendCh := make(chan capabilities.TriggerAndId[*http.Payload], 1) + require.NoError(t, handler.RegisterWorkflow(t.Context(), meterTestRegistrationInput(), sendCh)) + require.Equal(t, int32(1), resolver.calls.Load(), "org is resolved exactly once, at registration") + + // Two snapshot ticks: the stored org is served with zero resolver calls. + for range 2 { + require.NoError(t, clock.BlockUntilContext(t.Context(), 1)) + clock.Advance(time.Minute) + } + require.Eventually(t, func() bool { return len(emitter.snapshots) >= 2 }, time.Second, time.Millisecond) + require.Equal(t, int32(1), resolver.calls.Load(), "snapshot ticks must not resolve orgs") + require.Equal(t, "org-"+testWorkflowOwner, emitter.snapshots[0].GetUtilization()[0].GetOrgId(), + "snapshots carry the org resolved and stored at registration") +} + +func TestRegisterWorkflow_MeteringFailOpen(t *testing.T) { + lggr := logger.Test(t) + handler, emitter := setupWithMeterEmitter(t, lggr, errors.New("emit failed")) + + // Registration and unregistration succeed even though every emit fails. + sendCh := make(chan capabilities.TriggerAndId[*http.Payload], 1) + err := handler.RegisterWorkflow(t.Context(), meterTestRegistrationInput(), sendCh) + require.NoError(t, err) + err = handler.UnregisterWorkflow(t.Context(), testWorkflowID) + require.NoError(t, err) + require.Empty(t, emitter.records) +} + +// TestRegisterWorkflow_NilMeterEquivalence asserts the fail-open equivalence +// contract: with a nil meter (metering off), the register/unregister lifecycle +// behaves identically to the metered path. +func TestRegisterWorkflow_NilMeterEquivalence(t *testing.T) { + lggr := logger.Test(t) + cfg := ServiceConfig{MetadataBatchSize: 10, MaxAuthorizedKeysPerWorkflow: 3} + store := newWorkflowStore(lggr) + metadataPublisher := NewGatewayMetadataPublisher(lggr, &mockGatewayConnector{}, store, cfg, newMetrics(t)) + requestCache := newRequestCache(logger.Sugared(lggr), newTestKVStore(), time.Hour) + handler, err := NewConnectorHandler(lggr, &mockGatewayConnector{}, cfg, store, metadataPublisher, requestCache, newMetrics(t), nil, limits.Factory{}, nil) + require.NoError(t, err) + require.NoError(t, handler.Start(t.Context())) + t.Cleanup(func() { require.NoError(t, handler.Close()) }) + + sendCh := make(chan capabilities.TriggerAndId[*http.Payload], 1) + require.NoError(t, handler.RegisterWorkflow(t.Context(), meterTestRegistrationInput(), sendCh)) + require.NoError(t, handler.UnregisterWorkflow(t.Context(), testWorkflowID)) +} + +// registerMeterWorkflow registers a workflow with the given ID/owner under the +// metering test config (each registration uses a distinct reference so they +// coexist). +func registerMeterWorkflow(t *testing.T, handler *connectorHandler, workflowID, owner string) { + t.Helper() + input := meterTestRegistrationInput() + input.WorkflowSelector.WorkflowID = workflowID + input.WorkflowSelector.WorkflowOwner = owner + sendCh := make(chan capabilities.TriggerAndId[*http.Payload], 1) + require.NoError(t, handler.RegisterWorkflow(t.Context(), input, sendCh)) +} + +// TestSnapshot_EmitsOneEntryPerActiveWorkflow drives the meter's snapshot tick +// and asserts one MeterSnapshot per active workflow carrying the full identity, +// and that an unregistered workflow is released by its absence from the next +// tick. +func TestSnapshot_EmitsOneEntryPerActiveWorkflow(t *testing.T) { + lggr := logger.Test(t) + emitter := &fakeMeterEmitter{} + clock := clockwork.NewFakeClockAt(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)) + cfg := ServiceConfig{MetadataBatchSize: 10, MaxAuthorizedKeysPerWorkflow: 3} + store := newWorkflowStore(lggr) + metadataPublisher := NewGatewayMetadataPublisher(lggr, &mockGatewayConnector{}, store, cfg, newMetrics(t)) + requestCache := newRequestCache(logger.Sugared(lggr), newTestKVStore(), time.Hour) + handler, err := NewConnectorHandler(lggr, &mockGatewayConnector{}, cfg, store, metadataPublisher, requestCache, newMetrics(t), nil, limits.Factory{}, newTestMeter(lggr, store, emitter, nil, clock)) + require.NoError(t, err) + require.NoError(t, handler.Start(t.Context())) + t.Cleanup(func() { require.NoError(t, handler.Close()) }) + + registerMeterWorkflow(t, handler, testWorkflowID1, testWorkflowOwner1) + registerMeterWorkflow(t, handler, testWorkflowID2, testWorkflowOwner2) + + require.NoError(t, clock.BlockUntilContext(t.Context(), 1)) + clock.Advance(time.Minute) + + require.Eventually(t, func() bool { + return len(emitter.snapshots) == 2 + }, time.Second, time.Millisecond) + + // One MeterSnapshot per active workflow, keyed by utilization.resource_id. + require.Len(t, emitter.snapshots, 2) + byWorkflowID := map[string]*meteringpb.MeterSnapshot{} + for _, s := range emitter.snapshots { + byWorkflowID[s.GetUtilization()[0].GetResourceId()] = s + } + require.Len(t, byWorkflowID, 2) + + r1 := byWorkflowID[testWorkflowID1] + require.NotNil(t, r1) + require.Equal(t, testDeployment.Product, r1.GetIdentity().GetProduct()) + require.Equal(t, testDeployment.NodeID, r1.GetIdentity().GetDon().GetNodeId()) + require.Equal(t, "7", r1.GetIdentity().GetDon().GetDonId()) + require.Equal(t, meteringConfig.ResourcePool, r1.GetIdentity().GetResourcePool()) + require.Equal(t, meteringConfig.Service, r1.GetIdentity().GetService()) + require.Equal(t, meteringConfig.ResourceType, r1.GetUtilization()[0].GetResourceType()) + require.Equal(t, "1", r1.GetUtilization()[0].GetValue()) + + r2 := byWorkflowID[testWorkflowID2] + require.NotNil(t, r2) + require.Equal(t, "1", r2.GetUtilization()[0].GetValue()) + + // Release-by-absence: after unregistering workflow 2, the next tick + // snapshots only workflow 1. + require.NoError(t, handler.UnregisterWorkflow(t.Context(), testWorkflowID2)) + require.NoError(t, clock.BlockUntilContext(t.Context(), 1)) + clock.Advance(time.Minute) + require.Eventually(t, func() bool { + return len(emitter.snapshots) == 3 + }, time.Second, time.Millisecond) + require.Equal(t, testWorkflowID1, emitter.snapshots[2].GetUtilization()[0].GetResourceId(), + "an unregistered workflow is released by its absence from the next snapshot") + + // The snapshot stream is the only metering surface: no records, ever. + require.Empty(t, emitter.records) +} + +// TestClose_EmitsNoShutdownRecords asserts a graceful close emits NO metering +// at all. Process-lifecycle emissions are deleted by design: an active +// workflow is released by its absence from the next snapshot, not by a +// close-time drain. +func TestClose_EmitsNoShutdownRecords(t *testing.T) { + lggr := logger.Test(t) + emitter := &fakeMeterEmitter{} + cfg := ServiceConfig{MetadataBatchSize: 10, MaxAuthorizedKeysPerWorkflow: 3} + store := newWorkflowStore(lggr) + metadataPublisher := NewGatewayMetadataPublisher(lggr, &mockGatewayConnector{}, store, cfg, newMetrics(t)) + requestCache := newRequestCache(logger.Sugared(lggr), newTestKVStore(), time.Hour) + clock := clockwork.NewFakeClockAt(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)) + handler, err := NewConnectorHandler(lggr, &mockGatewayConnector{}, cfg, store, metadataPublisher, requestCache, newMetrics(t), nil, limits.Factory{}, newTestMeter(lggr, store, emitter, nil, clock)) + require.NoError(t, err) + require.NoError(t, handler.Start(t.Context())) + + registerMeterWorkflow(t, handler, testWorkflowID1, testWorkflowOwner1) + registerMeterWorkflow(t, handler, testWorkflowID2, testWorkflowOwner2) + + require.NoError(t, handler.Close()) + require.Empty(t, emitter.records, "graceful close must emit no meter records") + require.Empty(t, emitter.snapshots, "no snapshot tick ran; close must not force one") +} + +// TestDONIDNotInitialised_NoWorkflowDONSubstitution asserts that when the host +// has not injected a capability DON ID, snapshots are still emitted but with +// the DON dimension carrying only the node ID — the consumer workflow's DON ID +// is never substituted — and the meter's DonID surfaces +// triggermeter.ErrDonIDNotInitialised for callers that degrade explicitly +// (event labels, CRE-4409). +func TestDONIDNotInitialised_NoWorkflowDONSubstitution(t *testing.T) { + lggr := logger.Test(t) + emitter := &fakeMeterEmitter{} + cfg := ServiceConfig{MetadataBatchSize: 10, MaxAuthorizedKeysPerWorkflow: 3} + store := newWorkflowStore(lggr) + metadataPublisher := NewGatewayMetadataPublisher(lggr, &mockGatewayConnector{}, store, cfg, newMetrics(t)) + requestCache := newRequestCache(logger.Sugared(lggr), newTestKVStore(), time.Hour) + clock := clockwork.NewFakeClockAt(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)) + rm := resourcemanager.NewResourceManager(lggr, resourcemanager.ResourceManagerConfig{ + MeterRecordsEnabled: true, + MeterSnapshotsEnabled: true, + Emitter: emitter, + SnapshotInterval: time.Minute, + Clock: clock, + }) + // No capability DON injected (0) → the DON dimension carries only the node. + meter := triggermeter.New(lggr, rm, testDeployment, 0, meteringConfig, nil, store.snapshotRows) + handler, err := NewConnectorHandler(lggr, &mockGatewayConnector{}, cfg, store, metadataPublisher, requestCache, newMetrics(t), nil, limits.Factory{}, meter) + require.NoError(t, err) + require.NoError(t, handler.Start(t.Context())) + t.Cleanup(func() { require.NoError(t, handler.Close()) }) + + input := meterTestRegistrationInput() + input.Metadata.WorkflowDONID = 99 + sendCh := make(chan capabilities.TriggerAndId[*http.Payload], 1) + require.NoError(t, handler.RegisterWorkflow(t.Context(), input, sendCh)) + + require.NoError(t, clock.BlockUntilContext(t.Context(), 1)) + clock.Advance(time.Minute) + require.Eventually(t, func() bool { return len(emitter.snapshots) == 1 }, time.Second, time.Millisecond) + + require.Empty(t, emitter.snapshots[0].GetIdentity().GetDon().GetDonId(), + "the consumer workflow's DON ID must never be substituted for the capability DON") + require.Equal(t, "node-csa-pubkey", emitter.snapshots[0].GetIdentity().GetDon().GetNodeId(), + "the node dimension is preserved even without a DON ID") + _, donErr := handler.meter.DonID() + require.ErrorIs(t, donErr, triggermeter.ErrDonIDNotInitialised) +} + // TestResolveWorkflowMetadata_PreservesStoredWorkflowOwner tests that the workflowOwner // from the stored workflow is used, even if the incoming request has zeros or missing values. // This is a regression test for the bug where workflowOwner was being set to zeros. diff --git a/http_trigger/trigger/gateway_metadata_publisher_test.go b/http_trigger/trigger/gateway_metadata_publisher_test.go index bedf22bdf..2c6a8c10c 100644 --- a/http_trigger/trigger/gateway_metadata_publisher_test.go +++ b/http_trigger/trigger/gateway_metadata_publisher_test.go @@ -221,9 +221,9 @@ func TestSendWorkflows_Success(t *testing.T) { wf1 := newWorkflow(selector1, authorizedKeys1, sendCh1) wf2 := newWorkflow(selector2, authorizedKeys2, sendCh2) - err := workflowStore.upsertWorkflow(wf1) + _, err := workflowStore.upsertWorkflow(wf1) require.NoError(t, err) - err = workflowStore.upsertWorkflow(wf2) + _, err = workflowStore.upsertWorkflow(wf2) require.NoError(t, err) gatewayID := "gateway1" diff --git a/http_trigger/trigger/trigger.go b/http_trigger/trigger/trigger.go index abb164be9..c8c584aab 100644 --- a/http_trigger/trigger/trigger.go +++ b/http_trigger/trigger/trigger.go @@ -12,15 +12,27 @@ import ( "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/triggers/http" "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/triggers/http/server" "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink-common/pkg/resourcemanager" "github.com/smartcontractkit/chainlink-common/pkg/services" "github.com/smartcontractkit/chainlink-common/pkg/services/orgresolver" "github.com/smartcontractkit/chainlink-common/pkg/settings/limits" "github.com/smartcontractkit/chainlink-common/pkg/types/core" "github.com/smartcontractkit/chainlink-common/pkg/types/gateway" + + "github.com/smartcontractkit/capabilities/libs/triggermeter" ) const ServiceName = "HTTPTriggerCapability" +// meteringConfig carries the HTTP trigger's metering identity constants: the +// stable service constant (it must not encode environment or zone), the HTTP +// workflow-registration pool, and its billing unit. +var meteringConfig = triggermeter.Config{ + Service: "http-trigger", + ResourcePool: "http_workflows", + ResourceType: "operations", +} + var _ server.HTTPCapability = &service{} type WorkflowRegistrationInput struct { @@ -53,12 +65,16 @@ type service struct { metrics *Metrics limitsFactory limits.Factory orgResolver orgresolver.OrgResolver + // metering is the resolved metering Config (ResourceManagerConfig + + // DeploymentIdentity) produced by loop.Server.MeteringConfig in main. + metering resourcemanager.Config } -func NewService(lggr logger.Logger, limitsFactory limits.Factory) *service { +func NewService(lggr logger.Logger, limitsFactory limits.Factory, metering resourcemanager.Config) *service { return &service{ lggr: logger.Sugared(logger.Named(lggr, ServiceName)), limitsFactory: limitsFactory, + metering: metering, } } @@ -73,9 +89,11 @@ func (s *service) Initialise(ctx context.Context, dependencies core.StandardCapa } } s.cfg = applyDefaults(serviceConfig) - s.orgResolver = dependencies.OrgResolver - if s.orgResolver == nil { + if dependencies.OrgResolver == nil { s.lggr.Warn("OrgResolver is nil, HTTP trigger capability will not be able to fetch organization ID") + s.orgResolver = nil + } else { + s.orgResolver = dependencies.OrgResolver } workflowStore := newWorkflowStore(s.lggr) var err error @@ -85,12 +103,13 @@ func (s *service) Initialise(ctx context.Context, dependencies core.StandardCapa } metadataPublisher := NewGatewayMetadataPublisher(s.lggr, dependencies.GatewayConnector, workflowStore, s.cfg, s.metrics) requestCache := newRequestCache(s.lggr, dependencies.Store, time.Duration(s.cfg.RequestCacheTTL)*time.Second) - // dependencies.CapabilityDonID is the on-chain DON ID this plugin process - // serves, used to label emitted events with the *sending* DON. Zero means the - // host could not resolve it authoritatively (a multi-DON job-spec node, or a - // core node that pre-dates CRE-4409); the handler then falls back to - // RequestMetadata.WorkflowDONID. See CRE-4409. - s.connectorHandler, err = NewConnectorHandler(s.lggr, dependencies.GatewayConnector, s.cfg, dependencies.CapabilityDonID, workflowStore, metadataPublisher, requestCache, s.metrics, s.orgResolver, s.limitsFactory) + // The meter owns every metering concern: the ResourceManager lifecycle, the + // base identity (deployment dimensions from loop.EnvConfig, DON dimension + // from the host-injected CapabilityDonID), org resolution, and the snapshot + // registration over the workflow store's rows. + meter := triggermeter.New(s.lggr, resourcemanager.NewResourceManager(s.lggr, s.metering.ResourceManagerConfig), + s.metering.DeploymentIdentity, dependencies.CapabilityDonID, meteringConfig, s.orgResolver, workflowStore.snapshotRows) + s.connectorHandler, err = NewConnectorHandler(s.lggr, dependencies.GatewayConnector, s.cfg, workflowStore, metadataPublisher, requestCache, s.metrics, s.orgResolver, s.limitsFactory, meter) if err != nil { return err } diff --git a/http_trigger/trigger/trigger_test.go b/http_trigger/trigger/trigger_test.go index 6d0413547..5ab96ac4f 100644 --- a/http_trigger/trigger/trigger_test.go +++ b/http_trigger/trigger/trigger_test.go @@ -13,6 +13,7 @@ import ( "github.com/smartcontractkit/chainlink-common/pkg/capabilities" "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/triggers/http" "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink-common/pkg/resourcemanager" "github.com/smartcontractkit/chainlink-common/pkg/settings/limits" "github.com/smartcontractkit/chainlink-common/pkg/types/core" gcmocks "github.com/smartcontractkit/chainlink-common/pkg/types/core/mocks" @@ -56,7 +57,7 @@ func TestService_RegisterTrigger(t *testing.T) { mockHandler := &mockConnectorHandler{ registerErr: tc.registerErr, } - svc := NewService(logger.Test(t), limits.Factory{Logger: logger.Test(t)}) + svc := NewService(logger.Test(t), limits.Factory{Logger: logger.Test(t)}, resourcemanager.Config{}) cfgStr := fmt.Sprintf(`{"sendChannelBufferSize": %d}`, tc.sendChannelBufSize) gc := mockedGatewayConnector(t) err := svc.Initialise(t.Context(), core.StandardCapabilitiesDependencies{ @@ -118,7 +119,7 @@ func TestService_UnregisterTrigger(t *testing.T) { mockHandler := &mockConnectorHandler{ unregisterErr: tt.handlerErr, } - svc := NewService(logger.Test(t), limits.Factory{Logger: logger.Test(t)}) + svc := NewService(logger.Test(t), limits.Factory{Logger: logger.Test(t)}, resourcemanager.Config{}) cfg := "{}" gc := mockedGatewayConnector(t) err := svc.Initialise(t.Context(), core.StandardCapabilitiesDependencies{ @@ -141,7 +142,7 @@ func TestService_UnregisterTrigger(t *testing.T) { } func TestService_Initialise_EmptyConfig(t *testing.T) { - svc := NewService(logger.Test(t), limits.Factory{Logger: logger.Test(t)}) + svc := NewService(logger.Test(t), limits.Factory{Logger: logger.Test(t)}, resourcemanager.Config{}) gc := mockedGatewayConnector(t) err := svc.Initialise(context.Background(), core.StandardCapabilitiesDependencies{ @@ -157,7 +158,7 @@ func TestService_Initialise_EmptyConfig(t *testing.T) { func TestService_Start_HealthReport_Ready_Close(t *testing.T) { mockHandler := &mockConnectorHandler{} - svc := NewService(logger.Test(t), limits.Factory{Logger: logger.Test(t)}) + svc := NewService(logger.Test(t), limits.Factory{Logger: logger.Test(t)}, resourcemanager.Config{}) cfg := "{}" gc := mockedGatewayConnector(t) err := svc.Initialise(t.Context(), core.StandardCapabilitiesDependencies{ diff --git a/http_trigger/trigger/workflow.go b/http_trigger/trigger/workflow.go index 67daefa28..96bcb2530 100644 --- a/http_trigger/trigger/workflow.go +++ b/http_trigger/trigger/workflow.go @@ -10,6 +10,8 @@ import ( "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/triggers/http" "github.com/smartcontractkit/chainlink-common/pkg/logger" "github.com/smartcontractkit/chainlink-common/pkg/types/gateway" + + "github.com/smartcontractkit/capabilities/libs/triggermeter" ) const ( @@ -52,34 +54,38 @@ func newWorkflowStore(lggr logger.Logger) *workflowStore { // workflow reference (owner/name/tag combination) with new workflow instance. // upsertWorkflow should be invoked in the order of workflow registration, so that // the latest workflow instance is always used for the given reference. -func (s *workflowStore) upsertWorkflow(w *workflow) error { +// +// It returns the evicted workflow: the registration that previously held this +// owner/name/tag reference (nil when the reference was new). The eviction is +// determined atomically under the store lock so the caller can meter the +// replaced resource without a separate, racy pre-read. The evicted workflow's +// WorkflowID may equal the new one (a same-ID re-register, no level change) or +// differ (a version update whose old resource_id must be released). +func (s *workflowStore) upsertWorkflow(w *workflow) (evicted *workflow, err error) { // Validate workflow fields if err := validateWorkflowSelector(w.workflowSelector); err != nil { - return fmt.Errorf("invalid workflow selector: %w", err) + return nil, fmt.Errorf("invalid workflow selector: %w", err) } s.mu.Lock() defer s.mu.Unlock() - workflowID, exists := s.workflowReferenceToID[workflowReference{ + ref := workflowReference{ workflowOwner: w.workflowSelector.WorkflowOwner, workflowName: w.workflowSelector.WorkflowName, workflowTag: w.workflowSelector.WorkflowTag, - }] - if exists { + } + if prevWorkflowID, replaced := s.workflowReferenceToID[ref]; replaced { reference := fmt.Sprintf("%s/%s/%s", w.workflowSelector.WorkflowOwner, w.workflowSelector.WorkflowName, w.workflowSelector.WorkflowTag) - s.lggr.Debugw("Updating existing workflow reference and removing previous workflow", "reference", reference, "prevWorkflowID", workflowID) - if oldW, ok := s.workflows[workflowID]; ok { + s.lggr.Debugw("Updating existing workflow reference and removing previous workflow", "reference", reference, "prevWorkflowID", prevWorkflowID) + if oldW, ok := s.workflows[prevWorkflowID]; ok { + evicted = oldW oldW.close() } - delete(s.workflows, workflowID) + delete(s.workflows, prevWorkflowID) } s.workflows[w.workflowSelector.WorkflowID] = w - s.workflowReferenceToID[workflowReference{ - workflowOwner: w.workflowSelector.WorkflowOwner, - workflowName: w.workflowSelector.WorkflowName, - workflowTag: w.workflowSelector.WorkflowTag, - }] = w.workflowSelector.WorkflowID - return nil + s.workflowReferenceToID[ref] = w.workflowSelector.WorkflowID + return evicted, nil } // validateWorkflowSelector validates the workflow selector fields @@ -187,6 +193,26 @@ func (s *workflowStore) getWorkflows() []*workflow { return workflows } +// snapshotRows reports the absolute state of every currently registered HTTP +// workflow for the meter's snapshot tick: one row per workflow at value 1, +// with the org that was resolved and stored at registration. It is a cheap +// read-locked copy of in-memory state with no network calls (the Meterable +// contract). The HTTP trigger emits NO MeterRecord deltas: billing follows +// the snapshot level, and a workflow is released by its absence from the next +// snapshot. +func (s *workflowStore) snapshotRows(context.Context) []triggermeter.SnapshotRow { + workflows := s.getWorkflows() + rows := make([]triggermeter.SnapshotRow, 0, len(workflows)) + for _, w := range workflows { + rows = append(rows, triggermeter.SnapshotRow{ + Value: 1, + ResourceID: w.workflowSelector.WorkflowID, + OrgID: w.orgID, + }) + } + return rows +} + type workflow struct { mu sync.Mutex workflowSelector gateway.WorkflowSelector @@ -194,6 +220,10 @@ type workflow struct { sendCh chan<- capabilities.TriggerAndId[*http.Payload] closed bool metadata WorkflowRegistrationMetadata + // orgID is the organization ID resolved from the workflow owner once at + // registration (before the entry is published to the store) and read by the + // snapshot path, which must be network-free. It is immutable after publish. + orgID string } func newWorkflow(workflowSelector gateway.WorkflowSelector, authorizedKeys []gateway.AuthorizedKey, sendCh chan<- capabilities.TriggerAndId[*http.Payload]) *workflow { diff --git a/http_trigger/trigger/workflow_test.go b/http_trigger/trigger/workflow_test.go index 189ed0d39..e45a16f75 100644 --- a/http_trigger/trigger/workflow_test.go +++ b/http_trigger/trigger/workflow_test.go @@ -134,8 +134,9 @@ func TestWorkflowStore_upsertWorkflow(t *testing.T) { lggr := logger.Test(t) store := newWorkflowStore(lggr) wf, _ := testWorkflow() - err := store.upsertWorkflow(wf) + evicted, err := store.upsertWorkflow(wf) require.NoError(t, err) + require.Nil(t, evicted, "a new reference evicts nothing") w, exists := store.getWorkflowByID(wf.workflowSelector.WorkflowID) require.True(t, exists) @@ -257,7 +258,7 @@ func TestWorkflowStore_upsertWorkflow_ValidationErrors(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { wf := newWorkflow(tt.selector, authorizedKeys, sendCh) - err := store.upsertWorkflow(wf) + _, err := store.upsertWorkflow(wf) require.Error(t, err) require.Contains(t, err.Error(), tt.wantErr) }) @@ -273,12 +274,15 @@ func TestWorkflowStore_upsertWorkflow_Duplicate(t *testing.T) { w2, _ := testWorkflow() // Add first workflow - err := store.upsertWorkflow(w1) + evicted, err := store.upsertWorkflow(w1) require.NoError(t, err) + require.Nil(t, evicted) // Add second workflow with same ID (this should replace the first) - err = store.upsertWorkflow(w2) + evicted, err = store.upsertWorkflow(w2) require.NoError(t, err) + require.NotNil(t, evicted, "same-reference re-register evicts the previous workflow") + require.Equal(t, w1.workflowSelector.WorkflowID, evicted.workflowSelector.WorkflowID) // Verify the workflow was replaced - since both have same ID/reference, // the second one should be present @@ -294,7 +298,7 @@ func TestWorkflowStore_removeWorkflow_Success(t *testing.T) { lggr := logger.Test(t) store := newWorkflowStore(lggr) w, _ := testWorkflow() - err := store.upsertWorkflow(w) + _, err := store.upsertWorkflow(w) require.NoError(t, err) wf, exists := store.getWorkflowByID(w.workflowSelector.WorkflowID) @@ -393,11 +397,11 @@ func TestWorkflowStore_GetWorkflows_Multiple(t *testing.T) { wf2 := newWorkflow(wfSelector2, authorizedKeys, sendCh2) wf3 := newWorkflow(wfSelector3, authorizedKeys, sendCh3) - err := store.upsertWorkflow(wf1) + _, err := store.upsertWorkflow(wf1) require.NoError(t, err) - err = store.upsertWorkflow(wf2) + _, err = store.upsertWorkflow(wf2) require.NoError(t, err) - err = store.upsertWorkflow(wf3) + _, err = store.upsertWorkflow(wf3) require.NoError(t, err) // Get all workflows @@ -421,7 +425,7 @@ func TestWorkflowStore_getWorkflowIDByReference_Success(t *testing.T) { lggr := logger.Test(t) store := newWorkflowStore(lggr) wf, _ := testWorkflow() - err := store.upsertWorkflow(wf) + _, err := store.upsertWorkflow(wf) require.NoError(t, err) workflowID, exists := store.getWorkflowIDByReference( @@ -538,8 +542,9 @@ func TestWorkflowStore_upsertWorkflow_ReplaceWithSameReference(t *testing.T) { wf2 := newWorkflow(selector2, authorizedKeys, sendCh2) // Add first workflow - err := store.upsertWorkflow(wf1) + evicted, err := store.upsertWorkflow(wf1) require.NoError(t, err) + require.Nil(t, evicted) // Verify first workflow is there workflow, exists := store.getWorkflowByID(testWorkflowID1) @@ -551,9 +556,12 @@ func TestWorkflowStore_upsertWorkflow_ReplaceWithSameReference(t *testing.T) { require.True(t, exists) require.Equal(t, testWorkflowID1, workflowID) - // Add second workflow with same reference - err = store.upsertWorkflow(wf2) + // Add second workflow with same reference; the evicted workflow is + // surfaced so callers can release its resource. + evicted, err = store.upsertWorkflow(wf2) require.NoError(t, err) + require.NotNil(t, evicted) + require.Equal(t, testWorkflowID1, evicted.workflowSelector.WorkflowID) // First workflow should be removed _, exists = store.getWorkflowByID(testWorkflowID1) @@ -576,7 +584,7 @@ func TestWorkflowStore_removeWorkflow_RemovesReference(t *testing.T) { lggr := logger.Test(t) store := newWorkflowStore(lggr) wf, _ := testWorkflow() - err := store.upsertWorkflow(wf) + _, err := store.upsertWorkflow(wf) require.NoError(t, err) // Verify workflow and reference exist @@ -735,7 +743,7 @@ func TestWorkflowStore_getWorkflowIDByReference_PartialMatch(t *testing.T) { lggr := logger.Test(t) store := newWorkflowStore(lggr) wf, _ := testWorkflow() - err := store.upsertWorkflow(wf) + _, err := store.upsertWorkflow(wf) require.NoError(t, err) // Test with wrong owner diff --git a/integration_tests/go.mod b/integration_tests/go.mod index 7248ff4e8..c27ab3f3d 100644 --- a/integration_tests/go.mod +++ b/integration_tests/go.mod @@ -28,7 +28,7 @@ require ( github.com/smartcontractkit/capabilities/http_trigger v0.0.0-00010101000000-000000000000 github.com/smartcontractkit/capabilities/loadtestwritetarget v0.0.0-00010101000000-000000000000 github.com/smartcontractkit/chain-selectors v1.0.106 - github.com/smartcontractkit/chainlink-common v0.11.2-0.20260722132052-c7af471801f2 + github.com/smartcontractkit/chainlink-common v0.11.2-0.20260730010536-738aeedcaf64 github.com/smartcontractkit/chainlink-evm v0.3.4-0.20260722105404-d3fb34ccc58b github.com/smartcontractkit/chainlink-evm/gethwrappers v0.0.0-20260512150409-b4068bf735e6 github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260714170805-29c5577b5f55 @@ -314,7 +314,7 @@ require ( github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings v0.0.0-20260415165642-49f23e4d76cc // indirect github.com/smartcontractkit/chainlink-ccv v0.1.1-0.20260716164331-d938b371c5d6 // indirect github.com/smartcontractkit/chainlink-common/keystore v1.2.1-0.20260623104656-f39eba3e2bc6 // indirect - github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.11-0.20260716165322-7f2edff6e954 // indirect + github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.11-0.20260724142814-45996a1bcb72 // indirect github.com/smartcontractkit/chainlink-data-streams v1.0.0 // indirect github.com/smartcontractkit/chainlink-evm/contracts/cre/gobindings v0.0.0-20260403151002-2c91155b5501 // indirect github.com/smartcontractkit/chainlink-feeds v0.1.2-0.20250227211209-7cd000095135 // indirect @@ -330,7 +330,7 @@ require ( github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251211142334-5c3421fe2c8d // indirect github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260501174546-2e8846986b36 // indirect github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20260512230622-65f10f4cd305 // indirect - github.com/smartcontractkit/chainlink-protos/metering/go v0.0.0-20260710151514-27b5a126dabe // indirect + github.com/smartcontractkit/chainlink-protos/metering/go v0.0.0-20260729184203-90b4cdd48536 // indirect github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260709145319-7782fb89eb16 // indirect github.com/smartcontractkit/chainlink-protos/orchestrator v0.11.0 // indirect github.com/smartcontractkit/chainlink-protos/ring/go v0.0.0-20260331131315-f08a616d8dcd // indirect @@ -438,3 +438,5 @@ require ( ) tool github.com/smartcontractkit/chainlink/v2/core/store/cmd/preparetest + +replace github.com/smartcontractkit/capabilities/libs => ../libs diff --git a/integration_tests/go.sum b/integration_tests/go.sum index 3ae007e36..92e670815 100644 --- a/integration_tests/go.sum +++ b/integration_tests/go.sum @@ -1149,12 +1149,12 @@ github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings v0.0.0-20260 github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings v0.0.0-20260415165642-49f23e4d76cc/go.mod h1:67YbnoglYD61Pz/jTVCgav9wFq7S35OU8UyQSvPllRw= github.com/smartcontractkit/chainlink-ccv v0.1.1-0.20260716164331-d938b371c5d6 h1:t5VUHVFEdcqa0/faJiHWusuW+egKpqK3YwWZO4/yJto= github.com/smartcontractkit/chainlink-ccv v0.1.1-0.20260716164331-d938b371c5d6/go.mod h1:0v6RGdYa9NezVnBPIAVyxB3A9furKWwaSkLha+URYGk= -github.com/smartcontractkit/chainlink-common v0.11.2-0.20260722132052-c7af471801f2 h1:JZLdNP/jAB2bfjD2u9pC6zST7dyG3Ch3PDi/ctEPDHQ= -github.com/smartcontractkit/chainlink-common v0.11.2-0.20260722132052-c7af471801f2/go.mod h1:DGvW2Opi/qcOWNkOq18xTI5ZqByoxfPTL9H5M1bC4Ls= +github.com/smartcontractkit/chainlink-common v0.11.2-0.20260730010536-738aeedcaf64 h1:DL4bIjuJaPPE2p4I31IMFlJ5MW8PPfofS6OS2RM1n3c= +github.com/smartcontractkit/chainlink-common v0.11.2-0.20260730010536-738aeedcaf64/go.mod h1:euYQ2WxVYGU2ivHXPBRpbU0Z3/SkekfIK5YPf7hdDY8= github.com/smartcontractkit/chainlink-common/keystore v1.2.1-0.20260623104656-f39eba3e2bc6 h1:DvfhsiIxB4JMuR+r1UciKV8jKiwhI/OZI/QhKawC4pM= github.com/smartcontractkit/chainlink-common/keystore v1.2.1-0.20260623104656-f39eba3e2bc6/go.mod h1:6NefaCIMH4zFBN3T+cvsFGYoy3oTSPKDaxPAyBzlYTU= -github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.11-0.20260716165322-7f2edff6e954 h1:QhTMiEn3s+AB4xBoScuQglsqHGJYxheYrgpxdIdqNAI= -github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.11-0.20260716165322-7f2edff6e954/go.mod h1:UYcRMb4dZcoaIPgZJ3hckCySTqtJc9K4Q+tOKErwTq0= +github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.11-0.20260724142814-45996a1bcb72 h1:uWEwl7i2ryuRVoV4DmIKm6mqYevf1lH/8cQYhw/JXko= +github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.11-0.20260724142814-45996a1bcb72/go.mod h1:UYcRMb4dZcoaIPgZJ3hckCySTqtJc9K4Q+tOKErwTq0= github.com/smartcontractkit/chainlink-data-streams v1.0.0 h1:W+RfzuZHVt50ihlsB7+XpXiBw1v5hRks6DUK2HHH7A4= github.com/smartcontractkit/chainlink-data-streams v1.0.0/go.mod h1:dF5JiHWueHjYguUUUrFeb03MkcDqha/tssEkqTkgzp4= github.com/smartcontractkit/chainlink-evm v0.3.4-0.20260722105404-d3fb34ccc58b h1:WtPAXEk1BjrOZHahBriBfArSdI3o0UYMuINCvPAknhs= @@ -1191,8 +1191,8 @@ github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260501174546- github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260501174546-2e8846986b36/go.mod h1:vL1bDgPSJjV0EqHYs4dDlR+EEE0cJchgvGLYXhwIjXY= github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20260512230622-65f10f4cd305 h1:NJdGFhzT6zMaTod4QkBqVD2sg0I25iw1boOYtTpEwRo= github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20260512230622-65f10f4cd305/go.mod h1:qSTSwX3cBP3FKQwQacdjArqv0g6QnukjV4XuzO6UyoY= -github.com/smartcontractkit/chainlink-protos/metering/go v0.0.0-20260710151514-27b5a126dabe h1:MDnY5wQbWTpFdDnMRicEnoMfSP5nM/KncARr4skP1ug= -github.com/smartcontractkit/chainlink-protos/metering/go v0.0.0-20260710151514-27b5a126dabe/go.mod h1:z7lx7wI3XZ4u9kmUtAVdwn1BCC9T8aieWSDcuDgPTdQ= +github.com/smartcontractkit/chainlink-protos/metering/go v0.0.0-20260729184203-90b4cdd48536 h1:ecQYtdRA+NQLXf0aKYUMfcn1TRhcQ4RZCZzzadFsbSs= +github.com/smartcontractkit/chainlink-protos/metering/go v0.0.0-20260729184203-90b4cdd48536/go.mod h1:z7lx7wI3XZ4u9kmUtAVdwn1BCC9T8aieWSDcuDgPTdQ= github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260709145319-7782fb89eb16 h1:/vkKPJoweLkRd56V4YHGRAtTG4+/JAlgklGEfvH6l4c= github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260709145319-7782fb89eb16/go.mod h1:dkR2uYg9XYJuT1JASkPzWE51jjFkVb86P7a/yXe5/GM= github.com/smartcontractkit/chainlink-protos/orchestrator v0.11.0 h1:NXKTdIESAiCkVnPS6dyZP+NXVek3GzXa6P4uFAs0o8Y= diff --git a/libs/go.mod b/libs/go.mod index c092206f2..2f42b25da 100644 --- a/libs/go.mod +++ b/libs/go.mod @@ -7,9 +7,11 @@ require ( github.com/google/go-cmp v0.7.0 github.com/google/uuid v1.6.0 github.com/hashicorp/go-plugin v1.8.0 + github.com/jonboulle/clockwork v0.5.0 github.com/shopspring/decimal v1.4.0 - github.com/smartcontractkit/chainlink-common v0.11.2-0.20260713185857-30ad2e76c0f4 - github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260707203317-661b54b51a33 + github.com/smartcontractkit/chainlink-common v0.11.2-0.20260810181408-53d56f533c59 + github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260804191526-b7a850ae7648 + github.com/smartcontractkit/chainlink-protos/metering/go v0.0.0-20260729184203-90b4cdd48536 github.com/smartcontractkit/libocr v0.0.0-20250912173940-f3ab0246e23d github.com/stretchr/testify v1.11.1 go.opentelemetry.io/otel v1.43.0 @@ -56,7 +58,6 @@ require ( github.com/jackc/pgx/v5 v5.9.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jmoiron/sqlx v1.4.0 // indirect - github.com/jonboulle/clockwork v0.5.0 // indirect github.com/jpillora/backoff v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.18.0 // indirect @@ -81,7 +82,7 @@ require ( github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 // indirect github.com/scylladb/go-reflectx v1.0.1 // indirect github.com/smartcontractkit/chain-selectors v1.0.104 // indirect - github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.11-0.20260626151909-052e55e62e62 // indirect + github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.11-0.20260724142814-45996a1bcb72 // indirect github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b // indirect github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260709145319-7782fb89eb16 // indirect github.com/smartcontractkit/freeport v0.1.3-0.20250716200817-cb5dfd0e369e // indirect diff --git a/libs/go.sum b/libs/go.sum index 99686515d..2c601fefe 100644 --- a/libs/go.sum +++ b/libs/go.sum @@ -206,16 +206,18 @@ github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/smartcontractkit/chain-selectors v1.0.104 h1:/n9pPGM5W/+r1eHoWZv4VwX9LNS1af4+ICyhM8zKRNM= github.com/smartcontractkit/chain-selectors v1.0.104/go.mod h1:qy7whtgG5g+7z0jt0nRyii9bLND9m15NZTzuQPkMZ5w= -github.com/smartcontractkit/chainlink-common v0.11.2-0.20260713185857-30ad2e76c0f4 h1:KSg0EnUdefIGyR3Fa6/nXhWXaCUlMP5qFAsLHwzi6Fk= -github.com/smartcontractkit/chainlink-common v0.11.2-0.20260713185857-30ad2e76c0f4/go.mod h1:snfVBRRQTpC2x5O3bQHZe9SvJX5yv/SbG8oHkJTKLtE= -github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.11-0.20260626151909-052e55e62e62 h1:o7vfwNQjQbMKQ9YsZFQOxvU7RMXD/wKnZsX5N9sDS3w= -github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.11-0.20260626151909-052e55e62e62/go.mod h1:HmUyH2oD9m+GRpKq7q3vuRnm1F2Uczf/Nd1v3ipMSK8= +github.com/smartcontractkit/chainlink-common v0.11.2-0.20260810181408-53d56f533c59 h1:Kfn0SV1b3pA0MenAW87jmVQY931zoj0kvUn1g86oRn8= +github.com/smartcontractkit/chainlink-common v0.11.2-0.20260810181408-53d56f533c59/go.mod h1:P9kQKuadFvQJbZcracSvMzgnLt3IXda2FLl5NiPTbVM= +github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.11-0.20260724142814-45996a1bcb72 h1:uWEwl7i2ryuRVoV4DmIKm6mqYevf1lH/8cQYhw/JXko= +github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.11-0.20260724142814-45996a1bcb72/go.mod h1:UYcRMb4dZcoaIPgZJ3hckCySTqtJc9K4Q+tOKErwTq0= github.com/smartcontractkit/chainlink-protos/billing/go v0.0.0-20251024234028-0988426d98f4 h1:GCzrxDWn3b7jFfEA+WiYRi8CKoegsayiDoJBCjYkneE= github.com/smartcontractkit/chainlink-protos/billing/go v0.0.0-20251024234028-0988426d98f4/go.mod h1:HHGeDUpAsPa0pmOx7wrByCitjQ0mbUxf0R9v+g67uCA= -github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260707203317-661b54b51a33 h1:oW88YVT5ENU6rCPVOLV/hofmFBux2Mu1EOwz8KJu5ic= -github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260707203317-661b54b51a33/go.mod h1:/i8hjTPFdVWHiY+QjeSiVS2Z3GB3WAZznGgXHstC02E= +github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260804191526-b7a850ae7648 h1:WEUMkKQPAgcNMRgES6CBWrRUiII+HKEWQjulKQBSuMA= +github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260804191526-b7a850ae7648/go.mod h1:/i8hjTPFdVWHiY+QjeSiVS2Z3GB3WAZznGgXHstC02E= github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b h1:QuI6SmQFK/zyUlVWEf0GMkiUYBPY4lssn26nKSd/bOM= github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b/go.mod h1:qSTSwX3cBP3FKQwQacdjArqv0g6QnukjV4XuzO6UyoY= +github.com/smartcontractkit/chainlink-protos/metering/go v0.0.0-20260729184203-90b4cdd48536 h1:ecQYtdRA+NQLXf0aKYUMfcn1TRhcQ4RZCZzzadFsbSs= +github.com/smartcontractkit/chainlink-protos/metering/go v0.0.0-20260729184203-90b4cdd48536/go.mod h1:z7lx7wI3XZ4u9kmUtAVdwn1BCC9T8aieWSDcuDgPTdQ= github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260709145319-7782fb89eb16 h1:/vkKPJoweLkRd56V4YHGRAtTG4+/JAlgklGEfvH6l4c= github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260709145319-7782fb89eb16/go.mod h1:dkR2uYg9XYJuT1JASkPzWE51jjFkVb86P7a/yXe5/GM= github.com/smartcontractkit/chainlink-protos/workflows/go v0.0.0-20260528173149-f5b8336b19d9 h1:LQy2j2+TdKLSWsUTUYuqmQPn8kjqCLjGI3ZJYGtDc08= diff --git a/libs/triggermeter/triggermeter.go b/libs/triggermeter/triggermeter.go new file mode 100644 index 000000000..7fe5ad4d1 --- /dev/null +++ b/libs/triggermeter/triggermeter.go @@ -0,0 +1,285 @@ +// Package triggermeter isolates every metering concern of a trigger +// capability behind one type, so the trigger service body stays domain-only: +// it holds a single *TriggerMeter field and never branches on metering state. +// +// Trigger capabilities are snapshot-only producers. They hold no durable +// state (their registration stores are in-memory projections of workflow +// deployments), so they cannot anchor MeterRecord deltas on durable +// transitions the way the workflow syncer does: a node restart would re-emit +// every +1, and a delete that arrives while a capability-DON node is down is +// never observed at all. Billing for trigger resources therefore rides the +// ResourceManager's periodic MeterSnapshots exclusively — the level rises when +// a registration appears in the next snapshot and is released by its absence +// (the consumer drawdown contract counts any nonzero snapshot). There is no +// delta-emission surface on this type by design; do not add one. +package triggermeter + +import ( + "context" + "errors" + "strconv" + "sync" + + "github.com/smartcontractkit/chainlink-common/pkg/contexts" + "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink-common/pkg/resourcemanager" + "github.com/smartcontractkit/chainlink-common/pkg/services/orgresolver" + meteringpb "github.com/smartcontractkit/chainlink-protos/metering/go" +) + +// DefaultProduct is the metering product a trigger falls back to when neither +// the host (DeploymentIdentity.Product via loop.EnvConfig) nor the trigger's +// Config supplies one. Trigger capabilities are CRE products, so this is a +// meaningful default rather than resourcemanager.UnsetProduct. +const DefaultProduct = "cre" + +// ErrDonIDNotInitialised is returned by DonID when the host has not (yet) +// delivered a non-zero CapabilityDonID (StandardCapabilitiesDependencies) to +// the trigger's Initialise. The consumer workflow's DON ID is a different +// dimension and is never substituted for it: callers either degrade +// explicitly at their own call site (event labels, CRE-4409) or proceed with +// the DON dimension absent (metering snapshots). +var ErrDonIDNotInitialised = errors.New("capability DON ID not initialised: waiting for Initialise to deliver StandardCapabilitiesDependencies.CapabilityDonID") + +// Config carries the per-trigger metering identity constants. They are +// stamped once at construction (never settable later): Service is the stable +// service constant (it must not encode environment or zone), ResourcePool the +// service-level pool snapshots apply to, and ResourceType the billing unit on +// each Utilization. Product overrides DefaultProduct as the fallback used +// when the host supplies no product. +type Config struct { + Service string + ResourcePool string + ResourceType string + Product string +} + +// SnapshotRow is the absolute level of one active resource, as reported by +// the trigger's own store at a snapshot tick. DonID optionally re-stamps the +// DON dimension for this row only (a resource whose DON was resolved at its +// registration, e.g. an EVM log filter); when empty the meter's base identity +// is used unchanged. +type SnapshotRow struct { + Value int64 + ResourceID string + OrgID string + DonID string +} + +// SnapshotFunc supplies the trigger's current resource levels. It is invoked +// on the ResourceManager's snapshot tick and MUST be a cheap, non-blocking +// read-snapshot of in-memory state: no network, no disk, no lock held across +// I/O (the resourcemanager.Meterable contract). Org IDs must come from state +// captured at registration, never from a resolver call here. +type SnapshotFunc func(ctx context.Context) []SnapshotRow + +// TriggerMeter owns a trigger capability's metering: the ResourceManager +// lifecycle, the base ResourceIdentity, org resolution for registration +// paths, and the resourcemanager.Meterable implementation. A nil +// *TriggerMeter and a TriggerMeter constructed with a nil ResourceManager are +// both safe, indistinguishable no-ops — "meter is off" is expressed by the +// value, not by guards at call sites. DonID remains served from the base +// identity even when metering is off (event labels need it regardless). +type TriggerMeter struct { + lggr logger.Logger + // rm may be nil: metering off. The meter never constructs a disabled + // substitute; nil is the disabled state. + rm *resourcemanager.ResourceManager + cfg Config + base resourcemanager.ResourceIdentity + orgResolver orgresolver.OrgResolver + snapshot SnapshotFunc + + mu sync.Mutex + // started is set only when rm.Start succeeded; it gates Close so a + // fail-open Start can never turn into a Close error on a never-started + // ResourceManager. + started bool + unregister func() +} + +// New builds the meter for one trigger capability. rm may be nil (metering +// off; every method no-ops). dep carries the static deployment/node identity +// dimensions delivered via loop.EnvConfig; capabilityDonID is the +// host-injected DON ID from StandardCapabilitiesDependencies (0 = unknown: +// the DON dimension is left absent, never substituted). orgResolver may be +// nil (org IDs resolve to empty). snapshot supplies the trigger's levels on +// each snapshot tick and may be nil only if the trigger has nothing to +// snapshot. +func New( + lggr logger.Logger, + rm *resourcemanager.ResourceManager, + dep resourcemanager.DeploymentIdentity, + capabilityDonID uint32, + cfg Config, + orgResolver orgresolver.OrgResolver, + snapshot SnapshotFunc, +) *TriggerMeter { + if dep.Product == "" { + if cfg.Product != "" { + dep.Product = cfg.Product + } else { + dep.Product = DefaultProduct + } + } + base := resourcemanager.NewBaseIdentity(dep, cfg.Service, cfg.ResourcePool) + if capabilityDonID != 0 { + base = base.WithDonID(strconv.FormatUint(uint64(capabilityDonID), 10)) + } + return &TriggerMeter{ + lggr: logger.Named(lggr, "TriggerMeter"), + rm: rm, + cfg: cfg, + base: base, + orgResolver: orgResolver, + snapshot: snapshot, + } +} + +// Start starts the ResourceManager (it owns the snapshot tick) and registers +// this meter as its Meterable. Metering is fail-open: an RM start failure is +// logged and swallowed — snapshots are disabled but the trigger service must +// come up regardless — so Start never returns a non-nil error today. +func (tm *TriggerMeter) Start(ctx context.Context) error { + if tm == nil || tm.rm == nil { + return nil + } + tm.mu.Lock() + defer tm.mu.Unlock() + if tm.started { + return nil + } + if err := tm.rm.Start(ctx); err != nil { + logger.Sugared(tm.lggr).Errorw("failed to start metering ResourceManager; snapshots disabled", "err", err) + return nil + } + tm.started = true + tm.unregister = tm.rm.Register(tm) + return nil +} + +// Close deregisters the Meterable FIRST (so no snapshot tick can observe a +// half-torn-down trigger store) and then closes the ResourceManager — but +// only when Start actually started it, so a fail-open Start never becomes a +// Close error. Close is idempotent. +func (tm *TriggerMeter) Close() error { + if tm == nil || tm.rm == nil { + return nil + } + tm.mu.Lock() + defer tm.mu.Unlock() + if !tm.started { + return nil + } + if tm.unregister != nil { + tm.unregister() + tm.unregister = nil + } + tm.started = false + return tm.rm.Close() +} + +// Ready implements services.Service. The meter is fail-open by contract and +// therefore never blocks readiness. +func (tm *TriggerMeter) Ready() error { return nil } + +// HealthReport implements services.Service, folding in the ResourceManager's +// report while it is running. +func (tm *TriggerMeter) HealthReport() map[string]error { + if tm == nil { + return map[string]error{} + } + report := map[string]error{tm.Name(): nil} + tm.mu.Lock() + started := tm.started + tm.mu.Unlock() + if started { + for name, err := range tm.rm.HealthReport() { + report[name] = err + } + } + return report +} + +// Name implements services.Service. +func (tm *TriggerMeter) Name() string { + if tm == nil || tm.cfg.Service == "" { + return "TriggerMeter" + } + return "TriggerMeter." + tm.cfg.Service +} + +// DonID returns the capability DON identifier stamped on the base metering +// identity, or ErrDonIDNotInitialised when the host has not delivered one. +// It is served even when metering is off, because trigger event labels +// (CRE-4409) share the value; only a nil *TriggerMeter reports the error +// unconditionally. +func (tm *TriggerMeter) DonID() (string, error) { + if tm == nil { + return "", ErrDonIDNotInitialised + } + if id := tm.base.DonID(); id != "" { + return id, nil + } + return "", ErrDonIDNotInitialised +} + +// ResolveOrg resolves the org ID for a registration path, fail-open: the org +// already resolved upstream on the request context (contexts.CREValue) wins; +// otherwise one resolver call is made, with any error (or panic) logged and +// swallowed to an empty org. Callers store the result alongside the +// registration so snapshot paths never resolve — SnapshotFunc must be +// network-free. +func (tm *TriggerMeter) ResolveOrg(ctx context.Context, owner string) string { + if tm == nil { + return "" + } + if orgID := contexts.CREValue(ctx).Org; orgID != "" { + return orgID + } + if tm.orgResolver == nil || owner == "" { + return "" + } + var orgID string + func() { + defer func() { + if r := recover(); r != nil { + logger.Sugared(tm.lggr).Warnw("panic while resolving org ID for metering", "owner", owner, "panic", r) + } + }() + if resolved, err := tm.orgResolver.Get(ctx, owner); err != nil { + logger.Sugared(tm.lggr).Warnw("failed to resolve org ID for metering", "owner", owner, "err", err) + } else { + orgID = resolved + } + }() + return orgID +} + +// GetUtilization implements resourcemanager.Meterable: it maps the trigger's +// SnapshotFunc rows to snapshot entries, one per active resource, stamping +// the base identity (with an optional per-row DON re-stamp) and the +// configured resource type. The ResourceManager derives snapshot event_ids +// itself. +func (tm *TriggerMeter) GetUtilization(ctx context.Context) []resourcemanager.SnapshotEntry { + if tm == nil || tm.snapshot == nil || ctx.Err() != nil { + return nil + } + rows := tm.snapshot(ctx) + entries := make([]resourcemanager.SnapshotEntry, 0, len(rows)) + for _, row := range rows { + entries = append(entries, resourcemanager.SnapshotEntry{ + // WithDonID is a no-op for an empty DonID, so rows without a + // per-resource DON inherit the base identity unchanged. + Identity: tm.base.WithDonID(row.DonID), + Utilizations: []*meteringpb.Utilization{ + resourcemanager.NewUtilizationInt(row.Value, resourcemanager.UtilizationFields{ + ResourceType: tm.cfg.ResourceType, + ResourceID: row.ResourceID, + OrgID: row.OrgID, + }), + }, + }) + } + return entries +} diff --git a/libs/triggermeter/triggermeter_test.go b/libs/triggermeter/triggermeter_test.go new file mode 100644 index 000000000..657eef86d --- /dev/null +++ b/libs/triggermeter/triggermeter_test.go @@ -0,0 +1,305 @@ +package triggermeter + +import ( + "context" + "errors" + "sync/atomic" + "testing" + "time" + + "github.com/jonboulle/clockwork" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + + "github.com/smartcontractkit/chainlink-common/pkg/contexts" + "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink-common/pkg/resourcemanager" + meteringpb "github.com/smartcontractkit/chainlink-protos/metering/go" +) + +var testCfg = Config{ + Service: "test-trigger", + ResourcePool: "test_pool", + ResourceType: "operations", +} + +var testDep = resourcemanager.DeploymentIdentity{ + Product: "cre-test", + Tenant: "mainline", + NumericTenantID: "42", + Environment: "staging", + Zone: "wf-zone-a", + NodeID: "node-1", +} + +// fakeEmitter captures MeterSnapshot emissions (this package's meters emit no +// records); a non-nil err simulates delivery failure. +type fakeEmitter struct { + err error + emitCalls atomic.Int32 + snapshots []*meteringpb.MeterSnapshot +} + +func (f *fakeEmitter) Emit(_ context.Context, body []byte, attrKVs ...any) error { + f.emitCalls.Add(1) + if f.err != nil { + return f.err + } + for i := 0; i+1 < len(attrKVs); i += 2 { + if attrKVs[i] == "beholder_entity" && attrKVs[i+1] == "metering.v1.MeterSnapshot" { + var snapshot meteringpb.MeterSnapshot + if err := proto.Unmarshal(body, &snapshot); err != nil { + return err + } + f.snapshots = append(f.snapshots, &snapshot) + return nil + } + } + return nil +} + +// fakeOrgResolver counts Get calls; configurable to error or panic. +type fakeOrgResolver struct { + calls atomic.Int32 + err error + doPanic bool + resolved string +} + +func (f *fakeOrgResolver) Get(context.Context, string) (string, error) { + f.calls.Add(1) + if f.doPanic { + panic("resolver exploded") + } + return f.resolved, f.err +} +func (f *fakeOrgResolver) Start(context.Context) error { return nil } +func (f *fakeOrgResolver) Close() error { return nil } +func (f *fakeOrgResolver) Ready() error { return nil } +func (f *fakeOrgResolver) HealthReport() map[string]error { return nil } +func (f *fakeOrgResolver) Name() string { return "fakeOrgResolver" } + +func newEnabledRM(t *testing.T, emitter resourcemanager.Emitter, clock clockwork.Clock) *resourcemanager.ResourceManager { + t.Helper() + return resourcemanager.NewResourceManager(logger.Test(t), resourcemanager.ResourceManagerConfig{ + MeterRecordsEnabled: true, + MeterSnapshotsEnabled: true, + Emitter: emitter, + SnapshotInterval: time.Minute, + Clock: clock, + }) +} + +func TestNew_IdentityStamping(t *testing.T) { + t.Parallel() + + t.Run("full deployment + capability DON", func(t *testing.T) { + tm := New(logger.Test(t), nil, testDep, 7, testCfg, nil, nil) + donID, err := tm.DonID() + require.NoError(t, err) + assert.Equal(t, "7", donID) + assert.Equal(t, "cre-test", tm.base.Product) + assert.Equal(t, "test-trigger", tm.base.Service) + assert.Equal(t, "test_pool", tm.base.ResourcePool) + assert.Equal(t, "node-1", tm.base.NodeID()) + }) + + t.Run("empty product falls back to Config.Product", func(t *testing.T) { + dep := testDep + dep.Product = "" + cfg := testCfg + cfg.Product = "custom" + tm := New(logger.Test(t), nil, dep, 0, cfg, nil, nil) + assert.Equal(t, "custom", tm.base.Product) + }) + + t.Run("empty product and empty Config.Product falls back to DefaultProduct", func(t *testing.T) { + dep := testDep + dep.Product = "" + tm := New(logger.Test(t), nil, dep, 0, testCfg, nil, nil) + assert.Equal(t, DefaultProduct, tm.base.Product) + }) + + t.Run("zero capability DON leaves the DON dimension absent", func(t *testing.T) { + tm := New(logger.Test(t), nil, testDep, 0, testCfg, nil, nil) + _, err := tm.DonID() + require.ErrorIs(t, err, ErrDonIDNotInitialised) + assert.Equal(t, "node-1", tm.base.NodeID(), "the node dimension survives without a DON ID") + }) +} + +// TestNilReceiverSafety asserts every method is a safe no-op on a nil meter: +// a nil *TriggerMeter is the metering-off posture for partially constructed +// components and tests. +func TestNilReceiverSafety(t *testing.T) { + t.Parallel() + var tm *TriggerMeter + require.NoError(t, tm.Start(t.Context())) + require.NoError(t, tm.Close()) + require.NoError(t, tm.Ready()) + assert.NotNil(t, tm.HealthReport()) + assert.Equal(t, "TriggerMeter", tm.Name()) + _, err := tm.DonID() + require.ErrorIs(t, err, ErrDonIDNotInitialised) + assert.Empty(t, tm.ResolveOrg(t.Context(), "owner")) + assert.Nil(t, tm.GetUtilization(t.Context())) +} + +// TestNilRMSafety asserts a meter constructed with a nil ResourceManager +// (metering off) no-ops on lifecycle but still serves identity (DonID feeds +// event labels regardless of metering state). +func TestNilRMSafety(t *testing.T) { + t.Parallel() + tm := New(logger.Test(t), nil, testDep, 7, testCfg, nil, nil) + require.NoError(t, tm.Start(t.Context())) + require.NoError(t, tm.Close()) + donID, err := tm.DonID() + require.NoError(t, err) + assert.Equal(t, "7", donID) +} + +func TestStartClose_Lifecycle(t *testing.T) { + t.Parallel() + + t.Run("start registers, snapshot tick polls, close stops", func(t *testing.T) { + emitter := &fakeEmitter{} + clock := clockwork.NewFakeClockAt(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)) + rows := []SnapshotRow{{Value: 3, ResourceID: "res-1", OrgID: "org-1", DonID: ""}} + tm := New(logger.Test(t), newEnabledRM(t, emitter, clock), testDep, 7, testCfg, nil, + func(context.Context) []SnapshotRow { return rows }) + require.NoError(t, tm.Start(t.Context())) + + require.NoError(t, clock.BlockUntilContext(t.Context(), 1)) + clock.Advance(time.Minute) + require.Eventually(t, func() bool { return emitter.emitCalls.Load() >= 1 }, time.Second, time.Millisecond) + + require.NoError(t, tm.Close()) + require.Len(t, emitter.snapshots, 1) + s := emitter.snapshots[0] + assert.Equal(t, "res-1", s.GetUtilization()[0].GetResourceId()) + assert.Equal(t, "3", s.GetUtilization()[0].GetValue()) + assert.Equal(t, "org-1", s.GetUtilization()[0].GetOrgId()) + assert.Equal(t, "operations", s.GetUtilization()[0].GetResourceType()) + assert.Equal(t, "7", s.GetIdentity().GetDon().GetDonId()) + }) + + t.Run("close without start is a no-op (never closes an unstarted RM)", func(t *testing.T) { + tm := New(logger.Test(t), newEnabledRM(t, &fakeEmitter{}, nil), testDep, 7, testCfg, nil, nil) + require.NoError(t, tm.Close()) + }) + + t.Run("double start and double close are idempotent", func(t *testing.T) { + tm := New(logger.Test(t), newEnabledRM(t, &fakeEmitter{}, nil), testDep, 7, testCfg, nil, nil) + require.NoError(t, tm.Start(t.Context())) + require.NoError(t, tm.Start(t.Context())) + require.NoError(t, tm.Close()) + require.NoError(t, tm.Close()) + }) +} + +func TestResolveOrg(t *testing.T) { + t.Parallel() + + t.Run("context CRE org wins without a resolver call", func(t *testing.T) { + resolver := &fakeOrgResolver{resolved: "org-from-resolver"} + tm := New(logger.Test(t), nil, testDep, 7, testCfg, resolver, nil) + ctx := contexts.WithCRE(t.Context(), contexts.CRE{Org: "org-from-ctx"}) + assert.Equal(t, "org-from-ctx", tm.ResolveOrg(ctx, "owner")) + assert.Zero(t, resolver.calls.Load()) + }) + + t.Run("resolver fallback", func(t *testing.T) { + resolver := &fakeOrgResolver{resolved: "org-42"} + tm := New(logger.Test(t), nil, testDep, 7, testCfg, resolver, nil) + assert.Equal(t, "org-42", tm.ResolveOrg(t.Context(), "owner")) + assert.Equal(t, int32(1), resolver.calls.Load()) + }) + + t.Run("resolver error fails open to empty", func(t *testing.T) { + resolver := &fakeOrgResolver{err: errors.New("boom")} + tm := New(logger.Test(t), nil, testDep, 7, testCfg, resolver, nil) + assert.Empty(t, tm.ResolveOrg(t.Context(), "owner")) + }) + + t.Run("resolver panic fails open to empty", func(t *testing.T) { + resolver := &fakeOrgResolver{doPanic: true} + tm := New(logger.Test(t), nil, testDep, 7, testCfg, resolver, nil) + assert.Empty(t, tm.ResolveOrg(t.Context(), "owner")) + }) + + t.Run("nil resolver or empty owner resolves to empty", func(t *testing.T) { + tm := New(logger.Test(t), nil, testDep, 7, testCfg, nil, nil) + assert.Empty(t, tm.ResolveOrg(t.Context(), "owner")) + resolver := &fakeOrgResolver{resolved: "org"} + tm = New(logger.Test(t), nil, testDep, 7, testCfg, resolver, nil) + assert.Empty(t, tm.ResolveOrg(t.Context(), "")) + assert.Zero(t, resolver.calls.Load()) + }) +} + +func TestGetUtilization(t *testing.T) { + t.Parallel() + + t.Run("maps rows with per-row DON re-stamp", func(t *testing.T) { + tm := New(logger.Test(t), nil, testDep, 7, testCfg, nil, func(context.Context) []SnapshotRow { + return []SnapshotRow{ + {Value: 1, ResourceID: "a", OrgID: "org-a"}, + {Value: 5, ResourceID: "b", OrgID: "org-b", DonID: "99"}, + } + }) + entries := tm.GetUtilization(t.Context()) + require.Len(t, entries, 2) + assert.Equal(t, "7", entries[0].Identity.DonID(), "no per-row DON: base identity used") + assert.Equal(t, "99", entries[1].Identity.DonID(), "per-row DON re-stamps the identity") + assert.Equal(t, "node-1", entries[1].Identity.NodeID(), "the node dimension survives the re-stamp") + assert.Equal(t, "b", entries[1].Utilizations[0].GetResourceId()) + assert.Equal(t, "5", entries[1].Utilizations[0].GetValue()) + assert.Equal(t, "org-b", entries[1].Utilizations[0].GetOrgId()) + assert.Equal(t, "operations", entries[1].Utilizations[0].GetResourceType()) + }) + + t.Run("cancelled context short-circuits", func(t *testing.T) { + called := false + tm := New(logger.Test(t), nil, testDep, 7, testCfg, nil, func(context.Context) []SnapshotRow { + called = true + return nil + }) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + assert.Nil(t, tm.GetUtilization(ctx)) + assert.False(t, called) + }) + + t.Run("nil snapshot source yields nil", func(t *testing.T) { + tm := New(logger.Test(t), nil, testDep, 7, testCfg, nil, nil) + assert.Nil(t, tm.GetUtilization(t.Context())) + }) +} + +// TestFailOpen_ErroringEmitter asserts an emitter that fails on every call +// never disturbs the meter's lifecycle. +func TestFailOpen_ErroringEmitter(t *testing.T) { + t.Parallel() + emitter := &fakeEmitter{err: errors.New("collector down")} + clock := clockwork.NewFakeClockAt(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)) + tm := New(logger.Test(t), newEnabledRM(t, emitter, clock), testDep, 7, testCfg, nil, + func(context.Context) []SnapshotRow { return []SnapshotRow{{Value: 1, ResourceID: "r"}} }) + require.NoError(t, tm.Start(t.Context())) + require.NoError(t, clock.BlockUntilContext(t.Context(), 1)) + clock.Advance(time.Minute) + require.Eventually(t, func() bool { return emitter.emitCalls.Load() >= 1 }, time.Second, time.Millisecond) + require.NoError(t, tm.Close()) + assert.Empty(t, emitter.snapshots) +} + +func TestHealthReportAndName(t *testing.T) { + t.Parallel() + tm := New(logger.Test(t), newEnabledRM(t, &fakeEmitter{}, nil), testDep, 7, testCfg, nil, nil) + assert.Equal(t, "TriggerMeter.test-trigger", tm.Name()) + report := tm.HealthReport() + require.Contains(t, report, "TriggerMeter.test-trigger") + require.NoError(t, tm.Start(t.Context())) + assert.Greater(t, len(tm.HealthReport()), 1, "a started meter folds in the ResourceManager's report") + require.NoError(t, tm.Close()) +}