-
Notifications
You must be signed in to change notification settings - Fork 69
Expand file tree
/
Copy pathcompute_worker.py
More file actions
1613 lines (1387 loc) · 62.4 KB
/
Copy pathcompute_worker.py
File metadata and controls
1613 lines (1387 loc) · 62.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import asyncio
import glob
import hashlib
import json
import os
import traceback
import shutil
import signal
import socket
import tempfile
import time
import uuid
import requests
import websockets
import yaml
import docker
import logging
import sys # This is only needed for the pytests to pass
from shutil import make_archive
from urllib.error import HTTPError
from urllib.parse import urlparse
from urllib.request import urlretrieve
from zipfile import ZipFile, BadZipFile
from urllib3 import Retry
from rich.pretty import pprint
from rich.progress import Progress
from kombu import Queue, Exchange
from celery import Celery, shared_task, utils, signals
from billiard.exceptions import SoftTimeLimitExceeded
from logs_loguru import configure_logging, colorize_run_args
from docker_image_update_checker import DockerImageStatus, DockerImageUpdateChecker
logger = logging.getLogger(__name__)
sys.path.append("/app/src/settings/")
# -----------------------------------------------
# Settings
# -----------------------------------------------
class Settings:
@staticmethod
def get(key, default=None):
"""
Return the env var value if set, else default; returns None if not set and no default.
"""
val = os.getenv(key)
if val is not None:
return val
if default is not None:
return default
logger.warning(f"Environment variable '{key}' not found and no default provided.")
return None
@staticmethod
def to_bool(val):
try:
if isinstance(val, bool):
return val
val_str = str(val).strip()
if val_str in ("true", "True", "TRUE", "1"):
return True
if val_str in ("false", "False", "FALSE", "0"):
return False
logger.warning(f"Failed to parse boolean from '{val}'")
return val
except Exception as e:
logger.warning(f"Failed to parse boolean from '{val}': {e}")
return val
# Directories
# NOTE: we need to pass this directory to docker/podman so it knows where to store things!
HOST_DIRECTORY = get("HOST_DIRECTORY", "/tmp/codabench/")
MAX_CACHE_DIR_SIZE_GB = float(get("MAX_CACHE_DIR_SIZE_GB", 10))
BASE_DIR = "/codabench/" # base directory inside the container
CACHE_DIR = os.path.join(BASE_DIR, "cache")
# Constants
DOCKER = "docker"
PODMAN = "podman"
LOG_LEVEL_DEBUG = "debug"
# Defaults
DEFAULT_SOCKETS = {
DOCKER: "unix:///var/run/docker.sock",
PODMAN: "unix:///run/user/1000/podman/podman.sock",
}
# env variables
LOG_LEVEL = get("LOG_LEVEL", "INFO").lower()
SERIALIZED = get("SERIALIZED", "false")
USE_GPU = to_bool(get("USE_GPU", "false"))
CONTAINER_ENGINE_EXECUTABLE = get("CONTAINER_ENGINE_EXECUTABLE", DOCKER).lower()
GPU_DEVICE = get("GPU_DEVICE", "nvidia.com/gpu=all")
CONTAINER_SOCKET = get("CONTAINER_SOCKET", DEFAULT_SOCKETS.get(CONTAINER_ENGINE_EXECUTABLE))
COMPETITION_CONTAINER_NETWORK_DISABLED = to_bool(get("COMPETITION_CONTAINER_NETWORK_DISABLED", "False"))
COMPETITION_CONTAINER_HTTP_PROXY = get("COMPETITION_CONTAINER_HTTP_PROXY", "")
COMPETITION_CONTAINER_HTTPS_PROXY = get("COMPETITION_CONTAINER_HTTPS_PROXY", "")
CODALAB_IGNORE_CLEANUP_STEP = to_bool(get("CODALAB_IGNORE_CLEANUP_STEP"))
WORKER_BUNDLE_URL_REWRITE = get("WORKER_BUNDLE_URL_REWRITE", "").strip()
# Docker image config
DOCKER_IMAGE_NAMESPACE = get("DOCKER_IMAGE_NAMESPACE", "codalab")
DOCKER_IMAGE_REPOSITORY = get("DOCKER_IMAGE_REPOSITORY", "codabench-compute-worker")
DOCKER_IMAGE_TAG = get("DOCKER_IMAGE_TAG", "latest")
# -----------------------------------------------
# Program Kind
# -----------------------------------------------
class ProgramKind:
INGESTION_PROGRAM = "ingestion_program"
SCORING_PROGRAM = "scoring_program"
# -----------------------------------------------
# Submission status
# -----------------------------------------------
class SubmissionStatus:
NONE = "None"
SUBMITTING = "Submitting"
SUBMITTED = "Submitted"
PREPARING = "Preparing"
RUNNING = "Running"
SCORING = "Scoring"
FINISHED = "Finished"
FAILED = "Failed"
AVAILABLE_STATUSES = (
NONE,
SUBMITTING,
SUBMITTED,
PREPARING,
RUNNING,
SCORING,
FINISHED,
FAILED,
)
# -----------------------------------------------
# Logging
# -----------------------------------------------
configure_logging(
Settings.LOG_LEVEL, Settings.SERIALIZED
)
# -----------------------------------------------
# Initialize Docker or Podman depending on .env
# -----------------------------------------------
logger.info(
f"Using {Settings.CONTAINER_ENGINE_EXECUTABLE} "
f"{'with GPU capabilities: ' + Settings.GPU_DEVICE if Settings.USE_GPU else 'without GPU capabilities'}. "
f"Network disabled for the competition container is set to {Settings.COMPETITION_CONTAINER_NETWORK_DISABLED}"
)
# Intializing client
# NOTE: CONTAINER_SOCKET is set in Settings based on CONTAINER_ENGINE_EXECUTABLE which must has either podman or docker
client = docker.APIClient(
base_url=Settings.CONTAINER_SOCKET,
version="auto",
)
# -----------------------------------------------
# Show Progress bar on downloading images
# -----------------------------------------------
tasks = {}
def show_progress(line, progress):
try:
status = line.get("status") or ""
layer_id = line.get("id")
detail = line.get("progressDetail") or {}
current = detail.get("current")
total = detail.get("total")
if "Status: Image is up to date" in status:
logger.info(status)
if not layer_id:
return
completed = False
if status == "Download complete":
description = (
f"[blue][Download complete, waiting for extraction {layer_id}]"
)
completed = True
elif status == "Downloading":
description = f"[bold][Downloading {layer_id}]"
elif status == "Pull complete":
description = f"[green][Extraction complete {layer_id}]"
completed = True
elif status == "Extracting":
description = f"[blue][Extracting {layer_id}]"
else:
# skip other statuses, but show extraction progress
return
task_id = line["id"]
if task_id not in tasks.keys():
if completed:
# some layers are really small that they download immediately without showing
# anything as Downloading in the stream.
# For that case, show a completed progress bar
tasks[task_id] = progress.add_task(
description, total=100, completed=100
)
else:
tasks[task_id] = progress.add_task(
description, total=total
)
else:
if completed:
# due to the stream, the Download complete output can happen before the Downloading
# bar outputs the 100%. So when we detect that the download is in fact complete,
# update the progress bar to show 100%
progress.update(
tasks[task_id], description=description, total=100, completed=100
)
else:
progress.update(
tasks[task_id],
completed=current,
total=total,
)
except Exception as e:
if Settings.LOG_LEVEL == Settings.LOG_LEVEL_DEBUG:
logger.exception(f"There was an error showing the progress bar: {e}")
# -----------------------------------------------
# Celery + Rabbit MQ
# -----------------------------------------------
@signals.setup_logging.connect
def setup_celery_logging(**kwargs):
pass
# Init celery + rabbit queue definitions
app = Celery()
app.config_from_object("celery_config") # grabs celery_config.py
app.conf.task_queues = [
# Mostly defining queue here so we can set x-max-priority
Queue(
"compute-worker",
Exchange("compute-worker"),
routing_key="compute-worker",
queue_arguments={"x-max-priority": 10},
),
]
# -----------------------------------------------
# Exceptions
# -----------------------------------------------
class SubmissionException(Exception):
pass
class DockerImagePullException(Exception):
pass
class ExecutionTimeLimitExceeded(Exception):
pass
# -----------------------------------------------
# Local setups where public S3 URLs are not directly reachable from workers
# -----------------------------------------------
def rewrite_bundle_url_if_needed(url):
"""
Optionally rewrite presigned bundle URLs for worker networking.
Controlled by env: WORKER_BUNDLE_URL_REWRITE=FROM|TO
Example: http://localhost:9000|http://minio:9000
"""
rule = Settings.WORKER_BUNDLE_URL_REWRITE
if not rule or "|" not in rule:
return url
src, dst = rule.split("|", 1)
if url.startswith(src):
new_url = dst + url[len(src):]
logger.info(f"Rewriting bundle URL for worker: {url} -> {new_url}")
return new_url
return url
def check_docker_image_update():
"""
Compare local and remote compute worker Docker images and log the
synchronization status along with relevant image metadata.
"""
checker = DockerImageUpdateChecker(
namespace=Settings.DOCKER_IMAGE_NAMESPACE,
repository=Settings.DOCKER_IMAGE_REPOSITORY,
tag=Settings.DOCKER_IMAGE_TAG,
docker_base_url=Settings.CONTAINER_SOCKET
)
result = checker.compare_local_vs_remote_images(container_id=socket.gethostname())
status = result["status"]
log_level = logging.INFO
log_lines = [
"",
"=" * 60,
"COMPUTE WORKER DOCKER IMAGE UPDATE CHECK",
"=" * 60,
f"Image: {result.get('image_name')}",
]
remote = result.get("remote")
local = result.get("local")
if remote:
log_lines.append(f"Remote: digest={remote.get('digest')}, date={remote.get('date')}")
if local:
log_lines.append(f"Local: id={local.get('id')}, date={local.get('date')}")
log_lines.append("-" * 60)
if status == DockerImageStatus.UP_TO_DATE:
log_lines.append("Status: Local image is synchronized with remote")
log_level = logging.INFO
elif status == DockerImageStatus.BEHIND:
log_lines.append("Status: Local image is behind remote version. For better submission processing and to avoid any submission errors, fetch the latest image!")
log_level = logging.ERROR
elif status == DockerImageStatus.LOCAL_MISSING:
log_lines.append("Status: Local image not found. Pull required")
log_level = logging.ERROR
elif status == DockerImageStatus.REMOTE_UNAVAILABLE:
log_lines.append("Status: Could not fetch remote image metadata")
log_level = logging.ERROR
elif status == "error":
log_lines.append(f"Status: Image check failed: {result.get('error')}")
log_level = logging.ERROR
else:
log_lines.append(f"Unknown image status: {status}")
log_level = logging.ERROR
log_lines.append("=" * 60)
logger.log(log_level, "\n".join(log_lines))
if status != DockerImageStatus.UP_TO_DATE:
return "\n".join(log_lines) + "\n"
return None
# -----------------------------------------------------------------------------
# The main compute worker entrypoint, this is how a job is ran at the highest
# level.
# -----------------------------------------------------------------------------
@shared_task(name="compute_worker_run")
def run_wrapper(run_args):
# Check for docker image update
docker_image_warning = check_docker_image_update()
# We need to convert the UUID given by celery into a byte like object otherwise things will break
run_args.update(secret=str(run_args["secret"]))
logger.info(f"Received run arguments: \n {colorize_run_args(json.dumps(run_args))}")
run = Run(run_args)
if docker_image_warning:
run.docker_image_warning = docker_image_warning.encode()
try:
run.prepare()
run.start()
if run.is_scoring:
run.push_scores()
run.push_output()
except DockerImagePullException as e:
msg = str(e).strip()
if msg:
msg = f"Docker image pull failed: {msg}"
else:
msg = "Docker image pull failed."
run._update_status(SubmissionStatus.FAILED, extra_information=msg)
raise
except SoftTimeLimitExceeded:
run._update_status(
SubmissionStatus.FAILED, extra_information="Execution time limit exceeded.")
raise
except SubmissionException as e:
msg = str(e).strip()
if msg:
msg = f"Submission failed: {msg}. See logs for more details."
else:
msg = "Submission failed. See logs for more details."
run._update_status(SubmissionStatus.FAILED, extra_information=msg)
raise
except Exception:
# Catch any exception to avoid getting stuck in Running status
run._update_status(SubmissionStatus.FAILED, extra_information=traceback.format_exc())
raise
finally:
try:
# Try to push logs before cleanup
run.push_logs()
except Exception:
logger.exception("push_logs failed")
run.clean_up()
def replace_legacy_metadata_command(
command, kind, is_scoring, ingestion_only_during_scoring=False
):
vars_to_replace = [
("$input", "/app/input_data" if kind == ProgramKind.INGESTION_PROGRAM else "/app/input"),
("$output", "/app/output"),
(
"$program",
"/app/ingestion_program"
if ingestion_only_during_scoring and is_scoring
else "/app/program",
),
("$ingestion_program", "/app/program"),
("$hidden", "/app/input/ref"),
("$shared", "/app/shared"),
("$submission_program", "/app/ingested_program"),
# for v1.8 compatibility
("$tmp", "/app/output"),
("$predictions", "/app/input/res" if is_scoring else "/app/output"),
]
for var_string, var_replacement in vars_to_replace:
command = command.replace(var_string, var_replacement)
return command
def md5(filename):
"""Given some file return its md5, works well on large files"""
hash_md5 = hashlib.md5()
with open(filename, "rb") as f:
for chunk in iter(lambda: f.read(4096), b""):
hash_md5.update(chunk)
return hash_md5.hexdigest()
def get_folder_size_in_gb(folder):
# Check if the folder exists; if not, return 0 GB
if not os.path.exists(folder):
return 0
total_size = 0 # Initialize total size accumulator (in bytes)
# Walk through the folder and all its subdirectories
for root, dirs, files in os.walk(folder):
for f in files:
# Construct full path to the file
fp = os.path.join(root, f)
# Add the file size to total_size
total_size += os.path.getsize(fp)
# Convert bytes to gigabytes using decimal system (1 GB = 1000^3 bytes)
return total_size / (1000 ** 3)
def delete_files_in_folder(folder):
if not os.path.isdir(folder):
return
for filename in os.listdir(folder):
file_path = os.path.join(folder, filename)
if os.path.isfile(file_path) or os.path.islink(file_path):
os.unlink(file_path)
elif os.path.isdir(file_path):
shutil.rmtree(file_path)
def is_valid_zip(zip_path):
# Check zip integrity
try:
with ZipFile(zip_path, "r") as zf:
return zf.testzip() is None
except BadZipFile:
return False
def alarm_handler(signum, frame):
raise ExecutionTimeLimitExceeded
# -----------------------------------------------
# Class Run
# Responsible for running a submission inside a docker/podman container
# -----------------------------------------------
class Run:
"""A "Run" in Codabench is composed of some program, some data to work with, and some signed URLs to upload results
to. There is also a secret key to do special commands for just this submission.
Some example API's you can hit using this secret key are:
push_scores
(maybe later:
get previous submission
get sibling submission
get top submission
get some different dataset
post results to twitter)
"""
def __init__(self, run_args):
self.run_related_name = (
f"uPK-{run_args['user_pk']}_sID-{run_args['id']}"
)
# Directories for the run
self.watch = True
self.completed_program_counter = 0
self.root_dir = tempfile.mkdtemp(prefix=f'{self.run_related_name}__', dir=Settings.BASE_DIR)
self.bundle_dir = os.path.join(self.root_dir, "bundles")
self.input_dir = os.path.join(self.root_dir, "input")
self.output_dir = os.path.join(self.root_dir, "output")
self.data_dir = os.path.join(Settings.HOST_DIRECTORY, "data") # absolute path to data in the host
self.logs = {}
self.docker_image_warning = None
# Details for submission
self.is_scoring = run_args["is_scoring"]
self.user_pk = run_args["user_pk"]
self.submission_id = run_args["id"]
self.submissions_api_url = run_args["submissions_api_url"]
self.container_image = run_args["docker_image"]
self.secret = run_args["secret"]
self.prediction_result = run_args["prediction_result"]
self.scoring_result = run_args.get("scoring_result")
self.execution_time_limit = run_args["execution_time_limit"]
# stdout and stderr
self.stdout, self.stderr, self.ingestion_stdout, self.ingestion_stderr = (
self._get_stdout_stderr_file_names(run_args)
)
# Setting up container names for ingestion, scoring and submission
self.ingestion_program_container_name = f"ingestion_{self.run_related_name}"
self.scoring_program_container_name = f"scoring_{self.run_related_name}"
# Setting up ingestion, scoring and submission data
self.ingestion_program_data = run_args.get("ingestion_program_data")
self.scoring_program_data = run_args.get("scoring_program_data")
self.submission_data = run_args.get("submission_data")
self.input_data = run_args.get("input_data")
self.reference_data = run_args.get("reference_data")
self.ingestion_only_during_scoring = run_args.get("ingestion_only_during_scoring")
self.detailed_results_url = run_args.get("detailed_results_url")
self.ingestion_program_exit_code = None
self.ingestion_program_elapsed_time = None
self.scoring_program_exit_code = None
self.scoring_program_elapsed_time = None
# Socket connection to stream output of submission
submission_api_url_parsed = urlparse(self.submissions_api_url)
websocket_host = submission_api_url_parsed.netloc
websocket_scheme = "ws" if submission_api_url_parsed.scheme == "http" else "wss"
self.websocket_url = f"{websocket_scheme}://{websocket_host}/submission_input/{self.user_pk}/{self.submission_id}/{self.secret}/"
# Nice requests adapter with generous retries/etc.
self.requests_session = requests.Session()
adapter = requests.adapters.HTTPAdapter(
max_retries=Retry(
total=3,
backoff_factor=1,
)
)
self.requests_session.mount("http://", adapter)
self.requests_session.mount("https://", adapter)
async def watch_detailed_results(self):
"""Watches files alongside scoring + program containers, currently only used
for detailed_results.html"""
if not self.detailed_results_url:
return
file_path = self.get_detailed_results_file_path()
last_modified_time = None
start = time.time()
expiration_seconds = 60
# When running scoring program, we have at least one program to run i.e. scoring_program
# Sometimes when ingestion_only_during_scoring is True, we have two programs to run
expected_completed_program_counters = 1 + int(bool(self.ingestion_only_during_scoring))
while self.watch and self.completed_program_counter < expected_completed_program_counters:
if file_path:
new_time = os.path.getmtime(file_path)
if new_time != last_modified_time:
last_modified_time = new_time
await self.send_detailed_results(file_path)
else:
logger.info(time.time() - start)
if time.time() - start > expiration_seconds:
timeout_error_message = (
"WARNING: Detailed results not written before the execution."
)
logger.warning(timeout_error_message)
await asyncio.sleep(5)
file_path = self.get_detailed_results_file_path()
else:
# make sure we always send the final version of the file
if file_path:
await self.send_detailed_results(file_path)
def push_logs(self):
"""Upload any collected logs, even in case of crash.
"""
try:
for kind, logs in (self.logs or {}).items():
for stream_key in ("stdout", "stderr"):
entry = logs.get(stream_key) if isinstance(logs, dict) else None
if not entry:
continue
location = entry.get("location")
data = entry.get("data") or b""
if self.docker_image_warning and stream_key == "stderr":
data = self.docker_image_warning + data
if location:
self._put_file(location, raw_data=data)
except Exception as e:
logger.exception(f"Failed best-effort log upload: {e}")
def get_detailed_results_file_path(self):
default_detailed_results_path = os.path.join(
self.output_dir, "detailed_results.html"
)
if os.path.exists(default_detailed_results_path):
return default_detailed_results_path
else:
# v1.5 compatibility - get the first html file if detailed_results.html doesn't exists
html_files = glob.glob(os.path.join(self.output_dir, "*.html"))
if html_files:
return html_files[0]
async def send_detailed_results(self, file_path):
logger.info(
f"Updating detailed results {file_path} - {self.detailed_results_url}"
)
self._put_file(
self.detailed_results_url, file=file_path, content_type="text/html"
)
websocket_url = f"{self.websocket_url}?kind=detailed_results"
logger.info(f"Connecting to {websocket_url} for detailed results")
# Wrap this with a Try block to avoid getting stuck on Running
try:
websocket = await asyncio.wait_for(
websockets.connect(websocket_url), timeout=30.0
)
await websocket.send(
json.dumps(
{
"kind": "detailed_result_update",
}
)
)
except Exception as e:
logger.exception(e)
return
def _get_stdout_stderr_file_names(self, run_args):
# run_args should be the run_args argument passed to __init__ from the run_wrapper.
if not self.is_scoring:
DETAILED_OUTPUT_NAMES = [
"prediction_stdout",
"prediction_stderr",
"prediction_ingestion_stdout",
"prediction_ingestion_stderr",
]
else:
DETAILED_OUTPUT_NAMES = [
"scoring_stdout",
"scoring_stderr",
"scoring_ingestion_stdout",
"scoring_ingestion_stderr",
]
return [run_args[name] for name in DETAILED_OUTPUT_NAMES]
def _update_submission(self, data):
url = f"{self.submissions_api_url}/submissions/{self.submission_id}/"
data["secret"] = self.secret
logger.info(f"Updating submission @ {url} with data = {data}")
resp = self.requests_session.patch(url, data=data, timeout=150)
if resp.status_code == 200:
logger.info("Submission updated successfully!")
else:
logger.error(
f"Submission patch failed with status = {resp.status_code}, and response = \n{resp.content}"
)
raise SubmissionException("Failure updating submission data.")
def _update_status(self, status, extra_information=None):
# Update submission status
if status not in SubmissionStatus.AVAILABLE_STATUSES:
raise SubmissionException(
f"Status '{status}' is not in available statuses: {SubmissionStatus.AVAILABLE_STATUSES}"
)
data = {"status": status, "status_details": extra_information}
try:
self._update_submission(data)
except Exception as e:
# Always catch exception and never raise error
logger.exception(f"Failed to update submission status to {status}: {e}")
def _get_container_image(self, image_name):
logger.info("Running pull for image: {}".format(image_name))
retries, max_retries = (0, 3)
while retries < max_retries:
try:
with Progress() as progress:
resp = client.pull(image_name, stream=True, decode=True)
for line in resp:
if isinstance(line, dict) and line.get("error"):
raise DockerImagePullException(line["error"])
show_progress(line, progress)
break # Break if the loop is successful to exit "with Progress() as progress"
except (docker.errors.APIError, Exception) as pull_error:
retries += 1
if retries >= max_retries:
logger.error(
"There was a problem pulling the image : " + str(pull_error)
)
# Prepare data to be sent to submissions api
docker_pull_fail_data = {
"type": "Docker_Image_Pull_Fail",
"error_message": pull_error,
"is_scoring": self.is_scoring,
}
# Send data to be written to ingestion logs
self._update_submission(docker_pull_fail_data)
# Send error through web socket to the frontend
asyncio.run(self._send_data_through_socket(str(pull_error)))
raise DockerImagePullException(
f"Pull for {image_name} failed! Check the logs for more information"
)
else:
logger.warning("Failed. Retrying in 5 seconds...")
time.sleep(5) # Wait 5 seconds before retrying
async def _send_data_through_socket(self, error_message):
"""
This function gets an error messages and sends it through a web socket. This function is used for sending
- Docker image pull failure logs
- Execution time limit exceeded logs
"""
# Create a unique websocket URL for error messages
websocket_url = f"{self.websocket_url}?kind=error_logs"
logger.info(f"Connecting to {websocket_url} to send error message")
logger.info(f"Connecting to {websocket_url} to send docker image pull error")
# connect to web socket
websocket = await asyncio.wait_for(
websockets.connect(websocket_url), timeout=10.0
)
# define websocket errors
websocket_errors = (
socket.gaierror,
websockets.WebSocketException,
websockets.ConnectionClosedError,
ConnectionRefusedError,
)
try:
# send message
await websocket.send(
json.dumps({"kind": "stderr", "message": error_message})
)
except websocket_errors:
# handle websocket errors
logger.error("Error sending failed through websocket")
try:
await websocket.close()
except Exception as e:
logger.error(e)
else:
# no error in websocket message sending
logger.info("Error sent successfully through websocket")
logger.info(f"Disconnecting from websocket {websocket_url}")
# close websocket
await websocket.close()
def _get_bundle(self, url, destination, cache=True):
"""Downloads zip from url and unzips into destination. If cache=True then url is hashed and checked
against existence in CACHE_DIR/<hashed_url> and only downloaded if needed. Cache size is checked
during the prepare step and cleared if it's over MAX_CACHE_DIR_SIZE_GB.
:returns zip file path"""
logger.info(f"Getting bundle {url} to unpack @ {destination}")
download_needed = True
# Try to find the bundle in the cache of the worker
if cache:
# Hash url and download it if it doesn't exist
url_without_params = url.split("?")[0]
url_hash = hashlib.sha256(url_without_params.encode("utf8")).hexdigest()
bundle_file = os.path.join(Settings.CACHE_DIR, url_hash)
download_needed = not os.path.exists(bundle_file)
else:
if not os.path.exists(self.bundle_dir):
os.mkdir(self.bundle_dir)
bundle_file = tempfile.NamedTemporaryFile(
dir=self.bundle_dir, delete=False
).name
# Fetch and extract
retries, max_retries = (0, 10)
while retries < max_retries:
if download_needed:
try:
# Download the bundle
url = rewrite_bundle_url_if_needed(url)
urlretrieve(url, bundle_file)
except HTTPError:
raise SubmissionException(
f"Problem fetching {url} to put in {destination}"
)
try:
# Extract the contents to destination directory
with ZipFile(bundle_file, "r") as z:
z.extractall(os.path.join(self.root_dir, destination))
break # Break if the loop is successful
except BadZipFile:
retries += 1
if retries >= max_retries:
raise SubmissionException("Bad or empty zip file")
else:
logger.warning("Failed. Retrying in 20 seconds...")
time.sleep(20) # Wait 20 seconds before retrying
# Return the zip file path for other uses, e.g. for creating a MD5 hash to identify it
return bundle_file
def _create_container(
self,
container_name: str,
command: str,
volumes_host: list,
volumes_config: dict
):
"""
Helper to create and configure a container for ingestion, scoring, or submission.
Returns the container object.
"""
logger.info(
"Creating Container with: "
f"Container Name: {container_name} \n"
f"Command: {command} \n"
"Volumes config:"
)
pprint(volumes_config)
cap_drop_list = [
"AUDIT_WRITE",
"CHOWN",
"DAC_OVERRIDE",
"FOWNER",
"FSETID",
"KILL",
"MKNOD",
"NET_BIND_SERVICE",
"NET_RAW",
"SETFCAP",
"SETGID",
"SETPCAP",
"SETUID",
"SYS_CHROOT",
]
# Configure whether or not we use the GPU. Also setting auto_remove to False because
if Settings.CONTAINER_ENGINE_EXECUTABLE == Settings.DOCKER:
security_options = ["no-new-privileges"]
else:
security_options = ["label=disable"]
# Setting the device ID like this allows users to specify which gpu to use in the .env file, with all being the default if no value is given
device_id = [Settings.GPU_DEVICE]
if Settings.USE_GPU:
logger.info("Running the container with GPU capabilities")
host_config = client.create_host_config(
auto_remove=False,
cap_drop=cap_drop_list,
binds=volumes_config,
userns_mode="host",
security_opt=security_options,
device_requests=[
{
"Driver": "cdi",
"DeviceIDs": device_id,
},
],
)
else:
host_config = client.create_host_config(
auto_remove=False,
cap_drop=cap_drop_list,
binds=volumes_config,
userns_mode="host",
security_opt=security_options,
)
# Creating container
# COMPETITION_CONTAINER_NETWORK_DISABLED: Disable or not the competition container access to Internet (False by default)
# HTTP and HTTPS proxy for the competition container if needed
container = client.create_container(
self.container_image,
name=container_name,
host_config=host_config,
detach=False,
volumes=volumes_host,
command=command,
working_dir="/app/program",
environment=[
"PYTHONUNBUFFERED=1",
"http_proxy=" + Settings.COMPETITION_CONTAINER_HTTP_PROXY,
"https_proxy=" + Settings.COMPETITION_CONTAINER_HTTPS_PROXY,
],
network_disabled=Settings.COMPETITION_CONTAINER_NETWORK_DISABLED,
)
logger.debug("Created container: " + str(container))
return container
async def _run_container_engine_cmd(self, container, kind):
"""This runs a command and asynchronously writes the data to both a storage file
and a socket
:param engine_cmd: the list of container engine command arguments
:param kind: either 'ingestion' or 'program'
:return:
"""
# Creating this and setting 2 values to None in case there is not enough time for the worker to get logs, otherwise we will have errors later on
logs_Unified = [None, None]
# To store on-going logs and avoid empty logs returning to the platform
stdout_chunks = []
stderr_chunks = []
# Create a websocket to send the logs in real time to the codabench instance
# We need to set a timeout for the websocket connection otherwise the program will get stuck if he websocket does not connect.
websocket = None
try:
websocket_url = f"{self.websocket_url}?kind={kind}"
logger.debug(f"Connecting to {websocket_url} for container {str(container.get('Id'))}")
websocket = await asyncio.wait_for(
websockets.connect(websocket_url), timeout=10.0
)
logger.debug(f"connected to {websocket_url} for container {str(container.get('Id'))}")
except Exception as e:
logger.error(
f"There was an error trying to connect to the websocket on the codabench instance: {e}"
)
if Settings.LOG_LEVEL == Settings.LOG_LEVEL_DEBUG:
logger.exception(e)
start = time.time()
# Stream the logs of competition container while also sending them to the codabench instance
try:
logger.debug("Starting container " + container.get("Id"))
client.start(container=container.get("Id"))
logger.debug(
"Attaching to started container to get the logs :" + container.get("Id")
)
container_LogsDemux = client.attach(
container, demux=True, stream=True, logs=True
)
# If we enter the for loop after the container exited, the program will get stuck
if client.inspect_container(container)["State"]["Status"].lower() == "running":