Skip to content

Commit 64ea90c

Browse files
committed
Allow ignoring shelved periods when billing VMs
When a VM is in a shelved state, we don't want to bill users. `get_runtime_during()` has been refactored and corresponding test cases added. Time spent in various VM states is now calculated by simulating the VM as a finite state machine. Each state is represented as a `State` object instance, which is entered upon certain trigger events (i.e the `Running` state is entered on `unshelve`, `start`, or `create`). We assume all state transitions are valid, and will not include any validations for unusual event sequences (i.e `stopped` -> `unshelve`) When in the `Error` state, the VM is not billable. New test cases, mostly written by Kristi, have been added to reflect this desired behavior. This behavior has been clarified in discussions and led to the discovery of a bug regarding billing in the `Error` state [1]. [1] #114 (comment)
1 parent 53a67a8 commit 64ea90c

File tree

2 files changed

+219
-50
lines changed

2 files changed

+219
-50
lines changed

src/openstack_billing_db/model.py

Lines changed: 74 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,21 @@
1010
logger = logging.getLogger(__name__)
1111

1212

13+
@dataclass
14+
class State:
15+
name: str
16+
triggers: list[str]
17+
18+
time_in: int = 0
19+
_last_entered: datetime.datetime = None
20+
21+
def enter(self, time: datetime.datetime):
22+
self._last_entered = time
23+
24+
def exit(self, time: datetime.datetime):
25+
self.time_in += (time - self._last_entered).total_seconds()
26+
27+
1328
@dataclass_json()
1429
@dataclass()
1530
class Flavor(object):
@@ -73,61 +88,71 @@ def _clamp_time(time, min_time, max_time):
7388
return time
7489

7590
def get_runtime_during(self, start_time, end_time):
76-
runtime = InstanceRuntime()
77-
78-
last_start = None # Time the instance was last started
79-
last_stop = None # Time the instance was last stopped
80-
last_event_name = None
81-
in_error_state = False
82-
delete_action_found = False
83-
84-
for event in self.events:
85-
event_time = self._clamp_time(event.time, start_time, end_time)
86-
87-
if event.message == "Error":
88-
in_error_state = True
89-
continue
90-
91-
if event.name in ["create", "start"]:
92-
last_start = event_time
93-
in_error_state = False
94-
95-
# Count stopped time from last known stop.
96-
if last_stop:
97-
runtime.total_seconds_stopped += (
98-
last_start - last_stop
99-
).total_seconds()
100-
101-
if event.name == "stop":
102-
last_stop = event_time
103-
104-
# Count running time from last known start.
105-
if last_start:
106-
runtime.total_seconds_running += (
107-
last_stop - last_start
108-
).total_seconds()
91+
def run_state_machine():
92+
"""
93+
Iterates through `self.events` to determine time
94+
spent in various VM states (i.e running, stopped)
95+
"""
96+
current_state = None
97+
for event in self.events:
98+
event_time = self._clamp_time(event.time, start_time, end_time)
99+
100+
# Error state can only be determined by the event message
101+
if event.message == "Error":
102+
if current_state is None:
103+
current_state = enter_state("Error", event_time)
104+
else:
105+
current_state.exit(event_time)
106+
current_state = enter_state("Error", event.time)
107+
continue
108+
109+
for state in vm_states:
110+
if event.name in state.triggers:
111+
if current_state is None:
112+
current_state = state
113+
state.enter(event_time)
114+
elif state.name != current_state.name:
115+
current_state.exit(event_time)
116+
current_state = state
117+
state.enter(event_time)
118+
119+
# Some VM instances may have a `deleted_at` time, another trigger for the `Deleted` state
120+
if self.deleted_at:
121+
deleted_at_time = self._clamp_time(
122+
self.deleted_at, start_time, end_time
123+
)
124+
current_state.exit(deleted_at_time)
125+
current_state = enter_state("Deleted", deleted_at_time)
109126

110-
if event.name == "delete":
111-
# Some deleted instances do not have a delete event, they do
112-
# however have a deleted_at timestamp.
113-
# Delete event takes precedence over deleted_at.
114-
delete_action_found = True
115-
end_time = self._clamp_time(event_time, start_time, end_time)
116-
break
127+
current_state.exit(end_time)
117128

118-
last_event_name = event.name
129+
def get_state_time(state_name):
130+
for state in vm_states:
131+
if state.name == state_name:
132+
return state.time_in
119133

