From 11167d8424578d2e654f8245e017510dfc100b20 Mon Sep 17 00:00:00 2001 From: Gregor Eesmaa Date: Sun, 26 Aug 2018 15:26:57 +0000 Subject: [PATCH 1/8] Added compilation/evaluation rerun and archival --- cms/db/submission.py | 18 ++++++- cms/grading/Job.py | 33 +++++++++--- cms/grading/Sandbox.py | 24 ++++++++- cms/grading/tasktypes/Batch.py | 4 +- cms/grading/tasktypes/Communication.py | 6 +-- cms/grading/tasktypes/TwoSteps.py | 6 +-- cms/grading/tasktypes/util.py | 10 +++- cms/server/admin/templates/submission.html | 60 +++++++++++++++++++++- cms/service/EvaluationService.py | 35 ++++++++----- cms/service/esoperations.py | 38 +++++++++----- 10 files changed, 187 insertions(+), 47 deletions(-) diff --git a/cms/db/submission.py b/cms/db/submission.py index 6dacba9b7f..78315a24c1 100644 --- a/cms/db/submission.py +++ b/cms/db/submission.py @@ -365,6 +365,10 @@ class SubmissionResult(Base): compilation_sandbox: str | None = Column( Unicode, nullable=True) + compilation_sandbox_digests = Column( + ARRAY(String), + nullable=True + ) # Evaluation outcome (can be None = yet to evaluate, "ok" = # evaluation successful). At any time, this should be equal to @@ -594,16 +598,22 @@ def invalidate_compilation(self): self.compilation_memory = None self.compilation_shard = None self.compilation_sandbox = None + self.compilation_sandbox_digests = [] self.executables = {} - def invalidate_evaluation(self): + def invalidate_evaluation(self, testcase_id: int | None = None): """Blank the evaluation outcomes and the score. + testcase_id: ID of testcase to invalidate, or None to invalidate all. + """ self.invalidate_score() self.evaluation_outcome = None self.evaluation_tries = 0 - self.evaluations = [] + if testcase_id: + self.evaluations = [e for e in self.evaluations if e.testcase_id != testcase_id] + else: + self.evaluations = [] def invalidate_score(self): """Blank the score. @@ -777,6 +787,10 @@ class Evaluation(Base): evaluation_sandbox: str | None = Column( Unicode, nullable=True) + evaluation_sandbox_digests = Column( + ARRAY(String), + nullable=True + ) @property def codename(self) -> str: diff --git a/cms/grading/Job.py b/cms/grading/Job.py index 0e051ed3d6..646f21b3b6 100644 --- a/cms/grading/Job.py +++ b/cms/grading/Job.py @@ -85,9 +85,11 @@ def __init__( task_type_parameters: object = None, language: str | None = None, multithreaded_sandbox: bool = False, + archive_sandbox: bool = False, shard: int | None = None, keep_sandbox: bool = False, sandboxes: list[str] | None = None, + sandbox_digests: list[str] | None = None, info: str | None = None, success: bool | None = None, text: list[str] | None = None, @@ -104,12 +106,15 @@ def __init__( language: the language of the submission / user test. multithreaded_sandbox: whether the sandbox should allow multithreading. + archive_sandbox: whether the sandbox is to be archived. shard: the shard of the Worker completing this job. keep_sandbox: whether to forcefully keep the sandbox, even if other conditions (the config, the sandbox status) don't warrant it. sandboxes: the paths of the sandboxes used in the Worker during the execution of the job. + sandbox_digests: the digests of the sandbox + archives used to debug user solutions. info: a human readable description of the job. success: whether the job succeeded. text: description of the outcome of the job, @@ -125,6 +130,8 @@ def __init__( task_type = "" if sandboxes is None: sandboxes = [] + if sandbox_digests is None: + sandbox_digests = [] if info is None: info = "" if files is None: @@ -139,9 +146,11 @@ def __init__( self.task_type_parameters = task_type_parameters self.language = language self.multithreaded_sandbox = multithreaded_sandbox + self.archive_sandbox = archive_sandbox self.shard = shard self.keep_sandbox = keep_sandbox self.sandboxes = sandboxes + self.sandbox_digests = sandbox_digests self.info = info self.success = success @@ -161,9 +170,11 @@ def export_to_dict(self) -> dict: 'task_type_parameters': self.task_type_parameters, 'language': self.language, 'multithreaded_sandbox': self.multithreaded_sandbox, + 'archive_sandbox': self.archive_sandbox, 'shard': self.shard, 'keep_sandbox': self.keep_sandbox, 'sandboxes': self.sandboxes, + 'sandbox_digests': self.sandbox_digests, 'info': self.info, 'success': self.success, 'text': self.text, @@ -274,9 +285,11 @@ def __init__( shard: int | None = None, keep_sandbox: bool = False, sandboxes: list[str] | None = None, + sandbox_digests: list[str] | None = None, info: str | None = None, language: str | None = None, multithreaded_sandbox: bool = False, + archive_sandbox: bool = False, files: dict[str, File] | None = None, managers: dict[str, Manager] | None = None, success: bool | None = None, @@ -296,9 +309,9 @@ def __init__( """ Job.__init__(self, operation, task_type, task_type_parameters, - language, multithreaded_sandbox, - shard, keep_sandbox, sandboxes, info, success, text, - files, managers, executables) + language, multithreaded_sandbox, archive_sandbox, + shard, keep_sandbox, sandboxes, sandbox_digests, info, success, + text, files, managers, executables) self.compilation_success = compilation_success self.plus = plus @@ -341,6 +354,7 @@ def from_submission( task_type_parameters=dataset.task_type_parameters, language=submission.language, multithreaded_sandbox=multithreaded, + archive_sandbox=operation.archive_sandbox, files=dict(submission.files), managers=dict(dataset.managers), info="compile submission %d" % (submission.id) @@ -368,6 +382,7 @@ def to_submission(self, sr: SubmissionResult): sr.compilation_memory = self.plus.get('execution_memory') sr.compilation_shard = self.shard sr.compilation_sandbox = ":".join(self.sandboxes) + sr.compilation_sandbox_digests = self.sandbox_digests for executable in self.executables.values(): sr.executables.set(executable) @@ -431,6 +446,7 @@ def from_user_test( task_type_parameters=dataset.task_type_parameters, language=user_test.language, multithreaded_sandbox=multithreaded, + archive_sandbox=operation.archive_sandbox, files=dict(user_test.files), managers=managers, info="compile user test %d" % (user_test.id) @@ -485,9 +501,11 @@ def __init__( shard: int | None = None, keep_sandbox: bool = False, sandboxes: list[str] | None = None, + sandbox_digests: list[str] | None = None, info: str | None = None, language: str | None = None, multithreaded_sandbox: bool = False, + archive_sandbox: bool = False, files: dict[str, File] | None = None, managers: dict[str, Manager] | None = None, executables: dict[str, Executable] | None = None, @@ -526,9 +544,9 @@ def __init__( """ Job.__init__(self, operation, task_type, task_type_parameters, - language, multithreaded_sandbox, - shard, keep_sandbox, sandboxes, info, success, text, - files, managers, executables) + language, multithreaded_sandbox, archive_sandbox, + shard, keep_sandbox, sandboxes, sandbox_digests, info, success, + text, files, managers, executables) self.input = input self.output = output self.time_limit = time_limit @@ -592,6 +610,7 @@ def from_submission( task_type_parameters=dataset.task_type_parameters, language=submission.language, multithreaded_sandbox=multithreaded, + archive_sandbox=operation.archive_sandbox, files=dict(submission.files), managers=dict(dataset.managers), executables=dict(submission_result.executables), @@ -620,6 +639,7 @@ def to_submission(self, sr: SubmissionResult): execution_memory=self.plus.get('execution_memory'), evaluation_shard=self.shard, evaluation_sandbox=":".join(self.sandboxes), + evaluation_sandbox_digests=self.sandbox_digests, testcase=sr.dataset.testcases[self.operation.testcase_codename])] @staticmethod @@ -674,6 +694,7 @@ def from_user_test( task_type_parameters=dataset.task_type_parameters, language=user_test.language, multithreaded_sandbox=multithreaded, + archive_sandbox=operation.archive_sandbox, files=dict(user_test.files), managers=managers, executables=dict(user_test_result.executables), diff --git a/cms/grading/Sandbox.py b/cms/grading/Sandbox.py index 24aa619467..a02754df0c 100644 --- a/cms/grading/Sandbox.py +++ b/cms/grading/Sandbox.py @@ -27,6 +27,7 @@ import stat import tempfile import time +import zipfile from abc import ABCMeta, abstractmethod from functools import wraps, partial import typing @@ -532,10 +533,31 @@ def cleanup(self, delete: bool = False): delete: if True, also delete get_root_path() and everything it contains. - """ pass + def archive(self): + """Archive the directory where the sandbox operated. + + """ + logger.info("Archiving sandbox in %s.", self.get_root_path()) + + # Archive the working directory + sandbox_archive_filename = "sandbox.zip" + sandbox_archive = self.relative_path(sandbox_archive_filename) + content_path = self.get_root_path() + with zipfile.ZipFile(sandbox_archive, "w") as zip_file: + for root, dirs, files in os.walk(content_path): + if sandbox_archive_filename in files: + files.remove(sandbox_archive_filename) + zip_file.write(root, os.path.relpath(root, content_path)) + for f in files: + f_path = os.path.join(root, f) + zip_file.write(f_path, os.path.relpath(f_path, content_path)) + + # Put archive to FS + return self.get_file_to_storage(sandbox_archive, "Sandbox %s" % self.get_root_path()) + class StupidSandbox(SandboxBase): """A stupid sandbox implementation. It has very few features and diff --git a/cms/grading/tasktypes/Batch.py b/cms/grading/tasktypes/Batch.py index 464aa3dc08..11e93d755f 100644 --- a/cms/grading/tasktypes/Batch.py +++ b/cms/grading/tasktypes/Batch.py @@ -257,7 +257,7 @@ def _do_compile(self, job, file_cacher): Executable(executable_filename, digest) # Cleanup. - delete_sandbox(sandbox, job.success, job.keep_sandbox) + delete_sandbox(sandbox, job, job.success, job.keep_sandbox) def compile(self, job, file_cacher): """See TaskType.compile.""" @@ -380,7 +380,7 @@ def _evaluate_step(self, job, file_cacher, output_file_params, outcome, text, st job.plus = stats if sandbox is not None: - delete_sandbox(sandbox, job.success, job.keep_sandbox) + delete_sandbox(sandbox, job, job.success, job.keep_sandbox) def evaluate(self, job, file_cacher): """See TaskType.evaluate.""" diff --git a/cms/grading/tasktypes/Communication.py b/cms/grading/tasktypes/Communication.py index 441aef66eb..73372c8f9a 100644 --- a/cms/grading/tasktypes/Communication.py +++ b/cms/grading/tasktypes/Communication.py @@ -242,7 +242,7 @@ def compile(self, job, file_cacher): Executable(executable_filename, digest) # Cleanup. - delete_sandbox(sandbox, job.success, job.keep_sandbox) + delete_sandbox(sandbox, job, job.success, job.keep_sandbox) def evaluate(self, job, file_cacher): """See TaskType.evaluate.""" @@ -434,9 +434,9 @@ def evaluate(self, job, file_cacher): job.text = text job.plus = stats_user - delete_sandbox(sandbox_mgr, job.success, job.keep_sandbox) + delete_sandbox(sandbox_mgr, job, job.success, job.keep_sandbox) for s in sandbox_user: - delete_sandbox(s, job.success, job.keep_sandbox) + delete_sandbox(s, job, job.success, job.keep_sandbox) if job.success and not config.keep_sandbox and not job.keep_sandbox: for d in fifo_dir: rmtree(d) diff --git a/cms/grading/tasktypes/TwoSteps.py b/cms/grading/tasktypes/TwoSteps.py index 7e48e8e06e..2de6c8bf38 100644 --- a/cms/grading/tasktypes/TwoSteps.py +++ b/cms/grading/tasktypes/TwoSteps.py @@ -210,7 +210,7 @@ def compile(self, job, file_cacher): Executable(executable_filename, digest) # Cleanup - delete_sandbox(sandbox, job.success, job.keep_sandbox) + delete_sandbox(sandbox, job, job.success, job.keep_sandbox) def evaluate(self, job, file_cacher): """See TaskType.evaluate.""" @@ -346,5 +346,5 @@ def evaluate(self, job, file_cacher): job.text = text job.plus = stats - delete_sandbox(first_sandbox, job.success, job.keep_sandbox) - delete_sandbox(second_sandbox, job.success, job.keep_sandbox) + delete_sandbox(first_sandbox, job, job.success, job.keep_sandbox) + delete_sandbox(second_sandbox, job, job.success, job.keep_sandbox) diff --git a/cms/grading/tasktypes/util.py b/cms/grading/tasktypes/util.py index 1830b1816f..c065850484 100644 --- a/cms/grading/tasktypes/util.py +++ b/cms/grading/tasktypes/util.py @@ -69,15 +69,21 @@ def create_sandbox(file_cacher: FileCacher, name: str | None = None) -> Sandbox: return sandbox -def delete_sandbox(sandbox: Sandbox, success: bool = True, keep_sandbox: bool = False): +def delete_sandbox(sandbox: Sandbox, job: Job, success: bool = True, keep_sandbox: bool = False): """Delete the sandbox, if the configuration and job was ok. sandbox: the sandbox to delete. + job: the job currently running. success: if the job succeeded (no system errors). keep_sandbox: whether to keep the sandbox regardless of other conditions. """ + # Archive the sandbox if required + if job.archive_sandbox: + sandbox_digest = sandbox.archive() + job.sandbox_digests.append(sandbox_digest) + # If the job was not successful, we keep the sandbox around. if not success: logger.warning("Sandbox %s kept around because job did not succeed.", @@ -270,7 +276,7 @@ def eval_output( sandbox, checker_digest, job.input, job.output, EVAL_USER_OUTPUT_FILENAME, extra_args) - delete_sandbox(sandbox, success, job.keep_sandbox) + delete_sandbox(sandbox, job, success, job.keep_sandbox) return success, outcome, text else: diff --git a/cms/server/admin/templates/submission.html b/cms/server/admin/templates/submission.html index 56cece4082..7be4d37892 100644 --- a/cms/server/admin/templates/submission.html +++ b/cms/server/admin/templates/submission.html @@ -153,7 +153,34 @@

