From 02ad16a4597f0693c5e6a0fa971b51e9184c0693 Mon Sep 17 00:00:00 2001 From: Noah Metz Date: Tue, 30 Jan 2024 14:43:05 -0700 Subject: [PATCH] Reorganized DB, and fixed crashes --- src/database.erl | 218 +++++++++++++++++++++++++++++++++-------------- src/erlk_sup.erl | 5 +- src/mqtt.erl | 15 +++- src/schedule.erl | 14 ++- 4 files changed, 180 insertions(+), 72 deletions(-) diff --git a/src/database.erl b/src/database.erl index 7142df4..4a7bda1 100644 --- a/src/database.erl +++ b/src/database.erl @@ -2,7 +2,7 @@ -behaviour(gen_server). --export([start_link/1, init/1, handle_cast/2, handle_call/3]). +-export([start_link/1, init/1, handle_cast/2, handle_call/3, terminate/2]). -export([get_tick/0]). -record(state, {database_file = none, database = none, tick_start = none}). @@ -46,37 +46,67 @@ init([DatabaseFile]) -> end. init_db(Database) -> - ok = sqlite3:create_table(Database, divisions, [{division, integer, [not_null]}], + ok = sqlite3:create_table(Database, divisions, [{division, text, [not_null]}], [{primary_key, [division]}]), + ok = sqlite3:create_table(Database, fields, [{field, text, [not_null]}, + {serial, text, [unique]}, + {division, text}, + {other, text}], + [{primary_key, [field]}, + {foreign_key, {[division], divisions, [division], "ON DELETE SET NULL"}}, + {check, "other IN ('testing', 'skills')"}]), + ok = sqlite3:create_table(Database, teams, [{team, text, [not_null]}, - {division, integer}, + {division, text}, {inspection, blob}], [{primary_key, [team]}, - {foreign_key, {[division], divisions, [division], ""}}]), + {foreign_key, {[division], divisions, [division], "ON DELETE SET NULL"}}]), - ok = sqlite3:create_table(Database, matches, [{division, integer}, + ok = sqlite3:create_table(Database, matches, [{division, text}, {type, text, [not_null]}, - {number, integer, [not_null]}, - {instance, integer, [not_null]}], - [{primary_key, [division, type, number, instance]}, + {number, integer, [not_null]}], + [{primary_key, [division, type, number]}, {check, "type IN ('practice', 'qualification', 'elimination', 'final')"}, {foreign_key, {[division], divisions, [division], "ON DELETE CASCADE"}}]), - ok = sqlite3:create_table(Database, match_teams, [{division, integer, [not_null]}, + ok = sqlite3:create_table(Database, match_teams, [{division, text}, {type, text, [not_null]}, {number, integer, [not_null]}, - {instance, integer, [not_null]}, {side, text, [not_null]}, {anum, integer, [not_null]}, {team, text, [not_null]}], - [{primary_key, [division, type, number, instance, side, anum]}, + [{primary_key, [division, type, number, side, anum]}, {check, "side IN ('red', 'blue')"}, {check, "anum >= 1 AND anum <= 2"}, - {foreign_key, {[division, type, number, instance], matches, [division, type, number, instance], "ON DELETE CASCADE"}}, + {check, "type IN ('practice', 'qualification')"}, + {foreign_key, {[division, type, number], matches, [division, type, number], "ON DELETE CASCADE"}}, {foreign_key, {[team], teams, [team], ""}}]), - ok = sqlite3:create_table(Database, match_scores, [{division, integer}, + ok = sqlite3:create_table(Database, elim_alliances, [{division, text}, + {type, text, [not_null]}, + {number, integer, [not_null]}, + {side, text, [not_null]}, + {anum, integer, [not_null]}], + [{primary_key, [division, type, number, side]}, + {check, "side IN ('red', 'blue')"}, + {check, "type == 'elimination'"}, + {foreign_key, {[division, type, number], matches, [division, type, number], "ON DELETE CASCADE"}}, + {foreign_key, {[division, anum], alliances, [division, number], ""}}]), + + ok = sqlite3:create_table(Database, finals_alliances, [{division, text}, + {type, text, [not_null]}, + {number, integer, [not_null]}, + {side, text, [not_null]}, + {adiv, text, [not_null]}], + [{primary_key, [division, type, number, side]}, + {check, "side IN ('red', 'blue')"}, + {check, "division IS NULL"}, + {check, "type == 'final'"}, + {foreign_key, {[division, type, number], matches, [division, type, number], "ON DELETE CASCADE"}}, + {foreign_key, {[adiv], winners, [division], ""}}]), + + ok = sqlite3:create_table(Database, match_scores, [{division, text}, {type, text, [not_null]}, {number, integer, [not_null]}, {instance, integer, [not_null]}, @@ -103,32 +133,34 @@ init_db(Database) -> {check, "red_2 IS NULL OR red_2 IN ('dq', 'no show')"}, {check, "blue_1 IS NULL OR blue_1 IN ('dq', 'no show')"}, {check, "blue_2 IS NULL OR blue_2 IN ('dq', 'no show')"}, - {foreign_key, {[division, type, number, instance], matches, [division, type, number, instance], "ON DELETE CASCADE"}}]), + {foreign_key, {[division, type, number], matches, [division, type, number], "ON DELETE CASCADE"}}]), - ok = sqlite3:create_table(Database, match_states, [{division, integer}, + ok = sqlite3:create_table(Database, match_states, [{division, text}, {type, text, [not_null]}, {number, integer, [not_null]}, - {instance, integer, [not_null]}, {tick, integer, [not_null]}, {state, text, [not_null]}], - [{primary_key, [division, type, number, instance, tick]}, - {foreign_key, {[division, type, number, instance], matches, [division, type, number, instance], "ON DELETE CASCADE"}}]), + [{primary_key, [division, type, number, tick]}, + {foreign_key, {[division, type, number], matches, [division, type, number], "ON DELETE CASCADE"}}]), - ok = sqlite3:create_table(Database, alliances, [{division, integer, [not_null]}, - {number, integer, [not_null]}, - {team1, text, [not_null]}, - {team2, text, [not_null]}], + ok = sqlite3:create_table(Database, alliances, [{division, text, [not_null]}, + {number, integer, [not_null]}], [{primary_key, [division, number]}, - {foreign_key, {[team1], teams, [team], "ON DELETE CASCADE"}}, - {foreign_key, {[team2], teams, [team], "ON DELETE CASCADE"}}]), - - ok = sqlite3:create_table(Database, rankings, [{division, integer, [not_null]}, - {rank, integer, [not_null]}, - {team, text, [not_null]}], - [{primary_key, [division, rank]}, - {foreign_key, {[division], divisions, [division], "ON DELETE CASCADE"}}, + {foreign_key, {[division], divisions, [division], "ON DELETE CASCADE"}}]), + + ok = sqlite3:create_table(Database, alliance_members, [{division, text, [not_null]}, + {number, integer, [not_null]}, + {anum, integer, [not_null]}, + {team, text, [not_null]}], + [{primary_key, [division, number, anum]}, + {foreign_key, {[division, number], alliances, [division, number], "ON DELETE CASCADE"}}, {foreign_key, {[team], teams, [team], "ON DELETE CASCADE"}}]), + ok = sqlite3:create_table(Database, winners, [{division, text, [not_null]}, + {alliance, integer, [not_null]}], + [{primary_key, [division]}, + {foreign_key, {[division, alliance], alliances, [division, number], "ON DELETE CASCADE"}}]), + ok = sqlite3:create_table(Database, skills_scores, [{team, text, [not_null]}, {type, text, [not_null]}, {attempt, integer, [not_null]}, @@ -163,21 +195,21 @@ add_teams(Database, Teams) -> assign_divisions(Database, Teams) -> SQL = lists:append(["BEGIN; ", - lists:append([io_lib:format("UPDATE teams SET division = ~p WHERE team = '~s'; ", [Division, Team]) || {Team, Division} <- Teams]), + lists:append([io_lib:format("UPDATE teams SET division = '~s' WHERE team = '~s'; ", [Division, Team]) || {Team, Division} <- Teams]), "COMMIT;"]), first_error(sqlite3:sql_exec_script(Database, SQL)). write_division_matches(Database, Division, Type, Matches) -> Round = atom_to_list(Type), SQL = lists:append(["BEGIN; ", - io_lib:format("DELETE FROM matches WHERE division = ~p AND type = '~s';", + io_lib:format("DELETE FROM matches WHERE division = '~s' AND type = '~s';", [Division, Round]), lists:append([io_lib:format( - "INSERT INTO matches(division, type, number, instance) VALUES (~p, '~s', ~p, 1); - INSERT INTO match_teams(division, type, number, instance, side, anum, team) VALUES (~p, '~s', ~p, 1, 'red', 1, '~s'); - INSERT INTO match_teams(division, type, number, instance, side, anum, team) VALUES (~p, '~s', ~p, 1, 'red', 2, '~s'); - INSERT INTO match_teams(division, type, number, instance, side, anum, team) VALUES (~p, '~s', ~p, 1, 'blue', 1, '~s'); - INSERT INTO match_teams(division, type, number, instance, side, anum, team) VALUES (~p, '~s', ~p, 1, 'blue', 2, '~s');", + "INSERT INTO matches(division, type, number) VALUES ('~s', '~s', ~p); + INSERT INTO match_teams(division, type, number, side, anum, team) VALUES ('~s', '~s', ~p, 'red', 1, '~s'); + INSERT INTO match_teams(division, type, number, side, anum, team) VALUES ('~s', '~s', ~p, 'red', 2, '~s'); + INSERT INTO match_teams(division, type, number, side, anum, team) VALUES ('~s', '~s', ~p, 'blue', 1, '~s'); + INSERT INTO match_teams(division, type, number, side, anum, team) VALUES ('~s', '~s', ~p, 'blue', 2, '~s');", [Division, Round, N, Division, Round, N, B1, Division, Round, N, B2, Division, Round, N, R1, Division, Round, N, R2]) || {N, [B1, B2, R1, R2]} <- lists:enumerate(Matches)]), "COMMIT;"]), @@ -187,17 +219,14 @@ index_of(Item, List) -> index_of(Item, List, 1). index_of(Item, [Element | _], N) when Item == Element -> N; index_of(Item, [_ | List], N) -> index_of(Item, List, N+1). -first_empty([Num1, Num2 | Nums]) when Num2 == (Num1 + 1) -> first_empty([Num2 | Nums]); -first_empty([Num | _]) -> Num + 1. - get_column(_, [], _) -> []; get_column(_, _, []) -> []; get_column(Name, Columns, Rows) -> Index = index_of(Name, Columns), [element(Index, Row) || Row <- Rows]. -handle_call(get_tick_start, _, State) -> - {reply, State#state.tick_start, State}; +handle_call({time_sync, T0}, _, State) -> + {reply, {T0, get_tick(State)}, State}; handle_call({new_db, DatabaseFile}, _, State) -> ok = if State#state.database =:= none -> ok; true -> sqlite3:close(State#state.database) @@ -218,6 +247,52 @@ handle_call({load_db, DatabaseFile}, _, State) -> end, open_db(DatabaseFile) end, + + % Publish division/{division} with fields + [_, {rows, Divisions}] = sqlite3:sql_exec(Database, "SELECT division FROM divisions;"), + [_, {rows, DivisionFields}] = sqlite3:sql_exec(Database, "SELECT field, division FROM fields WHERE division IS NOT NULL;"), + DivisionMessages = [{list_to_binary(io_lib:format("division/~s", [Name])), + jsone:encode(#{<<"fields">> => + lists:filtermap(fun({Field, FieldDivision}) -> + if FieldDivision == Name -> + {'true', Field}; + true -> false + end + end, DivisionFields) + })} + || {Name} <- Divisions], + + % Publish team/{team} with inspection + % Publish division/{division}/teams with team list + [_, {rows, Teams}] = sqlite3:sql_exec(Database, "SELECT team, division, inspection FROM teams"), + TeamsMessages = [{list_to_binary(io_lib:format("team/~s", [Team])), + jsone:encode(#{<<"inspection">> => Inspection})} + || {Team, _, Inspection} <- Teams], + DivisionTeams = [{list_to_binary(io_lib:format("division/~s/teams", [Name])), + jsone:encode(lists:filtermap(fun({Team, Div, _}) -> + if Div == Name -> + {'true', Team}; + true -> + false + end + end, Teams))} + || {Name} <- Divisions], + + [_, {rows, Alliances}] = sqlite3:sql_exec(Database, "SELECT team, div"), + + % Read matches and match_teams tables and publish to match/{div}/{round}/{number} + % Read match_sates and publish to match/{div}/{round}/{number}/state + % Read match_scores and publish to match/{div}/{round}/{number}/state + % Read rankings and publish divisions/{div}/rankings + % Read skills scores and publish team/{team}/skills + % Read alliances and publish to division/{div}/alliances + + + % DEBUG PRINTS + io:fwrite("DIVISIONS: ~p~n", [DivisionMessages]), + io:fwrite("TEAMS: ~p~n", [TeamsMessages]), + io:fwrite("INSPECTION: ~p~n", [DivisionTeams]), + {reply, ok, State#state{database_file = DatabaseFile, database = Database, tick_start = TickStart}}; handle_call(close_db, _, State) -> if State#state.database =:= none -> {reply, ok, State}; @@ -234,8 +309,10 @@ handle_call({assign_divisions, Teams}, _, State) -> {reply, ok, State}; handle_call(split_teams, _, State) -> Teams = [X||{_,X} <- lists:sort([{rand:uniform(), N} || N <- get_teams(State#state.database)])], - [{columns, _}, {rows, Divisions}] = sqlite3:sql_exec(State#state.database, "SELECT division FROM divisions;"), - Assignments = [{Team, (I rem length(Divisions)) + 1} || {I, Team} <- lists:enumerate(Teams)], + [{columns, Cols}, {rows, Rows}] = sqlite3:sql_exec(State#state.database, "SELECT division FROM divisions;"), + Divisions = get_column("division", Cols, Rows), + Assignments = [{Team, lists:nth((I rem length(Divisions)) + 1, Divisions)} + || {I, Team} <- lists:enumerate(Teams)], ok = assign_divisions(State#state.database, Assignments), {reply, ok, State}; handle_call({delete_teams, Removed}, _, State) -> @@ -253,31 +330,38 @@ handle_call({match_score, Division, Round, Number, Instance, Red, Blue, Score}, end, ok = first_error(sqlite3:sql_exec_script(State#state.database, lists:append( [io_lib:format("INSERT INTO match_scores(division, type, number, instance, history, red, blue, score) VALUES(~p, '~s', ~p, ~p, ~p, ~p, ~p, '~s');", [Division, atom_to_list(Round), Number, Instance, NextInstance, Red, Blue, Score]), - io_lib:format("INSERT INTO match_states(division, type, number, instance, tick, state) VALUES(~p, '~s', ~p, ~p, ~p, '~s');", [Division, atom_to_list(Round), Number, Instance, get_tick(State), "scored"])]))), + io_lib:format("INSERT INTO match_states(division, type, number, tick, state) VALUES(~p, '~s', ~p, ~p, '~s');", [Division, atom_to_list(Round), Number, get_tick(State), "scored"])]))), {reply, ok, State}; -handle_call({match_state, Division, Round, Number, Instance, MatchState}, _, State) -> +handle_call({match_state, Division, Round, Number, MatchState}, _, State) -> {rowid, _} = sqlite3:sql_exec(State#state.database, - io_lib:format("INSERT INTO match_states(division, type, number, instance, tick, state) VALUES(~p, '~s', ~p, ~p, ~p, '~s');", - [Division, atom_to_list(Round), Number, Instance, get_tick(State), MatchState])), + io_lib:format("INSERT INTO match_states(division, type, number, tick, state) VALUES(~p, '~s', ~p, ~p, '~s');", + [Division, atom_to_list(Round), Number, get_tick(State), MatchState])), {reply, ok, State}; -handle_call(add_division, _, State) -> - [{columns, Columns}, {rows, Rows}] = sqlite3:sql_exec(State#state.database, "SELECT division FROM divisions;"), - Divisions = lists:sort(get_column("division", Columns, Rows)), - NextDivision = first_empty([0 | Divisions]), - {rowid, _} = sqlite3:sql_exec(State#state.database, - io_lib:format("INSERT INTO divisions(division) VALUES(~p);", - [NextDivision])), +handle_call({add_division, Name}, _, State) -> + {rowid, _} = sqlite3:sql_exec( + State#state.database, + io_lib:format("INSERT INTO divisions(division) VALUES('~s');", + [Name])), {reply, ok, State}; handle_call({delete_division, Division}, _, State) -> - ok = first_error(sqlite3:sql_exec_script(State#state.database, io_lib:format("UPDATE teams SET division = NULL WHERE division = ~p; DELETE FROM divisions WHERE division = ~p;", [Division, Division]))), + ok = first_error(sqlite3:sql_exec_script( + State#state.database, + io_lib:format( + "UPDATE teams SET division = NULL WHERE division = '~s'; + DELETE FROM divisions WHERE division = '~s';", + [Division, Division]))), {reply, ok, State}; -handle_call({generate_division, Division, Round, Size, Seed}, _, State) -> +handle_call({generate_division, Division, Round, Size}, _, State) -> Teams = get_div_teams(State#state.database, Division), - io:fwrite("Generating division ~p/~s with teams ~p, size ~p, and seed ~p~n", [Division, Round, Teams, Size, Seed]), - Matches = schedule:create(Seed, Size, Teams), - ok = write_division_matches(State#state.database, Division, Round, Matches), - {reply, ok, State}. + Seed = rand:uniform(10000000), + io:fwrite("Generating division ~s/~s with teams ~p, size ~p, and seed ~p~n", [Division, Round, Teams, Size, Seed]), + case schedule:create(Seed, Size, Teams) of + error -> {reply, error, State}; + Matches -> {reply, write_division_matches(State#state.database, Division, Round, Matches), State} + end; +handle_call(_, _, State) -> + {reply, nofunc, State}. get_div_teams(Database, Division) -> [{columns, Columns}, {rows, Rows}] = sqlite3:sql_exec(Database, io_lib:format("SELECT team FROM teams WHERE division = ~p;", [Division])), @@ -292,4 +376,14 @@ handle_cast(_, State) -> get_tick(State) -> erlang:monotonic_time(millisecond) - State#state.tick_start. get_tick() -> - erlang:monotonic_time(millisecond) - gen_server:call(?MODULE, get_tick_start). + T0 = erlang:monotonic_time(millisecond), + {T0, T1} = gen_server:call(?MODULE, {time_sync, T0}), + T2 = erlang:monotonic_time(millisecond), + Offset = trunc(((T1-T0) + (T1-T2))/2), + NetworkDelay = trunc((T2-T0)/2), + ServerTick = T2 + Offset + NetworkDelay, + {Offset, ServerTick}. + +terminate(_, State) -> + DB = State#state.database, + ok = if DB =:= none -> ok; true -> sqlite3:close(DB) end. diff --git a/src/erlk_sup.erl b/src/erlk_sup.erl index 95dc396..c7fb97e 100644 --- a/src/erlk_sup.erl +++ b/src/erlk_sup.erl @@ -12,7 +12,7 @@ -export([init/1]). start_link(DBPath) -> - supervisor:start_link(?MODULE, [DBPath]). + supervisor:start_link({local, ?MODULE}, ?MODULE, [DBPath]). %% sup_flags() = #{strategy => strategy(), % optional %% intensity => non_neg_integer(), % optional @@ -25,7 +25,8 @@ start_link(DBPath) -> %% modules => modules()} % optional init(DBPath) -> - SupFlags = #{strategy => rest_for_one}, + SupFlags = #{strategy => one_for_one, + intensity => 3}, ChildSpecs = [ #{id => schedule, start => {schedule, start_link, []} diff --git a/src/mqtt.erl b/src/mqtt.erl index e74440f..59d7361 100644 --- a/src/mqtt.erl +++ b/src/mqtt.erl @@ -3,6 +3,7 @@ -behaviour(gen_server). -export([start_link/0, init/1, handle_cast/2, handle_info/2, handle_call/3]). +-export([publish/2, publish/3]). start_link() -> gen_server:start_link({local, ?MODULE}, ?MODULE, [], []). @@ -12,12 +13,18 @@ init(_) -> {ok, Props} = emqtt:connect(PID), {ok, {PID, Props}}. -handle_info(Request, State) -> - io:fwrite("~p~n", [Request]), +handle_info(_, State) -> {noreply, State}. handle_cast(_, State) -> {noreply, State}. -handle_call(_, _, State) -> - {noreply, State}. +publish(Topic, Payload) -> + gen_server:call(?MODULE, {publish, Topic, Payload, []}). + +publish(Topic, Payload, Options) -> + gen_server:call(?MODULE, {publish, Topic, Payload, Options}). + +handle_call({publish, Topic, Payload, Options}, _, {PID, Props}) -> + ok = emqtt:publish(PID, Topic, Payload, Options), + {reply, ok, {PID, Props}}. diff --git a/src/schedule.erl b/src/schedule.erl index 49bdb31..cdd5ec7 100644 --- a/src/schedule.erl +++ b/src/schedule.erl @@ -50,8 +50,12 @@ create_schedule(Seed, Size, Teams) -> TeamsPadded = TeamsRepeated ++ lists:duplicate(padding(length(Teams)*Size), padding), rand:seed(default, Seed), TeamsRandom = [X || {_, X} <- lists:sort([{rand:uniform(), N} || N <- TeamsPadded])], - fill_padding_schedule(pick_matches(TeamsRandom), Teams). + case pick_matches(TeamsRandom) of + error -> error; + Matches -> fill_padding_schedule(Matches, Teams) + end. +pick_teams(_, _, _, 0) -> error; pick_teams(Teams, Picked, 0, Safety) when Safety > 0 -> {Teams, Picked}; pick_teams([Team | Teams], Picked, N, Safety) when Safety > 0 -> Duplicate = lists:member(Team, Picked), @@ -59,11 +63,13 @@ pick_teams([Team | Teams], Picked, N, Safety) when Safety > 0 -> true -> pick_teams(Teams, [Team | Picked], N-1, Safety) end. -pick_teams(Teams, N) -> pick_teams(Teams, [], N, 1000). +pick_teams(Teams, N) -> pick_teams(Teams, [], N, 4*length(Teams)). pick_matches([], Matches) -> Matches; pick_matches(Teams, Matches) -> - {NewTeams, Match} = pick_teams(Teams, 4), - pick_matches(NewTeams, [Match | Matches]). + case pick_teams(Teams, 4) of + {NewTeams, Match} -> pick_matches(NewTeams, [Match | Matches]); + error -> error + end. pick_matches(Teams) -> pick_matches(Teams, []).