120-
if self.deleted_at and not delete_action_found:
121-
self.no_delete_action = True
122-
end_time = self._clamp_time(self.deleted_at, start_time, end_time)
134+
def enter_state(state_name, enter_time) -> State:
135+
for state in vm_states:
136+
if state.name == state_name:
137+
state.enter(enter_time)
138+
return state
123139

124-
# Handle the time since the last event.
125-
if last_event_name in ["create", "start"]:
126-
runtime.total_seconds_running += (end_time - last_start).total_seconds()
140+
runtime = InstanceRuntime()
141+
vm_states = [
142+
State(state_name, state_triggers)
143+
for state_name, state_triggers in (
144+
("Running", ["unshelve", "create", "start"]),
145+
("Shelved", ["shelve"]),
146+
("Stopped", ["stop"]),
147+
("Deleted", ["delete"]),
148+
("Error", []),
149+
)
150+
]
127151

128-
if last_event_name == "stop" and not in_error_state:
129-
runtime.total_seconds_stopped += (end_time - last_stop).total_seconds()
152+
run_state_machine()
130153

154+
runtime.total_seconds_running = get_state_time("Running")
155+
runtime.total_seconds_stopped = get_state_time("Stopped")
131156
return runtime
132157

133158
@property

src/openstack_billing_db/tests/unit/test_instance.py

Lines changed: 145 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from datetime import datetime, timedelta
33

44
from openstack_billing_db.model import Instance, InstanceEvent, Database
5-
from openstack_billing_db.tests.unit.utils import FLAVORS, MINUTE, DAY, MONTH
5+
from openstack_billing_db.tests.unit.utils import FLAVORS, MINUTE, HOUR, DAY, MONTH
66

77

88
def test_instance_simple_runtime():
@@ -182,6 +182,150 @@ def test_instance_stopped_and_deleted():
182182
assert r.total_seconds_stopped == (1 * HOUR)
183183

184184

185+
def test_instance_shelved_and_unshelved():
186+
time = datetime(year=2000, month=1, day=2, hour=0, minute=0, second=0)
187+
events = [
188+
InstanceEvent(time=time, name="create", message=""),
189+
InstanceEvent(time=time + timedelta(minutes=40), name="shelve", message=""),
190+
InstanceEvent(time=time + timedelta(days=1), name="unshelve", message=""),
191+
]
192+
i = Instance(
193+
uuid=uuid.uuid4().hex,
194+
name=uuid.uuid4().hex,
195+
flavor=FLAVORS[1],
196+
events=events,
197+
deleted_at=time + timedelta(days=1, minutes=40),
198+
)
199+
200+
r = i.get_runtime_during(
201+
datetime(year=2000, month=1, day=1, hour=0, minute=0, second=0),
202+
datetime(year=2000, month=2, day=1, hour=0, minute=0, second=0),
203+
)
204+
205+
assert r.total_seconds_running == (40 * MINUTE) + (40 * MINUTE)
206+
assert r.total_seconds_stopped == 0
207+
208+
209+
def test_instance_shelved_no_unshelved():
210+
time = datetime(year=2000, month=1, day=2, hour=0, minute=0, second=0)
211+
events = [
212+
InstanceEvent(time=time, name="create", message=""),
213+
InstanceEvent(time=time + timedelta(minutes=40), name="shelve", message=""),
214+
]
215+
i = Instance(
216+
uuid=uuid.uuid4().hex,
217+
name=uuid.uuid4().hex,
218+
flavor=FLAVORS[1],
219+
events=events,
220+
deleted_at=time + timedelta(days=1, minutes=40),
221+
)
222+
223+
r = i.get_runtime_during(
224+
datetime(year=2000, month=1, day=1, hour=0, minute=0, second=0),
225+
datetime(year=2000, month=2, day=1, hour=0, minute=0, second=0),
226+
)
227+
assert r.total_seconds_running == (40 * MINUTE)
228+
assert r.total_seconds_stopped == 0
229+
230+
231+
def test_instance_shelved_unshelved_stopped():
232+
time = datetime(year=2000, month=1, day=2, hour=0, minute=0, second=0)
233+
events = [
234+
InstanceEvent(time=time, name="create", message=""),
235+
InstanceEvent(time=time + timedelta(minutes=40), name="stop", message=""),
236+
InstanceEvent(time=time + timedelta(days=1), name="start", message=""),
237+
InstanceEvent(
238+
time=time + timedelta(days=1, hours=6), name="shelve", message=""
239+
),
240+
InstanceEvent(
241+
time=time + timedelta(days=1, hours=12), name="unshelve", message=""
242+
),
243+
]
244+
i = Instance(
245+
uuid=uuid.uuid4().hex,
246+
name=uuid.uuid4().hex,
247+
flavor=FLAVORS[1],
248+
events=events,
249+
deleted_at=time + timedelta(days=2),
250+
)
251+
252+
r = i.get_runtime_during(
253+
datetime(year=2000, month=1, day=1, hour=0, minute=0, second=0),
254+
datetime(year=2000, month=2, day=1, hour=0, minute=0, second=0),
255+
)
256+
assert r.total_seconds_running == (40 * MINUTE) + (6 * HOUR) + (12 * HOUR)
257+
assert r.total_seconds_stopped == DAY - (40 * MINUTE)
258+
259+
260+
def test_instance_error_deleted():
261+
time = datetime(year=2000, month=1, day=2, hour=0, minute=0, second=0)
262+
events = [
263+
InstanceEvent(time=time, name="create", message="Error"),
264+
InstanceEvent(time=time + timedelta(hours=1), name="delete", message=""),
265+
]
266+
i = Instance(
267+
uuid=uuid.uuid4().hex, name=uuid.uuid4().hex, flavor=FLAVORS[1], events=events
268+
)
269+
270+
r = i.get_runtime_during(
271+
datetime(year=2000, month=1, day=1, hour=0, minute=0, second=0),
272+
datetime(year=2000, month=2, day=1, hour=0, minute=0, second=0),
273+
)
274+
assert r.total_seconds_running == 0
275+
assert r.total_seconds_stopped == 0
276+
277+
278+
def test_instance_error_restart_failed():
279+
time = datetime(year=2000, month=1, day=2, hour=0, minute=0, second=0)
280+
events = [
281+
InstanceEvent(time=time, name="create", message=""),
282+
InstanceEvent(time=time + timedelta(minutes=45), name="stop", message=""),
283+
InstanceEvent(
284+
time=time + timedelta(hours=1), name="start", message="Error"
285+
), # This start period should not be counted
286+
InstanceEvent(
287+
time=time + timedelta(hours=1, minutes=10), name="delete", message=""
288+
),
289+
]
290+
i = Instance(
291+
uuid=uuid.uuid4().hex, name=uuid.uuid4().hex, flavor=FLAVORS[1], events=events
292+
)
293+
294+
r = i.get_runtime_during(
295+
datetime(year=2000, month=1, day=1, hour=0, minute=0, second=0),
296+
datetime(year=2000, month=2, day=1, hour=0, minute=0, second=0),
297+
)
298+
assert r.total_seconds_running == 45 * MINUTE
299+
assert r.total_seconds_stopped == 15 * MINUTE
300+
301+
302+
def test_instance_error_restarted():
303+
time = datetime(year=2000, month=1, day=2, hour=0, minute=0, second=0)
304+
events = [
305+
InstanceEvent(time=time, name="create", message=""),
306+
InstanceEvent(time=time + timedelta(minutes=45), name="stop", message=""),
307+
InstanceEvent(
308+
time=time + timedelta(hours=1), name="start", message="Error"
309+
), # This start/stop period should not be counted
310+
InstanceEvent(
311+
time=time + timedelta(hours=1, minutes=15), name="start", message=""
312+
),
313+
InstanceEvent(
314+
time=time + timedelta(hours=1, minutes=25), name="delete", message=""
315+
),
316+
]
317+
i = Instance(
318+
uuid=uuid.uuid4().hex, name=uuid.uuid4().hex, flavor=FLAVORS[1], events=events
319+
)
320+
321+
r = i.get_runtime_during(
322+
datetime(year=2000, month=1, day=1, hour=0, minute=0, second=0),
323+
datetime(year=2000, month=2, day=1, hour=0, minute=0, second=0),
324+
)
325+
assert r.total_seconds_running == 45 * MINUTE + 10 * MINUTE
326+
assert r.total_seconds_stopped == 15 * MINUTE
327+
328+
185329
def test_instance_get_gpu_flavor():
186330
test_pci_info = [("a100", "2"), ("a100-sxm4", "4")]
187331
answers = [("gpu_a100", 2), ("gpu_a100sxm4", 4)]

0 commit comments

Comments
 (0)