Skip to content

Commit bc92f93

Browse files
committed
Emit TASK_STOPPED usage events based on recorded start evidence
create_stop_event_if_needed skipped the TASK_STOPPED event whenever the TASK_STARTED event was absent. So a task whose start event the cleanup had already deleted never got a stop event when it finished, and a billing consumer that had recorded the start billed the task forever. Now the stop is written when either piece of recorded start evidence exists: the TASK_STARTED event, or the TASK_WAS_RUNNING baseline the backfill seeds for tasks that were already running when the keep-running cleanup was introduced. A legitimately started task always has one of the two: the cleanup no longer deletes the start event of a running task, and the backfill covers tasks that had already lost theirs. When neither exists (say a task canceled before it ever ran), no consumer ever saw the task start, and a stop event would be noise nothing can pair with. The after_destroy hook now goes through the same check. It used to write a stop unconditionally, so destroying a never-started PENDING task (app deletion destroys each non-terminal task) produced exactly the unmatched stop the update path avoids. Both pieces of evidence are looked up in one query, and a comment pins a MySQL constraint: at MySQL's default REPEATABLE READ isolation level, the evidence read must be the first query in the surrounding transaction.
1 parent f0f1b93 commit bc92f93

3 files changed

Lines changed: 126 additions & 10 deletions

File tree

app/models/runtime/task_model.rb

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ def after_update
4242

4343
def after_destroy
4444
super
45-
create_stop_event unless terminal_state?
45+
create_stop_event_if_needed unless terminal_state?
4646
end
4747

4848
def run_action_user
@@ -137,9 +137,25 @@ def create_start_event
137137
def create_stop_event_if_needed
138138
app_usage_repo = Repositories::AppUsageEventRepository.new
139139

140-
start_event = app_usage_repo.find_by_task_and_state(task: self, state: 'TASK_STARTED')
141-
existing_stop_event = app_usage_repo.find_by_task_and_state(task: self, state: 'TASK_STOPPED')
142-
return if start_event.nil? || existing_stop_event.present?
140+
return if app_usage_repo.find_by_task_and_state(task: self, state: Repositories::AppUsageEventRepository::TASK_STOPPED_EVENT_STATE).present?
141+
142+
# Record the stop only when there is recorded evidence that the task
143+
# started: the TASK_STARTED event, or the TASK_WAS_RUNNING baseline seeded
144+
# for tasks that were already running when the keep-running cleanup was
145+
# introduced. Without either, no consumer ever saw the task start, so a
146+
# stop event would be unmatched noise.
147+
#
148+
# NOTE: on MySQL (default REPEATABLE READ) these must be the first reads
149+
# in the surrounding transaction. MySQL freezes what a transaction can
150+
# see at its first read; if an earlier hook ran a query first, a baseline
151+
# committed in the meantime would be invisible here, and the stop would
152+
# be wrongly skipped.
153+
start_evidence_states = [
154+
Repositories::AppUsageEventRepository::TASK_STARTED_EVENT_STATE,
155+
Repositories::AppUsageEventRepository::TASK_WAS_RUNNING_EVENT_STATE
156+
]
157+
started = app_usage_repo.find_by_task_and_state(task: self, state: start_evidence_states)
158+
return if started.nil?
143159

144160
create_stop_event
145161
end

spec/unit/actions/task_delete_spec.rb

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -61,17 +61,23 @@ module VCAP::CloudController
6161
expect(events[0].metadata['task_guid']).to eq(task4.guid)
6262
end
6363

64-
it 'creates a usage event for non-terminal tasks' do
64+
it 'creates a usage event for non-terminal tasks with recorded start evidence' do
65+
# task4 and task5 wrote TASK_STARTED when they moved to RUNNING. task3
66+
# is still PENDING: no consumer ever saw it start, so destroying it
67+
# must not write a TASK_STOPPED that nothing can pair with a start.
68+
create(:app_usage_event, task_guid: task4.guid, state: 'TASK_STARTED')
69+
create(:app_usage_event, task_guid: task5.guid, state: 'TASK_STARTED')
70+
6571
task_delete.delete_for_app(app.guid)
6672

67-
events = AppUsageEvent.where(parent_app_guid: app.guid).all
68-
expect(events.size).to eq(3)
69-
task_guids = [task3.guid, task4.guid, task5.guid]
73+
events = AppUsageEvent.where(parent_app_guid: app.guid, state: 'TASK_STOPPED').all
74+
expect(events.size).to eq(2)
75+
task_guids = [task4.guid, task5.guid]
7076
events.each do |event|
71-
expect(event.state).to eq('TASK_STOPPED')
7277
expect(task_guids.delete(event.task_guid)).not_to be_nil
7378
end
7479
expect(task_guids).to be_empty
80+
expect(AppUsageEvent.where(task_guid: task3.guid, state: 'TASK_STOPPED')).to be_empty
7581
end
7682
end
7783
end

