diff --git a/src/util/schedule.star b/src/util/schedule.star new file mode 100644 index 00000000..47559180 --- /dev/null +++ b/src/util/schedule.star @@ -0,0 +1,268 @@ +def create(): + __self_ref = [None] + __items_by_id = {} + + def __self(): + return __self_ref[0] + + # Adds an item to the schedule. + # + # This function will fail if the item already exists in the schedule, + # or if the item is not valid. + # + # It will return the schedule itself so that it can be chained. + def add(*items): + for item in items: + _assert_item(item) + + if __items_by_id.get(item.id): + fail( + "schedule: Failed to add item {}: item with the same ID already exists".format( + item.id + ) + ) + + __items_by_id[item.id] = item + + return __self() + + # Updates an item in the schedule. + # + # This is useful for inserting launch steps into the schedule + # after the item has been added by changing the dependencies of the item. + # + # This function will fail if the item does not exist in the schedule, + # or if the updater is not a function, or if the updater changes the ID of the item. + # + # It will return the schedule itself so that it can be chained. + def update(id, updater): + if id not in __items_by_id: + fail("schedule: Failed to update item {}: item does not exist".format(id)) + + # We rigorously ensure that all is well because the errors from here would not very readable + type_of_updater = type(updater) + if type_of_updater != "function": + fail( + "schedule: Failed to update item {}: expected 'updater' to be of type function but 'updater' is of type {}".format( + id, type_of_updater + ) + ) + + item = __items_by_id[id] + updated_item = _assert_item(updater(item=item)) + + if updated_item.id != item.id: + fail( + "schedule: Failed to update item {}: updater changed the ID from {} to {}".format( + id, item.id, updated_item.id + ) + ) + + __items_by_id[id] = updated_item + + return __self() + + # Returns all the items in the schedule in an unspecified order + def items(): + return __items_by_id.values() + + # This function returns the items in the order they should be launched + # based on their dependencies. + # + # It will try to preserve the order in which the items were added, + # only reordering them if necessary to satisfy the dependencies. + # + # If there are any cycles in the dependencies, it will fail. + # If there are any missing dependencies, it will also fail. + def sequence(): + # First we check whether we have all the items + all_dependency_ids = [ + dependency + for item in __items_by_id.values() + for dependency in item.dependencies + ] + + # Now check we have all of them + missing_dependency_ids = [ + id for id in all_dependency_ids if id not in __items_by_id + ] + if missing_dependency_ids: + fail( + "Failed to launch: Missing items {}".format( + ",".join(missing_dependency_ids) + ) + ) + + # Now we have to order the items based on their dependencies + # + # First we start with the default sequence - the order in which the items were added + ordered_items = __items_by_id.values() + num_items = len(ordered_items) + + for index in range(num_items): + item = ordered_items[index] + + # Since we are not allowed any unbound loops, we'll have to resort to somewhat different strategy + # + # We will calculate the lowest index at which this item can be placed + # based on its dependencies. + lowest_desired_index = _lowest_desired_index(item, ordered_items) + + # If the lowest index is lower or equal to the current index, everything is fine and we can continue + if lowest_desired_index <= index: + continue + + # If the lowest index is greater than the current index, we need to swap the item with the item at the lowest index + item_to_swap = ordered_items[lowest_desired_index] + + # We cannot just swap thew though - we also need to check that the item being swapped in is not dependent on the item being swapped out + # + # We do this by checking the lowest desired index for the item being swapped in, + # and if it is greater than the current index, we fail + # + # In other words, if the item we want to swap with the current item is dependent on the current item, + # we cannot swap them because we have a cycle + lowest_desired_index_for_item_to_swap = _lowest_desired_index( + item_to_swap, ordered_items + ) + + if lowest_desired_index_for_item_to_swap > index: + fail( + "Cannot create launch sequence: Item {} <-> {}".format( + item.id, item_to_swap.id + ) + ) + + ordered_items[index] = item_to_swap + ordered_items[lowest_desired_index] = item + + return ordered_items + + __self_ref[0] = struct( + add=add, + update=update, + items=items, + sequence=sequence, + ) + + return __self() + + +# Launches a scheule by executing each item in the order determined by the schedule. +def launch(plan, schedule): + items = schedule.sequence() + launched = {} + + for item in items: + missing_dependencies = [id for id in item.dependencies if id not in launched] + if missing_dependencies: + fail( + "schedule: Launch error: Missing dependencies {} for item {}".format( + ",".join(missing_dependencies), + item.id, + ) + ) + + # We will always only pass the explicitly defined dependencies + item_dependencies = {id: launched[id] for id in item.dependencies} + + launched[item.id] = item.launch(plan=plan, dependencies=item_dependencies) + + return launched + + +def item(id, launch, dependencies=[]): + return _assert_item( + struct( + id=id, + launch=launch, + dependencies=dependencies, + ) + ) + + +def _lowest_desired_index(item, items): + items_without_item = list(items) + items_without_item.remove(item) + + for index in range(len(items)): + previous_items = items_without_item[:index] + previous_ids = [i.id for i in previous_items] + + missing_dependencies = [ + id for id in item.dependencies if id not in previous_ids + ] + + if not missing_dependencies: + return index + + +def _assert_item(item): + type_of_item = type(item) + if type_of_item != "struct": + fail( + "schedule: Expected an item to be a struct, got {} of type {}".format( + item, type_of_item + ) + ) + + if not hasattr(item, "id"): + fail( + "schedule: Expected an item to have a property 'id', got {}".format( + item, type_of_item + ) + ) + + type_of_id = type(item.id) + if type_of_id != "string": + fail( + "schedule: Expected an item to have an 'id' of type string but 'id' is of type {}".format( + type_of_id + ) + ) + + if not hasattr(item, "dependencies"): + fail( + "schedule: Expected an item to have a property 'dependencies', got {}".format( + item, type_of_item + ) + ) + + type_of_dependencies = type(item.dependencies) + if type_of_dependencies != "list": + fail( + "schedule: Expected an item to have a 'dependencies' property of type list but 'dependencies' is of type {}".format( + type_of_dependencies + ) + ) + + mistyped_dependencies = [d for d in item.dependencies if type(d) != "string"] + if mistyped_dependencies: + fail( + "schedule: Expected an item to have a 'dependencies' property of type list of strings but 'dependencies' contains {}".format( + ", ".join( + ["{} of type {}".format(d, type(d)) for d in mistyped_dependencies] + ) + ) + ) + + has_self_as_dependency = item.id in item.dependencies + if has_self_as_dependency: + fail("schedule: Item {} specifies itself as its dependency".format(item.id)) + + if not hasattr(item, "launch"): + fail( + "schedule: Expected an item to have a property 'launch', got {}".format( + item, type_of_item + ) + ) + + type_of_launch = type(item.launch) + if type_of_launch != "function": + fail( + "schedule: Expected an item to have a 'launch' property of type function but 'launch' is of type {}".format( + type_of_launch + ) + ) + + return item diff --git a/test/util/schedule_test.star b/test/util/schedule_test.star new file mode 100644 index 00000000..fa7b7c5a --- /dev/null +++ b/test/util/schedule_test.star @@ -0,0 +1,360 @@ +_schedule = import_module("/src/util/schedule.star") + + +def _default_launch(plan, dependencies): + return None + + +def _default_updater(item): + return item + + +def test_util_schedule_dependency_invalid_item(plan): + schedule = _schedule.create() + + # We check for a missing id + expect.fails( + lambda: schedule.add(struct()), + "schedule: Expected an item to have a property 'id'", + ) + + # We check for a mistyped id + expect.fails( + lambda: schedule.add(struct(id=123)), + "schedule: Expected an item to have an 'id' of type string but 'id' is of type int", + ) + + # We check for missing dependencies + expect.fails( + lambda: schedule.add(struct(id="a", launch=_default_launch)), + "schedule: Expected an item to have a property 'dependencies'", + ) + + # We check for mistyped dependencies + expect.fails( + lambda: schedule.add(struct(id="a", launch=_default_launch, dependencies="b")), + "schedule: Expected an item to have a 'dependencies' property of type list but 'dependencies' is of type string", + ) + + # We check for mistyped dependencies + expect.fails( + lambda: schedule.add( + struct(id="a", launch=_default_launch, dependencies=[123, [], {}, False]) + ), + "schedule: Expected an item to have a 'dependencies' property of type list of strings but 'dependencies' contains 123 of type int, \\[\\] of type list, \\{\\} of type dict, False of type bool", + ) + + # We check for missing launch + expect.fails( + lambda: schedule.add(struct(id="a", dependencies=["b"])), + "schedule: Expected an item to have a property 'launch'", + ) + + # We check for mistyped launch + expect.fails( + lambda: schedule.add(struct(id="a", launch=123, dependencies=["b"])), + "schedule: Expected an item to have a 'launch' property of type function but 'launch' is of type int", + ) + + +def test_util_schedule_dependency_on_self(plan): + schedule = _schedule.create() + + # We check whether the item() utility function catches this + expect.fails( + lambda: _schedule.item(id="a", launch=_default_launch, dependencies=["a"]), + "schedule: Item a specifies itself as its dependency", + ) + + # And whether the schedule.add() function catches this + expect.fails( + lambda: schedule.add( + struct(id="a", launch=_default_launch, dependencies=["a"]) + ), + "schedule: Item a specifies itself as its dependency", + ) + + +def test_util_schedule_add_duplicate_id(plan): + schedule = _schedule.create() + + item_a = _schedule.item(id="a", launch=_default_launch) + schedule.add(item_a) + + # Adding an item with the same id should fail + expect.fails( + lambda: schedule.add(item_a), + "schedule: Failed to add item a: item with the same ID already exists", + ) + + +def test_util_schedule_update_missing_item(plan): + schedule = _schedule.create() + + expect.fails( + lambda: schedule.update(id="a", updater=_default_updater), + "schedule: Failed to update item a: item does not exist", + ) + + +def test_util_schedule_update_invalid_updater(plan): + schedule = _schedule.create() + + item_a = _schedule.item(id="a", launch=_default_launch) + schedule.add(item_a) + + expect.fails( + lambda: schedule.update(id="a", updater=123), + "schedule: Failed to update item a: expected 'updater' to be of type function but 'updater' is of type int", + ) + + +def test_util_schedule_update_success(plan): + schedule = _schedule.create() + + item_a = _schedule.item(id="a", launch=_default_launch) + item_b = _schedule.item(id="b", launch=_default_launch, dependencies=["a"]) + item_c = _schedule.item(id="c", launch=_default_launch, dependencies=["a"]) + + schedule.add(item_a) + schedule.add(item_b) + schedule.add(item_c) + + expect.eq(schedule.sequence(), [item_a, item_b, item_c]) + + updated_item_b = _schedule.item(id="b", launch=_default_launch, dependencies=["c"]) + schedule.update(id="b", updater=lambda item: updated_item_b) + + expect.eq(schedule.sequence(), [item_a, item_c, updated_item_b]) + + +def test_util_schedule_update_changed_id(plan): + schedule = _schedule.create() + + item_a = _schedule.item(id="a", launch=_default_launch) + schedule.add(item_a) + + expect.fails( + lambda: schedule.update( + id="a", updater=lambda item: _schedule.item(id="b", launch=_default_launch) + ), + "schedule: Failed to update item a: updater changed the ID from a to b", + ) + + +def test_util_schedule_no_dependencies(plan): + schedule = _schedule.create() + + item_a = _schedule.item(id="a", launch=_default_launch) + item_b = _schedule.item(id="b", launch=_default_launch) + + schedule.add(item_b) + schedule.add(item_a) + + expect.eq(schedule.sequence(), [item_b, item_a]) + + +def test_util_schedule_simple_linear_dependencies(plan): + schedule = _schedule.create() + + item_a = _schedule.item(id="a", launch=_default_launch) + item_b = _schedule.item(id="b", launch=_default_launch, dependencies=["a"]) + + schedule.add(item_b) + schedule.add(item_a) + + expect.eq(schedule.sequence(), [item_a, item_b]) + + +def test_util_schedule_simple_simple_cycle_dependencies(plan): + schedule = _schedule.create() + + item_a = _schedule.item(id="a", launch=_default_launch, dependencies=["b"]) + item_b = _schedule.item(id="b", launch=_default_launch, dependencies=["a"]) + + schedule.add(item_b) + schedule.add(item_a) + + expect.fails( + lambda: schedule.sequence(), "Cannot create launch sequence: Item b <-> a" + ) + + +def test_util_schedule_simple_large_cycle_dependencies(plan): + schedule = _schedule.create() + + item_a = _schedule.item(id="a", launch=_default_launch, dependencies=["d"]) + item_b = _schedule.item(id="b", launch=_default_launch, dependencies=["a"]) + item_c = _schedule.item(id="c", launch=_default_launch, dependencies=["b"]) + item_d = _schedule.item(id="d", launch=_default_launch, dependencies=["a"]) + + schedule.add(item_b) + schedule.add(item_a) + schedule.add(item_c) + schedule.add(item_d) + + expect.fails( + lambda: schedule.sequence(), "Cannot create launch sequence: Item b <-> a" + ) + + +def test_util_schedule_simple_branching_dependencies(plan): + schedule = _schedule.create() + + item_a = _schedule.item(id="a", launch=_default_launch) + item_b = _schedule.item(id="b", launch=_default_launch) + item_c1 = _schedule.item(id="c1", launch=_default_launch, dependencies=["b"]) + item_c2 = _schedule.item(id="c2", launch=_default_launch, dependencies=["b"]) + item_c21 = _schedule.item(id="c21", launch=_default_launch, dependencies=["c2"]) + item_c22 = _schedule.item(id="c22", launch=_default_launch, dependencies=["c21"]) + item_c3 = _schedule.item(id="c3", launch=_default_launch, dependencies=["b"]) + item_d = _schedule.item( + id="d", launch=_default_launch, dependencies=["c1", "c22", "c3"] + ) + + schedule.add(item_b) + schedule.add(item_c1) + schedule.add(item_d) + schedule.add(item_c21) + schedule.add(item_c22) + schedule.add(item_a) + schedule.add(item_c3) + schedule.add(item_c2) + + expect.eq( + schedule.sequence(), + [item_b, item_c1, item_c3, item_c2, item_c21, item_a, item_c22, item_d], + ) + + +def test_util_schedule_launch_empty(plan): + schedule = _schedule.create() + + # Launching an empty schedule should return an empty dict + expect.eq(_schedule.launch(plan, schedule), {}) + + +def test_util_schedule_launch_simple(plan): + schedule = _schedule.create() + + schedule.add( + _schedule.item( + id="a", + launch=lambda plan, dependencies: "a launched with dependencies {}".format( + dependencies + ), + ) + ) + schedule.add( + _schedule.item( + id="b", + launch=lambda plan, dependencies: "b launched with dependencies {}".format( + dependencies + ), + dependencies=["a"], + ) + ) + + expect.eq( + _schedule.launch(plan, schedule), + { + "a": "a launched with dependencies {}", + "b": 'b launched with dependencies {"a": "a launched with dependencies {}"}', + }, + ) + + +def test_util_schedule_launch_branching(plan): + schedule = _schedule.create() + + schedule.add( + _schedule.item( + id="a", + launch=lambda plan, dependencies: "a launched with dependencies {}".format( + ",".join(dependencies.keys()) + ), + ) + ) + schedule.add( + _schedule.item( + id="b", + launch=lambda plan, dependencies: "b launched with dependencies {}".format( + ",".join(dependencies.keys()) + ), + dependencies=["a"], + ) + ) + schedule.add( + _schedule.item( + id="c1", + launch=lambda plan, dependencies: "c1 launched with dependencies {}".format( + ",".join(dependencies.keys()) + ), + dependencies=["b"], + ) + ) + schedule.add( + _schedule.item( + id="c2", + launch=lambda plan, dependencies: "c2 launched with dependencies {}".format( + ",".join(dependencies.keys()) + ), + dependencies=["b"], + ) + ) + schedule.add( + _schedule.item( + id="d", + launch=lambda plan, dependencies: "d launched with dependencies {}".format( + ",".join(dependencies.keys()) + ), + dependencies=["c1", "c2"], + ) + ) + + expect.eq( + _schedule.launch(plan, schedule), + { + "a": "a launched with dependencies ", + "b": "b launched with dependencies a", + "c1": "c1 launched with dependencies b", + "c2": "c2 launched with dependencies b", + "d": "d launched with dependencies c1,c2", + }, + ) + + +def test_util_schedule_launch_no_implicit_dependencies(plan): + schedule = _schedule.create() + + schedule.add( + _schedule.item( + id="a", + launch=lambda plan, dependencies: expect.eq(dependencies, {}), + ) + ) + + schedule.add( + _schedule.item( + id="b", + launch=lambda plan, dependencies: expect.eq(dependencies, {"a": None}), + dependencies=["a"], + ) + ) + + schedule.add( + _schedule.item( + id="c", + launch=lambda plan, dependencies: expect.eq(dependencies, {"b": None}), + dependencies=["b"], + ) + ) + + expect.eq( + _schedule.launch(plan, schedule), + { + "a": None, + "b": None, + "c": None, + }, + )