Submission details

Compilation sandbox - {{ sr.compilation_sandbox }} + + {{ sr.compilation_sandbox }} + + {% if sr.compilation_sandbox_digests %} + ({%- for sandbox_digest in sr.compilation_sandbox_digests -%} + {%- set filename = "submission_%s_compilation_sandbox_%s.zip"|format(sr.submission_id, loop.index) -%} + + {{- loop.index -}} + + {%- endfor -%}) + {% endif %} + + + Failures during evaluation @@ -256,7 +283,36 @@

Evaluation (as seen by the a ({{ ev.execution_memory // (1024 * 1024) }} MiB) {% endif %} - {{ ev.evaluation_sandbox }} + + {{ ev.evaluation_sandbox }} + + {% if ev.evaluation_sandbox_digests %} + ({%- for sandbox_digest in ev.evaluation_sandbox_digests -%} + {%- set filename = "submission_%s_testcase_%s_sandbox_%s.zip"|format(sr.submission_id, ev.codename, loop.index) -%} + + {{- loop.index -}} + + {%- endfor -%}) + {% endif %} + + + {% endfor %} {% endif %} diff --git a/cms/service/EvaluationService.py b/cms/service/EvaluationService.py index 2fcb883141..d5e098a31f 100644 --- a/cms/service/EvaluationService.py +++ b/cms/service/EvaluationService.py @@ -302,10 +302,11 @@ def __init__(self, shard: int, contest_id: int | None = None): .total_seconds(), immediately=False) - def submission_enqueue_operations(self, submission: Submission) -> int: + def submission_enqueue_operations(self, submission: Submission, archive_sandbox: bool = False) -> int: """Push in queue the operations required by a submission. submission: a submission. + archive_sandbox: whether to archive the sandbox. return: the number of actually enqueued operations. @@ -315,7 +316,7 @@ def submission_enqueue_operations(self, submission: Submission) -> int: submission_result = submission.get_result(dataset) number_of_operations = 0 for operation, priority, timestamp in submission_get_operations( - submission_result, submission, dataset): + submission_result, submission, dataset, archive_sandbox): number_of_operations += 1 if self.enqueue(operation, priority, timestamp): new_operations += 1 @@ -505,12 +506,12 @@ def write_results(self, items: list[tuple[ESOperation, Result]]): ] by_object_and_type = defaultdict(list) for operation, result in items: - t = (operation.type_, operation.object_id, operation.dataset_id) + t = (operation.type_, operation.object_id, operation.dataset_id, operation.archive_sandbox) by_object_and_type[t].append((operation, result)) with SessionGen() as session: for key, operation_results in by_object_and_type.items(): - type_, object_id, dataset_id = key + type_, object_id, dataset_id, archive_sandbox = key dataset = Dataset.get_from_id(dataset_id, session) if dataset is None: @@ -541,7 +542,7 @@ def write_results(self, items: list[tuple[ESOperation, Result]]): session.commit() num_testcases_per_dataset = dict() - for type_, object_id, dataset_id in by_object_and_type.keys(): + for type_, object_id, dataset_id, archive_sandbox in by_object_and_type.keys(): if type_ == ESOperation.EVALUATION: if dataset_id not in num_testcases_per_dataset: num_testcases_per_dataset[dataset_id] = session\ @@ -561,16 +562,16 @@ def write_results(self, items: list[tuple[ESOperation, Result]]): logger.info("Ending operations for %s objects...", len(by_object_and_type)) - for type_, object_id, dataset_id in by_object_and_type.keys(): + for type_, object_id, dataset_id, archive_sandbox in by_object_and_type.keys(): if type_ == ESOperation.COMPILATION: submission_result = SubmissionResult.get_from_id( (object_id, dataset_id), session) - self.compilation_ended(submission_result) + self.compilation_ended(submission_result, archive_sandbox) elif type_ == ESOperation.EVALUATION: submission_result = SubmissionResult.get_from_id( (object_id, dataset_id), session) if submission_result.evaluated(): - self.evaluation_ended(submission_result) + self.evaluation_ended(submission_result, archive_sandbox) elif type_ == ESOperation.USER_TEST_COMPILATION: user_test_result = UserTestResult.get_from_id( (object_id, dataset_id), session) @@ -674,7 +675,7 @@ def write_results_one_row( else: logger.error("Invalid operation type %r.", operation.type_) - def compilation_ended(self, submission_result: SubmissionResult): + def compilation_ended(self, submission_result: SubmissionResult, archive_sandbox: bool): """Actions to be performed when we have a submission that has ended compilation. In particular: we queue evaluation if compilation was ok, we inform ScoringService if the @@ -682,6 +683,7 @@ def compilation_ended(self, submission_result: SubmissionResult): requeue the compilation if there was an error in CMS. submission_result: the submission result. + archive_sandbox: whether we need to archive the sandbox. """ submission = submission_result.submission @@ -724,12 +726,13 @@ def compilation_ended(self, submission_result: SubmissionResult): # Enqueue next steps to be done self.submission_enqueue_operations(submission) - def evaluation_ended(self, submission_result: SubmissionResult): + def evaluation_ended(self, submission_result: SubmissionResult, archive_sandbox: bool = False): """Actions to be performed when we have a submission that has been evaluated. In particular: we inform ScoringService on success, we requeue on failure. submission_result: the submission result. + archive_sandbox: whether we need to archive the sandbox. """ submission = submission_result.submission @@ -760,7 +763,7 @@ def evaluation_ended(self, submission_result: SubmissionResult): submission_result.dataset_id) # Enqueue next steps to be done (e.g., if evaluation failed). - self.submission_enqueue_operations(submission) + self.submission_enqueue_operations(submission, archive_sandbox) def user_test_compilation_ended(self, user_test_result: UserTestResult): """Actions to be performed when we have a user test that has @@ -884,9 +887,11 @@ def invalidate_submission( contest_id: int | None = None, submission_id: int | None = None, dataset_id: int | None = None, + testcase_id: int | None = None, participation_id: int | None = None, task_id: int | None = None, level: str = "compilation", + archive_sandbox: bool = False, ): """Request to invalidate some computed data. @@ -899,6 +904,8 @@ def invalidate_submission( - belong to dataset_id or, if None, to any dataset of task_id or, if None, to any dataset of any task of the contest this service is running for. + - if invalidating evaluation data, if testcase_id is specified then + only that testcase of all matched submissions will be invalidated. The data is cleared, the operations involving the submissions currently enqueued are deleted, and the ones already assigned to @@ -907,9 +914,11 @@ def invalidate_submission( submission_id: id of the submission to invalidate, or None. dataset_id: id of the dataset to invalidate, or None. + testcase_id: id of the testcase to invalidate, or None. participation_id: id of the participation to invalidate, or None. task_id: id of the task to invalidate, or None. level: 'compilation' or 'evaluation' + archive_sandbox: whether to store submission output. """ logger.info("Invalidation request received.") @@ -984,12 +993,12 @@ def invalidate_submission( if level == "compilation": submission_result.invalidate_compilation() elif level == "evaluation": - submission_result.invalidate_evaluation() + submission_result.invalidate_evaluation(testcase_id=testcase_id) # Finally, we re-enqueue the operations for the # submissions. for submission in submissions: - self.submission_enqueue_operations(submission) + self.submission_enqueue_operations(submission, archive_sandbox) session.commit() logger.info("Invalidate successfully completed.") diff --git a/cms/service/esoperations.py b/cms/service/esoperations.py index cc267c3670..c31a5e4a97 100644 --- a/cms/service/esoperations.py +++ b/cms/service/esoperations.py @@ -154,7 +154,10 @@ def user_test_to_evaluate(user_test_result: UserTestResult | None) -> bool: def submission_get_operations( - submission_result: SubmissionResult | None, submission: Submission, dataset: Dataset + submission_result: SubmissionResult | None, + submission: Submission, + dataset: Dataset, + archive_sandbox: bool = False, ) -> Generator[tuple["ESOperation", int, datetime]]: """Generate all operations originating from a submission for a given dataset. @@ -162,6 +165,7 @@ def submission_get_operations( submission_result: a submission result. submission: the submission for submission_result. dataset: the dataset for submission_result. + archive_sandbox: whether to archive the sandbox. yield: an iterator providing triplets consisting of a ESOperation for a certain operation to @@ -179,7 +183,8 @@ def submission_get_operations( yield ESOperation(ESOperation.COMPILATION, submission.id, - dataset.id), \ + dataset.id, + archive_sandbox=archive_sandbox), \ priority, \ submission.timestamp @@ -200,7 +205,8 @@ def submission_get_operations( yield ESOperation(ESOperation.EVALUATION, submission.id, dataset.id, - testcase_codename), \ + testcase_codename, + archive_sandbox=archive_sandbox), \ priority, \ submission.timestamp @@ -516,18 +522,21 @@ def __init__( object_id: int, dataset_id: int, testcase_codename: str | None = None, + archive_sandbox: bool = False, ): self.type_ = type_ self.object_id = object_id self.dataset_id = dataset_id self.testcase_codename = testcase_codename + self.archive_sandbox = archive_sandbox @staticmethod def from_dict(d): return ESOperation(d["type"], d["object_id"], d["dataset_id"], - d["testcase_codename"]) + d["testcase_codename"], + d["archive_sandbox"]) def __eq__(self, other): # We may receive a non-ESOperation other when comparing with @@ -538,27 +547,29 @@ def __eq__(self, other): return self.type_ == other.type_ \ and self.object_id == other.object_id \ and self.dataset_id == other.dataset_id \ - and self.testcase_codename == other.testcase_codename + and self.testcase_codename == other.testcase_codename \ + and self.archive_sandbox == other.archive_sandbox def __hash__(self): return hash((self.type_, self.object_id, self.dataset_id, - self.testcase_codename)) + self.testcase_codename, self.archive_sandbox)) def __str__(self): if self.type_ == ESOperation.EVALUATION: - return "%s on %d against dataset %d, testcase %s" % ( + return "%s on %d against dataset %d, testcase %s, archiving sandbox %s" % ( self.type_, self.object_id, self.dataset_id, - self.testcase_codename) + self.testcase_codename, self.archive_sandbox) else: - return "%s on %d against dataset %d" % ( - self.type_, self.object_id, self.dataset_id) + return "%s on %d against dataset %d, archiving sandbox %s" % ( + self.type_, self.object_id, self.dataset_id, self.archive_sandbox) def __repr__(self): - return "(\"%s\", %s, %s, %s)" % ( + return "(\"%s\", %s, %s, %s, %s)" % ( self.type_, self.object_id, self.dataset_id, - self.testcase_codename) + self.testcase_codename, + self.archive_sandbox) def for_submission(self) -> bool: """Return if the operation is for a submission or for a user test. @@ -574,7 +585,8 @@ def to_dict(self): "type": self.type_, "object_id": self.object_id, "dataset_id": self.dataset_id, - "testcase_codename": self.testcase_codename + "testcase_codename": self.testcase_codename, + "archive_sandbox": self.archive_sandbox } def short_key(self): From 9b6447d6a0f20256ee2a321cd15be00aa7292b04 Mon Sep 17 00:00:00 2001 From: prandla Date: Tue, 8 Jul 2025 15:05:29 +0300 Subject: [PATCH 2/8] Cleanup signature of delete_sandbox --- cms/grading/tasktypes/Batch.py | 4 ++-- cms/grading/tasktypes/Communication.py | 6 +++--- cms/grading/tasktypes/TwoSteps.py | 6 +++--- cms/grading/tasktypes/util.py | 14 ++++++++------ 4 files changed, 16 insertions(+), 14 deletions(-) diff --git a/cms/grading/tasktypes/Batch.py b/cms/grading/tasktypes/Batch.py index 11e93d755f..d23ab04e51 100644 --- a/cms/grading/tasktypes/Batch.py +++ b/cms/grading/tasktypes/Batch.py @@ -257,7 +257,7 @@ def _do_compile(self, job, file_cacher): Executable(executable_filename, digest) # Cleanup. - delete_sandbox(sandbox, job, job.success, job.keep_sandbox) + delete_sandbox(sandbox, job) def compile(self, job, file_cacher): """See TaskType.compile.""" @@ -380,7 +380,7 @@ def _evaluate_step(self, job, file_cacher, output_file_params, outcome, text, st job.plus = stats if sandbox is not None: - delete_sandbox(sandbox, job, job.success, job.keep_sandbox) + delete_sandbox(sandbox, job) def evaluate(self, job, file_cacher): """See TaskType.evaluate.""" diff --git a/cms/grading/tasktypes/Communication.py b/cms/grading/tasktypes/Communication.py index 73372c8f9a..df5a8fdfc2 100644 --- a/cms/grading/tasktypes/Communication.py +++ b/cms/grading/tasktypes/Communication.py @@ -242,7 +242,7 @@ def compile(self, job, file_cacher): Executable(executable_filename, digest) # Cleanup. - delete_sandbox(sandbox, job, job.success, job.keep_sandbox) + delete_sandbox(sandbox, job) def evaluate(self, job, file_cacher): """See TaskType.evaluate.""" @@ -434,9 +434,9 @@ def evaluate(self, job, file_cacher): job.text = text job.plus = stats_user - delete_sandbox(sandbox_mgr, job, job.success, job.keep_sandbox) + delete_sandbox(sandbox_mgr, job) for s in sandbox_user: - delete_sandbox(s, job, job.success, job.keep_sandbox) + delete_sandbox(s, job) if job.success and not config.keep_sandbox and not job.keep_sandbox: for d in fifo_dir: rmtree(d) diff --git a/cms/grading/tasktypes/TwoSteps.py b/cms/grading/tasktypes/TwoSteps.py index 2de6c8bf38..da22b4278f 100644 --- a/cms/grading/tasktypes/TwoSteps.py +++ b/cms/grading/tasktypes/TwoSteps.py @@ -210,7 +210,7 @@ def compile(self, job, file_cacher): Executable(executable_filename, digest) # Cleanup - delete_sandbox(sandbox, job, job.success, job.keep_sandbox) + delete_sandbox(sandbox, job) def evaluate(self, job, file_cacher): """See TaskType.evaluate.""" @@ -346,5 +346,5 @@ def evaluate(self, job, file_cacher): job.text = text job.plus = stats - delete_sandbox(first_sandbox, job, job.success, job.keep_sandbox) - delete_sandbox(second_sandbox, job, job.success, job.keep_sandbox) + delete_sandbox(first_sandbox, job) + delete_sandbox(second_sandbox, job) diff --git a/cms/grading/tasktypes/util.py b/cms/grading/tasktypes/util.py index c065850484..3f675d4bee 100644 --- a/cms/grading/tasktypes/util.py +++ b/cms/grading/tasktypes/util.py @@ -69,16 +69,18 @@ def create_sandbox(file_cacher: FileCacher, name: str | None = None) -> Sandbox: return sandbox -def delete_sandbox(sandbox: Sandbox, job: Job, success: bool = True, keep_sandbox: bool = False): +def delete_sandbox(sandbox: Sandbox, job: Job, success: bool | None = None): """Delete the sandbox, if the configuration and job was ok. sandbox: the sandbox to delete. job: the job currently running. - success: if the job succeeded (no system errors). - keep_sandbox: whether to keep the sandbox regardless of other - conditions. + success: if the job succeeded (no system errors). If not provided, + job.success is used. """ + if success is None: + success = job.success + # Archive the sandbox if required if job.archive_sandbox: sandbox_digest = sandbox.archive() @@ -89,7 +91,7 @@ def delete_sandbox(sandbox: Sandbox, job: Job, success: bool = True, keep_sandbo logger.warning("Sandbox %s kept around because job did not succeed.", sandbox.get_root_path()) - delete = success and not config.keep_sandbox and not keep_sandbox + delete = success and not config.keep_sandbox and not job.keep_sandbox try: sandbox.cleanup(delete=delete) except OSError: @@ -276,7 +278,7 @@ def eval_output( sandbox, checker_digest, job.input, job.output, EVAL_USER_OUTPUT_FILENAME, extra_args) - delete_sandbox(sandbox, job, success, job.keep_sandbox) + delete_sandbox(sandbox, job, success) return success, outcome, text else: From 371b08dfa0ad16950ed65a1cab0c51e3e948ad7b Mon Sep 17 00:00:00 2001 From: prandla Date: Tue, 8 Jul 2025 17:44:14 +0300 Subject: [PATCH 3/8] Cleaner UI for archived sandboxes Also did most of the work to allow rerun+archive of user tests, though there's no way to invalidate them currently so it's not usable yet... --- cms/db/submission.py | 18 ++++----- cms/db/usertest.py | 14 +++++-- cms/grading/Job.py | 46 ++++++++++++++++------ cms/grading/tasktypes/util.py | 2 +- cms/server/admin/templates/submission.html | 24 +++++------ cms/server/admin/templates/user_test.html | 6 ++- 6 files changed, 70 insertions(+), 40 deletions(-) diff --git a/cms/db/submission.py b/cms/db/submission.py index 78315a24c1..10647e1d11 100644 --- a/cms/db/submission.py +++ b/cms/db/submission.py @@ -362,13 +362,12 @@ class SubmissionResult(Base): compilation_shard: int | None = Column( Integer, nullable=True) - compilation_sandbox: str | None = Column( - Unicode, + compilation_sandbox_paths: list[str] | None = Column( + ARRAY(Unicode), nullable=True) - compilation_sandbox_digests = Column( + compilation_sandbox_digests: list[str] | None = Column( ARRAY(String), - nullable=True - ) + nullable=True) # Evaluation outcome (can be None = yet to evaluate, "ok" = # evaluation successful). At any time, this should be equal to @@ -784,13 +783,12 @@ class Evaluation(Base): evaluation_shard: int | None = Column( Integer, nullable=True) - evaluation_sandbox: str | None = Column( - Unicode, + evaluation_sandbox_paths: list[str] | None = Column( + ARRAY(Unicode), nullable=True) - evaluation_sandbox_digests = Column( + evaluation_sandbox_digests: list[str] | None = Column( ARRAY(String), - nullable=True - ) + nullable=True) @property def codename(self) -> str: diff --git a/cms/db/usertest.py b/cms/db/usertest.py index 35381a52c0..6c9e8a2a9a 100644 --- a/cms/db/usertest.py +++ b/cms/db/usertest.py @@ -312,8 +312,11 @@ class UserTestResult(Base): compilation_shard: int | None = Column( Integer, nullable=True) - compilation_sandbox: str | None = Column( - String, + compilation_sandbox_paths: list[str] | None = Column( + ARRAY(Unicode), + nullable=True) + compilation_sandbox_digests: list[str] | None = Column( + ARRAY(String), nullable=True) # Evaluation outcome (can be None = yet to evaluate, "ok" = @@ -352,8 +355,11 @@ class UserTestResult(Base): evaluation_shard: int | None = Column( Integer, nullable=True) - evaluation_sandbox: str | None = Column( - String, + evaluation_sandbox_paths: list[str] | None = Column( + ARRAY(Unicode), + nullable=True) + evaluation_sandbox_digests: list[str] | None = Column( + ARRAY(String), nullable=True) # These one-to-many relationships are the reversed directions of diff --git a/cms/grading/Job.py b/cms/grading/Job.py index 646f21b3b6..3b08094d02 100644 --- a/cms/grading/Job.py +++ b/cms/grading/Job.py @@ -89,7 +89,7 @@ def __init__( shard: int | None = None, keep_sandbox: bool = False, sandboxes: list[str] | None = None, - sandbox_digests: list[str] | None = None, + sandbox_digests: dict[str, str] | None = None, info: str | None = None, success: bool | None = None, text: list[str] | None = None, @@ -113,8 +113,8 @@ def __init__( don't warrant it. sandboxes: the paths of the sandboxes used in the Worker during the execution of the job. - sandbox_digests: the digests of the sandbox - archives used to debug user solutions. + sandbox_digests: the digests of the sandbox archives used to + debug solutions. (map of sandbox path -> archive digest) info: a human readable description of the job. success: whether the job succeeded. text: description of the outcome of the job, @@ -131,7 +131,7 @@ def __init__( if sandboxes is None: sandboxes = [] if sandbox_digests is None: - sandbox_digests = [] + sandbox_digests = {} if info is None: info = "" if files is None: @@ -264,6 +264,26 @@ def from_operation( job = EvaluationJob.from_user_test(operation, object_, dataset) return job + def get_sandbox_digest_list(self) -> list[str] | None: + """ + Convert self.sandbox_digests into a list, where each index matches the + corresponding index in self.sandboxes. + """ + if not self.sandbox_digests: + return None + res: list[str | None] = [None] * len(self.sandboxes) + for k,v in self.sandbox_digests.items(): + if k in self.sandboxes: + index = self.sandboxes.index(k) + res[index] = v + else: + logger.warning("Have digest for unknown sandbox %s", k) + if None in res: + ind = res.index(None) + logger.warning("Sandbox %s was not archived", self.sandboxes[ind]) + return None + return res + class CompilationJob(Job): """Job representing a compilation. @@ -285,7 +305,7 @@ def __init__( shard: int | None = None, keep_sandbox: bool = False, sandboxes: list[str] | None = None, - sandbox_digests: list[str] | None = None, + sandbox_digests: dict[str, str] | None = None, info: str | None = None, language: str | None = None, multithreaded_sandbox: bool = False, @@ -381,8 +401,8 @@ def to_submission(self, sr: SubmissionResult): self.plus.get('execution_wall_clock_time') sr.compilation_memory = self.plus.get('execution_memory') sr.compilation_shard = self.shard - sr.compilation_sandbox = ":".join(self.sandboxes) - sr.compilation_sandbox_digests = self.sandbox_digests + sr.compilation_sandbox_paths = self.sandboxes + sr.compilation_sandbox_digests = self.get_sandbox_digest_list() for executable in self.executables.values(): sr.executables.set(executable) @@ -473,7 +493,8 @@ def to_user_test(self, ur: UserTestResult): self.plus.get('execution_wall_clock_time') ur.compilation_memory = self.plus.get('execution_memory') ur.compilation_shard = self.shard - ur.compilation_sandbox = ":".join(self.sandboxes) + ur.compilation_sandbox_paths = self.sandboxes + ur.compilation_sandbox_digests = self.get_sandbox_digest_list() for executable in self.executables.values(): u_executable = UserTestExecutable( executable.filename, executable.digest) @@ -501,7 +522,7 @@ def __init__( shard: int | None = None, keep_sandbox: bool = False, sandboxes: list[str] | None = None, - sandbox_digests: list[str] | None = None, + sandbox_digests: dict[str, str] | None = None, info: str | None = None, language: str | None = None, multithreaded_sandbox: bool = False, @@ -638,8 +659,8 @@ def to_submission(self, sr: SubmissionResult): 'execution_wall_clock_time'), execution_memory=self.plus.get('execution_memory'), evaluation_shard=self.shard, - evaluation_sandbox=":".join(self.sandboxes), - evaluation_sandbox_digests=self.sandbox_digests, + evaluation_sandbox_paths=self.sandboxes, + evaluation_sandbox_digests=self.get_sandbox_digest_list(), testcase=sr.dataset.testcases[self.operation.testcase_codename])] @staticmethod @@ -725,7 +746,8 @@ def to_user_test(self, ur: UserTestResult): self.plus.get('execution_wall_clock_time') ur.execution_memory = self.plus.get('execution_memory') ur.evaluation_shard = self.shard - ur.evaluation_sandbox = ":".join(self.sandboxes) + ur.evaluation_sandbox_paths = self.sandboxes + ur.evaluation_sandbox_digests = self.get_sandbox_digest_list() ur.output = self.user_output diff --git a/cms/grading/tasktypes/util.py b/cms/grading/tasktypes/util.py index 3f675d4bee..80006a0ca6 100644 --- a/cms/grading/tasktypes/util.py +++ b/cms/grading/tasktypes/util.py @@ -84,7 +84,7 @@ def delete_sandbox(sandbox: Sandbox, job: Job, success: bool | None = None): # Archive the sandbox if required if job.archive_sandbox: sandbox_digest = sandbox.archive() - job.sandbox_digests.append(sandbox_digest) + job.sandbox_digests[sandbox.get_root_path()] = sandbox_digest # If the job was not successful, we keep the sandbox around. if not success: diff --git a/cms/server/admin/templates/submission.html b/cms/server/admin/templates/submission.html index 7be4d37892..5f638958fe 100644 --- a/cms/server/admin/templates/submission.html +++ b/cms/server/admin/templates/submission.html @@ -154,15 +154,15 @@

Submission details

Compilation sandbox - {{ sr.compilation_sandbox }} - {% if sr.compilation_sandbox_digests %} - ({%- for sandbox_digest in sr.compilation_sandbox_digests -%} + {% for sandbox_digest in sr.compilation_sandbox_digests %} {%- set filename = "submission_%s_compilation_sandbox_%s.zip"|format(sr.submission_id, loop.index) -%} - - {{- loop.index -}} + + {{- sr.compilation_sandbox_paths[loop.index0] -}} - {%- endfor -%}) + {% endfor %} + {% else %} + {{ sr.compilation_sandbox_paths|join(" ") }} {% endif %}

Compilation sandbox - {{ utr.compilation_sandbox }} + {{ utr.compilation_sandbox_paths|join(" ") }} Failures during evaluation {{ utr.evaluation_tries }} + + Evaluation sandbox + {{ utr.evaluation_sandbox_paths|join(" ") }} + {% endif %} From 117093ba65e71b4aeb4f6b4efb89a2d7c1aadfa1 Mon Sep 17 00:00:00 2001 From: prandla Date: Tue, 8 Jul 2025 18:24:42 +0300 Subject: [PATCH 4/8] Better sandbox zipping implementation --- cms/grading/Sandbox.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/cms/grading/Sandbox.py b/cms/grading/Sandbox.py index a02754df0c..52d8f15705 100644 --- a/cms/grading/Sandbox.py +++ b/cms/grading/Sandbox.py @@ -542,21 +542,21 @@ def archive(self): """ logger.info("Archiving sandbox in %s.", self.get_root_path()) - # Archive the working directory - sandbox_archive_filename = "sandbox.zip" - sandbox_archive = self.relative_path(sandbox_archive_filename) - content_path = self.get_root_path() - with zipfile.ZipFile(sandbox_archive, "w") as zip_file: - for root, dirs, files in os.walk(content_path): - if sandbox_archive_filename in files: - files.remove(sandbox_archive_filename) - zip_file.write(root, os.path.relpath(root, content_path)) - for f in files: - f_path = os.path.join(root, f) - zip_file.write(f_path, os.path.relpath(f_path, content_path)) - - # Put archive to FS - return self.get_file_to_storage(sandbox_archive, "Sandbox %s" % self.get_root_path()) + with tempfile.TemporaryFile(dir=self.temp_dir) as sandbox_archive: + # Archive the working directory + content_path = self.get_root_path() + with zipfile.ZipFile(sandbox_archive, "w", + compression=zipfile.ZIP_DEFLATED, + compresslevel=9) as zip_file: + for root, dirs, files in os.walk(content_path): + zip_file.write(root, os.path.relpath(root, content_path)) + for f in files: + f_path = os.path.join(root, f) + zip_file.write(f_path, os.path.relpath(f_path, content_path)) + + # Put archive to FS + sandbox_archive.seek(0) + return self.file_cacher.put_file_from_fobj(sandbox_archive, "Sandbox %s" % self.get_root_path()) class StupidSandbox(SandboxBase): From b7026e9c0a28a4172f8d54b16fbedbdd2d73fed3 Mon Sep 17 00:00:00 2001 From: prandla Date: Thu, 10 Jul 2025 13:42:49 +0300 Subject: [PATCH 5/8] Change sandbox archive type to tar.gz --- cms/grading/Sandbox.py | 12 +++--------- cms/server/admin/templates/submission.html | 4 ++-- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/cms/grading/Sandbox.py b/cms/grading/Sandbox.py index 52d8f15705..eea1202a20 100644 --- a/cms/grading/Sandbox.py +++ b/cms/grading/Sandbox.py @@ -27,7 +27,7 @@ import stat import tempfile import time -import zipfile +import tarfile from abc import ABCMeta, abstractmethod from functools import wraps, partial import typing @@ -545,14 +545,8 @@ def archive(self): with tempfile.TemporaryFile(dir=self.temp_dir) as sandbox_archive: # Archive the working directory content_path = self.get_root_path() - with zipfile.ZipFile(sandbox_archive, "w", - compression=zipfile.ZIP_DEFLATED, - compresslevel=9) as zip_file: - for root, dirs, files in os.walk(content_path): - zip_file.write(root, os.path.relpath(root, content_path)) - for f in files: - f_path = os.path.join(root, f) - zip_file.write(f_path, os.path.relpath(f_path, content_path)) + with tarfile.open(fileobj=sandbox_archive, mode='w:gz') as tar_file: + tar_file.add(content_path, os.path.basename(content_path)) # Put archive to FS sandbox_archive.seek(0) diff --git a/cms/server/admin/templates/submission.html b/cms/server/admin/templates/submission.html index 5f638958fe..253ddd2d9a 100644 --- a/cms/server/admin/templates/submission.html +++ b/cms/server/admin/templates/submission.html @@ -156,7 +156,7 @@

Submission details

{% if sr.compilation_sandbox_digests %} {% for sandbox_digest in sr.compilation_sandbox_digests %} - {%- set filename = "submission_%s_compilation_sandbox_%s.zip"|format(sr.submission_id, loop.index) -%} + {%- set filename = "submission_%s_compilation_sandbox_%s.tar.gz"|format(sr.submission_id, loop.index) -%} {{- sr.compilation_sandbox_paths[loop.index0] -}} @@ -286,7 +286,7 @@

Evaluation (as seen by the a {% if ev.evaluation_sandbox_digests %} {% for sandbox_digest in ev.evaluation_sandbox_digests %} - {%- set filename = "submission_%s_testcase_%s_sandbox_%s.zip"|format(sr.submission_id, ev.codename, loop.index) -%} + {%- set filename = "submission_%s_testcase_%s_sandbox_%s.tar.gz"|format(sr.submission_id, ev.codename, loop.index) -%} {{- ev.evaluation_sandbox_paths[loop.index0] -}} From 4353eac5091760c9293070cfb4c7dcba4fa13f19 Mon Sep 17 00:00:00 2001 From: prandla Date: Thu, 10 Jul 2025 16:13:51 +0300 Subject: [PATCH 6/8] Add DB updater for the schema change --- cms/db/__init__.py | 2 +- cmscontrib/updaters/update_46.py | 57 +++++++++++++++++++++++++ cmscontrib/updaters/update_from_1.5.sql | 18 ++++++++ 3 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 cmscontrib/updaters/update_46.py diff --git a/cms/db/__init__.py b/cms/db/__init__.py index 1e7ee98152..18f87364c8 100644 --- a/cms/db/__init__.py +++ b/cms/db/__init__.py @@ -81,7 +81,7 @@ # Instantiate or import these objects. -version = 45 +version = 46 engine = create_engine(config.database, echo=config.database_debug, pool_timeout=60, pool_recycle=120) diff --git a/cmscontrib/updaters/update_46.py b/cmscontrib/updaters/update_46.py new file mode 100644 index 0000000000..bbc3aba82a --- /dev/null +++ b/cmscontrib/updaters/update_46.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 + +# Contest Management System - http://cms-dev.github.io/ +# Copyright © 2025 p. randla +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""A class to update a dump created by CMS. + +Used by DumpImporter and DumpUpdater. + +Converts the '*_sandbox' columns to '*_sandbox_paths' columns. + +""" + +def convert(val: str | None): + if val is None: + return None + if val == '': + return [] + return val.split(':') + +class Updater: + + def __init__(self, data): + assert data["_version"] == 44 + self.objs = data + + def run(self): + for k, v in self.objs.items(): + if k.startswith("_"): + continue + if v["_class"] == "SubmissionResult": + v['compilation_sandbox_paths'] = convert(v.get('compilation_sandbox')) + del v['compilation_sandbox'] + elif v["_class"] == "Evaluation": + v['evaluation_sandbox_paths'] = convert(v.get('evaluation_sandbox')) + del v['evaluation_sandbox'] + elif v["_class"] == "UserTestResult": + v['compilation_sandbox_paths'] = convert(v.get('compilation_sandbox')) + v['evaluation_sandbox_paths'] = convert(v.get('evaluation_sandbox')) + del v['compilation_sandbox'] + del v['evaluation_sandbox'] + + return self.objs + diff --git a/cmscontrib/updaters/update_from_1.5.sql b/cmscontrib/updaters/update_from_1.5.sql index 6db2c8fa98..c22b736c6a 100644 --- a/cmscontrib/updaters/update_from_1.5.sql +++ b/cmscontrib/updaters/update_from_1.5.sql @@ -24,4 +24,22 @@ UPDATE submissions SET opaque_id = id WHERE opaque_id IS NULL; ALTER TABLE submissions ADD CONSTRAINT participation_opaque_unique UNIQUE (participation_id, opaque_id); ALTER TABLE submissions ALTER COLUMN opaque_id SET NOT NULL; +-- https://github.com/cms-dev/cms/pull/1456 +ALTER TABLE submission_results ADD COLUMN compilation_sandbox_paths VARCHAR[]; +ALTER TABLE submission_results ADD COLUMN compilation_sandbox_digests VARCHAR[]; +UPDATE submission_results SET compilation_sandbox_paths = string_to_array(compilation_sandbox, ':'); +ALTER TABLE submission_results DROP COLUMN compilation_sandbox; +ALTER TABLE evaluations ADD COLUMN evaluation_sandbox_paths VARCHAR[]; +ALTER TABLE evaluations ADD COLUMN evaluation_sandbox_digests VARCHAR[]; +UPDATE evaluations SET evaluation_sandbox_paths = string_to_array(evaluation_sandbox, ':'); +ALTER TABLE evaluations DROP COLUMN evaluation_sandbox; +ALTER TABLE user_test_results ADD COLUMN compilation_sandbox_paths VARCHAR[]; +ALTER TABLE user_test_results ADD COLUMN compilation_sandbox_digests VARCHAR[]; +UPDATE user_test_results SET compilation_sandbox_paths = string_to_array(compilation_sandbox, ':'); +ALTER TABLE user_test_results DROP COLUMN compilation_sandbox; +ALTER TABLE user_test_results ADD COLUMN evaluation_sandbox_paths VARCHAR[]; +ALTER TABLE user_test_results ADD COLUMN evaluation_sandbox_digests VARCHAR[]; +UPDATE user_test_results SET evaluation_sandbox_paths = string_to_array(evaluation_sandbox, ':'); +ALTER TABLE user_test_results DROP COLUMN evaluation_sandbox; + COMMIT; From 5350d36b890cc8dc81c20db7068a259cae2d8f1e Mon Sep 17 00:00:00 2001 From: prandla Date: Thu, 10 Jul 2025 16:31:24 +0300 Subject: [PATCH 7/8] fixup dump updater --- cmscontrib/updaters/update_46.py | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/cmscontrib/updaters/update_46.py b/cmscontrib/updaters/update_46.py index bbc3aba82a..61f4ab8c2e 100644 --- a/cmscontrib/updaters/update_46.py +++ b/cmscontrib/updaters/update_46.py @@ -24,17 +24,22 @@ """ -def convert(val: str | None): - if val is None: - return None - if val == '': - return [] - return val.split(':') +def convert(obj: dict, key: str): + old_val = obj.pop(key, None) + + if old_val is None: + new_val = None + elif old_val == '': + new_val = [] + else: + new_val = old_val.split(':') + + obj[key + '_paths'] = new_val class Updater: def __init__(self, data): - assert data["_version"] == 44 + assert data["_version"] == 45 self.objs = data def run(self): @@ -42,16 +47,12 @@ def run(self): if k.startswith("_"): continue if v["_class"] == "SubmissionResult": - v['compilation_sandbox_paths'] = convert(v.get('compilation_sandbox')) - del v['compilation_sandbox'] + convert(v, 'compilation_sandbox') elif v["_class"] == "Evaluation": - v['evaluation_sandbox_paths'] = convert(v.get('evaluation_sandbox')) - del v['evaluation_sandbox'] + convert(v, 'evaluation_sandbox') elif v["_class"] == "UserTestResult": - v['compilation_sandbox_paths'] = convert(v.get('compilation_sandbox')) - v['evaluation_sandbox_paths'] = convert(v.get('evaluation_sandbox')) - del v['compilation_sandbox'] - del v['evaluation_sandbox'] + convert(v, 'compilation_sandbox') + convert(v, 'evaluation_sandbox') return self.objs From 7b7f2b840da09fa19f8b4f182f5a677e9a48e45e Mon Sep 17 00:00:00 2001 From: prandla Date: Fri, 11 Jul 2025 15:07:15 +0300 Subject: [PATCH 8/8] handle failure to archive sandbox better --- cms/grading/Sandbox.py | 13 ++++++++++--- cms/grading/tasktypes/util.py | 3 ++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/cms/grading/Sandbox.py b/cms/grading/Sandbox.py index eea1202a20..c22158f1d7 100644 --- a/cms/grading/Sandbox.py +++ b/cms/grading/Sandbox.py @@ -536,17 +536,24 @@ def cleanup(self, delete: bool = False): """ pass - def archive(self): + def archive(self) -> str | None: """Archive the directory where the sandbox operated. + Stores the archived sandbox in the file cacher and returns its digest. + Returns None if archiving failed. + """ logger.info("Archiving sandbox in %s.", self.get_root_path()) with tempfile.TemporaryFile(dir=self.temp_dir) as sandbox_archive: # Archive the working directory content_path = self.get_root_path() - with tarfile.open(fileobj=sandbox_archive, mode='w:gz') as tar_file: - tar_file.add(content_path, os.path.basename(content_path)) + try: + with tarfile.open(fileobj=sandbox_archive, mode='w:gz') as tar_file: + tar_file.add(content_path, os.path.basename(content_path)) + except Exception: + logger.warning("Failed to archive sandbox", exc_info=True) + return None # Put archive to FS sandbox_archive.seek(0) diff --git a/cms/grading/tasktypes/util.py b/cms/grading/tasktypes/util.py index 80006a0ca6..59f4dc1b07 100644 --- a/cms/grading/tasktypes/util.py +++ b/cms/grading/tasktypes/util.py @@ -84,7 +84,8 @@ def delete_sandbox(sandbox: Sandbox, job: Job, success: bool | None = None): # Archive the sandbox if required if job.archive_sandbox: sandbox_digest = sandbox.archive() - job.sandbox_digests[sandbox.get_root_path()] = sandbox_digest + if sandbox_digest is not None: + job.sandbox_digests[sandbox.get_root_path()] = sandbox_digest # If the job was not successful, we keep the sandbox around. if not success: