compose-runner 0.6.3rc1__tar.gz → 0.6.4rc1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. {compose_runner-0.6.3rc1 → compose_runner-0.6.4rc1}/Dockerfile +7 -0
  2. {compose_runner-0.6.3rc1 → compose_runner-0.6.4rc1}/PKG-INFO +2 -2
  3. {compose_runner-0.6.3rc1 → compose_runner-0.6.4rc1}/README.md +1 -1
  4. {compose_runner-0.6.3rc1 → compose_runner-0.6.4rc1}/compose_runner/_version.py +2 -2
  5. {compose_runner-0.6.3rc1 → compose_runner-0.6.4rc1}/compose_runner/aws_lambda/run_handler.py +71 -1
  6. {compose_runner-0.6.3rc1 → compose_runner-0.6.4rc1}/compose_runner/ecs_task.py +3 -0
  7. compose_runner-0.6.4rc1/compose_runner/tests/cassettes/test_lambda_handlers/test_select_task_size_uses_large_for_montecarlo.yaml +60 -0
  8. compose_runner-0.6.4rc1/compose_runner/tests/cassettes/test_lambda_handlers/test_select_task_size_uses_standard_for_fdr.yaml +55 -0
  9. {compose_runner-0.6.3rc1 → compose_runner-0.6.4rc1}/compose_runner/tests/test_lambda_handlers.py +51 -0
  10. {compose_runner-0.6.3rc1 → compose_runner-0.6.4rc1}/infra/cdk/stacks/compose_runner_stack.py +96 -38
  11. {compose_runner-0.6.3rc1 → compose_runner-0.6.4rc1}/.gitignore +0 -0
  12. {compose_runner-0.6.3rc1 → compose_runner-0.6.4rc1}/LICENSE +0 -0
  13. {compose_runner-0.6.3rc1 → compose_runner-0.6.4rc1}/aws_lambda/.dockerignore +0 -0
  14. {compose_runner-0.6.3rc1 → compose_runner-0.6.4rc1}/aws_lambda/Dockerfile +0 -0
  15. {compose_runner-0.6.3rc1 → compose_runner-0.6.4rc1}/compose_runner/__init__.py +0 -0
  16. {compose_runner-0.6.3rc1 → compose_runner-0.6.4rc1}/compose_runner/aws_lambda/__init__.py +0 -0
  17. {compose_runner-0.6.3rc1 → compose_runner-0.6.4rc1}/compose_runner/aws_lambda/common.py +0 -0
  18. {compose_runner-0.6.3rc1 → compose_runner-0.6.4rc1}/compose_runner/aws_lambda/log_poll_handler.py +0 -0
  19. {compose_runner-0.6.3rc1 → compose_runner-0.6.4rc1}/compose_runner/aws_lambda/results_handler.py +0 -0
  20. {compose_runner-0.6.3rc1 → compose_runner-0.6.4rc1}/compose_runner/aws_lambda/status_handler.py +0 -0
  21. {compose_runner-0.6.3rc1 → compose_runner-0.6.4rc1}/compose_runner/cli.py +0 -0
  22. {compose_runner-0.6.3rc1 → compose_runner-0.6.4rc1}/compose_runner/run.py +0 -0
  23. {compose_runner-0.6.3rc1 → compose_runner-0.6.4rc1}/compose_runner/sentry.py +0 -0
  24. {compose_runner-0.6.3rc1 → compose_runner-0.6.4rc1}/compose_runner/tests/cassettes/test_run/test_download_bundle.yaml +0 -0
  25. {compose_runner-0.6.3rc1 → compose_runner-0.6.4rc1}/compose_runner/tests/cassettes/test_run/test_run_database_workflow.yaml +0 -0
  26. {compose_runner-0.6.3rc1 → compose_runner-0.6.4rc1}/compose_runner/tests/cassettes/test_run/test_run_group_comparison_workflow.yaml +0 -0
  27. {compose_runner-0.6.3rc1 → compose_runner-0.6.4rc1}/compose_runner/tests/cassettes/test_run/test_run_string_group_comparison_workflow.yaml +0 -0
  28. {compose_runner-0.6.3rc1 → compose_runner-0.6.4rc1}/compose_runner/tests/cassettes/test_run/test_run_workflow.yaml +0 -0
  29. {compose_runner-0.6.3rc1 → compose_runner-0.6.4rc1}/compose_runner/tests/conftest.py +0 -0
  30. {compose_runner-0.6.3rc1 → compose_runner-0.6.4rc1}/compose_runner/tests/test_cli.py +0 -0
  31. {compose_runner-0.6.3rc1 → compose_runner-0.6.4rc1}/compose_runner/tests/test_ecs_task.py +0 -0
  32. {compose_runner-0.6.3rc1 → compose_runner-0.6.4rc1}/compose_runner/tests/test_run.py +0 -0
  33. {compose_runner-0.6.3rc1 → compose_runner-0.6.4rc1}/infra/cdk/app.py +0 -0
  34. {compose_runner-0.6.3rc1 → compose_runner-0.6.4rc1}/infra/cdk/cdk.json +0 -0
  35. {compose_runner-0.6.3rc1 → compose_runner-0.6.4rc1}/infra/cdk/requirements.txt +0 -0
  36. {compose_runner-0.6.3rc1 → compose_runner-0.6.4rc1}/infra/cdk/stacks/__init__.py +0 -0
  37. {compose_runner-0.6.3rc1 → compose_runner-0.6.4rc1}/pyproject.toml +0 -0
