Skip to content

Commit 4fc337c

Browse files
committed
Keep usage event records of running apps, service instances, and tasks
App and service usage event cleanup previously pruned every record older than the cutoff, including the opening STARTED/CREATED event of a resource that is still running -- which makes it impossible to reconstruct current usage once that event ages out. Database::OldRecordCleanup can now optionally keep "running" records. For each lifecycle a model declares via usage_lifecycles (beginning states, ending state, guid column), a beginning-state event (STARTED/CREATED/TASK_STARTED, and the WAS_RUNNING/TASK_WAS_RUNNING baselines) is retained unless: * a later ending-state event (STOPPED/DELETED/TASK_STOPPED) for the same resource also falls outside the retention window -- the run is over; or * it is a superseded baseline: an earlier beginning of the same run and a later beginning both exist outside the window. Consumers only need the first beginning of the current run (the true start time) and the latest one (the current footprint), so the in-between events written by scaling an app or updating a service instance are pruned and cutoff_age_in_days keeps bounding the table size for long-running, frequently-changed resources. The app and service usage event repositories enable the behavior with keep_running_records: true; requesting it for a model without usage_lifecycles raises instead of silently deleting the records of running resources. Task events get their own lifecycle (TASK_STARTED/TASK_WAS_RUNNING -> TASK_STOPPED, keyed by task_guid), so the start events of long-running tasks survive cleanup as well. The task baseline state is distinct from WAS_RUNNING because task events share the app_usage_events table but carry an empty app_guid: reusing WAS_RUNNING would let the app lifecycle correlate every task baseline through app_guid = '' and wrongly prune them as superseded baselines of one phantom app (and the app backfill's stale-row sweep would delete them outright). Deletion runs in ordered passes -- prunable beginning rows first, while the rows that make them prunable still exist, then everything else -- so a beginning row cannot be stranded when its pair is removed in an earlier batch. The cleanup log line now reports the row counts BatchDelete returns instead of issuing extra COUNT queries, and BatchDelete fetches each batch's ids in the same query that checks for emptiness, halving evaluations of the (potentially expensive) filtered dataset. Also renames the positional days_ago to a cutoff_age_in_days keyword.
1 parent d95a172 commit 4fc337c

10 files changed

Lines changed: 537 additions & 33 deletions

File tree

app/jobs/runtime/events_cleanup.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ def initialize(cutoff_age_in_days)
99
end
1010

1111
def perform
12-
Database::OldRecordCleanup.new(Event, cutoff_age_in_days).delete
12+
Database::OldRecordCleanup.new(Event, cutoff_age_in_days:).delete
1313
end
1414

1515
def job_name_in_configuration

app/models/runtime/app_usage_event.rb

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,23 @@ class AppUsageEvent < Sequel::Model
99
:buildpack_guid, :buildpack_name,
1010
:package_state, :previous_package_state, :parent_app_guid,
1111
:parent_app_name, :process_type, :task_name, :task_guid
12+
13+
def self.usage_lifecycles
14+
[
15+
{
16+
beginning_states: [ProcessModel::STARTED, Repositories::AppUsageEventRepository::WAS_RUNNING_EVENT_STATE],
17+
ending_state: ProcessModel::STOPPED,
18+
guid_column: :app_guid
19+
},
20+
{
21+
beginning_states: [Repositories::AppUsageEventRepository::TASK_STARTED_EVENT_STATE,
22+
Repositories::AppUsageEventRepository::TASK_WAS_RUNNING_EVENT_STATE],
23+
ending_state: Repositories::AppUsageEventRepository::TASK_STOPPED_EVENT_STATE,
24+
guid_column: :task_guid
25+
}
26+
].freeze
27+
end
28+
1229
AppUsageEvent.dataset_module do
1330
def supports_window_functions?
1431
false

app/models/services/service_usage_event.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,17 @@ class ServiceUsageEvent < Sequel::Model
77
:service_plan_guid, :service_plan_name,
88
:service_guid, :service_label,
99
:service_broker_name, :service_broker_guid
10+
11+
def self.usage_lifecycles
12+
[
13+
{
14+
beginning_states: [Repositories::ServiceUsageEventRepository::CREATED_EVENT_STATE,
15+
Repositories::ServiceUsageEventRepository::UPDATED_EVENT_STATE,
16+
Repositories::ServiceUsageEventRepository::WAS_RUNNING_EVENT_STATE],
17+
ending_state: Repositories::ServiceUsageEventRepository::DELETED_EVENT_STATE,
18+
guid_column: :service_instance_guid
19+
}
20+
].freeze
21+
end
1022
end
1123
end

app/repositories/app_usage_event_repository.rb

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,16 @@
44
module VCAP::CloudController
55
module Repositories
66
class AppUsageEventRepository
7+
WAS_RUNNING_EVENT_STATE = 'WAS_RUNNING'.freeze
8+
TASK_STARTED_EVENT_STATE = 'TASK_STARTED'.freeze
9+
TASK_STOPPED_EVENT_STATE = 'TASK_STOPPED'.freeze
10+
# Task baselines get their own state (rather than reusing WAS_RUNNING)
11+
# because task events share the app_usage_events table with app events but
12+
# carry an empty app_guid: a WAS_RUNNING row keyed by app_guid '' would be
13+
# correlated with every other task's baseline by the app lifecycle's
14+
# cleanup and swept by the app backfill's stale-row sweep.
15+
TASK_WAS_RUNNING_EVENT_STATE = 'TASK_WAS_RUNNING'.freeze
16+
717
def find(guid)
818
AppUsageEvent.find(guid:)
919
end
@@ -152,7 +162,7 @@ def purge_and_reseed_started_apps!
152162
end
153163

154164
def delete_events_older_than(cutoff_age_in_days)
155-
Database::OldRecordCleanup.new(AppUsageEvent, cutoff_age_in_days, keep_at_least_one_record: true).delete
165+
Database::OldRecordCleanup.new(AppUsageEvent, cutoff_age_in_days: cutoff_age_in_days, keep_at_least_one_record: true, keep_running_records: true).delete
156166
end
157167

158168
private

app/repositories/service_usage_event_repository.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ class ServiceUsageEventRepository
77
DELETED_EVENT_STATE = 'DELETED'.freeze
88
CREATED_EVENT_STATE = 'CREATED'.freeze
99
UPDATED_EVENT_STATE = 'UPDATED'.freeze
10+
WAS_RUNNING_EVENT_STATE = 'WAS_RUNNING'.freeze
1011

1112
def find(guid)
1213
ServiceUsageEvent.find(guid:)
@@ -92,7 +93,7 @@ def purge_and_reseed_service_instances!
9293
end
9394

9495
def delete_events_older_than(cutoff_age_in_days)
95-
Database::OldRecordCleanup.new(ServiceUsageEvent, cutoff_age_in_days, keep_at_least_one_record: true).delete
96+
Database::OldRecordCleanup.new(ServiceUsageEvent, cutoff_age_in_days: cutoff_age_in_days, keep_at_least_one_record: true, keep_running_records: true).delete
9697
end
9798
end
9899
end

lib/database/batch_delete.rb

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,21 @@ def delete
1111
total_count = 0
1212

1313
loop do
14-
set = dataset.limit(amount)
15-
break if set.empty?
14+
# Fetch the batch's ids in the same query that checks for emptiness, so the
15+
# (potentially expensive) filtered dataset is evaluated once per batch.
16+
ids = dataset.limit(amount).select_map(:id)
17+
break if ids.empty?
1618

17-
total_count += delete_batch(set)
19+
total_count += delete_batch(ids)
1820
end
1921

2022
total_count
2123
end
2224

2325
private
2426

25-
def delete_batch(set)
26-
dataset.model.where(id: set.select_map(:id)).delete
27+
def delete_batch(ids)
28+
dataset.model.where(id: ids).delete
2729
end
2830
end
2931
end

lib/database/old_record_cleanup.rb

Lines changed: 110 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,25 +3,33 @@
33
module Database
44
class OldRecordCleanup
55
class NoCurrentTimestampError < StandardError; end
6-
attr_reader :model, :days_ago, :keep_at_least_one_record
6+
attr_reader :model, :cutoff_age_in_days, :keep_at_least_one_record, :keep_running_records
77

8-
def initialize(model, days_ago, keep_at_least_one_record: false)
8+
def initialize(model, cutoff_age_in_days:, keep_at_least_one_record: false, keep_running_records: false)
99
@model = model
10-
@days_ago = days_ago
10+
@cutoff_age_in_days = cutoff_age_in_days
1111
@keep_at_least_one_record = keep_at_least_one_record
12+
@keep_running_records = keep_running_records
1213
end
1314

15+
# keep_running_records and keep_at_least_one_record compose: a still-running
16+
# resource (a beginning-state event with no later ending-state event for the
17+
# same resource) is always retained, and keep_at_least_one_record additionally
18+
# protects the single newest row so the table is never fully emptied for
19+
# clients that poll the most recent event.
1420
def delete
15-
cutoff_date = current_timestamp_from_database - days_ago.to_i.days
16-
21+
cutoff_date = current_timestamp_from_database - cutoff_age_in_days.to_i.days
1722
old_records = model.dataset.where(Sequel.lit('created_at < ?', cutoff_date))
18-
if keep_at_least_one_record
19-
last_record = model.order(:id).last
20-
old_records = old_records.where(Sequel.lit('id < ?', last_record.id)) if last_record
21-
end
22-
logger.info("Cleaning up #{old_records.count} #{model.table_name} table rows")
2323

24-
Database::BatchDelete.new(old_records, 1000).delete
24+
if keep_running_records
25+
raise ArgumentError.new("keep_running_records requires #{model} to define .usage_lifecycles") unless model.respond_to?(:usage_lifecycles)
26+
27+
delete_keeping_running_records(old_records)
28+
else
29+
old_records = exclude_newest_record(old_records)
30+
logger.info("Cleaning up #{old_records.count} #{model.table_name} table rows")
31+
Database::BatchDelete.new(old_records, 1000).delete
32+
end
2533
end
2634

2735
private
@@ -35,5 +43,96 @@ def current_timestamp_from_database
3543
def logger
3644
@logger ||= Steno.logger('cc.old_record_cleanup')
3745
end
46+
47+
# Deletes old records while retaining a usable billing baseline for
48+
# still-running resources.
49+
#
50+
# For each lifecycle of the model, a beginning-state row (e.g.
51+
# STARTED/CREATED/WAS_RUNNING) is prunable when:
52+
# * a later ending-state row (e.g. STOPPED/DELETED) is also old -- the run is
53+
# over; or
54+
# * it is a superseded baseline: an earlier beginning of the same run and a
55+
# later beginning both exist (and are old). Consumers only need the first
56+
# beginning of the current run (the true start time) and the latest one (the
57+
# current footprint); the in-between rows written by scaling/updating a
58+
# running resource carry no baseline information.
59+
#
60+
# The deletes are ordered deliberately: prunable beginning rows are removed
61+
# FIRST, while the rows that make them prunable still exist, so each beginning
62+
# stays prunable until it is itself deleted. Only then are the ending rows (and
63+
# any other, non-lifecycle states) removed. Reversing the order could strand a
64+
# beginning row whose paired ending was deleted in an earlier batch.
65+
def delete_keeping_running_records(old_records)
66+
lifecycles = model.usage_lifecycles
67+
prunable_beginnings = lifecycles.map { |lifecycle| prunable_beginnings_dataset(old_records, lifecycle) }
68+
69+
# Everything that is not a beginning-state row of some lifecycle (ending rows
70+
# plus any other, non-lifecycle states) is unconditionally prunable.
71+
all_beginning_states = lifecycles.flat_map { |lifecycle| lifecycle.fetch(:beginning_states) }
72+
unconditional_records = exclude_newest_record(old_records.exclude(state: all_beginning_states))
73+
74+
deleted_count = prunable_beginnings.sum { |dataset| Database::BatchDelete.new(dataset, 1000).delete }
75+
deleted_count += Database::BatchDelete.new(unconditional_records, 1000).delete
76+
77+
logger.info("Cleaned up #{deleted_count} #{model.table_name} table rows")
78+
end
79+
80+
# Builds the dataset of old beginning-state rows that are prunable for one
81+
# lifecycle. All correlations use the (state, guid, id) lifecycle index; higher
82+
# id implies later creation throughout. The probes only consider OLD rows: a
83+
# superseded beginning is kept until the row superseding it is itself old,
84+
# which keeps the pruning decision stable for consumers reading within the
85+
# retention window.
86+
def prunable_beginnings_dataset(old_records, lifecycle)
87+
beginning_states = lifecycle.fetch(:beginning_states)
88+
ending_state = lifecycle.fetch(:ending_state)
89+
guid_column = lifecycle.fetch(:guid_column)
90+
91+
old_beginnings = old_records.where(state: beginning_states)
92+
old_endings = old_records.where(state: ending_state)
93+
initial_records = old_beginnings.from_self(alias: :initial_records)
94+
95+
# The run is over: an ending row for the same resource was created later.
96+
matching_ending = old_endings.from_self(alias: :final_records).
97+
where(Sequel[:final_records][guid_column] => Sequel[:initial_records][guid_column]).
98+
where { Sequel[:final_records][:id] > Sequel[:initial_records][:id] }.
99+
select(1).exists
100+
101+
# Not the run's true start: an earlier beginning of the same run exists,
102+
# i.e. one with no ending event between the two.
103+
intervening_ending = old_endings.from_self(alias: :intervening_endings).
104+
where(Sequel[:intervening_endings][guid_column] => Sequel[:earlier_beginnings][guid_column]).
105+
where { Sequel[:intervening_endings][:id] > Sequel[:earlier_beginnings][:id] }.
106+
where { Sequel[:intervening_endings][:id] < Sequel[:initial_records][:id] }.
107+
select(1).exists
108+
earlier_beginning_in_same_run = old_beginnings.from_self(alias: :earlier_beginnings).
109+
where(Sequel[:earlier_beginnings][guid_column] => Sequel[:initial_records][guid_column]).
110+
where { Sequel[:earlier_beginnings][:id] < Sequel[:initial_records][:id] }.
111+
where(Sequel.~(intervening_ending)).
112+
select(1).exists
113+
114+
# Not the latest baseline: a later beginning for the same resource exists.
115+
later_beginning = old_beginnings.from_self(alias: :later_beginnings).
116+
where(Sequel[:later_beginnings][guid_column] => Sequel[:initial_records][guid_column]).
117+
where { Sequel[:later_beginnings][:id] > Sequel[:initial_records][:id] }.
118+
select(1).exists
119+
120+
superseded_baseline = Sequel.&(earlier_beginning_in_same_run, later_beginning)
121+
exclude_newest_record(initial_records.where(Sequel.|(matching_ending, superseded_baseline)))
122+
end
123+
124+
# When keep_at_least_one_record is set, never delete the single newest row so
125+
# the table always retains at least one record.
126+
def exclude_newest_record(records)
127+
return records unless keep_at_least_one_record && newest_record_id
128+
129+
records.where(Sequel.lit('id < ?', newest_record_id))
130+
end
131+
132+
def newest_record_id
133+
return @newest_record_id if defined?(@newest_record_id)
134+
135+
@newest_record_id = model.order(:id).last&.id
136+
end
38137
end
39138
end

spec/unit/jobs/runtime/app_usage_events_cleanup_spec.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ module Jobs::Runtime
55
RSpec.describe AppUsageEventsCleanup, job_context: :worker do
66
let(:cutoff_age_in_days) { 30 }
77
let(:logger) { double(Steno::Logger, info: nil) }
8-
let!(:event_before_threshold) { AppUsageEvent.make(created_at: (cutoff_age_in_days + 1).days.ago) }
8+
let!(:event_before_threshold) { AppUsageEvent.make(created_at: (cutoff_age_in_days + 1).days.ago, state: 'STOPPED') }
99
let!(:event_after_threshold) { AppUsageEvent.make(created_at: (cutoff_age_in_days - 1).days.ago) }
1010

1111
subject(:job) do

spec/unit/jobs/services/service_usage_events_cleanup_spec.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ module Jobs::Services
55
RSpec.describe ServiceUsageEventsCleanup, job_context: :worker do
66
let(:cutoff_age_in_days) { 30 }
77
let(:logger) { double(Steno::Logger, info: nil) }
8-
let!(:event_before_threshold) { ServiceUsageEvent.make(created_at: (cutoff_age_in_days + 1).days.ago) }
8+
let!(:event_before_threshold) { ServiceUsageEvent.make(created_at: (cutoff_age_in_days + 1).days.ago, state: 'DELETED') }
99
let!(:event_after_threshold) { ServiceUsageEvent.make(created_at: (cutoff_age_in_days - 1).days.ago) }
1010

1111
subject(:job) do

0 commit comments

Comments
 (0)