Skip to content

adding resource manager for workflows - #22845

Merged
patrickhuie19 merged 28 commits into
developfrom
feat/shared-2712
Aug 11, 2026
Merged

adding resource manager for workflows#22845
patrickhuie19 merged 28 commits into
developfrom
feat/shared-2712

Conversation

@patrickhuie19

@patrickhuie19 patrickhuie19 commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Durable resource metering for workflow specs (workflow-syncer-v2 × ResourceManager)

SHARED-2712

Requires


TL;DR

This PR makes the v2 workflow registry syncer emit billing-grade MeterRecord deltas for workflow-spec storage: +1 when a workflow's artifacts are first persisted, -1 when they are deleted. Pause and Activate do not produce MeterRecords. To ensure no drift from the deltas (i.e. think of register -> delete -> re-register) we change the ArtifactStorageORM to add a registered_at field to the table, sourced from the CreatedAt uint64 on the smartcontract and centralized registry sources. To preserve the previous functionality we still clear out the workflow and config fields. This additional state prevents us from emitting the same eventID within a dedup window on the billing service.


1. What is being metered

One metered unit = one persisted registration generation in workflow_specs_v2. That row is the node's durable copy of the workflow: the workflow column holds the complete hex-encoded wasm binary and config holds its config (the on-disk module cache and in-memory module LRU are rebuildable caches on top of it).

Identity on every record: service=workflow-syncer-v2, resource_pool=workflow_specs_v2, resource_type=operations, resource_id=workflow_id, plus deployment dimensions (product/tenant/environment/zone/node) from the new [Metering] config and don_id resolved from the DON notifier. Org ID is resolved fail-open from the workflow owner at emit time.

2. Constraints

  1. Every workflow-DON node emits every record. Deltas are deduplicated downstream by event_id, so all nodes fielding the same logical transition must derive byte-identical event_ids from DON-consistent inputs only (resourcemanager.EventID). Any locally-derived discriminator (wall clock, block head at tick time, per-node counters) silently breaks dedup and double-counts.
  2. The consumer's dedup window is hours-to-days and is NOT load-bearing. At steady state there will be hundreds of millions of MeterRecords and workflows live for years; an unbounded exact-once KV is infeasible. Therefore: two different logical transitions must never share an event_id (the later one would be dropped inside the window), and drift must not be "corrected later".
  3. The chain gives us no per-occurrence discriminator for pause/activate. In WorkflowRegistry.sol, _applyPause/_applyActivate mutate only status; createdAt is set once at registration and never changes. The metadata view the syncer reconciles against has no updatedAt, no nonce, no status-change block.
  4. The syncer cannot distinguish a registration from an activation. Reconciliation is state-based; every active-workflow-without-engine becomes a WorkflowActivated event (see the comment in generateReconciliationEvents: "we can't tell the difference between an activation and registration without holding state in the db").

Given the above constraints, if pause deleted the row and emitted -1, the subsequent activation would re-persist the row through the create path and be forced to reuse the register event_id. (wfID, on-chain createdAt) is the only DON-consistent material available, and it's identical to the original registration's. The consumer would then drop that +1 as a duplicate whenever register → pause → activate happen within one dedup window:

There is no id scheme that escapes this without new state. So we hold state: the row survives pause, and deltas are emitted only at generation boundaries, where each fires at most once per (workflow_id, registered_at) and ids never collide across generations. Zero drift by construction, not by reconciliation.

3. Flows

stateDiagram-v2
    direction LR
    [*] --> Absent
    Absent --> Active: register / activate — fetch + INSERT — emit +1
    Active --> Tombstone: pause — one UPDATE sets status=paused and clears binary+config — no delta
    Tombstone --> Active: activate — refetch + UPSERT via existing create path — no delta
    Active --> Absent: delete or orphan sweep — DELETE — emit -1
    Tombstone --> Absent: delete or orphan sweep — DELETE — emit -1
    Active --> Active: status-only flip or redelivered event — no delta
    Tombstone --> Tombstone: redelivered pause — no-op
    Absent --> Absent: redelivered pause / delete — no-op, no delta
Loading

Event_ids

