Skip to content

Commit 497a8ca

Browse files
authored
Merge pull request #114 from QuanMPhm/ops_1100/shelve_state
Allow ignoring shelved periods when billing VMs using state machines
2 parents b2c26f2 + 64ea90c commit 497a8ca

File tree

2 files changed

+244
-55
lines changed

2 files changed

+244
-55
lines changed

src/openstack_billing_db/model.py

Lines changed: 77 additions & 54 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,63 +88,71 @@ def _clamp_time(time, min_time, max_time):
7388
return time
7489

7590
def get_runtime_during(self, start_time, end_time):
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)
126+
127+
current_state.exit(end_time)
128+
129+
def get_state_time(state_name):
130+
for state in vm_states:
131+
if state.name == state_name:
132+
return state.time_in
133+
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
139+
76140
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+
]
77151

78-
last_start = None # Time the instance was last started
79-
last_stop = None # Time the instance was last stopped
80-
in_error_state = False
81-
delete_action_found = False
82-
83-
for event in self.events:
84-
event_time = self._clamp_time(event.time, start_time, end_time)
85-
86-
if event.message == "Error":
87-
in_error_state = True
88-
continue
89-
90-
if event.name in ["create", "start"]:
91-
last_start = event_time
92-
in_error_state = False
93-
94-
# Count stopped time from last known stop.
95-
if last_stop:
96-
runtime.total_seconds_stopped += (
97-
last_start - last_stop
98-
).total_seconds()
99-
last_stop = None
100-
101-
# Some deleted instances do not have a delete event, they do
102-
# however have a deleted_at timestamp.
103-
if event.name == "delete":
104-
delete_action_found = True
105-
106-
if event.name in ["delete", "stop"]:
107-
last_stop = event_time
108-
109-
# Count running time from last known start.
110-
if last_start:
111-
runtime.total_seconds_running += (
112-
last_stop - last_start
113-
).total_seconds()
114-
last_start = None
115-
116-
if event.name == "delete":
117-
# Prevent counting deletion as a stopped state by
118-
# unsetting the last stop time.
119-
last_stop = None
120-
break
121-
122-
if self.deleted_at and not delete_action_found:
123-
self.no_delete_action = True
124-
end_time = self._clamp_time(self.deleted_at, start_time, end_time)
125-
126-
# Handle the time since the last event.
127-
if last_start:
128-
runtime.total_seconds_running += (end_time - last_start).total_seconds()
129-
130-
if last_stop and not in_error_state:
131-
runtime.total_seconds_stopped += (end_time - last_stop).total_seconds()
152+
run_state_machine()
132153

154+
runtime.total_seconds_running = get_state_time("Running")
155+
runtime.total_seconds_stopped = get_state_time("Stopped")
133156
return runtime
134157

135158
@property

src/openstack_billing_db/tests/unit/test_instance.py

Lines changed: 167 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():
@@ -160,6 +160,172 @@ def test_instance_no_delete_action_stopped_restarted():
160160
assert r.total_seconds_stopped == DAY - (40 * MINUTE)
161161

162162

163+
def test_instance_stopped_and_deleted():
164+
time = datetime(year=2000, month=1, day=2, hour=0, minute=0, second=0)
165+
events = [
166+
InstanceEvent(time=time, name="create", message=""),
167+
InstanceEvent(time=time + timedelta(hours=1), name="stop", message=""),
168+
InstanceEvent(time=time + timedelta(hours=2), name="delete", message=""),
169+
]
170+
i = Instance(
171+
uuid=uuid.uuid4().hex,
172+
name=uuid.uuid4().hex,
173+
flavor=FLAVORS[1],
174+
events=events,
175+
)
176+
177+
r = i.get_runtime_during(
178+
datetime(year=2000, month=1, day=1, hour=0, minute=0, second=0),
179+
datetime(year=2000, month=2, day=1, hour=0, minute=0, second=0),
180+
)
181+
assert r.total_seconds_running == (1 * HOUR)
182+
assert r.total_seconds_stopped == (1 * HOUR)
183+
184+
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+
163329
def test_instance_get_gpu_flavor():
164330
test_pci_info = [("a100", "2"), ("a100-sxm4", "4")]
165331
answers = [("gpu_a100", 2), ("gpu_a100sxm4", 4)]

0 commit comments

Comments
 (0)