@@ -1,5 +1,12 @@
1
1
  FROM python:3.13-slim
2
2
 
3
+ ARG COMPOSE_RUNNER_VERSION
4
+ ENV COMPOSE_RUNNER_VERSION=${COMPOSE_RUNNER_VERSION}
5
+ LABEL org.opencontainers.image.title="compose-runner ecs task"
6
+ LABEL org.opencontainers.image.version=${COMPOSE_RUNNER_VERSION}
7
+
8
+ RUN test -n "$COMPOSE_RUNNER_VERSION" || (echo "COMPOSE_RUNNER_VERSION build arg is required" && exit 1)
9
+
3
10
  RUN apt-get update && apt-get install -y \
4
11
  git \
5
12
  && rm -rf /var/lib/apt/lists/*
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: compose-runner
3
- Version: 0.6.3rc1
3
+ Version: 0.6.4rc1
4
4
  Summary: A package for running neurosynth-compose analyses
5
5
  Project-URL: Repository, https://github.com/neurostuff/compose-runner
6
6
  Author-email: James Kent <jamesdkent21@gmail.com>
@@ -67,7 +67,7 @@ The deployed architecture works like this:
67
67
  Pass `-c resultsBucketName=<bucket>` to use an existing S3 bucket, or omit it
68
68
  to let the stack create and retain a dedicated bucket. Additional knobs:
69
69
 
70
- - `-c stateMachineTimeoutSeconds=7200` to control the max wall clock per run
70
+ - `-c stateMachineTimeoutSeconds=32400` to control the max wall clock per run
71
71
  - `-c submitTimeoutSeconds` / `-c statusTimeoutSeconds` / `-c pollTimeoutSeconds`
72
72
  to tune Lambda timeouts
73
73
  - `-c taskEphemeralStorageGiB` if the default 21 GiB scratch volume is insufficient
@@ -44,7 +44,7 @@ The deployed architecture works like this:
44
44
  Pass `-c resultsBucketName=<bucket>` to use an existing S3 bucket, or omit it
45
45
  to let the stack create and retain a dedicated bucket. Additional knobs:
46
46
 
47
- - `-c stateMachineTimeoutSeconds=7200` to control the max wall clock per run
47
+ - `-c stateMachineTimeoutSeconds=32400` to control the max wall clock per run
48
48
  - `-c submitTimeoutSeconds` / `-c statusTimeoutSeconds` / `-c pollTimeoutSeconds`
49
49
  to tune Lambda timeouts
50
50
  - `-c taskEphemeralStorageGiB` if the default 21 GiB scratch volume is insufficient
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.6.3rc1'
32
- __version_tuple__ = version_tuple = (0, 6, 3, 'rc1')
31
+ __version__ = version = '0.6.4rc1'
32
+ __version_tuple__ = version_tuple = (0, 6, 4, 'rc1')
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -4,6 +4,8 @@ import json
4
4
  import logging
5
5
  import os
6
6
  import uuid
7
+ import urllib.error
8
+ import urllib.request
7
9
  from typing import Any, Dict, Optional
8
10
 
9
11
  import boto3
@@ -22,6 +24,8 @@ RESULTS_PREFIX_ENV = "RESULTS_PREFIX"
22
24
  NSC_KEY_ENV = "NSC_KEY"
23
25
  NV_KEY_ENV = "NV_KEY"
24
26
 
27
+ DEFAULT_TASK_SIZE = "standard"
28
+
25
29
 
26
30
  def _log(job_id: str, message: str, **details: Any) -> None:
27
31
  payload = {"job_id": job_id, "message": message, **details}
@@ -29,6 +33,67 @@ def _log(job_id: str, message: str, **details: Any) -> None:
29
33
  logger.info(json.dumps(payload))
30
34
 
31
35
 
36
+ def _compose_api_base_url(environment: str) -> str:
37
+ env = (environment or "production").lower()
38
+ if env == "staging":
39
+ return "https://synth.neurostore.xyz/api"
40
+ if env == "local":
41
+ return "http://localhost:81/api"
42
+ return "https://compose.neurosynth.org/api"
43
+
44
+
45
+ def _fetch_meta_analysis(meta_analysis_id: str, environment: str) -> Optional[Dict[str, Any]]:
46
+ base_url = _compose_api_base_url(environment).rstrip("/")
47
+ url = f"{base_url}/meta-analyses/{meta_analysis_id}?nested=true"
48
+ request = urllib.request.Request(url, headers={"User-Agent": "compose-runner/submit"})
49
+ try:
50
+ with urllib.request.urlopen(request, timeout=10) as response:
51
+ return json.load(response)
52
+ except (urllib.error.URLError, urllib.error.HTTPError, json.JSONDecodeError) as exc:
53
+ logger.warning("Failed to fetch meta-analysis %s: %s", meta_analysis_id, exc)
54
+ return None
55
+
56
+
57
+ def _requires_large_task(specification: Dict[str, Any]) -> bool:
58
+ if not isinstance(specification, dict):
59
+ return False
60
+ corrector = specification.get("corrector")
61
+ if not isinstance(corrector, dict):
62
+ return False
63
+ if corrector.get("type") != "FWECorrector":
64
+ return False
65
+ args = corrector.get("args")
66
+ if not isinstance(args, dict):
67
+ return False
68
+ method = args.get("method")
69
+ if method is None:
70
+ kwargs = args.get("**kwargs")
71
+ if isinstance(kwargs, dict):
72
+ method = kwargs.get("method")
73
+ if isinstance(method, str) and method.lower() == "montecarlo":
74
+ return True
75
+ return False
76
+
77
+
78
+ def _select_task_size(meta_analysis_id: str, environment: str, artifact_prefix: str) -> str:
79
+ doc = _fetch_meta_analysis(meta_analysis_id, environment)
80
+ if not doc:
81
+ return DEFAULT_TASK_SIZE
82
+ specification = doc.get("specification")
83
+ try:
84
+ if _requires_large_task(specification):
85
+ _log(
86
+ artifact_prefix,
87
+ "workflow.task_size_selected",
88
+ task_size="large",
89
+ reason="montecarlo_fwe",
90
+ )
91
+ return "large"
92
+ except Exception as exc: # noqa: broad-except
93
+ logger.warning("Failed to evaluate specification for %s: %s", meta_analysis_id, exc)
94
+ return DEFAULT_TASK_SIZE
95
+
96
+
32
97
  def _job_input(
33
98
  payload: Dict[str, Any],
34
99
  artifact_prefix: str,
@@ -36,6 +101,7 @@ def _job_input(
36
101
  prefix: Optional[str],
37
102
  nsc_key: Optional[str],
38
103
  nv_key: Optional[str],
104
+ task_size: str,
39
105
  ) -> Dict[str, Any]:
40
106
  no_upload_flag = bool(payload.get("no_upload", False))
41
107
  doc: Dict[str, Any] = {
@@ -44,6 +110,7 @@ def _job_input(
44
110
  "environment": payload.get("environment", "production"),
45
111
  "no_upload": "true" if no_upload_flag else "false",
46
112
  "results": {"bucket": bucket or "", "prefix": prefix or ""},
113
+ "task_size": task_size,
47
114
  }
48
115
  n_cores = payload.get("n_cores")
49
116
  doc["n_cores"] = str(n_cores) if n_cores is not None else ""
@@ -76,7 +143,10 @@ def handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
76
143
  nsc_key = payload.get("nsc_key") or os.environ.get(NSC_KEY_ENV)
77
144
  nv_key = payload.get("nv_key") or os.environ.get(NV_KEY_ENV)
78
145
 
79
- job_input = _job_input(payload, artifact_prefix, bucket, prefix, nsc_key, nv_key)
146
+ environment = payload.get("environment", "production")
147
+ task_size = _select_task_size(payload["meta_analysis_id"], environment, artifact_prefix)
148
+
149
+ job_input = _job_input(payload, artifact_prefix, bucket, prefix, nsc_key, nv_key, task_size)
80
150
  params = {
81
151
  "stateMachineArn": os.environ[STATE_MACHINE_ARN_ENV],
82
152
  "name": artifact_prefix,
@@ -93,6 +93,7 @@ def main() -> None:
93
93
  nv_key = os.environ.get(NV_KEY_ENV) or None
94
94
  no_upload = _bool_from_env(os.environ.get(NO_UPLOAD_ENV))
95
95
  n_cores = _resolve_n_cores(os.environ.get(N_CORES_ENV))
96
+ compose_runner_version = os.environ.get("COMPOSE_RUNNER_VERSION", "unknown")
96
97
 
97
98
  bucket = os.environ.get(RESULTS_BUCKET_ENV)
98
99
  prefix = os.environ.get(RESULTS_PREFIX_ENV)
@@ -106,6 +107,7 @@ def main() -> None:
106
107
  meta_analysis_id=meta_analysis_id,
107
108
  environment=environment,
108
109
  no_upload=no_upload,
110
+ compose_runner_version=compose_runner_version,
109
111
  )
110
112
  try:
111
113
  url, _ = run_compose(
@@ -125,6 +127,7 @@ def main() -> None:
125
127
  "result_url": url,
126
128
  "artifacts_bucket": bucket,
127
129
  "artifacts_prefix": prefix,
130
+ "compose_runner_version": compose_runner_version,
128
131
  }
129
132
 
130
133
  if bucket:
@@ -0,0 +1,60 @@
1
+ interactions:
2
+ - request:
3
+ method: GET
4
+ uri: https://synth.neurostore.xyz/api/meta-analyses/ZPSvyvhZAopz?nested=true
5
+ body: null
6
+ headers:
7
+ Accept:
8
+ - '*/*'
9
+ Accept-Encoding:
10
+ - gzip, deflate
11
+ Connection:
12
+ - keep-alive
13
+ User-Agent:
14
+ - python-requests/2.32.4
15
+ response:
16
+ status:
17
+ code: 200
18
+ message: OK
19
+ headers:
20
+ Server:
21
+ - nginx/1.21.6
22
+ Date:
23
+ - Tue, 21 Oct 2025 14:08:45 GMT
24
+ Content-Type:
25
+ - application/json
26
+ Transfer-Encoding:
27
+ - chunked
28
+ Connection:
29
+ - keep-alive
30
+ Vary:
31
+ - Accept-Encoding
32
+ Content-Encoding:
33
+ - gzip
34
+ Strict-Transport-Security:
35
+ - max-age=31536000
36
+ body:
37
+ string: '{"id": "ZPSvyvhZAopz", "created_at": "2025-10-21T04:57:40.236536+00:00",
38
+ "updated_at": null, "user": "github|12564882", "username": "James Kent", "name":
39
+ "Untitled MKDADensity Meta Analysis: included", "description": "MKDADensity
40
+ meta analysis with FWECorrector", "provenance": null, "specification": {"id":
41
+ "zQdMa4uAaYYU", "created_at": "2025-10-21T04:57:39.888528+00:00", "updated_at":
42
+ null, "user": "github|12564882", "username": "James Kent", "type": "CBMA",
43
+ "estimator": {"type": "MKDADensity", "args": {"null_method": "approximate",
44
+ "n_iters": 5000, "**kwargs": {}, "kernel__r": 10, "kernel__value": 1}}, "database_studyset":
45
+ null, "filter": "included", "corrector": {"type": "FWECorrector", "args":
46
+ {"voxel_thresh": 0.001, "n_iters": 5000, "vfwe_only": false, "method": "montecarlo"}},
47
+ "conditions": [true], "weights": [1.0]}, "neurostore_analysis": {"id": "8S5xRedCGRkz",
48
+ "created_at": "2025-10-21T04:57:40.255480+00:00", "updated_at": null, "neurostore_id":
49
+ null, "exception": null, "traceback": null, "status": "PENDING"}, "studyset":
50
+ {"id": "9jPvdkuRufUP", "created_at": "2025-10-21T04:57:40.008456+00:00", "updated_at":
51
+ null, "user": "github|12564882", "username": "James Kent", "snapshot": null,
52
+ "neurostore_id": "3EmvH2LELwR2", "version": null, "url": "https://neurostore.org/api/studysets/3EmvH2LELwR2"},
53
+ "annotation": {"id": "YVLt6DRFKdd5", "created_at": "2025-10-21T04:57:40.121637+00:00",
54
+ "updated_at": null, "user": "github|12564882", "username": "James Kent", "snapshot":
55
+ null, "neurostore_id": "TebrRstj8ofh", "studyset": "3EmvH2LELwR2", "url":
56
+ "https://neurostore.org/api/annotations/TebrRstj8ofh"}, "project": "D2cTfoxNfpLy",
57
+ "cached_studyset": "9jPvdkuRufUP", "cached_annotation": "YVLt6DRFKdd5", "run_key":
58
+ "PDeDnh_8MXc88xoVJySz3w", "results": [], "neurostore_url": null}'
59
+ http_version: HTTP/1.1
60
+ version: 1
@@ -0,0 +1,55 @@
1
+ interactions:
2
+ - request:
3
+ body: null
4
+ headers:
5
+ Connection:
6
+ - close
7
+ Host:
8
+ - synth.neurostore.xyz
9
+ User-Agent:
10
+ - compose-runner/submit
11
+ method: GET
12
+ uri: https://synth.neurostore.xyz/api/meta-analyses/VtFZJFniCKvG?nested=true
13
+ response:
14
+ body:
15
+ string: '{"id": "VtFZJFniCKvG", "created_at": "2025-10-21T14:10:35.309383+00:00",
16
+ "updated_at": null, "user": "github|12564882", "username": "James Kent", "name":
17
+ "Untitled MKDADensity Meta Analysis: included (1)", "description": "MKDADensity
18
+ meta analysis with FDRCorrector", "provenance": null, "specification": {"id":
19
+ "DtVzKEKGaXLu", "created_at": "2025-10-21T14:10:34.564365+00:00", "updated_at":
20
+ null, "user": "github|12564882", "username": "James Kent", "type": "CBMA",
21
+ "estimator": {"type": "MKDADensity", "args": {"null_method": "approximate",
22
+ "n_iters": 5000, "**kwargs": {}, "kernel__r": 10, "kernel__value": 1}}, "database_studyset":
23
+ null, "filter": "included", "corrector": {"type": "FDRCorrector", "args":
24
+ {"method": "indep", "alpha": 0.05}}, "conditions": [true], "weights": [1.0]},
25
+ "neurostore_analysis": {"id": "564c8kRnJVT4", "created_at": "2025-10-21T14:10:35.325173+00:00",
26
+ "updated_at": null, "neurostore_id": null, "exception": null, "traceback":
27
+ null, "status": "PENDING"}, "studyset": {"id": "FA3BDBdGRZ5d", "created_at":
28
+ "2025-10-21T14:10:34.821625+00:00", "updated_at": null, "user": "github|12564882",
29
+ "username": "James Kent", "snapshot": null, "neurostore_id": "3EmvH2LELwR2",
30
+ "version": null, "url": "https://neurostore.org/api/studysets/3EmvH2LELwR2"},
31
+ "annotation": {"id": "XELVYV7ftp7e", "created_at": "2025-10-21T14:10:35.183354+00:00",
32
+ "updated_at": null, "user": "github|12564882", "username": "James Kent", "snapshot":
33
+ null, "neurostore_id": "TebrRstj8ofh", "studyset": "3EmvH2LELwR2", "url":
34
+ "https://neurostore.org/api/annotations/TebrRstj8ofh"}, "project": "D2cTfoxNfpLy",
35
+ "cached_studyset": "FA3BDBdGRZ5d", "cached_annotation": "XELVYV7ftp7e", "run_key":
36
+ "V_jTcP2zfNlWD4KhwKKcJw", "results": [], "neurostore_url": null}'
37
+ headers:
38
+ Connection:
39
+ - close
40
+ Content-Length:
41
+ - '1750'
42
+ Content-Type:
43
+ - application/json
44
+ Date:
45
+ - Tue, 21 Oct 2025 14:14:50 GMT
46
+ Server:
47
+ - nginx/1.21.6
48
+ Strict-Transport-Security:
49
+ - max-age=31536000
50
+ Vary:
51
+ - Accept-Encoding
52
+ status:
53
+ code: 200
54
+ message: OK
55
+ version: 1
@@ -4,6 +4,8 @@ import json
4
4
  from datetime import datetime, timezone
5
5
  from typing import Any, Dict
6
6
 
7
+ import pytest
8
+
7
9
  from compose_runner.aws_lambda import log_poll_handler, results_handler, run_handler, status_handler
8
10
 
9
11
 
@@ -23,6 +25,28 @@ def _make_http_event(payload: Dict[str, Any]) -> Dict[str, Any]:
23
25
  }
24
26
 
25
27
 
28
+ def test_requires_large_task_detection():
29
+ spec = {"corrector": {"type": "FWECorrector", "args": {"method": "montecarlo"}}}
30
+ assert run_handler._requires_large_task(spec)
31
+
32
+
33
+ def test_requires_large_task_false_when_method_differs():
34
+ spec = {"corrector": {"type": "FWECorrector", "args": {"method": "bonferroni"}}}
35
+ assert run_handler._requires_large_task(spec) is False
36
+
37
+
38
+ @pytest.mark.vcr(record_mode="once")
39
+ def test_select_task_size_uses_large_for_montecarlo():
40
+ task_size = run_handler._select_task_size("ZPSvyvhZAopz", "staging", "artifact-test")
41
+ assert task_size == "large"
42
+
43
+
44
+ @pytest.mark.vcr(record_mode="once")
45
+ def test_select_task_size_uses_standard_for_fdr():
46
+ task_size = run_handler._select_task_size("VtFZJFniCKvG", "staging", "artifact-test")
47
+ assert task_size == "standard"
48
+
49
+
26
50
  def test_run_handler_http_success(monkeypatch, tmp_path):
27
51
  captured = {}
28
52
 
@@ -36,6 +60,7 @@ def test_run_handler_http_success(monkeypatch, tmp_path):
36
60
  ...
37
61
 
38
62
  monkeypatch.setattr(run_handler, "_SFN_CLIENT", FakeSFN())
63
+ monkeypatch.setattr(run_handler, "_select_task_size", lambda *args: "standard")
39
64
  monkeypatch.setenv("STATE_MACHINE_ARN", "arn:aws:states:state-machine")
40
65
  monkeypatch.setenv("RESULTS_BUCKET", "bucket")
41
66
  monkeypatch.setenv("RESULTS_PREFIX", "prefix")
@@ -63,6 +88,32 @@ def test_run_handler_http_success(monkeypatch, tmp_path):
63
88
  assert input_doc["results"]["prefix"] == "prefix"
64
89
  assert input_doc["nsc_key"] == "nsc"
65
90
  assert input_doc["nv_key"] == "nv"
91
+ assert input_doc["task_size"] == "standard"
92
+
93
+
94
+ def test_run_handler_http_uses_large_task(monkeypatch):
95
+ captured = {}
96
+
97
+ class FakeSFN:
98
+ def start_execution(self, **kwargs):
99
+ captured.update(kwargs)
100
+ return {"executionArn": "arn:aws:states:us-east-1:123:execution:state-machine:run-456"}
101
+
102
+ class exceptions:
103
+ class ExecutionAlreadyExists(Exception):
104
+ ...
105
+
106
+ monkeypatch.setattr(run_handler, "_SFN_CLIENT", FakeSFN())
107
+ monkeypatch.setattr(run_handler, "_select_task_size", lambda *args: "large")
108
+ monkeypatch.setenv("STATE_MACHINE_ARN", "arn:aws:states:state-machine")
109
+ monkeypatch.setenv("RESULTS_BUCKET", "bucket")
110
+ monkeypatch.setenv("RESULTS_PREFIX", "prefix")
111
+
112
+ event = _make_http_event({"meta_analysis_id": "abc123"})
113
+ response = run_handler.handler(event, DummyContext())
114
+ assert response["statusCode"] == 202
115
+ input_doc = json.loads(captured["input"])
116
+ assert input_doc["task_size"] == "large"
66
117
 
67
118
 
68
119
  def test_run_handler_missing_meta_analysis(monkeypatch):
@@ -40,7 +40,12 @@ class ComposeRunnerStack(Stack):
40
40
  task_cpu = int(self.node.try_get_context("taskCpu") or 4096)
41
41
  task_memory_mib = int(self.node.try_get_context("taskMemoryMiB") or 30720)
42
42
  task_ephemeral_storage_gib = int(self.node.try_get_context("taskEphemeralStorageGiB") or 21)
43
- state_machine_timeout_seconds = int(self.node.try_get_context("stateMachineTimeoutSeconds") or 7200)
43
+ task_cpu_large = int(self.node.try_get_context("taskCpuLarge") or 16384)
44
+ task_memory_large_mib = int(self.node.try_get_context("taskMemoryLargeMiB") or 65536)
45
+ state_machine_timeout_seconds = int(self.node.try_get_context("stateMachineTimeoutSeconds") or 32400)
46
+
47
+ if task_cpu_large >= 16384 and task_memory_large_mib < 32768:
48
+ raise ValueError("taskMemoryLargeMiB must be at least 32768 MiB for 16 vCPU tasks.")
44
49
 
45
50
  project_root = Path(__file__).resolve().parents[3]
46
51
  project_version = self.node.try_get_context("composeRunnerVersion")
@@ -121,6 +126,20 @@ class ComposeRunnerStack(Stack):
121
126
  ephemeral_storage_gib=task_ephemeral_storage_gib,
122
127
  )
123
128
 
129
+ task_definition_large = ecs.FargateTaskDefinition(
130
+ self,
131
+ "ComposeRunnerLargeTaskDefinition",
132
+ cpu=task_cpu_large,
133
+ memory_limit_mib=task_memory_large_mib,
134
+ ephemeral_storage_gib=task_ephemeral_storage_gib,
135
+ )
136
+
137
+ container_environment = {
138
+ "RESULTS_BUCKET": results_bucket.bucket_name,
139
+ "RESULTS_PREFIX": results_prefix,
140
+ "DELETE_TMP": "true",
141
+ }
142
+
124
143
  container = task_definition.add_container(
125
144
  "ComposeRunnerContainer",
126
145
  image=ecs.ContainerImage.from_docker_image_asset(fargate_asset),
@@ -129,16 +148,46 @@ class ComposeRunnerStack(Stack):
129
148
  log_group=task_log_group,
130
149
  stream_prefix="compose-runner",
131
150
  ),
132
- environment={
133
- "RESULTS_BUCKET": results_bucket.bucket_name,
134
- "RESULTS_PREFIX": results_prefix,
135
- "DELETE_TMP": "true",
136
- },
151
+ environment=container_environment,
152
+ )
153
+
154
+ container_large = task_definition_large.add_container(
155
+ "ComposeRunnerLargeContainer",
156
+ image=ecs.ContainerImage.from_docker_image_asset(fargate_asset),
157
+ entry_point=["python", "-m", "compose_runner.ecs_task"],
158
+ logging=ecs.LogDriver.aws_logs(
159
+ log_group=task_log_group,
160
+ stream_prefix="compose-runner",
161
+ ),
162
+ environment=container_environment,
137
163
  )