Transition Storage effect Delta event_id
First persistence of a generation (register, or activate with no row, or artifact-mismatch re-persist) INSERT full row incl. registered_at +1 EventID("workflow-spec-register", wfID, onChainCreatedAt)
Pause row kept; status='paused', workflow='', config='' (one statement) none
Activate-after-pause refetch artifacts, full UPSERT, status='active' none
Status-only flip status UPSERT none
Delete (event path or orphan sweep) DELETE … RETURNING registered_at −1 iff a row was removed EventID("workflow-spec-delete", wfID, registered_at) — timestamp part omitted when 0 (pre-migration rows)

registered_at is a new column (migration 0302) persisting the on-chain registration timestamp. the generation discriminator. Without it, a delete → re-register (same content ⇒ same workflowID) → delete sequence inside one dedup window would collide the two -1s.

4. ArtifactStorage Lifecycle Change

Prior to this change workflowPausedEvent delegates to the delete path. The entire row in workflow_specs_v2 is removed, and activation later refetches the binary/config from the storage service and re-inserts it.

Now: pause = drain engine (unchanged ErrDrainInProgress semantics) → stop engine → single UPDATE workflow_specs_v2 SET status='paused', workflow='', config='', updated_at=now() → module-cache cleanup → engine-registry pop. Same step order as the delete path; only the storage operation differs.

I considered keeping the binary/config, but decided against it:

  • Storage is still freed. The hex-encoded binary is the heavy payload in the row (2× binary size as text); the tombstone keeps only identity columns. What the tombstone buys is state: it is the only thing that lets the handler distinguish "activate after pause" (no delta) from "first registration"
  • Activation is unchanged in kind. Activate-after-pause detects the tombstone (spec.Workflow == "" — an invariant, since createWorkflowSpec always persists a non-empty hex binary; commented in code) and runs the existing createWorkflowSpec fetch/upsert path — the exact same storage-service fetch it performs today, minus the delta. Same failure mode on fetch errors (event retried, engine not started).
  • It mirrors the chain. The on-chain registry retains paused registrations; local durable state now has the same shape (same workflow set, same statuses).

5. DB changes

  • 0302_workflow_specs_v2_registered_at.sql: ADD COLUMN registered_at bigint NOT NULL DEFAULT 0.
  • Pre-migration rows carry 0 → their delete-id falls back to the timestamp-less form. Backfill is opportunistic: any event that touches an existing row with registered_at = 0 writes the payload's CreatedAt. The first post-upgrade boot delivers WorkflowActivated for every engine-less active workflow, so fleets converge in one reconciliation pass — before metering can emit anything, because [Metering].MeterRecordsEnabled defaults to false and enabling it requires a restart.

6. Testing

Storage state machine — Test_specStorage_StateMachine. Table-driven over (row state: absent | active | paused-tombstone | paused-with-artifacts) × (event: Registered(active), Registered(paused), Activated, Paused, Deleted — each delivered twice). After every step it asserts: row existence, status, payload emptiness, registered_at, engine-registry contents, artifact-fetch call count, returned error.

Metering lifecycle — handler_metering_test.go (reworked).

  • Test_meterRecords_FullLifecycleEmitsExactlyTwoRecords: register(+1) → pause(0) → activate(0) → delete(−1); asserts exactly two records with the exact event_ids, registered_at flowing insert → RETURNING.
  • Test_meterRecords_PauseActivateCycleIsLevelNeutral; redelivered delete emits exactly one −1; drain-deferred delete emits nothing until the successful retry; Test_meterRecords_TransientDeleteErrorThenRetryEmitsOnce (the lost-−1 bug); Test_meterRecords_LegacyRowDeleteUsesFallbackID; reprocessed register yields the identical event_id; org-ID and don_id stamping; disabled ResourceManager emits nothing; emitter failures never fail event handling.
  • Test_meterRecords_FailOpenEquivalence: identical event sequences with the ResourceManager nil / disabled / erroring produce byte-identical row and engine outcomes and zero handler errors — metering cannot alter deployment behavior by construction.
  • Test_meterRecords_SweepReleasesPausedTombstone: deleted-while-paused through the sweep → exactly one −1 with the generation id.