spec/unit/models/runtime/task_model_spec.rb

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,30 @@ module VCAP::CloudController
2828
expect(event.task_guid).to eq(task.guid)
2929
expect(event.parent_app_guid).to eq(task.app.guid)
3030
end
31+
32+
context 'when the TASK_STARTED event has been pruned and a TASK_WAS_RUNNING baseline exists' do
33+
let!(:start_event) { create(:app_usage_event, task_guid: task.guid, state: 'TASK_WAS_RUNNING') }
34+
35+
it 'still creates a TASK_STOPPED event' do
36+
task.update(state: TaskModel::SUCCEEDED_STATE)
37+
38+
event = AppUsageEvent.find(task_guid: task.guid, state: 'TASK_STOPPED')
39+
expect(event).not_to be_nil
40+
expect(event.task_guid).to eq(task.guid)
41+
expect(event.parent_app_guid).to eq(task.app.guid)
42+
end
43+
end
44+
45+
context 'when there is neither a TASK_STARTED event nor a TASK_WAS_RUNNING baseline' do
46+
let!(:start_event) { nil }
47+
48+
it 'does not create a TASK_STOPPED event, since no consumer ever saw the task start' do
49+
task.update(state: TaskModel::SUCCEEDED_STATE)
50+
51+
event = AppUsageEvent.find(task_guid: task.guid, state: 'TASK_STOPPED')
52+
expect(event).to be_nil
53+
end
54+
end
3155
end
3256

3357
context 'when the task is moving to the FAILED_STATE' do
@@ -43,6 +67,28 @@ module VCAP::CloudController
4367
expect(event.task_guid).to eq(task.guid)
4468
expect(event.parent_app_guid).to eq(task.app.guid)
4569
end
70+
71+
context 'when the TASK_STARTED event has been pruned and a TASK_WAS_RUNNING baseline exists' do
72+
let!(:start_event) { create(:app_usage_event, task_guid: task.guid, state: 'TASK_WAS_RUNNING') }
73+
74+
it 'still creates a TASK_STOPPED event' do
75+
task.update(state: TaskModel::FAILED_STATE)
76+
77+
event = AppUsageEvent.find(task_guid: task.guid, state: 'TASK_STOPPED')
78+
expect(event).not_to be_nil
79+
end
80+
end
81+
82+
context 'when there is neither a TASK_STARTED event nor a TASK_WAS_RUNNING baseline' do
83+
let!(:start_event) { nil }
84+
85+
it 'does not create a TASK_STOPPED event, since no consumer ever saw the task start' do
86+
task.update(state: TaskModel::FAILED_STATE)
87+
88+
event = AppUsageEvent.find(task_guid: task.guid, state: 'TASK_STOPPED')
89+
expect(event).to be_nil
90+
end
91+
end
4692
end
4793

4894
context 'when the task is moving from the PENDING state' do
@@ -72,6 +118,17 @@ module VCAP::CloudController
72118
end
73119
end
74120

121+
context 'when the TASK_STARTED event has been pruned and a TASK_WAS_RUNNING baseline exists' do
122+
let!(:start_event) { create(:app_usage_event, task_guid: task.guid, state: 'TASK_WAS_RUNNING') }
123+
124+
it 'still creates a TASK_STOPPED event' do
125+
task.update(state: TaskModel::FAILED_STATE)
126+
127+
event = AppUsageEvent.find(task_guid: task.guid, state: 'TASK_STOPPED')
128+
expect(event).not_to be_nil
129+
end
130+
end
131+
75132
context 'when the task does not have a TASK_STARTED event' do
76133
let!(:start_event) { nil }
77134

@@ -132,7 +189,8 @@ module VCAP::CloudController
132189
end
133190

134191
describe 'after destroy' do
135-
let(:task) { create(:task_model, app: parent_app, state: TaskModel::PENDING_STATE) }
192+
let(:task) { create(:task_model, app: parent_app, state: TaskModel::RUNNING_STATE) }
193+
let!(:start_event) { create(:app_usage_event, task_guid: task.guid, state: 'TASK_STARTED') }
136194

137195
it 'creates a TASK_STOPPED event' do
138196
task.destroy
@@ -143,6 +201,42 @@ module VCAP::CloudController
143201
expect(event.parent_app_guid).to eq(task.app.guid)
144202
end
145203

204+
context 'when the TASK_STARTED event has been pruned and a TASK_WAS_RUNNING baseline exists' do
205+
let!(:start_event) { create(:app_usage_event, task_guid: task.guid, state: 'TASK_WAS_RUNNING') }
206+
207+
it 'still creates a TASK_STOPPED event' do
208+
task.destroy
209+
210+
event = AppUsageEvent.find(task_guid: task.guid, state: 'TASK_STOPPED')
211+
expect(event).not_to be_nil
212+
end
213+
end
214+
215+
context 'when there is neither a TASK_STARTED event nor a TASK_WAS_RUNNING baseline' do
216+
let(:task) { create(:task_model, app: parent_app, state: TaskModel::PENDING_STATE) }
217+
let!(:start_event) { nil }
218+
219+
it 'does not create a TASK_STOPPED event, since no consumer ever saw the task start' do
220+
task.destroy
221+
222+
event = AppUsageEvent.find(task_guid: task.guid, state: 'TASK_STOPPED')
223+
expect(event).to be_nil
224+
end
225+
end
226+
227+
context 'when the task already has a TASK_STOPPED event' do
228+
before do
229+
create(:app_usage_event, task_guid: task.guid, state: 'TASK_STOPPED')
230+
end
231+
232+
it 'does not create an additional TASK_STOPPED event' do
233+
task.destroy
234+
235+
events = AppUsageEvent.where(task_guid: task.guid, state: 'TASK_STOPPED')
236+
expect(events.count).to equal(1)
237+
end
238+
end
239+
146240
context 'when the task is already in a terminal state (and thus already has a stop event)' do
147241
describe 'when the task is failed' do
148242
let(:task) { create(:task_model, app: parent_app, state: TaskModel::FAILED_STATE) }

0 commit comments

Comments
 (0)