← Files FlyteARCHIVED FILE
skills/flyte-migrate-slurm/SKILL.md
25.6 KB · Oct 3, 2026 · 06:17 UTC
---
name: flyte-migrate-slurm
description: Migrates Slurm (sbatch/srun) HPC workloads to Flyte 2 (the flyte Python SDK) — job scripts become typed tasks, `#SBATCH` pragmas become TaskEnvironment config, job arrays become flyte.map, and multi-node training becomes a clustered task environment. Use when porting an HPC or supercomputer cluster workload off Slurm, translating `#SBATCH` pragmas, or replacing sbatch chains, job arrays, and module load with Flyte. Trigger words are sbatch, srun, SLURM, `#SBATCH`, HPC, job array, partition, squeue, sinfo, module load, scancel, supercomputer, cluster migration.
---
# Slurm to Flyte 2 Migration Skill
Slurm schedules jobs. Somewhere along the way ML work stopped being jobs and became pipelines — a data prep step, a training step, an eval step, a sweep over configs, each with different hardware and failure characteristics. A Slurm job is a bash script with `#SBATCH` pragmas at the top; in Flyte 2 that script becomes a typed Python function decorated with `@env.task`, a pipeline is just a task that calls other tasks, and control flow is plain Python. This skill maps the Slurm surface area onto the Flyte 2 SDK and is honest about the places where Slurm still wins.
## Grounding References
| Resource | URL |
| ----------------- | ---------------------------------------------------------------- |
| Migration guide | https://www.union.ai/docs/v2/flyte/user-guide/migration/flyte-2/ |
| Official docs | https://www.union.ai/docs/v2/flyte |
| Docs index (LLMs) | https://www.union.ai/docs/v2/flyte/llms.txt |
| SDK API reference | https://www.union.ai/docs/v2/union/api-reference/flyte-sdk/ |
| Example code | https://github.com/unionai/unionai-examples |
| Flyte MCP tools | Available via `flyte-mcp` server |
## Three mental shifts
Almost every point of friction for a Slurm migrant traces back to one of these three.
### 1. Modules become images
On Slurm you `module load cuda/12.1`, activate a venv on NFS, and hope the login node and the compute node agree. In Flyte 2 the environment is declared once, in Python, as a `flyte.Image`. There is no Dockerfile to write: images are built remotely and content-hashed, so an unchanged spec is a cache hit and a changed one rebuilds automatically.
```python
image = (
flyte.Image.from_debian_base(python_version=(3, 12))
.with_pip_packages("torch", "transformers", "datasets")
.with_env_vars({"HF_HUB_ENABLE_HF_TRANSFER": "1"})
)
```
### 2. The shared filesystem becomes explicit data
There is no shared `/home` or `/scratch` that every node sees. Instead you pass `flyte.io.File` and `flyte.io.Dir` — typed references to object storage that stream rather than copy. This is more typing than `/scratch/$USER/run17/ckpt.pt`, and in exchange you get lineage for free: every input and output of every task is recorded, so "which dataset produced this checkpoint" is a question the system answers instead of a question you grep for. If you genuinely need a parallel filesystem (Lustre, GPFS, FSx), mount it through a CSI driver and a `flyte.PodTemplate` — that path stays open.
### 3. Job scripts become functions
`sbatch` chains held together by `--dependency=afterok`, sentinel files on NFS, and a cron job that polls for them all collapse into ordinary Python: call a function, await it, branch on the result. Binaries you can't or won't rewrite in Python still run — as container tasks with typed inputs and outputs.
## Migration Cheat Sheet
| Slurm | Flyte 2 |
| ------------------------------------------------- | ----------------------------------------------------------- |
| `sbatch train.sh` | `flyte run train.py main` |
| `srun --pty python train.py` (interactive) | `flyte run --local train.py main`, or a devbox |
| `#SBATCH --gres=gpu:a100:8` | `flyte.Resources(gpu="A100:8")` |
| `#SBATCH --cpus-per-task=16 --mem=64G` | `flyte.Resources(cpu=16, memory="64Gi")` |
| `#SBATCH --tmp=100G` | `flyte.Resources(disk="100Gi")` |
| `#SBATCH --array=0-999` | `flyte.map(step, range(1000), concurrency=200)` |
| `#SBATCH --nodes=4 --ntasks-per-node=8` | `ClusteredTaskEnvironment(replicas=4, nproc_per_node=8)` |
| `#SBATCH --partition=gpu --qos=high` | `queue="gpu-high"` (queue must exist in cluster config) |
| `#SBATCH --requeue` | `retries=3` plus `interruptible=True` for spot |
| `#SBATCH --time=04:00:00` | `timeout=timedelta(hours=4)` |
| `#SBATCH --begin=...` / a crontab entry | `flyte.Trigger(...)` with `flyte.Cron(...)` |
| `#SBATCH --dependency=afterok:$JOBID` | Plain Python — call the next task after the first returns |
| `module load cuda && source venv/bin/activate` | `flyte.Image.from_debian_base().with_pip_packages(...)` |
| `$SLURM_PROCID`, `$SLURM_NNODES`, `$SLURM_NTASKS` | `flyte.ctx().rank`, `.nnodes`, `.world_size` |
| `$SLURM_ARRAY_TASK_ID` | The argument you mapped over |
| `/scratch/$USER/data.parquet` | `flyte.io.File` / `flyte.io.Dir` passed between tasks |
| `squeue`, `sacct` | `flyte get run`, `flyte get logs`, the UI |
| `scancel <jobid>` | `flyte stop`, or abort from the UI |
| `srun --pty bash` / `ssh node042` | `flyte run --debug ...`, or `flyte debug <run-name>` (beta) |
## The job script becomes a task
This is the canonical translation. Everything above the `module load` line is configuration and moves onto the `TaskEnvironment` or the task decorator; everything below it is the function body.
### Slurm
```bash
#!/bin/bash
#SBATCH --job-name=train
#SBATCH --partition=gpu
#SBATCH --gres=gpu:a100:8
#SBATCH --cpus-per-task=16
#SBATCH --mem=64G
#SBATCH --time=04:00:00
#SBATCH --requeue
module load cuda/12.1
source ~/venvs/train/bin/activate
srun python train.py --lr 3e-4
```
### Flyte 2
```python
from datetime import timedelta
import flyte
from flyte.io import File
env = flyte.TaskEnvironment(
name="training",
image=flyte.Image.from_debian_base(python_version=(3, 12)).with_pip_packages("torch"),
resources=flyte.Resources(cpu=16, memory="64Gi", gpu="A100:8"),
)
@env.task(retries=3, timeout=timedelta(hours=4))
async def train(lr: float = 3e-4) -> File:
import torch
model = build_model().cuda()
optimizer = torch.optim.AdamW(model.parameters(), lr=lr)
... # the same training loop that was in train.py
torch.save(model.state_dict(), "model.pt")
return await File.from_local("model.pt")
```
```bash
flyte run train.py train --lr 3e-4 # remote (the sbatch equivalent)
flyte run --local train.py train # same code, on your laptop
flyte deploy train.py env # register the environment + triggers
```
The `#SBATCH --time` line has a richer counterpart than a single number. `timeout=` accepts a `timedelta`, an int number of seconds, or a `flyte.Timeout` object that separates the budgets Slurm collapses into one:
```python
@env.task(
retries=2,
timeout=flyte.Timeout(
max_runtime=timedelta(hours=4), # per-attempt wall clock
max_queued_time=timedelta(minutes=30), # fail fast if capacity never appears
deadline=timedelta(hours=10), # absolute budget across all attempts
),
)
async def train(...) -> File: ...
```
## Job arrays and sweeps
A Slurm array plus a results-collection script is two artifacts held together by a filename convention. In Flyte 2 the fan-out and the reduction live in the same function.
### Slurm
```bash
#!/bin/bash
#SBATCH --array=0-63
#SBATCH --gres=gpu:1
CONFIG=$(sed -n "$((SLURM_ARRAY_TASK_ID + 1))p" configs.txt)
python train.py --config "$CONFIG" --out "/scratch/$USER/sweep/$SLURM_ARRAY_TASK_ID.json"
# ...then a separate job, after the array drains, to read 64 JSON files and pick a winner.
```
### Flyte 2
```python
import asyncio
import flyte
env = flyte.TaskEnvironment(
name="sweep",
image=flyte.Image.from_debian_base().with_pip_packages("torch"),
resources=flyte.Resources(cpu=8, memory="32Gi", gpu="L4:1"),
)
@env.task
async def train_one(lr: float, batch_size: int) -> float:
... # returns a validation metric
return val_loss
@env.task
async def sweep() -> dict:
configs = [(lr, bs) for lr in (1e-4, 3e-4, 1e-3) for bs in (16, 32, 64)]
# Small fan-out: gather is the most direct translation of an array job.
losses = await asyncio.gather(*[train_one(lr, bs) for lr, bs in configs])
# Picking the winner is plain Python — no second job, no sentinel files.
best = min(range(len(losses)), key=lambda i: losses[i])
return {"lr": configs[best][0], "batch_size": configs[best][1], "loss": losses[best]}
```
For a 10,000-element array, `flyte.map` gives you a bounded fan-out with a concurrency cap — the `%` throttle in `--array=0-9999%200`:
```python
@env.task
async def big_sweep(n: int = 10_000) -> float:
# flyte.map returns a generator; wrap it in list() to materialize.
losses = list(flyte.map(train_one_indexed, range(n), concurrency=200))
return min(losses)
```
## Job dependencies become function calls
`--dependency=afterok:$JOBID` is the construct that most often turns into a shell script full of `sbatch --parsable` and `awk`. It has no counterpart in Flyte 2 because it doesn't need one: awaiting a task _is_ the dependency, and the value it returns _is_ the handoff.
### Slurm
```bash
PREP=$(sbatch --parsable prep.sh)
TRAIN=$(sbatch --parsable --dependency=afterok:$PREP train.sh)
sbatch --dependency=afterok:$TRAIN eval.sh
```
### Flyte 2
```python
from flyte.io import Dir, File
@env.task
async def pipeline(raw: Dir) -> float:
prepared = await prep(raw) # runs first
model = await train(prepared) # waits for prep, gets its output directly
return await evaluate(model, prepared)
```
Branching that Slurm can't express at all — `afterok` on one job but `afternotok` on another, or "promote only if the eval didn't regress" — is just `try`/`except` and `if`:
```python
@env.task
async def pipeline_with_gate(raw: Dir) -> str:
prepared = await prep(raw)
try:
model = await train(prepared)
except Exception:
model = await train_with_fallback_config(prepared)
if await evaluate(model, prepared) < 0.85:
return "held back"
await promote(model)
return "promoted"
```
## Data: from `/scratch` to typed references
The Slurm version writes to a path both jobs happen to agree on. The Flyte version passes a value.
### Slurm
```bash
# prep.sh
python prep.py --in /scratch/$USER/raw --out /scratch/$USER/prepared
# train.sh — coupled to prep.sh only by this string
python train.py --data /scratch/$USER/prepared --ckpt /scratch/$USER/ckpt.pt
```
### Flyte 2
```python
import flyte
from flyte.io import Dir, File
@env.task
async def prep(raw: Dir) -> Dir:
local = await raw.download()
... # write outputs into ./prepared
return await Dir.from_local("prepared")
@env.task
async def train(prepared: Dir) -> File:
local = await prepared.download() # streams from object storage to this pod
...
return await File.from_local("ckpt.pt")
```
Scratch space _inside_ a task is still just the local filesystem — request it with `flyte.Resources(disk="100Gi")` and use `os.getcwd()`. What changes is that anything another task needs must leave as a typed output. If a parallel filesystem is non-negotiable (a dataset too large or too latency-sensitive to stream), mount it with a CSI driver through `pod_template=flyte.PodTemplate.from_spec(pod_spec_with_lustre_pvc)` on the `TaskEnvironment`.
## Multi-node training
`--nodes=4 --ntasks-per-node=8` with `srun` as the launcher becomes a `ClusteredTaskEnvironment`. It launches its replicas as a single Kubernetes JobSet, runs `torchrun` rendezvous across them, and exposes the standard `RANK` / `WORLD_SIZE` / `MASTER_ADDR` environment variables — so training code written for `torchrun` needs no changes. The same values are on `flyte.ctx()`.
### Slurm
```bash
#!/bin/bash
#SBATCH --nodes=4
#SBATCH --ntasks-per-node=8
#SBATCH --gres=gpu:h100:8
export MASTER_ADDR=$(scontrol show hostnames "$SLURM_JOB_NODELIST" | head -n1)
srun python -m torch.distributed.run --nnodes=4 --nproc_per_node=8 pretrain.py
```
### Flyte 2
```python
import flyte
from flyte.clustered import ClusteredTaskEnvironment, ClusterFailurePolicy, TorchRun
env = ClusteredTaskEnvironment(
name="pretrain",
image=image,
resources=flyte.Resources(cpu=16, memory="64Gi", gpu="H100:8", shm="auto"),
replicas=4, # pods == nodes
nproc_per_node=8, # processes per pod => world size 32
runtime=TorchRun(rdzv_backend="c10d"), # "static" relies on JobSet restarts instead
failure_policy=ClusterFailurePolicy(max_restarts=2, restart_on_host_maintenance=True),
)
@env.task
async def pretrain(steps: int = 10_000) -> File:
import torch
import torch.distributed as dist
ctx = flyte.ctx()
torch.cuda.set_device(ctx.local_rank or 0)
dist.init_process_group(backend="nccl") # torchrun already set RANK/WORLD_SIZE/MASTER_ADDR
print(f"rank {ctx.rank}/{ctx.world_size} on node {ctx.node_rank}/{ctx.nnodes}", flush=True)
... # the same DDP/FSDP loop you ran under srun
dist.barrier()
dist.destroy_process_group()
# Only rank 0 has anything to return.
return await File.from_local("ckpt.pt")
```
`restart_on_host_maintenance=True` is the piece with no Slurm analogue: a node reclaimed by the cloud provider (spot reclaim, host maintenance, drain) restarts the whole set _for free_, leaving the `max_restarts` budget untouched, so an unreliable cluster can't burn the budget you reserved for real bugs.
A clustered task is a worker, not a driver — it cannot launch subtasks. To compose distributed steps, orchestrate from a plain `TaskEnvironment` that declares `depends_on=[clustered_env]`:
```python
driver_env = flyte.TaskEnvironment(
name="driver",
image=image,
resources=flyte.Resources(cpu=1, memory="1Gi"),
depends_on=[env], # without this, awaiting pretrain() fails on image-cache lookup
)
@driver_env.task
async def main(steps: int = 10_000) -> float:
ckpt = await pretrain(steps) # JobSet #1
return await evaluate(ckpt) # JobSet #2
```
Ephemeral, per-task Ray / Spark / Dask clusters (via the `flyteplugins-ray`, `flyteplugins-spark`, and `flyteplugins-dask` integrations) replace the long-lived clusters an HPC site usually stands up by hand — they exist for the duration of the task and are torn down with it.
## Fault tolerance: `--requeue`, decomposed
`#SBATCH --requeue` restarts the job from the top and hopes you wrote your own checkpoint logic. Flyte 2 splits that into four independent mechanisms you compose.
**Retries, declaratively.** `retries=3` on the decorator, or a `RetryStrategy` when you want backoff:
```python
@env.task(
retries=flyte.RetryStrategy(
count=4,
backoff=flyte.Backoff(base=timedelta(seconds=10), factor=2.0, cap=timedelta(minutes=5)),
),
)
async def flaky_download(url: str) -> File: ...
```
**Spot capacity, safely.** `interruptible=True` says the task may run on preemptible instances. Preemptions are tracked as _system_ failures and do not consume your retry budget, and the final attempt falls back to on-demand — so `interruptible=True, retries=2` means two spot attempts and one guaranteed on-demand attempt. Set it on the environment, override it per task:
```python
env = flyte.TaskEnvironment(name="sweep", image=image, interruptible=True)
@env.task(interruptible=False) # the one step you don't want preempted
async def publish(model: File) -> str: ...
```
**Checkpoints that survive node changes.** Checkpoints go to object storage, not `/scratch`, so a retry resumes on whatever node it lands on. No shared filesystem required:
```python
@env.task(retries=3)
async def train(n_epochs: int = 100) -> int:
checkpoint = flyte.ctx().checkpoint
path = await checkpoint.load() # None on the first attempt
start = int(path.read_bytes()) if path else 0
for epoch in range(start, n_epochs):
...
await checkpoint.save(f"{epoch + 1}".encode())
return n_epochs
```
**Durable function calls.** `@flyte.trace` records the result of an individual function call inside a task. On a retry, recorded calls are skipped instead of re-executed — the granularity Slurm has no way to express:
```python
@flyte.trace
async def call_expensive_api(prompt: str) -> str:
... # on retry, an already-recorded call replays instead of re-running
```
**Caching.** Task-level caching is keyed on the code and the inputs, so rerunning a twelve-hour pipeline after fixing step nine starts at step nine:
```python
env = flyte.TaskEnvironment(name="etl", image=image, cache="auto")
```
## Warm pools
Slurm feels fast at submit time because the allocation is already running — `srun` inside an existing allocation starts in milliseconds. `flyte.ReusePolicy` is the equivalent: containers stay warm between tasks, keeping in-memory state, so a model loaded once serves thousands of task invocations.
```python
env = flyte.TaskEnvironment(
name="scorer",
image=image,
resources=flyte.Resources(cpu=4, memory="16Gi", gpu="L4:1"),
reusable=flyte.ReusePolicy(
replicas=(2, 10), # autoscaling range; a bare int pins the count
concurrency=4, # concurrent tasks per replica (async tasks only)
idle_ttl=300, # shut the environment down after 5 idle minutes
),
)
```
The caveat is the same one that bites long-lived Slurm allocations: the process outlives the task. Treat module-level globals and caches deliberately, and don't let one task's state leak into the next.
## Existing binaries
A Fortran solver, a C++ simulator, a genomics tool — anything you're not rewriting runs as a `ContainerTask` with typed inputs and outputs. Inputs are staged into `input_data_dir`, and whatever the command writes into `output_data_dir` is read back as the declared types.
```python
from flyte.extras import ContainerTask
from flyte.io import File
align = ContainerTask(
name="align_reads",
image="quay.io/biocontainers/bwa:0.7.17",
resources=flyte.Resources(cpu=16, memory="64Gi"),
inputs={"reference": File, "reads": File},
outputs={"alignment": File},
input_data_dir="/var/inputs",
output_data_dir="/var/outputs",
file_input_layout="NAMED_DIR", # preserves original filenames + extensions
command=[
"/bin/sh", "-c",
"bwa mem /var/inputs/reference/* /var/inputs/reads/* > /var/outputs/alignment",
],
)
@env.task
async def main(reference: File, reads: File) -> File:
return await align(reference=reference, reads=reads)
```
## Scheduling, monitoring, and interactive work
`#SBATCH --begin=` and the crontab that wraps most recurring HPC work become a `flyte.Trigger` attached to the task and deployed with it:
```python
from datetime import datetime
nightly = flyte.Trigger(
name="nightly_retrain",
automation=flyte.Cron("0 2 * * *", timezone="America/Los_Angeles"),
inputs={"start_time": flyte.TriggerTime, "lr": 3e-4},
auto_activate=True,
)
@env.task(triggers=nightly)
async def retrain(start_time: datetime, lr: float) -> File: ...
```
For monitoring, `squeue` and `sacct` become `flyte get run` and `flyte get logs` (or the UI, which shows the pipeline structure rather than a flat job list):
```bash
flyte get run # like squeue
flyte get run <run_name> # detail for one run
flyte get logs <run_name> --attempt 0 # like sacct + tailing a slurm-*.out
flyte stop <run_name> # like scancel
```
`srun --pty bash` and `ssh node042` have two counterparts. `flyte run --debug` opens a browser-based VS Code session in the task pod:
```bash
flyte run --debug train.py train --lr 3e-4
```
```python
run = flyte.with_runcontext(debug=True).run(train, lr=3e-4)
print(run.get_debug_url())
```
SSH into the running task is available in beta (requires `flyteplugins-union`) and is closer to the muscle memory of `ssh` onto a compute node:
```bash
flyte debug <run-name> --write-config
ssh flyte-debug
```
## Migration order that works
Do not start with the thing Slurm does best.
1. **Pipeline-shaped work first** — data processing, evals, sweeps, batch inference, RL rollouts. These are multi-step, embarrassingly parallel, and failure-prone in boring ways, so composition, caching, and retries pay off on day one. They also exercise images and data plumbing on workloads where a bad hour costs little.
2. **Single-node training next** — one `TaskEnvironment`, one GPU resource string, checkpoints to object storage. At this point you've validated that your image builds, your data streams, and your logs are where you expect.
3. **Multi-node training last** — once images, data, and observability are proven. `ClusteredTaskEnvironment` is the piece with the most moving parts and the least tolerance for a half-migrated environment.
Nothing forces a big bang: Slurm and Flyte can run side by side indefinitely, and a Flyte task can `subprocess` out to `sbatch` during the overlap if you need a bridge.
## Gotchas
- **`flyte.map` returns a generator.** Wrap it in `list()` to materialize results.
- **`memory`, not `mem`.** And GPUs use a combined `"A100:8"` string — type and count together, not `--gres` plus a separate accelerator argument.
- **`shm="auto"` matters for PyTorch DataLoader.** The container default `/dev/shm` is tiny; multi-worker data loading will fail cryptically without it.
- **A clustered task cannot launch subtasks.** Orchestrate from a plain `TaskEnvironment` with `depends_on=[clustered_env]`, or you'll hit an image-cache lookup failure at runtime.
- **`nproc_per_node` must not exceed the GPU count.** `ClusteredTaskEnvironment` validates this locally and raises `ValueError` before anything is submitted.
- **Only rank 0 should return outputs.** Every replica runs the task body; have non-zero ranks return early or return a trivial value.
- **Retries have no platform cap.** Total attempts equal `retries + 1`, so audit any large values ported from a `--requeue` habit.
- **`interruptible=True` with zero retries runs on-demand.** The final attempt always falls back off spot, and a single attempt _is_ the final attempt.
- **Clustered tasks are torchrun-focused.** Classic MPI HPC codes (`mpirun`, tightly-coupled CFD, molecular dynamics) are not the target. Keep those on Slurm.
- **Gang scheduling and topology-aware placement are still maturing on Kubernetes.** Slurm's scheduler has decades of work behind co-scheduling N nodes on the same rack or fabric; the Kubernetes ecosystem is closing the gap but is not there.
- **Queues order work; they don't preempt it.** `queue="gpu-high"` is the nearest analogue to `--partition` plus `--qos`, but a lower-priority task already running is not interrupted. Queue names must exist in your cluster configuration, and the feature is platform-dependent — check what your deployment supports before designing around it.
- **Frontier scale is still Slurm's.** Hundreds of GPUs per job with explicit InfiniBand topology control is where Slurm keeps winning. Migrate the pipeline layer; be deliberate about the rest.
## Anti-Patterns
1. **Don't recreate `/scratch` as a hardcoded bucket path.** Pass `flyte.io.File` / `flyte.io.Dir` between tasks. Two tasks agreeing on a string is exactly the coupling you're migrating away from — and it forfeits the lineage you get for free.
2. **Don't port `#SBATCH` pragmas onto every task decorator.** Image, resources, cache, and interruptibility belong on a shared `flyte.TaskEnvironment`; override per task only where a task genuinely differs.
3. **Don't translate `--dependency=afterok` into a sentinel-file poll.** Await the task and use its return value.
4. **Don't keep the array-plus-collector split.** After `asyncio.gather` or `flyte.map`, reduce in plain Python inside the same task.
5. **Don't `srun` inside a task.** The task body _is_ the rank's process. For multi-node, use `ClusteredTaskEnvironment` and let torchrun do the launching.
6. **Don't checkpoint to a local path and expect a retry to find it.** Use `flyte.ctx().checkpoint` or write a `File` to object storage — a retry may land on a different node.
7. **Don't do heavy compute in an orchestrating task.** A task that calls other tasks is a driver pod; CPU-bound work between awaits stalls everything downstream.
8. **Don't lean on global state in a reusable environment.** `ReusePolicy` keeps the process alive across tasks — cache the model deliberately, and don't let mutable state leak between invocations.
9. **Don't migrate tightly-coupled MPI simulation first (or at all).** Start with pipeline-shaped work; leave the workloads Slurm is genuinely better at on Slurm.
## Related skills
- **`flyte-sdk-ml`** — greenfield ML authoring in Flyte 2 (training, HPO, inference) once the migration shape is clear.
- **`flyte-migrate`** — the entry point if you _also_ have Flyte 1 (`flytekit`) code to port.
- **`flyte-sdk-ship`** — image specs, dependency management, and reproducible builds, i.e. everything that replaces `module load`.
- **`flyte-sdk-app`** — serving and endpoints, for the step after training that Slurm never covered.
SHA-256: aaff17a21728cfb2e24bca9da231117816cdff12a5994463ad3014cd07016db1