Source parity — Test_handler_SourceParity_PauseActivateCycle. The full register → pause → activate → delete cycle run twice, once with a contract-source identifier and once with a centralized-registry (gRPC) identifier, asserting identical storage effects, engine-registry source scoping, and identical metering records — pinning that mainline and centralized flows cannot diverge.

Sweep — Test_reconcileOrphanedSpecs_ReleasesEnginelessOrphansOnly (+ engine-present orphans skipped; zero Handle dispatches).

Deliberately flipped assertions (the complete list — everything else in the existing syncer suite passes unchanged): Test_workflowDeletedHandler and drain tests move to the 2-arg workflowDeletedEvent signature; tests asserting "pause deletes the row" now assert "pause tombstones the row"; ORM/store delete tests assert (registeredAt, deleted, err).

End-to-end: local CRE environment (this repo's tooling, metering-enabled TOML in the DON configs) — register → pause → activate → delete on both sources; chip-ingress shows exactly one +1 and one -1 per generation with event_ids identical across all four workflow nodes; engines healthy throughout; during the paused interval the row is present with an empty workflow column

Reviewer guide

Diff area What to look at
core/services/workflows/syncer/v2/handler.go workflowPausedEvent (the behavior change), workflowRegisteredEvent restore branch, releaseSpecStorage, stopEngine extraction, emitSpecDelta
core/services/workflows/syncer/v2/workflow_registry.go Orphan sweep (reconcileOrphanedSpecs, ReleaseOrphanedSpec on evtHandler)
core/services/workflows/artifacts/v2/{orm,store}.go, migration 0302 registered_at, DELETE … RETURNING, PauseWorkflowSpec
core/config/**, core/services/chainlink/**, plugins/loop_registry.go [Metering] config plumbing + LOOP env propagation
core/services/cre/cre.go ResourceManager construction + identity wiring
Test files start with Test_specStorage_StateMachine and Test_meterRecords_FullLifecycleEmitsExactlyTwoRecords

@patrickhuie19 patrickhuie19 changed the title adding resource manager for wf size adding resource manager for workflows Jun 15, 2026
@patrickhuie19 patrickhuie19 added build-publish Build and Publish image to SDLC do not merge labels Jun 15, 2026
@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

CORA - Pending Reviewers

All codeowners have approved! ✅

Legend: ✅ Approved | ❌ Changes Requested | 💬 Commented | 🚫 Dismissed | ⏳ Pending | ❓ Unknown

For more details, see the full review summary.

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

I see you updated files related to core. Please run make gocs in the root directory to add a changeset as well as in the text include at least one of the following tags:

  • #added For any new functionality added.
  • #breaking_change For any functionality that requires manual action for the node to boot.
  • #bugfix For bug fixes.
  • #changed For any change to the existing functionality.
  • #db_update For any feature that introduces updates to database schema.
  • #deprecation_notice For any upcoming deprecation functionality.
  • #internal For changesets that need to be excluded from the final changelog.
  • #nops For any feature that is NOP facing and needs to be in the official Release Notes for the release.
  • #removed For any functionality/config that is removed.
  • #updated For any functionality that is updated.
  • #wip For any change that is not ready yet and external communication about it should be held off till it is feature complete.

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

✅ No conflicts with other open PRs targeting develop

@trunk-io

trunk-io Bot commented Jul 7, 2026

Copy link
Copy Markdown

Static BadgeStatic BadgeStatic Badge

View Full Report ↗︎Docs

…re-anchor

Phase 4 of the delta-based metering redesign, built against the new
chainlink-common resourcemanager API (EmitDelta/EmitUsage, RM-generated
event_id, no UtilizationFields.EventID).

TOML-only gating (kills split-brain):
- Delete env.MeterRecordsEnabled and the CL_METER_RECORDS_ENABLED LOOP
  pass-through (provably shadowed by AsCmdEnv) + its subtests.
- Delete cre.meterRecordsEnabled(); gate the syncer ResourceManager on
  cfg.Metering().MeterRecordsEnabled()/MeterSnapshotsEnabled().
- Default [Metering].Product = "cre" (accessor + core.toml + CONFIG.md).

Dead-plumbing removal:
- Remove standardcapabilities.NodeIdentity struct, delegate field/ctor param,
  and its population in application.go. Identity delivery is loop.EnvConfig.

node_id logical name:
- loop_registry already sources NodeID from [Metering].NodeID; fix fixtures
  (csa-pubkey-1 -> clp-cre-wf-zone-a-1) and drop the CSA-pubkey comment.

Host-side CapDONID (rule 8):
- Delegate already resolves and populates CapabilityDonID on the dependencies
  handed to capability LOOPs at Initialise; add a test asserting a nonzero
  CapDONID round-trips (and zero is preserved for the workflow-DON fallback).

Syncer v2 metering re-anchor (handler.go):
- Emit anchors on artifact persistence: EmitDelta(+1) when a new spec's
  artifacts are first persisted, EmitDelta(-1) when a real delete removes them.
  Pause/activate and status-only updates emit nothing.
- Delete emitGracefulCloseReleases and its call in close (no lifecycle emits).
- GetUtilization enumerates persisted specs (WorkflowSpecsDS.GetWorkflowSpecList)
  at value 1 per workflow; org resolved from the stored owner via the shared
  CachingOrgResolver. Records resolve org from payload owner via ResolveOrEmpty.
- Default ResourceManager to nil (nil-guarded), reuse one emitSpecDelta helper.

Regenerate txtar validate/merge goldens and effective TOMLs for the new
Product default and the Tenant/NumericTenantID dimensions.

Note: local replace directives point chainlink, core/scripts and deployment at
the ../chainlink-common and ../chainlink-protos/metering/go siblings so this
branch builds against the finished upstream API; the maintainer drops these and
bumps the module pins at merge time.
…w syncer

The common EmitDelta now requires a producer-supplied event_id. The syncer is a
workflow-DON service driven by reconciliation against the shared on-chain
WorkflowRegistry, so every workflow-DON node sees the same event and derives the
identical id via resourcemanager.EventID:

- create (+1): EventID("workflow-spec-register", workflowID, CreatedAt) — the
  on-chain CreatedAt is DON-consistent and distinguishes a re-registration from
  the original (re-activate-after-delete does not collide).
- delete (-1): EventID("workflow-spec-delete", workflowID). WorkflowDeletedEvent
  carries only the workflow ID (no on-chain CreatedAt/block), so repeated
  delete cycles of the same workflowID share an event_id; this residual drift is
  bounded by snapshot reconciliation. See the returned blocker note.

Scrubs the "UUIDv4" event_id wording and the UUID-format validation from the
metering test (event_id is an opaque string; the only check is empty-vs-nonempty).
Aligns the metering pin to the merged chainlink-common pin.
…w_specs_v2 to eliminate delta drift on register->pause->activate flow within billing dedup time bucket
@patrickhuie19
patrickhuie19 marked this pull request as ready for review July 31, 2026 14:37
@patrickhuie19
patrickhuie19 requested review from a team as code owners July 31, 2026 14:37
return fmt.Errorf("failed to start resource manager: %w", err)
}
// Register returns a func that unregisters.
h.rmUnregister = h.resourceManager.Register(h)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This logic should move into your wrapper type -- it should accept the workflowArtifactStorage as input and unregister should be called on its Close() method.

spec.Status = status
if _, innerErr := h.workflowArtifactsStore.UpsertWorkflowSpec(ctx, spec); innerErr != nil {
return fmt.Errorf("failed to update workflow spec: %w", innerErr)
if status == job.WorkflowSpecStatusActive && spec.Workflow == "" {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth bubbling this up to its own case clause?

return
}
var orgID string
if h.orgResolver != nil && owner != "" {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

orgResolver + resourceManager should never be nil if the API is used correctly; I wouldn't try to handle this here if that's true, it feels overly defensive

}

if !sourceFetchFailed && len(w.workflowSources) > 0 {
w.reconcileOrphanedSpecs(ctx, allMetadataIDs)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider extending generateReconciliationEvents so that it does this bit for you -- i.e. it should detect orphaned rows and emit Delete events for those

These then get picked up + processed by the handler, which no longer requires a running engine

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agreed, thanks

spec = newSpec
} else {
spec.Status = status
if spec.RegisteredAt == 0 && payload.CreatedAt > 0 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is basically the same as lines 689 -> 693

Do we need to duplicate the logic? It looks like we'll run it twice for this case

// folded in once it has been set by the registry syncer. meterIdentity itself
// is immutable after construction (set via WithIdentity); only the DON id is
// learned later, so it is read from an atomic and overlaid here.
func (h *eventHandler) baseIdentity() resourcemanager.ResourceIdentity {

@cedric-cordenier cedric-cordenier Aug 3, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This plus ResourceIdentity should be moved into the wrapper type ideally

Also looking at the ResourceManager interface, does a Meterable need to implement ResourceIdentity?

It seems to me that the ResourceManager can be called in one of two ways:

  • for metering/usage -> in which case we pass the identity in anyway
  • for snapshotting -> in which case GetUtilization is expected to return the identity

So unless I'm missing something, we don't actually need the ResourceIdentity on the Meterable; in which case I wonder if it's worth removing?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, i used ResourceIdentity at one point for testing that identities were supplied correctly but it can be stripped out to simplify the interface

Comment thread core/services/cre/cre.go Outdated
syncerV2.WithLocalSecretOverrides(lggr, cfg.CRE().LocalSecretOverrides()),
syncerV2.WithShardExecutionGuard(shardOrchestratorClient, shardingEnabled, shardIndex),
syncerV2.WithShardRoutingSteady(shardRoutingSteady),
syncerV2.WithResourceManager(resourcemanager.NewResourceManager(lggr, resourcemanager.ResourceManagerConfig{

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here I would create the wrapper type we discussed and then you will effectively have bypassed a layer of abstraction -- there'll be no need for the handler to know anything about snapshotting, identities etc

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agreed, thanks

// the union of every source's metadata after the loop (see
// reconcileOrphanedSpecs); a listing failure skips only orphan
// reconciliation this tick — engines still reconcile.
persistedSpecs, specsErr := w.handler.ListWorkflowSpecs(ctx)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@patrickhuie19 I think you can go one step further with this:

Put this logic in generateReconciliationEvents, and have it generate events that just get processed through the call other events go through (line 983)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@cedric-cordenier generateReconciliationEvents is called per-source.

Lets say that in the spec_v2 table there are currently two legacy workflows, i.e. workflows created before we added source to the specs_v2 table:

workflow_ID_A - registered in grpc source
workflow_ID_B - registered in contract source

Lets assume we've made it past line 866, where for _, source := range w.workflowSources { we start the loop for each source. generateReconciliationEvents is called in this loop, and we pass source in its arguments.

We call generateReconciliationEvents within the source because if the grpc source were to be unavailable, or the contract source were to be unavailable it allows us to avoid deleting workflows that were not deleted by a customer. This also allows one source to be unavailable while we can still process events from a live source, i.e. the centralized registry goes down but we can still process customer deployment events on the workflow registry.

To call ListWorkflowSpecs in generateReconciliationEvents would require passing a source.

lets say that we assign workflow_ID_A and workflow_ID_B a default source of contract. When we list the specs, and filter for that source, the reconciliation events will see that workflow_ID_B was deployed by the customer, but that workflow_ID_A had been deleted. This would be a bug, so we can't do that.

We could look for a null value of source, and thus exclude all legacy workflows from the reconcile orphan functionality.

Or, we can continue to do what is on the current version of this PR, which is to wait to handle all orphan events until the very end until we've built a union of all sources AND we've seen the each source was healthy.

justinkaseman
justinkaseman previously approved these changes Aug 11, 2026
@cl-sonarqube-production

Copy link
Copy Markdown

@patrickhuie19
patrickhuie19 added this pull request to the merge queue Aug 11, 2026
Merged via the queue into develop with commit ddc18fc Aug 11, 2026
243 of 245 checks passed
@patrickhuie19
patrickhuie19 deleted the feat/shared-2712 branch August 11, 2026 21:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

build-publish Build and Publish image to SDLC do not merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants