Skip to content

OSAC-3848: fix started.v1/resumed.v1 mapping on first activation - #227

Merged
omer-vishlitzky merged 3 commits into
osac-project:mainfrom
omer-vishlitzky:fix/OSAC-3848-metering-started-resumed
Aug 10, 2026
Merged

OSAC-3848: fix started.v1/resumed.v1 mapping on first activation#227
omer-vishlitzky merged 3 commits into
osac-project:mainfrom
omer-vishlitzky:fix/OSAC-3848-metering-started-resumed

Conversation

@omer-vishlitzky

@omer-vishlitzky omer-vishlitzky commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Watch Consumer mapped every non-empty previous state crossing into billable to resumed.v1, including a resource's genuine first activation — CREATE always seeds a concrete state before the billable transition fires, so the started.v1 table row was unreachable. Resolves started vs resumed from a persisted EverBillable flag instead.

Jira: https://redhat.atlassian.net/browse/OSAC-3848

Assisted-by: Claude Code noreply@anthropic.com

Summary by CodeRabbit

  • New Features

    • Added persistent tracking to distinguish a resource’s first billable activation from later resumptions.
    • Updated metering events for compute instances and clusters to report accurate started or resumed states.
    • Improved resource-state persistence and reconciliation for billability history.
  • Bug Fixes

    • Corrected event reporting when resources return to billable states.
    • Fixed first-boot scenarios that could be incorrectly reported as resumptions.
    • Ensured billability indicators remain consistent with resource history.

…tivation

Transition tables mapped every non-empty previous state crossing into
billable to resumed.v1, including a resource's genuine first activation.
Resolve started vs resumed from a persisted EverBillable flag instead.

Jira: OSAC-3848
Assisted-by: Claude Code <noreply@anthropic.com>
Signed-off-by: omer-vishlitzky <omer.vishlitzky@gmail.com>
@openshift-ci-robot

openshift-ci-robot commented Aug 10, 2026

Copy link
Copy Markdown

@omer-vishlitzky: This pull request references OSAC-3848 which is a valid jira issue.

Warning: The referenced jira issue has an invalid target version for the target branch this PR targets: expected the bug to target the "5.0.0" version, but no target version was set.

Details

In response to this:

Watch Consumer mapped every non-empty previous state crossing into billable to resumed.v1, including a resource's genuine first activation — CREATE always seeds a concrete state before the billable transition fires, so the started.v1 table row was unreachable. Resolves started vs resumed from a persisted EverBillable flag instead.

Jira: https://redhat.atlassian.net/browse/OSAC-3848

Assisted-by: Claude Code noreply@anthropic.com

Instructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the openshift-eng/jira-lifecycle-plugin repository.

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The metering service now persists cumulative billability in resource state. Event mapping uses that history to emit started.v1 for first activation and resumed.v1 for later activation. Watch and reconciliation paths preserve the state. Instance.URL was removed.

Changes

Cumulative billability tracking

Layer / File(s) Summary
Persist cumulative billability
osac-metering/metering-service/internal/database/migrations/..., internal/projection/..., internal/reconciliation/...
The schema and ResourceState model add EverBillable. PostgreSQL reads and writes persist it. Reconciliation initializes and preserves it. Tests cover derived billing state and first-time activation.
Classify billable transitions
osac-metering/metering-service/internal/events/...
Billable transitions use eventBillableStart. MapWatchEvent selects started.v1 or resumed.v1 from EverBillable. Transition tests cover initial, resumed, suspended, skipped, and transient paths.
Propagate state through watch processing
osac-metering/metering-service/internal/watch/...
Watch processing carries cumulative billability through projections and state contexts. Lifecycle tests verify first-boot compute-instance and cluster events emit started.v1, followed by resumed.v1 after reactivation.

Database API cleanup

Layer / File(s) Summary
Remove database URL accessor
osac-metering/metering-service/internal/database/container.go
The public Instance.URL method was removed.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant WatchConsumer
  participant ProjectionPostgres
  participant MapWatchEvent
  participant EventStream
  WatchConsumer->>ProjectionPostgres: Read ResourceState.EverBillable
  ProjectionPostgres-->>WatchConsumer: Return cumulative billability
  WatchConsumer->>ProjectionPostgres: Upsert updated EverBillable
  WatchConsumer->>MapWatchEvent: Pass StateContext.EverBillable
  MapWatchEvent->>EventStream: Emit started.v1 or resumed.v1
Loading

Possibly related PRs

Suggested labels: approved, jira/valid-reference, lgtm

Suggested reviewers: eranco74, crystalchun

🚥 Pre-merge checks | ✅ 10 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (10 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the fix to started.v1 and resumed.v1 mapping during first activation.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
No-Hardcoded-Secrets ✅ Passed PR-added content has no credential assignments, private-key material, embedded-credential URLs, or qualifying base64 blobs; gitleaks also reports no leaks.
No-Weak-Crypto ✅ Passed The PR adds no MD5, SHA-1, DES, RC4, Blowfish, ECB, custom crypto, or secret comparisons; current production crypto use is limited to TLS and X.509.
No-Injection-Vectors ✅ Passed The PR adds no listed unsafe sinks. New PostgreSQL writes use $1–$12 placeholders, and the migration is static SQL; no shell=True, eval/exec, pickle.loads, yaml.load, os.system, or DOM injection wa...
Container-Privileges ✅ Passed The PR changes only Go and SQL files; the complete diff adds no container/Kubernetes manifests or prohibited privilege settings.
No-Sensitive-Data-In-Logs ✅ Passed The PR adds no production logging. The only logger-related addition is logr.Discard() in a test, and no passwords, tokens, PII, or customer payloads are logged.
Ai-Attribution ✅ Passed The PR description and all three PR commits include Assisted-by: Claude Code; no AI Co-Authored-By trailer appears.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (2)
osac-metering/metering-service/internal/projection/postgres.go (1)

100-100: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Consider enforcing monotonicity of ever_billable in SQL.

ever_billable = EXCLUDED.ever_billable allows a caller that computes false to erase a stored true. Today buildProjectionState ORs with the existing value, so the invariant holds. That invariant lives only in Go, and a single caller that builds a ResourceState without reading the current row will silently downgrade billing history. A downgrade later re-emits started.v1 for a resource that already started.

Making the column monotonic in the statement removes the risk for every caller.

♻️ Proposed hardening
-			ever_billable = EXCLUDED.ever_billable,
+			ever_billable = metering_resource_state.ever_billable OR EXCLUDED.ever_billable,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@osac-metering/metering-service/internal/projection/postgres.go` at line 100,
Update the UPSERT assignment for ever_billable to preserve the stored true
value, combining the existing database value with EXCLUDED.ever_billable rather
than replacing it. Keep the column monotonic so once it is true, later writes
cannot downgrade it to false.
osac-metering/metering-service/internal/watch/consumer_test.go (1)

988-1033: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Consider extending this test to cover the full start-suspend-resume cycle.

This test proves started.v1 on first boot. The test at line 653 proves resumed.v1 from a pre-seeded EverBillable: true. Neither test proves the seam between them: that the consumer writes EverBillable = true itself at buildProjectionState, that the value survives in the store, and that a later reactivation then reads it back as resumed.v1.

A regression in the accumulator at consumer.go line 424 — for example dropping the || existing.EverBillable term — passes both current tests. Two more events on this stream close the gap and exercise the real store path.

💚 Suggested extension
 			runningEvent := &privatev1.Event{
 				Id:      "evt-running",
 				Type:    privatev1.EventType_EVENT_TYPE_OBJECT_UPDATED,
 				Payload: &privatev1.Event_ComputeInstance{ComputeInstance: runningCI},
 			}
 
+			stoppedCI := makeComputeInstance("vm-fresh", "tenant-1")
+			stoppedCI.Status.State = privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_STOPPED
+			stoppedCI.Metadata.Version = 3
+			stoppedEvent := &privatev1.Event{
+				Id:      "evt-stopped",
+				Type:    privatev1.EventType_EVENT_TYPE_OBJECT_UPDATED,
+				Payload: &privatev1.Event_ComputeInstance{ComputeInstance: stoppedCI},
+			}
+
+			restartedCI := makeComputeInstance("vm-fresh", "tenant-1")
+			restartedCI.Status.State = privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_RUNNING
+			restartedCI.Metadata.Version = 4
+			restartedEvent := &privatev1.Event{
+				Id:      "evt-restarted",
+				Type:    privatev1.EventType_EVENT_TYPE_OBJECT_UPDATED,
+				Payload: &privatev1.Event_ComputeInstance{ComputeInstance: restartedCI},
+			}
+
 			stream := &mockWatchStream{
 				responses: []*privatev1.EventsWatchResponse{
 					makeResponse(createEvent),
 					makeResponse(runningEvent),
+					makeResponse(stoppedEvent),
+					makeResponse(restartedEvent),
 				},
 			}
 			client.results = []mockStreamResult{{stream: stream}}
 
-			pub := &mockPublisher{published: make([]cloudevents.Event, 0, 2), cancelFunc: cancel}
+			pub := &mockPublisher{published: make([]cloudevents.Event, 0, 4), cancelFunc: cancel}
 			consumer := newConsumerWithStore(pub, store)
 
 			err := consumer.Run(ctx)
 			Expect(err).ToNot(HaveOccurred())
 
 			pub.mu.Lock()
 			defer pub.mu.Unlock()
-			Expect(pub.published).To(HaveLen(2))
+			Expect(pub.published).To(HaveLen(4))
 			Expect(pub.published[0].Type()).To(Equal(events.EventCreated))
 			Expect(pub.published[1].Type()).To(Equal(events.EventStarted),
 				"first-ever activation of a brand-new resource must be started.v1, not resumed.v1")
+			Expect(pub.published[2].Type()).To(Equal(events.EventSuspended))
+			Expect(pub.published[3].Type()).To(Equal(events.EventResumed),
+				"reactivation must be resumed.v1 once the consumer has recorded EverBillable")
 		})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@osac-metering/metering-service/internal/watch/consumer_test.go` around lines
988 - 1033, Extend the test around newConsumerWithStore and its mock stream to
cover the complete lifecycle: after the initial STARTING→RUNNING events, add
suspension and subsequent reactivation events, then assert the first activation
is started.v1 and the later activation is resumed.v1. Use the same store and
consumer throughout so buildProjectionState persists EverBillable=true and the
later event reads that value back through the real store path.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@osac-metering/metering-service/internal/database/migrations/0_create_metering_resource_state.up.sql`:
- Line 16: Confirm migration 0 has not been applied to existing databases;
regardless, add a new forward migration for metering_resource_state that adds
the non-null ever_billable column with a false default and backfills it to true
where is_billable is true. Keep the column definition in the initial migration
aligned with this schema.

In `@osac-metering/metering-service/internal/reconciliation/reconciler.go`:
- Line 176: Update the StateDrift branch to set ps.EverBillable alongside
ps.IsBillable, using the existing wasBillable and isBillable state transition
logic so a drift into a billable state marks the resource as ever billable and
preserves resumed.v1 behavior.

---

Nitpick comments:
In `@osac-metering/metering-service/internal/projection/postgres.go`:
- Line 100: Update the UPSERT assignment for ever_billable to preserve the
stored true value, combining the existing database value with
EXCLUDED.ever_billable rather than replacing it. Keep the column monotonic so
once it is true, later writes cannot downgrade it to false.

In `@osac-metering/metering-service/internal/watch/consumer_test.go`:
- Around line 988-1033: Extend the test around newConsumerWithStore and its mock
stream to cover the complete lifecycle: after the initial STARTING→RUNNING
events, add suspension and subsequent reactivation events, then assert the first
activation is started.v1 and the later activation is resumed.v1. Use the same
store and consumer throughout so buildProjectionState persists EverBillable=true
and the later event reads that value back through the real store path.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository: osac-project/coderabbit/.coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 5443e539-fcec-40e8-a537-451963f812b3

📥 Commits

Reviewing files that changed from the base of the PR and between 58e38f5 and a6a0c66.

📒 Files selected for processing (13)
  • osac-metering/metering-service/internal/database/container.go
  • osac-metering/metering-service/internal/database/migrations/0_create_metering_resource_state.up.sql
  • osac-metering/metering-service/internal/events/cluster.go
  • osac-metering/metering-service/internal/events/cluster_test.go
  • osac-metering/metering-service/internal/events/compute_instance.go
  • osac-metering/metering-service/internal/events/mapper.go
  • osac-metering/metering-service/internal/events/mapper_test.go
  • osac-metering/metering-service/internal/events/transitions.go
  • osac-metering/metering-service/internal/projection/postgres.go
  • osac-metering/metering-service/internal/projection/types.go
  • osac-metering/metering-service/internal/reconciliation/reconciler.go
  • osac-metering/metering-service/internal/watch/consumer.go
  • osac-metering/metering-service/internal/watch/consumer_test.go
💤 Files with no reviewable changes (1)
  • osac-metering/metering-service/internal/database/container.go

Reconciler's state-drift path set IsBillable without EverBillable,
same bug reintroduced through a different path. Made the Postgres
upsert monotonic as a backstop. Extended the consumer test to cover
a full start-suspend-resume cycle through the real store.

Jira: OSAC-3848
Assisted-by: Claude Code <noreply@anthropic.com>
Signed-off-by: omer-vishlitzky <omer.vishlitzky@gmail.com>
@masayag

masayag commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Following up on the discussion about reconciling ever_billable and is_billable — here's a concrete suggestion for relying on ever_billable + billable_since together, rather than three independently-writable booleans/timestamps.

The invariant already holds: every write path in this PR sets is_billable == true iff billable_since != nil. Instead of trusting each of the four write sites (Watch Consumer, both Reconciler branches, and the base case) to keep is_billable in sync by convention, Postgres can enforce it structurally:

is_billable BOOLEAN GENERATED ALWAYS AS (billable_since IS NOT NULL) STORED

This removes is_billable from the INSERT/UPDATE column list entirely — it can never drift from billable_since again, by construction, regardless of what any current or future caller does.

ever_billable can go one step further too. It can't be a GENERATED column (its value depends on the row's previous state, which a same-row expression can't see), but the UPSERT can derive it from billable_since directly instead of trusting the caller's already-OR'd value:

ON CONFLICT (resource_id) DO UPDATE SET
    ...
    ever_billable = metering_resource_state.ever_billable OR (EXCLUDED.billable_since IS NOT NULL),
    ...

vs. the current:

ever_billable = metering_resource_state.ever_billable OR EXCLUDED.ever_billable,

The difference matters: the current formula still trusts whatever ever_billable the caller computed and just prevents it from decreasing. If a caller computes ever_billable=false when it should be true (exactly the class of bug fixed in 60dc25a2's reconciler state_drift path), false OR false doesn't self-correct. Deriving straight from billable_since would have — that field was already being set correctly at every call site (including the buggy reconciler path, before this PR's fix), so this formula is a strict robustness improvement, not just a style preference. It also collapses "how do we compute ever_billable" from 3+ call sites down to a single formula.

Happy to open this as a fast-follow PR if useful — didn't want to push a commit onto this branch without checking first.

current_state TEXT NOT NULL,
previous_state TEXT,
is_billable BOOLEAN NOT NULL DEFAULT FALSE,
ever_billable BOOLEAN NOT NULL DEFAULT FALSE,

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 edit is on an existing migration (0_create_...). I assume that we can make this manipulation on an existing migration as we're pre-production? Is there a case for adding another migration for DB "version management"?

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.

I would say no, we don't want to add more migration files if there is nothing to migrate.

return nil
}

func (i *Instance) URL(ctx context.Context) (string, error) {

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.

Is this related to the changes introduced in this PR?

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.

no, just dead code

is_billable is now a GENERATED column, never independently written.
ever_billable derives from billable_since on both insert and conflict
update instead of trusting a caller's own computed value, so it
self-heals even if a caller gets it wrong (per masayag's review).

Jira: OSAC-3848
Assisted-by: Claude Code <noreply@anthropic.com>
Signed-off-by: omer-vishlitzky <omer.vishlitzky@gmail.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
osac-metering/metering-service/internal/projection/postgres_test.go (1)

151-164: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Cover the ON CONFLICT branch in this regression test.

This test inserts a new row. It covers only the INSERT expression in osac-metering/metering-service/internal/projection/postgres.go. It does not execute the ON CONFLICT expression that preserves or derives ever_billable.

Add a case that first stores a non-billable row, then upserts the same resource with BillableSince set. Assert that EverBillable becomes true and remains true after a later non-billable update.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@osac-metering/metering-service/internal/projection/postgres_test.go` around
lines 151 - 164, The regression test around the “self-heals ever_billable” case
only exercises insertion; extend it to execute the Upsert conflict path by first
storing a non-billable state, then upserting the same resource with
BillableSince set and asserting EverBillable is true, followed by a later
non-billable upsert that still leaves EverBillable true. Use the existing
makeState, store.Upsert, and store.Get flow.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@osac-metering/metering-service/internal/projection/postgres_test.go`:
- Around line 151-164: The regression test around the “self-heals ever_billable”
case only exercises insertion; extend it to execute the Upsert conflict path by
first storing a non-billable state, then upserting the same resource with
BillableSince set and asserting EverBillable is true, followed by a later
non-billable upsert that still leaves EverBillable true. Use the existing
makeState, store.Upsert, and store.Get flow.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository: osac-project/coderabbit/.coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 8baeff9c-7a00-4525-bde2-3834de0551dd

📥 Commits

Reviewing files that changed from the base of the PR and between a6a0c66 and b9d4933.

📒 Files selected for processing (6)
  • osac-metering/metering-service/internal/database/migrations/0_create_metering_resource_state.up.sql
  • osac-metering/metering-service/internal/projection/postgres.go
  • osac-metering/metering-service/internal/projection/postgres_test.go
  • osac-metering/metering-service/internal/reconciliation/reconciler.go
  • osac-metering/metering-service/internal/reconciliation/reconciler_test.go
  • osac-metering/metering-service/internal/watch/consumer_test.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • osac-metering/metering-service/internal/watch/consumer_test.go
  • osac-metering/metering-service/internal/projection/postgres.go

@masayag

masayag commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

/lgtm
/approve

@openshift-ci openshift-ci Bot added the lgtm label Aug 10, 2026
@openshift-ci

openshift-ci Bot commented Aug 10, 2026

Copy link
Copy Markdown

[APPROVALNOTIFIER] This PR is APPROVED

This pull-request has been approved by: masayag, omer-vishlitzky

The full list of commands accepted by this bot can be found here.

The pull request process is described here

Details Needs approval from an approver in each of these files:
  • OWNERS [masayag,omer-vishlitzky]

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@omer-vishlitzky
omer-vishlitzky merged commit e93395e into osac-project:main Aug 10, 2026
28 of 29 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants