diff --git a/common/src/errors/mod.rs b/common/src/errors/mod.rs index 9650814..825a820 100644 --- a/common/src/errors/mod.rs +++ b/common/src/errors/mod.rs @@ -3,9 +3,13 @@ pub mod infallible; pub mod not_found; pub mod register_error; pub mod submit_result_error; +pub mod validate_fetch_error; +pub mod validate_submit_error; pub use fetch_tasks_error::FetchTasksError; pub use infallible::Infallible; pub use not_found::NotFound; pub use register_error::RegisterError; pub use submit_result_error::SubmitResultError; +pub use validate_fetch_error::ValidateFetchError; +pub use validate_submit_error::ValidateSubmitError; diff --git a/common/src/errors/validate_fetch_error.rs b/common/src/errors/validate_fetch_error.rs new file mode 100644 index 0000000..9fdc122 --- /dev/null +++ b/common/src/errors/validate_fetch_error.rs @@ -0,0 +1,8 @@ +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +#[derive(Clone, Hash, Debug, Serialize, Deserialize, Error)] +pub enum ValidateFetchError { + #[error("invalid project")] + InvalidProject, +} diff --git a/common/src/errors/validate_submit_error.rs b/common/src/errors/validate_submit_error.rs new file mode 100644 index 0000000..1c1e8dd --- /dev/null +++ b/common/src/errors/validate_submit_error.rs @@ -0,0 +1,16 @@ +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +#[derive(Clone, Hash, Debug, Serialize, Deserialize, Error)] +pub enum ValidateSubmitError { + #[error("invalid result")] + InvalidResult, + #[error("expected results for exactly one task")] + InvalidTaskCount, + #[error("the group id of all results in a group must be the first submitted result")] + InconsistentGroup, + #[error("forbidden state transition")] + ForbiddenStateTransition, + #[error("missing some results for this task")] + MissingResults, +} diff --git a/common/src/records/result.rs b/common/src/records/result.rs index c32e590..343ee43 100644 --- a/common/src/records/result.rs +++ b/common/src/records/result.rs @@ -1,7 +1,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use crate::types::Id; +use crate::types::{Id, ResultState}; use super::Assignment; @@ -13,12 +13,16 @@ pub struct Result { pub stdout: String, pub stderr: String, pub exit_code: Option, + pub group_result_id: Option>, + pub state: ResultState, } #[non_exhaustive] #[derive(Clone, Hash, Debug, Default, Serialize, Deserialize)] pub struct ResultFilter { pub assignment_id: Option>, + pub group_result_id: Option>, + pub state: Option, } impl ResultFilter { @@ -26,4 +30,14 @@ impl ResultFilter { self.assignment_id = Some(assignment_id); self } + + pub fn group_result_id(mut self, group_result_id: Id) -> Self { + self.group_result_id = Some(group_result_id); + self + } + + pub fn state(mut self, state: ResultState) -> Self { + self.state = Some(state); + self + } } diff --git a/common/src/records/task.rs b/common/src/records/task.rs index 385f28e..7964b9d 100644 --- a/common/src/records/task.rs +++ b/common/src/records/task.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use crate::types::{Id, Interval}; -use super::{Project, User}; +use super::{Project, Result, User}; #[derive(Clone, Hash, Debug, Serialize, Deserialize)] pub struct Task { @@ -14,12 +14,15 @@ pub struct Task { pub stdin: String, pub assignments_needed: i32, pub assignment_user_ids: Vec>, + pub canonical_result_id: Option>, + pub quorum: i32, } #[non_exhaustive] #[derive(Clone, Hash, Debug, Default, Serialize, Deserialize)] pub struct TaskFilter { pub project_id: Option>, + pub canonical_result_id: Option>, } impl TaskFilter { @@ -27,4 +30,9 @@ impl TaskFilter { self.project_id = Some(project_id); self } + + pub fn canonical_result_id(mut self, canonical_result_id: Id) -> Self { + self.canonical_result_id = Some(canonical_result_id); + self + } } diff --git a/common/src/requests/mod.rs b/common/src/requests/mod.rs index bb3d8e7..6bbfe7e 100644 --- a/common/src/requests/mod.rs +++ b/common/src/requests/mod.rs @@ -1,7 +1,9 @@ pub mod fetch_tasks_request; pub mod register_request; pub mod submit_result_request; +pub mod validate_submit_request; pub use fetch_tasks_request::FetchTasksRequest; pub use register_request::RegisterRequest; pub use submit_result_request::SubmitResultRequest; +pub use validate_submit_request::ValidateSubmitRequest; diff --git a/common/src/requests/validate_submit_request.rs b/common/src/requests/validate_submit_request.rs new file mode 100644 index 0000000..9688528 --- /dev/null +++ b/common/src/requests/validate_submit_request.rs @@ -0,0 +1,11 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use crate::{records::Result, types::Id}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ValidateSubmitRequest { + // Map from result id to group id. None means error. + pub results: HashMap, Option>>, +} diff --git a/common/src/types/assignment_state.rs b/common/src/types/assignment_state.rs index d5336bd..3f7338c 100644 --- a/common/src/types/assignment_state.rs +++ b/common/src/types/assignment_state.rs @@ -11,7 +11,4 @@ pub enum AssignmentState { Canceled, Expired, Submitted, - Valid, - Invalid, - Inconclusive, } diff --git a/common/src/types/mod.rs b/common/src/types/mod.rs index 543eb3b..16c8c8f 100644 --- a/common/src/types/mod.rs +++ b/common/src/types/mod.rs @@ -1,7 +1,9 @@ pub mod assignment_state; pub mod id; pub mod interval; +pub mod result_state; pub use assignment_state::AssignmentState; pub use id::Id; pub use interval::Interval; +pub use result_state::ResultState; diff --git a/common/src/types/result_state.rs b/common/src/types/result_state.rs new file mode 100644 index 0000000..e35a76f --- /dev/null +++ b/common/src/types/result_state.rs @@ -0,0 +1,15 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "sqlx", derive(sqlx::Type))] +#[cfg_attr( + feature = "sqlx", + sqlx(type_name = "result_state", rename_all = "snake_case") +)] +pub enum ResultState { + Init, + Valid, + Invalid, + Inconclusive, + Error, +} diff --git a/server/.sqlx/query-0a85c57626456d79f6a57c607437b2e6b5a31199b622f28ca3692fe60eec53c9.json b/server/.sqlx/query-0a85c57626456d79f6a57c607437b2e6b5a31199b622f28ca3692fe60eec53c9.json index ced2773..ee9c8a1 100644 --- a/server/.sqlx/query-0a85c57626456d79f6a57c607437b2e6b5a31199b622f28ca3692fe60eec53c9.json +++ b/server/.sqlx/query-0a85c57626456d79f6a57c607437b2e6b5a31199b622f28ca3692fe60eec53c9.json @@ -39,10 +39,7 @@ "init", "canceled", "expired", - "submitted", - "valid", - "invalid", - "inconclusive" + "submitted" ] } } @@ -61,10 +58,7 @@ "init", "canceled", "expired", - "submitted", - "valid", - "invalid", - "inconclusive" + "submitted" ] } } diff --git a/server/.sqlx/query-9d2127cb05e7631e969e289ff57ffd170787045c3233daad045204b9aa53f66c.json b/server/.sqlx/query-0fbc6c31fbd780f76738a14f5e0b19dac70d34e283d16169617ad59af7f04ff5.json similarity index 52% rename from server/.sqlx/query-9d2127cb05e7631e969e289ff57ffd170787045c3233daad045204b9aa53f66c.json rename to server/.sqlx/query-0fbc6c31fbd780f76738a14f5e0b19dac70d34e283d16169617ad59af7f04ff5.json index 7dbd4cf..fd66392 100644 --- a/server/.sqlx/query-9d2127cb05e7631e969e289ff57ffd170787045c3233daad045204b9aa53f66c.json +++ b/server/.sqlx/query-0fbc6c31fbd780f76738a14f5e0b19dac70d34e283d16169617ad59af7f04ff5.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n *\n FROM\n results\n WHERE\n assignment_id = $1 IS NOT FALSE\n ", + "query": "\n SELECT\n *\n FROM\n results\n WHERE\n id = ANY($1)\n ", "describe": { "columns": [ { @@ -15,28 +15,51 @@ }, { "ordinal": 2, + "name": "state", + "type_info": { + "Custom": { + "name": "result_state", + "kind": { + "Enum": [ + "init", + "valid", + "invalid", + "inconclusive", + "error" + ] + } + } + } + }, + { + "ordinal": 3, "name": "assignment_id", "type_info": "Int8" }, { - "ordinal": 3, + "ordinal": 4, "name": "stdout", "type_info": "Text" }, { - "ordinal": 4, + "ordinal": 5, "name": "stderr", "type_info": "Text" }, { - "ordinal": 5, + "ordinal": 6, "name": "exit_code", "type_info": "Int4" + }, + { + "ordinal": 7, + "name": "group_result_id", + "type_info": "Int8" } ], "parameters": { "Left": [ - "Int8" + "Int8Array" ] }, "nullable": [ @@ -45,8 +68,10 @@ false, false, false, + false, + true, true ] }, - "hash": "9d2127cb05e7631e969e289ff57ffd170787045c3233daad045204b9aa53f66c" + "hash": "0fbc6c31fbd780f76738a14f5e0b19dac70d34e283d16169617ad59af7f04ff5" } diff --git a/server/.sqlx/query-1260e6fd3c1f30f651d1f86bf86af3351a6629a942b732ccf6740b2e683eedae.json b/server/.sqlx/query-1260e6fd3c1f30f651d1f86bf86af3351a6629a942b732ccf6740b2e683eedae.json index 4e370e3..ffa6665 100644 --- a/server/.sqlx/query-1260e6fd3c1f30f651d1f86bf86af3351a6629a942b732ccf6740b2e683eedae.json +++ b/server/.sqlx/query-1260e6fd3c1f30f651d1f86bf86af3351a6629a942b732ccf6740b2e683eedae.json @@ -39,10 +39,7 @@ "init", "canceled", "expired", - "submitted", - "valid", - "invalid", - "inconclusive" + "submitted" ] } } diff --git a/server/.sqlx/query-2daeacfdb74c4d12e2e5801d58931e6b9e29f27da54f8ed525dc71e8f3162a0a.json b/server/.sqlx/query-2daeacfdb74c4d12e2e5801d58931e6b9e29f27da54f8ed525dc71e8f3162a0a.json index 1c1d7db..3f4340e 100644 --- a/server/.sqlx/query-2daeacfdb74c4d12e2e5801d58931e6b9e29f27da54f8ed525dc71e8f3162a0a.json +++ b/server/.sqlx/query-2daeacfdb74c4d12e2e5801d58931e6b9e29f27da54f8ed525dc71e8f3162a0a.json @@ -15,23 +15,46 @@ }, { "ordinal": 2, + "name": "state", + "type_info": { + "Custom": { + "name": "result_state", + "kind": { + "Enum": [ + "init", + "valid", + "invalid", + "inconclusive", + "error" + ] + } + } + } + }, + { + "ordinal": 3, "name": "assignment_id", "type_info": "Int8" }, { - "ordinal": 3, + "ordinal": 4, "name": "stdout", "type_info": "Text" }, { - "ordinal": 4, + "ordinal": 5, "name": "stderr", "type_info": "Text" }, { - "ordinal": 5, + "ordinal": 6, "name": "exit_code", "type_info": "Int4" + }, + { + "ordinal": 7, + "name": "group_result_id", + "type_info": "Int8" } ], "parameters": { @@ -45,6 +68,8 @@ false, false, false, + false, + true, true ] }, diff --git a/server/.sqlx/query-37247bf346c477389245f83ce58c4971bd7f593a6bcec201970fe5ae9e120e7f.json b/server/.sqlx/query-37247bf346c477389245f83ce58c4971bd7f593a6bcec201970fe5ae9e120e7f.json new file mode 100644 index 0000000..8f8b328 --- /dev/null +++ b/server/.sqlx/query-37247bf346c477389245f83ce58c4971bd7f593a6bcec201970fe5ae9e120e7f.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE\n results\n SET\n state = CASE\n WHEN group_result_id = $1 THEN 'valid'::result_state\n ELSE 'invalid'::result_state\n END\n WHERE\n id = ANY($2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8Array" + ] + }, + "nullable": [] + }, + "hash": "37247bf346c477389245f83ce58c4971bd7f593a6bcec201970fe5ae9e120e7f" +} diff --git a/server/.sqlx/query-37787d0e5dbb0fd034a68efbe9eeb11432326d631d24b5ebaea76946aa913df1.json b/server/.sqlx/query-37787d0e5dbb0fd034a68efbe9eeb11432326d631d24b5ebaea76946aa913df1.json new file mode 100644 index 0000000..d3b1b7e --- /dev/null +++ b/server/.sqlx/query-37787d0e5dbb0fd034a68efbe9eeb11432326d631d24b5ebaea76946aa913df1.json @@ -0,0 +1,70 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n t.*\n FROM\n tasks t\n JOIN assignments a ON\n a.task_id = t.id\n LEFT JOIN results r ON\n r.assignment_id = a.id\n AND r.state = 'init'\n WHERE\n a.state = 'submitted'\n GROUP BY\n t.id\n HAVING\n t.project_id = $1\n AND count(a.id) >= t.assignments_needed\n AND count(r.id) > 0\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "deadline", + "type_info": "Interval" + }, + { + "ordinal": 3, + "name": "project_id", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "stdin", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "assignments_needed", + "type_info": "Int4" + }, + { + "ordinal": 6, + "name": "assignment_user_ids", + "type_info": "Int8Array" + }, + { + "ordinal": 7, + "name": "canonical_result_id", + "type_info": "Int8" + }, + { + "ordinal": 8, + "name": "quorum", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + false + ] + }, + "hash": "37787d0e5dbb0fd034a68efbe9eeb11432326d631d24b5ebaea76946aa913df1" +} diff --git a/server/.sqlx/query-4f28c9855a87500c39fc4e88308b16b87bf12743e25be5d1707c07edd8d94dfd.json b/server/.sqlx/query-4f28c9855a87500c39fc4e88308b16b87bf12743e25be5d1707c07edd8d94dfd.json index b33cd31..a86be38 100644 --- a/server/.sqlx/query-4f28c9855a87500c39fc4e88308b16b87bf12743e25be5d1707c07edd8d94dfd.json +++ b/server/.sqlx/query-4f28c9855a87500c39fc4e88308b16b87bf12743e25be5d1707c07edd8d94dfd.json @@ -37,6 +37,16 @@ "ordinal": 6, "name": "assignment_user_ids", "type_info": "Int8Array" + }, + { + "ordinal": 7, + "name": "canonical_result_id", + "type_info": "Int8" + }, + { + "ordinal": 8, + "name": "quorum", + "type_info": "Int4" } ], "parameters": { @@ -51,6 +61,8 @@ false, false, false, + false, + true, false ] }, diff --git a/server/.sqlx/query-5008175ade98bc9947127cbdfb3cfd0e1d578432b0ec081732c79d49d843abfe.json b/server/.sqlx/query-5008175ade98bc9947127cbdfb3cfd0e1d578432b0ec081732c79d49d843abfe.json new file mode 100644 index 0000000..d052858 --- /dev/null +++ b/server/.sqlx/query-5008175ade98bc9947127cbdfb3cfd0e1d578432b0ec081732c79d49d843abfe.json @@ -0,0 +1,79 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n r.*\n FROM\n results r\n JOIN assignments a ON\n a.id = r.assignment_id\n WHERE\n a.task_id = $1\n AND r.id < $2\n AND r.id != ALL($3)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "state", + "type_info": { + "Custom": { + "name": "result_state", + "kind": { + "Enum": [ + "init", + "valid", + "invalid", + "inconclusive", + "error" + ] + } + } + } + }, + { + "ordinal": 3, + "name": "assignment_id", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "stdout", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "stderr", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "exit_code", + "type_info": "Int4" + }, + { + "ordinal": 7, + "name": "group_result_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Int8Array" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true + ] + }, + "hash": "5008175ade98bc9947127cbdfb3cfd0e1d578432b0ec081732c79d49d843abfe" +} diff --git a/server/.sqlx/query-758af5d6a249187a308a1746d22636256031600e6a52fc94e57880b40e306b2b.json b/server/.sqlx/query-758af5d6a249187a308a1746d22636256031600e6a52fc94e57880b40e306b2b.json new file mode 100644 index 0000000..4facc00 --- /dev/null +++ b/server/.sqlx/query-758af5d6a249187a308a1746d22636256031600e6a52fc94e57880b40e306b2b.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE\n results\n SET\n state = 'inconclusive'\n WHERE\n id = ANY($1)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [] + }, + "hash": "758af5d6a249187a308a1746d22636256031600e6a52fc94e57880b40e306b2b" +} diff --git a/server/.sqlx/query-8660893ff85be731039fb2402bf7c47896571f1b0b9b844e84e298f87e3ed09b.json b/server/.sqlx/query-8660893ff85be731039fb2402bf7c47896571f1b0b9b844e84e298f87e3ed09b.json index 73ddf24..d190667 100644 --- a/server/.sqlx/query-8660893ff85be731039fb2402bf7c47896571f1b0b9b844e84e298f87e3ed09b.json +++ b/server/.sqlx/query-8660893ff85be731039fb2402bf7c47896571f1b0b9b844e84e298f87e3ed09b.json @@ -37,6 +37,16 @@ "ordinal": 6, "name": "assignment_user_ids", "type_info": "Int8Array" + }, + { + "ordinal": 7, + "name": "canonical_result_id", + "type_info": "Int8" + }, + { + "ordinal": 8, + "name": "quorum", + "type_info": "Int4" } ], "parameters": { @@ -53,6 +63,8 @@ false, false, false, + false, + true, false ] }, diff --git a/server/.sqlx/query-0d6d91c4e0acb78e4fda4df1804d430966e8ae83a168de0d3444bb8a4c7b1051.json b/server/.sqlx/query-ab022e8774fc4cef4434e9ba30b902f2e0e01a252ef1aa79e45da16db3a15fad.json similarity index 71% rename from server/.sqlx/query-0d6d91c4e0acb78e4fda4df1804d430966e8ae83a168de0d3444bb8a4c7b1051.json rename to server/.sqlx/query-ab022e8774fc4cef4434e9ba30b902f2e0e01a252ef1aa79e45da16db3a15fad.json index 8117500..73478a5 100644 --- a/server/.sqlx/query-0d6d91c4e0acb78e4fda4df1804d430966e8ae83a168de0d3444bb8a4c7b1051.json +++ b/server/.sqlx/query-ab022e8774fc4cef4434e9ba30b902f2e0e01a252ef1aa79e45da16db3a15fad.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n *\n FROM\n tasks\n WHERE\n project_id = $1 IS NOT FALSE\n ", + "query": "\n SELECT\n *\n FROM\n tasks\n WHERE\n project_id = $1 IS NOT FALSE\n AND (canonical_result_id = $2 OR $2 IS NULL)\n ", "describe": { "columns": [ { @@ -37,10 +37,21 @@ "ordinal": 6, "name": "assignment_user_ids", "type_info": "Int8Array" + }, + { + "ordinal": 7, + "name": "canonical_result_id", + "type_info": "Int8" + }, + { + "ordinal": 8, + "name": "quorum", + "type_info": "Int4" } ], "parameters": { "Left": [ + "Int8", "Int8" ] }, @@ -51,8 +62,10 @@ false, false, false, + false, + true, false ] }, - "hash": "0d6d91c4e0acb78e4fda4df1804d430966e8ae83a168de0d3444bb8a4c7b1051" + "hash": "ab022e8774fc4cef4434e9ba30b902f2e0e01a252ef1aa79e45da16db3a15fad" } diff --git a/server/.sqlx/query-bf6f3f4b26b32f1f446cf0fd67c462cd94a548cd83fbc39fb826ced7c0bce9f1.json b/server/.sqlx/query-bf6f3f4b26b32f1f446cf0fd67c462cd94a548cd83fbc39fb826ced7c0bce9f1.json new file mode 100644 index 0000000..f98d975 --- /dev/null +++ b/server/.sqlx/query-bf6f3f4b26b32f1f446cf0fd67c462cd94a548cd83fbc39fb826ced7c0bce9f1.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE \n results\n SET \n state = $1\n WHERE\n id = ANY($2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + { + "Custom": { + "name": "result_state", + "kind": { + "Enum": [ + "init", + "valid", + "invalid", + "inconclusive", + "error" + ] + } + } + }, + "Int8Array" + ] + }, + "nullable": [] + }, + "hash": "bf6f3f4b26b32f1f446cf0fd67c462cd94a548cd83fbc39fb826ced7c0bce9f1" +} diff --git a/server/.sqlx/query-cd5ffd3d5d60bb7c7072db520c4d1c9875f8443a0982e81d1538161656074183.json b/server/.sqlx/query-cd5ffd3d5d60bb7c7072db520c4d1c9875f8443a0982e81d1538161656074183.json new file mode 100644 index 0000000..9ea0108 --- /dev/null +++ b/server/.sqlx/query-cd5ffd3d5d60bb7c7072db520c4d1c9875f8443a0982e81d1538161656074183.json @@ -0,0 +1,70 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n t.*\n FROM\n tasks t\n JOIN assignments a ON\n a.task_id = t.id\n WHERE\n a.id = ANY($1)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "deadline", + "type_info": "Interval" + }, + { + "ordinal": 3, + "name": "project_id", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "stdin", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "assignments_needed", + "type_info": "Int4" + }, + { + "ordinal": 6, + "name": "assignment_user_ids", + "type_info": "Int8Array" + }, + { + "ordinal": 7, + "name": "canonical_result_id", + "type_info": "Int8" + }, + { + "ordinal": 8, + "name": "quorum", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + false + ] + }, + "hash": "cd5ffd3d5d60bb7c7072db520c4d1c9875f8443a0982e81d1538161656074183" +} diff --git a/server/.sqlx/query-ce2921487afc54738ea394ab248c5602d215db4e5cf61484cf7b80c84b0cfe5c.json b/server/.sqlx/query-ce2921487afc54738ea394ab248c5602d215db4e5cf61484cf7b80c84b0cfe5c.json index c49c6dc..e351a95 100644 --- a/server/.sqlx/query-ce2921487afc54738ea394ab248c5602d215db4e5cf61484cf7b80c84b0cfe5c.json +++ b/server/.sqlx/query-ce2921487afc54738ea394ab248c5602d215db4e5cf61484cf7b80c84b0cfe5c.json @@ -39,10 +39,7 @@ "init", "canceled", "expired", - "submitted", - "valid", - "invalid", - "inconclusive" + "submitted" ] } } diff --git a/server/.sqlx/query-d3f83d3bf9b010cdf4a5c8c65b2faf7ba6f659832bd727aa8faa69fe761254d1.json b/server/.sqlx/query-d3f83d3bf9b010cdf4a5c8c65b2faf7ba6f659832bd727aa8faa69fe761254d1.json index 67cb28d..78957cc 100644 --- a/server/.sqlx/query-d3f83d3bf9b010cdf4a5c8c65b2faf7ba6f659832bd727aa8faa69fe761254d1.json +++ b/server/.sqlx/query-d3f83d3bf9b010cdf4a5c8c65b2faf7ba6f659832bd727aa8faa69fe761254d1.json @@ -13,10 +13,7 @@ "init", "canceled", "expired", - "submitted", - "valid", - "invalid", - "inconclusive" + "submitted" ] } } diff --git a/server/.sqlx/query-d87fa0f565013412bab63b399f7dd435b8f6ef317eec2981cf24b97d9a1394ed.json b/server/.sqlx/query-d87fa0f565013412bab63b399f7dd435b8f6ef317eec2981cf24b97d9a1394ed.json new file mode 100644 index 0000000..b16c3f7 --- /dev/null +++ b/server/.sqlx/query-d87fa0f565013412bab63b399f7dd435b8f6ef317eec2981cf24b97d9a1394ed.json @@ -0,0 +1,92 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n *\n FROM\n results\n WHERE\n assignment_id = $1 IS NOT FALSE\n AND (group_result_id = $2 OR $2 IS NULL)\n AND state = $3 IS NOT FALSE\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "state", + "type_info": { + "Custom": { + "name": "result_state", + "kind": { + "Enum": [ + "init", + "valid", + "invalid", + "inconclusive", + "error" + ] + } + } + } + }, + { + "ordinal": 3, + "name": "assignment_id", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "stdout", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "stderr", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "exit_code", + "type_info": "Int4" + }, + { + "ordinal": 7, + "name": "group_result_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8", + { + "Custom": { + "name": "result_state", + "kind": { + "Enum": [ + "init", + "valid", + "invalid", + "inconclusive", + "error" + ] + } + } + } + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true + ] + }, + "hash": "d87fa0f565013412bab63b399f7dd435b8f6ef317eec2981cf24b97d9a1394ed" +} diff --git a/server/.sqlx/query-dd2ccc411586e044836d4f5c9d3dad562d8eecde82db258dc183868b211f4410.json b/server/.sqlx/query-dd2ccc411586e044836d4f5c9d3dad562d8eecde82db258dc183868b211f4410.json new file mode 100644 index 0000000..4a4102a --- /dev/null +++ b/server/.sqlx/query-dd2ccc411586e044836d4f5c9d3dad562d8eecde82db258dc183868b211f4410.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE\n results\n SET\n group_result_id = $1\n WHERE\n id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "dd2ccc411586e044836d4f5c9d3dad562d8eecde82db258dc183868b211f4410" +} diff --git a/server/.sqlx/query-f0f7b39962dd47d3a523564bbe52e6fd3fabcc164935906cd974dabd677ec14f.json b/server/.sqlx/query-f0f7b39962dd47d3a523564bbe52e6fd3fabcc164935906cd974dabd677ec14f.json new file mode 100644 index 0000000..5b8d773 --- /dev/null +++ b/server/.sqlx/query-f0f7b39962dd47d3a523564bbe52e6fd3fabcc164935906cd974dabd677ec14f.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE\n tasks\n SET\n assignments_needed = $1\n WHERE\n id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int4", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "f0f7b39962dd47d3a523564bbe52e6fd3fabcc164935906cd974dabd677ec14f" +} diff --git a/server/.sqlx/query-f7b43273fca1553daea58cb93f9b829c0a9d46d6dc4aa18e76f9fd309e207d54.json b/server/.sqlx/query-f7b43273fca1553daea58cb93f9b829c0a9d46d6dc4aa18e76f9fd309e207d54.json new file mode 100644 index 0000000..6cdadc0 --- /dev/null +++ b/server/.sqlx/query-f7b43273fca1553daea58cb93f9b829c0a9d46d6dc4aa18e76f9fd309e207d54.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n *\n FROM\n projects\n WHERE\n id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "disabled_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "name", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + true, + false + ] + }, + "hash": "f7b43273fca1553daea58cb93f9b829c0a9d46d6dc4aa18e76f9fd309e207d54" +} diff --git a/server/migrations/20250426220809_init.sql b/server/migrations/20250426220809_init.sql index 7088a1a..2ef73e5 100644 --- a/server/migrations/20250426220809_init.sql +++ b/server/migrations/20250426220809_init.sql @@ -38,17 +38,16 @@ CREATE TABLE tasks ( project_id int8 NOT NULL REFERENCES projects(id) ON DELETE RESTRICT ON UPDATE RESTRICT, stdin text NOT NULL, assignments_needed int4 NOT NULL, - assignment_user_ids int8[] NOT NULL DEFAULT ARRAY[]::int8[] + assignment_user_ids int8[] NOT NULL DEFAULT ARRAY[]::int8[], + canonical_result_id int8, + quorum int4 NOT NULL ); CREATE TYPE assignment_state AS ENUM ( - 'init', - 'canceled', + 'init', + 'canceled', 'expired', - 'submitted', - 'valid', - 'invalid', - 'inconclusive' + 'submitted' ); CREATE TABLE assignments ( @@ -64,15 +63,28 @@ CREATE UNIQUE INDEX assignments_task_id_user_id_key ON assignments (task_id, user_id) WHERE state != 'canceled' AND state != 'expired'; +CREATE TYPE result_state AS ENUM ( + 'init', + 'valid', + 'invalid', + 'inconclusive', + 'error' +); + CREATE TABLE results ( id int8 GENERATED ALWAYS AS IDENTITY NOT NULL PRIMARY KEY, created_at timestamptz NOT NULL DEFAULT now(), + state result_state NOT NULL DEFAULT 'init', assignment_id int8 NOT NULL UNIQUE REFERENCES assignments(id) ON DELETE RESTRICT ON UPDATE RESTRICT, stdout text NOT NULL, stderr text NOT NULL, - exit_code int4 + exit_code int4, + group_result_id int8 REFERENCES results(id) ON DELETE RESTRICT ON UPDATE RESTRICT ); +ALTER TABLE tasks +ADD FOREIGN KEY (canonical_result_id) REFERENCES results(id) ON DELETE RESTRICT ON UPDATE RESTRICT; + CREATE FUNCTION trigger_function_tasks_remove_assignment_user_id() RETURNS TRIGGER AS $$ BEGIN diff --git a/server/src/main.rs b/server/src/main.rs index 91368df..1aa9526 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -13,6 +13,7 @@ use axum::{ use clusterizer_common::records::{ Assignment, Platform, Project, ProjectVersion, Result, Task, User, }; + use routes::{get_all, get_one}; use sqlx::PgPool; use state::AppState; @@ -57,6 +58,8 @@ async fn serve_task(state: AppState, address: String) { .route("/register", post(routes::register)) .route("/fetch_tasks", post(routes::fetch_tasks)) .route("/submit_result/{id}", post(routes::submit_result)) + .route("/validate_fetch/{id}", get(routes::validate_fetch)) + .route("/validate_submit", post(routes::validate_submit)) .layer(TraceLayer::new_for_http()) .with_state(state); diff --git a/server/src/result/status.rs b/server/src/result/status.rs index d41991c..15b5baa 100644 --- a/server/src/result/status.rs +++ b/server/src/result/status.rs @@ -1,6 +1,7 @@ use axum::http::StatusCode; use clusterizer_common::errors::{ - FetchTasksError, Infallible, NotFound, RegisterError, SubmitResultError, + FetchTasksError, Infallible, NotFound, RegisterError, SubmitResultError, ValidateFetchError, + ValidateSubmitError, }; pub trait Status { @@ -36,3 +37,15 @@ impl Status for SubmitResultError { StatusCode::BAD_REQUEST } } + +impl Status for ValidateSubmitError { + fn status(&self) -> StatusCode { + StatusCode::BAD_REQUEST + } +} + +impl Status for ValidateFetchError { + fn status(&self) -> StatusCode { + StatusCode::BAD_REQUEST + } +} diff --git a/server/src/routes/fetch_tasks.rs b/server/src/routes/fetch_tasks.rs index d75b28f..03d346e 100644 --- a/server/src/routes/fetch_tasks.rs +++ b/server/src/routes/fetch_tasks.rs @@ -28,7 +28,7 @@ pub async fn fetch_tasks( WHERE id = ANY($1) "#, - request.project_ids + request.project_ids, ) .fetch_all(&mut *tx) .await?; diff --git a/server/src/routes/mod.rs b/server/src/routes/mod.rs index 511e0bc..2bdf9e2 100644 --- a/server/src/routes/mod.rs +++ b/server/src/routes/mod.rs @@ -16,10 +16,14 @@ use crate::{ pub mod fetch_tasks; pub mod register; pub mod submit_result; +pub mod validate_fetch; +pub mod validate_submit; pub use fetch_tasks::fetch_tasks; pub use register::register; pub use submit_result::submit_result; +pub use validate_fetch::validate_fetch; +pub use validate_submit::validate_submit; pub async fn get_all( State(state): State, diff --git a/server/src/routes/validate_fetch.rs b/server/src/routes/validate_fetch.rs new file mode 100644 index 0000000..dd99c0a --- /dev/null +++ b/server/src/routes/validate_fetch.rs @@ -0,0 +1,63 @@ +use axum::{ + Json, + extract::{Path, State}, +}; +use clusterizer_common::{ + errors::ValidateFetchError, + records::{Project, Task}, + types::Id, +}; + +use crate::{ + result::{AppResult, ResultExt}, + state::AppState, +}; + +pub async fn validate_fetch( + State(state): State, + Path(project_id): Path>, +) -> AppResult>, ValidateFetchError> { + let project = sqlx::query_as_unchecked!( + Project, + r#" + SELECT + * + FROM + projects + WHERE + id = $1 + "#, + project_id, + ) + .fetch_one(&state.pool) + .await + .map_not_found(ValidateFetchError::InvalidProject)?; + + let tasks = sqlx::query_as_unchecked!( + Task, + r#" + SELECT + t.* + FROM + tasks t + JOIN assignments a ON + a.task_id = t.id + LEFT JOIN results r ON + r.assignment_id = a.id + AND r.state = 'init' + WHERE + a.state = 'submitted' + GROUP BY + t.id + HAVING + t.project_id = $1 + AND count(a.id) >= t.assignments_needed + AND count(r.id) > 0 + "#, + project.id, + ) + .fetch_all(&state.pool) + .await?; + + Ok(Json(tasks)) +} diff --git a/server/src/routes/validate_submit.rs b/server/src/routes/validate_submit.rs new file mode 100644 index 0000000..bf9d260 --- /dev/null +++ b/server/src/routes/validate_submit.rs @@ -0,0 +1,248 @@ +use axum::{Json, extract::State}; +use clusterizer_common::{ + errors::ValidateSubmitError, + records::{Result, Task}, + requests::ValidateSubmitRequest, + types::ResultState, +}; + +use std::collections::HashMap; + +use crate::{ + result::{AppError, AppResult}, + state::AppState, + util::set_result_state, +}; + +pub async fn validate_submit( + State(state): State, + Json(request): Json, +) -> AppResult<(), ValidateSubmitError> { + // Fetch results from the request. + let result_ids: Vec<_> = request.results.keys().collect(); + + let results = sqlx::query_as_unchecked!( + Result, + r#" + SELECT + * + FROM + results + WHERE + id = ANY($1) + "#, + result_ids + ) + .fetch_all(&state.pool) + .await?; + + // Check all result ids were valid. + if results.len() != request.results.len() { + Err(AppError::Specific(ValidateSubmitError::InvalidResult))? + } + + // Check all results have the 'init' state. + if results + .iter() + .any(|result| result.state != ResultState::Init) + { + Err(AppError::Specific( + ValidateSubmitError::ForbiddenStateTransition, + ))? + } + + // Fetch tasks for the results we are going to validate. + let assignment_ids: Vec<_> = results.iter().map(|result| result.assignment_id).collect(); + + let tasks = sqlx::query_as_unchecked!( + Task, + r#" + SELECT + t.* + FROM + tasks t + JOIN assignments a ON + a.task_id = t.id + WHERE + a.id = ANY($1) + "#, + assignment_ids, + ) + .fetch_all(&state.pool) + .await?; + + // Can only validate one task at a time, for now. + if tasks.len() != 1 { + Err(AppError::Specific(ValidateSubmitError::InvalidTaskCount))? + } + + let task = &tasks[0]; + + // Fetch the remaining results for this task. This ignores results whose id exceeds the largest + // id from the validation request, because the validator program also did not consider them. + let last_result_id = request + .results + .keys() + .max() + .expect("results cannot be empty"); + + let previous_results = sqlx::query_as_unchecked!( + Result, + r#" + SELECT + r.* + FROM + results r + JOIN assignments a ON + a.id = r.assignment_id + WHERE + a.task_id = $1 + AND r.id < $2 + AND r.id != ALL($3) + "#, + task.id, + last_result_id, + result_ids, + ) + .fetch_all(&state.pool) + .await?; + + // Check the validator didn't miss any tasks. This is needed for deterministic validation. + if previous_results + .iter() + .any(|result| result.state == ResultState::Init) + { + Err(AppError::Specific(ValidateSubmitError::MissingResults))? + } + + // Build groups and errored results. + let mut groups: HashMap<_, Vec<_>> = HashMap::new(); + let mut error_result_ids = Vec::new(); + + for result in &previous_results { + if let Some(group_id) = result.group_result_id { + groups.entry(group_id).or_default().push(result.id); + } + } + + for (&result_id, &group_id) in &request.results { + if let Some(group_id) = group_id { + groups.entry(group_id).or_default().push(result_id); + } else { + error_result_ids.push(result_id); + } + } + + // Check that each group id is the lowest of any result ids in the group. + for (group_id, result_ids) in &groups { + if group_id != result_ids.iter().min().expect("group cannot be empty") { + Err(AppError::Specific(ValidateSubmitError::InconsistentGroup))? + } + } + + // Update state of error results. + set_result_state(&error_result_ids, ResultState::Error) + .execute(&state.pool) + .await?; + + // Update group ids. + for (&result_id, &group_id) in &request.results { + if let Some(group_id) = group_id { + sqlx::query_unchecked!( + r#" + UPDATE + results + SET + group_result_id = $1 + WHERE + id = $2 + "#, + group_id, + result_id, + ) + .execute(&state.pool) + .await?; + } + } + + // Find the id of a group that meets quorum, if any. When multiple groups meet quorum, we + // select the one with the lowest id instead of the largest group. This is needed for + // deterministic validation. + let valid_group_id = groups + .iter() + .filter(|(_, results)| results.len() as i32 >= task.quorum) + .map(|(&group_id, _)| group_id) + .min(); + + if let Some(valid_group_id) = valid_group_id { + // If there was a valid group, update the state of all results. + let group_result_ids: Vec<_> = groups.values().flatten().collect(); + + sqlx::query_unchecked!( + r#" + UPDATE + results + SET + state = CASE + WHEN group_result_id = $1 THEN 'valid'::result_state + ELSE 'invalid'::result_state + END + WHERE + id = ANY($2) + "#, + valid_group_id, + group_result_ids, + ) + .execute(&state.pool) + .await?; + } else { + // Otherwise, update the state of the new results to 'inconclusive'. + let inconclusive_result_ids: Vec<_> = request + .results + .iter() + .filter(|(_, group_id)| group_id.is_some()) + .map(|(result_id, _)| result_id) + .collect(); + + sqlx::query_unchecked!( + r#" + UPDATE + results + SET + state = 'inconclusive' + WHERE + id = ANY($1) + "#, + inconclusive_result_ids, + ) + .execute(&state.pool) + .await?; + + // Finally, update the number of assignments needed. + let largest_inconclusive_group = groups + .values() + .max_by_key(|results| results.len()) + .expect("there is at least one group"); + + let assignments_needed = (results.len() + previous_results.len() + - largest_inconclusive_group.len()) as i32 + + task.quorum; + + sqlx::query_unchecked!( + r#" + UPDATE + tasks + SET + assignments_needed = $1 + WHERE + id = $2 + "#, + assignments_needed, + task.id, + ) + .execute(&state.pool) + .await?; + } + + Ok(()) +} diff --git a/server/src/util/mod.rs b/server/src/util/mod.rs index b1c2080..4c2d0bb 100644 --- a/server/src/util/mod.rs +++ b/server/src/util/mod.rs @@ -6,10 +6,12 @@ use sqlx::{ pub mod assignment_deadline; pub mod select; pub mod set_assignment_state; +pub mod set_result_state; pub use assignment_deadline::update_expired_assignments; pub use select::Select; pub use set_assignment_state::set_assignment_state; +pub use set_result_state::set_result_state; type Query = sqlx::query::Query<'static, Postgres, PgArguments>; type QueryScalar = sqlx::query::QueryScalar<'static, Postgres, T, PgArguments>; diff --git a/server/src/util/select.rs b/server/src/util/select.rs index 755692c..43a500b 100644 --- a/server/src/util/select.rs +++ b/server/src/util/select.rs @@ -122,8 +122,10 @@ impl Select for Task { tasks WHERE project_id = $1 IS NOT FALSE + AND (canonical_result_id = $2 OR $2 IS NULL) "#, filter.project_id, + filter.canonical_result_id, ) } @@ -172,8 +174,12 @@ impl Select for Result { results WHERE assignment_id = $1 IS NOT FALSE + AND (group_result_id = $2 OR $2 IS NULL) + AND state = $3 IS NOT FALSE "#, filter.assignment_id, + filter.group_result_id, + filter.state, ) } diff --git a/server/src/util/set_assignment_state.rs b/server/src/util/set_assignment_state.rs index aeb1c3f..54d891c 100644 --- a/server/src/util/set_assignment_state.rs +++ b/server/src/util/set_assignment_state.rs @@ -19,6 +19,6 @@ pub fn set_assignment_state( id = ANY($2) "#, assignment_state, - assignment_ids + assignment_ids, ) } diff --git a/server/src/util/set_result_state.rs b/server/src/util/set_result_state.rs new file mode 100644 index 0000000..75cdf49 --- /dev/null +++ b/server/src/util/set_result_state.rs @@ -0,0 +1,21 @@ +use clusterizer_common::{ + records::Result, + types::{Id, ResultState}, +}; + +use super::Query; + +pub fn set_result_state(result_ids: &[Id], result_state: ResultState) -> Query { + sqlx::query_unchecked!( + r#" + UPDATE + results + SET + state = $1 + WHERE + id = ANY($2) + "#, + result_state, + result_ids, + ) +}