138
164
 
139
165
  results_bucket.grant_read_write(task_definition.task_role)
166
+ results_bucket.grant_read_write(task_definition_large.task_role)
167
+
168
+ container_env_overrides = [
169
+ tasks.TaskEnvironmentVariable(
170
+ name="ARTIFACT_PREFIX", value=sfn.JsonPath.string_at("$.artifact_prefix")
171
+ ),
172
+ tasks.TaskEnvironmentVariable(
173
+ name="META_ANALYSIS_ID", value=sfn.JsonPath.string_at("$.meta_analysis_id")
174
+ ),
175
+ tasks.TaskEnvironmentVariable(
176
+ name="ENVIRONMENT", value=sfn.JsonPath.string_at("$.environment")
177
+ ),
178
+ tasks.TaskEnvironmentVariable(name="NSC_KEY", value=sfn.JsonPath.string_at("$.nsc_key")),
179
+ tasks.TaskEnvironmentVariable(name="NV_KEY", value=sfn.JsonPath.string_at("$.nv_key")),
180
+ tasks.TaskEnvironmentVariable(name="NO_UPLOAD", value=sfn.JsonPath.string_at("$.no_upload")),
181
+ tasks.TaskEnvironmentVariable(name="N_CORES", value=sfn.JsonPath.string_at("$.n_cores")),
182
+ tasks.TaskEnvironmentVariable(
183
+ name="RESULTS_BUCKET", value=sfn.JsonPath.string_at("$.results.bucket")
184
+ ),
185
+ tasks.TaskEnvironmentVariable(
186
+ name="RESULTS_PREFIX", value=sfn.JsonPath.string_at("$.results.prefix")
187
+ ),
188
+ ]
140
189
 
141
- run_task = tasks.EcsRunTask(
190
+ run_task_standard = tasks.EcsRunTask(
142
191
  self,
143
192
  "RunFargateJob",
144
193
  integration_pattern=sfn.IntegrationPattern.RUN_JOB,
@@ -153,41 +202,41 @@ class ComposeRunnerStack(Stack):
153
202
  container_overrides=[
154
203
  tasks.ContainerOverride(
155
204
  container_definition=container,
156
- environment=[
157
- tasks.TaskEnvironmentVariable(
158
- name="ARTIFACT_PREFIX", value=sfn.JsonPath.string_at("$.artifact_prefix")
159
- ),
160
- tasks.TaskEnvironmentVariable(
161
- name="META_ANALYSIS_ID", value=sfn.JsonPath.string_at("$.meta_analysis_id")
162
- ),
163
- tasks.TaskEnvironmentVariable(
164
- name="ENVIRONMENT", value=sfn.JsonPath.string_at("$.environment")
165
- ),
166
- tasks.TaskEnvironmentVariable(
167
- name="NSC_KEY", value=sfn.JsonPath.string_at("$.nsc_key")
168
- ),
169
- tasks.TaskEnvironmentVariable(
170
- name="NV_KEY", value=sfn.JsonPath.string_at("$.nv_key")
171
- ),
172
- tasks.TaskEnvironmentVariable(
173
- name="NO_UPLOAD", value=sfn.JsonPath.string_at("$.no_upload")
174
- ),
175
- tasks.TaskEnvironmentVariable(
176
- name="N_CORES", value=sfn.JsonPath.string_at("$.n_cores")
177
- ),
178
- tasks.TaskEnvironmentVariable(
179
- name="RESULTS_BUCKET", value=sfn.JsonPath.string_at("$.results.bucket")
180
- ),
181
- tasks.TaskEnvironmentVariable(
182
- name="RESULTS_PREFIX", value=sfn.JsonPath.string_at("$.results.prefix")
183
- ),
184
- ],
205
+ environment=container_env_overrides,
185
206
  )
186
207
  ],
187
208
  result_path="$.ecs",
188
209
  )
189
210
 
190
- run_task.add_retry(
211
+ run_task_large = tasks.EcsRunTask(
212
+ self,
213
+ "RunFargateJobLarge",
214
+ integration_pattern=sfn.IntegrationPattern.RUN_JOB,
215
+ cluster=cluster,
216
+ task_definition=task_definition_large,
217
+ launch_target=tasks.EcsFargateLaunchTarget(
218
+ platform_version=ecs.FargatePlatformVersion.LATEST
219
+ ),
220
+ assign_public_ip=True,
221
+ security_groups=[task_security_group],
222
+ subnets=ec2.SubnetSelection(subnet_type=ec2.SubnetType.PUBLIC),
223
+ container_overrides=[
224
+ tasks.ContainerOverride(
225
+ container_definition=container_large,
226
+ environment=container_env_overrides,
227
+ )
228
+ ],
229
+ result_path="$.ecs",
230
+ )
231
+
232
+ run_task_standard.add_retry(
233
+ errors=["States.ALL"],
234
+ interval=Duration.seconds(30),
235
+ backoff_rate=2.0,
236
+ max_attempts=2,
237
+ )
238
+
239
+ run_task_large.add_retry(
191
240
  errors=["States.ALL"],
192
241
  interval=Duration.seconds(30),
193
242
  backoff_rate=2.0,
@@ -202,11 +251,20 @@ class ComposeRunnerStack(Stack):
202
251
  "meta_analysis_id.$": "$.meta_analysis_id",
203
252
  "environment.$": "$.environment",
204
253
  "results.$": "$.results",
254
+ "task_size.$": "$.task_size",
205
255
  "ecs.$": "$.ecs",
206
256
  },
207
257
  )
208
258
 
209
- definition_chain = run_task.next(run_output)
259
+ definition_chain = sfn.Choice(
260
+ self,
261
+ "SelectFargateTask",
262
+ ).when(
263
+ sfn.Condition.string_equals("$.task_size", "large"),
264
+ run_task_large.next(run_output),
265
+ ).otherwise(
266
+ run_task_standard.next(run_output)
267
+ )
210
268
 
211
269
  state_machine = sfn.StateMachine(
212
270
  self,