시작하기#
기본 개념#
데이터와 모델 파일을 필요한 장비로 자동 전송하기
AI 업무에서는 원본 데이터, 전처리 결과, 학습 데이터, 모델 파일, 추론 결과 등 다양한 파일이 여러 장비와 환경에서 사용됩니다.
각 파일이 생성되는 위치와 사용하는 장비를 연결하면, 데이터가 준비된 이후 다음 처리 환경으로 전송하고 생성된 모델과 결과 파일도 필요한 위치로 이어갈 수 있습니다.
Data Source
│
▼
Data Processing
│
▼
AI Training
│
▼
Model Output
│
▼
Result Storage
이렇게 구성하면 AI 데이터와 모델 파일을 각 단계의 처리 환경에 맞춰 관리하고, 파일이 준비되는 시점에 다음 작업으로 자동 연결할 수 있습니다.
워크플로 흐름#
데이터 수집부터 처리와 결과 활용까지 연결하기
여러 장비에서 생성되는 데이터를 수집한 후 필요한 AI 처리 환경으로 전송합니다.
데이터 처리와 학습이 완료되면 생성된 모델과 결과 파일을 다음 장비 또는 저장 환경으로 전송해 후속 업무에 활용합니다.
데이터 생성
│
▼
데이터 수집
│
▼
전처리 · AI 처리
│
▼
모델 생성
│
├──────────────┐
▼ ▼
모델 저장 결과 전달
│ │
└──────┬───────┘
▼
다음 작업각 단계는 앞선 작업에서 생성된 파일과 처리 결과를 다음 단계의 입력으로 활용할 수 있습니다.
자동화 효과#
단계마다 반복되는 대용량 파일 이동과 관리 작업 줄이기
AI 업무에서는 데이터의 크기와 처리 단계가 늘어날수록 파일을 준비하고 필요한 장비로 전송하는 작업도 함께 증가합니다.
파일 생성과 처리 상태를 기준으로 다음 작업을 자동 실행하도록 구성하면, 각 단계의 파일 흐름을 정해진 순서에 따라 연결할 수 있습니다.
| 구분 | 개별 파일 관리 | 워크플로 자동화 |
|---|---|---|
| 데이터 수집 | 장비별 파일 확인 | 여러 장비의 데이터를 하나의 흐름으로 수집 |
| 처리 환경 연결 | 다음 장비로 파일 준비 | 처리 조건에 따라 필요한 장비로 자동 전송 |
| 모델 관리 | 생성된 모델 파일 확인 | 모델 생성 후 지정된 환경으로 연결 |
| 결과 활용 | 처리 결과를 다음 업무에 준비 | 결과 파일을 다음 작업과 자동 연결 |
이를 통해 데이터, 모델, 결과 파일의 흐름을 단계별로 구성하고 AI 작업 전체를 하나의 파일 워크플로로 관리할 수 있습니다.
IT 엔지니어#
AI 데이터와 모델 파일의 자동화 흐름 구성하기
데이터 수집#
여러 장비의 데이터를 하나의 흐름으로 모으기
먼저 AI 처리에 사용할 데이터가 생성되거나 저장되는 여러 장비를 워크플로에 연결합니다.
각 장비의 파일 경로와 수집 대상을 설정하면 여러 위치의 데이터를 하나의 처리 흐름으로 모아 다음 단계에서 활용할 수 있습니다.
Source A ──┐
│
Source B ──┼──→ Data Collection
│
Source C ──┘
│
▼
AI Processing[제품UI_여러 Source 장비와 데이터 폴더를 하나의 데이터 수집 작업으로 연결하는 Flow Canvas 화면]
필요한 경우 파일 경로와 이름, 확장자 등의 조건을 설정해 AI 처리에 사용할 데이터만 수집할 수 있습니다.
처리 연결#
수집된 데이터를 필요한 AI 처리 장비로 전송하기
수집한 데이터가 준비되면 전처리, 학습, 추론 등 다음 작업을 수행할 AI 처리 장비로 연결합니다.
각 처리 단계에서 사용할 장비와 파일 경로를 지정하고, 앞선 단계의 결과 파일이 다음 장비의 입력으로 이어지도록 구성합니다.
Collected Data
│
▼
Preprocessing
│
▼
Training Server
│
▼
Inference / Analysis
이렇게 구성하면 각 AI 처리 단계에서 필요한 파일을 준비된 작업 환경으로 자동 전송할 수 있습니다.
작업 조건#
데이터와 처리 상태에 따라 다음 작업 실행하기
워크플로의 각 단계는 파일 생성, 데이터 수집 완료, 처리 완료 등 설정한 조건을 기준으로 다음 작업을 시작하도록 구성할 수 있습니다.
예를 들어 여러 장비에서 수집할 데이터가 준비된 후 전처리 작업을 시작하고, 전처리 결과가 생성되면 학습 작업으로 이어지도록 설정할 수 있습니다.
Data Ready
│
▼
Collection Complete
│
▼
Start Processing
│
▼
Processing Complete
│
▼
Start Training
작업 조건을 연결하면 각 단계의 처리 상태에 따라 데이터 수집부터 AI 처리와 모델 생성까지 순서에 맞춰 실행할 수 있습니다.
결과 전송#
생성된 모델과 처리 결과를 필요한 장비로 전송하기
학습과 처리 작업이 완료되면 생성된 모델 파일과 결과 데이터를 다음 환경으로 전송합니다.
모델 저장소, 추론 장비, 검증 환경, 업무 시스템 등 파일을 활용할 위치를 연결하고, 각 결과물을 필요한 대상에 맞춰 전송할 수 있습니다.
AI Training
│
▼
Model Generated
│
┌───┴───────────┐
▼ ▼
Model Storage Inference Server
│
▼
Result Output
│
▼
Target System하나의 모델 또는 결과 파일을 여러 환경에서 활용하는 경우에는 대상별 파일 흐름을 연결해 필요한 작업 위치까지 자동으로 확장할 수 있습니다.
실행 관리#
데이터부터 모델과 결과까지 단계별 처리 상태 확인하기
실행된 워크플로는 전체 흐름과 각 단계의 작업 상태를 함께 확인합니다.
데이터 수집과 전처리, AI 처리, 모델 생성, 결과 전송 과정에서 처리된 파일과 진행 상태를 단계별로 확인할 수 있습니다.
AI Workflow
│
├── Data Collection ✓
│
├── Processing ✓
│
├── Model Training ●
│
└── Result Transfer ○
주요 확인 항목은 다음과 같습니다.
| 확인 항목 | 확인 내용 |
|---|---|
| 단계 | 현재 실행 중인 워크플로 단계 |
| Source | 데이터를 수집한 장비와 파일 위치 |
| Files | 처리된 파일 수와 전체 용량 |
| Progress | 단계별 처리 진행 상태 |
| Target | 모델과 결과 파일의 전송 대상 |
| Status | 전체 워크플로와 각 작업의 실행 결과 |
이를 통해 특정 단계의 처리 현황과 전체 AI 파일 흐름을 함께 관리할 수 있습니다.
예외 대응#
전송과 처리 상태를 확인하고 필요한 단계 다시 실행하기
실행 과정에서 추가 확인이 필요한 단계가 있으면 해당 작업의 상세 정보와 실행 기록을 기준으로 데이터 상태와 장비 연결, 파일 전송 결과를 확인합니다.
데이터 수집부터 AI 처리와 결과 전송까지 워크플로의 각 단계를 구분해 확인한 후 필요한 작업을 다시 실행할 수 있습니다.
Workflow Run
│
▼
Stage Status
│
┌────┴─────┐
▼ ▼
Completed Check Required
│
▼
Run Details
│
┌────────┼────────┐
▼ ▼ ▼
Data Device Transfer
│
▼
Retry Stage
│
▼
Result Check
개발자#
데이터셋을 여러 학습 노드로 배포하고 노드별 결과를 보관 장비로 회수하기
연동 준비#
공통 호출 코드와 경로 표기 준비하기
import os
import requests
BASE_URL = os.getenv("INNORIX_BASE_URL", "https://app.innorix.com").rstrip("/")
TOKEN = os.environ["INNORIX_ACCESS_TOKEN"]
WORKSPACE_ID = os.getenv("INNORIX_WORKSPACE_ID") # optional; falls back to the current workspace
STATUS_COMPLETE = 2
TERMINAL = {2, 4, 5, 9, 99} # complete / error / cancelled / partial / failed
NOT_SUCCEEDED = {4, 5, 9, 99}
def api(method, path, body=None, params=None):
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {TOKEN}",
}
if WORKSPACE_ID:
headers["x-workspace-id"] = WORKSPACE_ID
response = requests.request(
method, BASE_URL + path,
headers=headers, json=body, params=params, timeout=30,
)
payload = response.json() if response.content else {}
if not response.ok:
raise RuntimeError(payload.get("message") or f"HTTP {response.status_code}")
return payload.get("data")
def is_terminal(detail):
return detail.get("isTerminal", detail.get("status") in TERMINAL)// InnorixClient.java
public static final String BASE_URL =
env("INNORIX_BASE_URL", "https://app.innorix.com").replaceAll("/+quot;, "");
public static final String WORKSPACE_ID = env("INNORIX_WORKSPACE_ID", null);
public static final int STATUS_COMPLETE = 2;
// States the transfer no longer moves out of
public static final Set<Integer> TERMINAL = Set.of(2, 4, 5, 9, 99);
// Terminal states that are not a full success
public static final Set<Integer> NOT_SUCCEEDED = Set.of(4, 5, 9, 99);
private HttpRequest.Builder headers(HttpRequest.Builder builder) {
builder.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + session.accessToken());
// When omitted the account's current workspace is used.
if (workspaceId != null) builder.header("x-workspace-id", workspaceId);
return builder;
}
/** Unwraps and returns data from the response. Throws ApiError on failure. */
public Object api(String method, String path, Object body, Map<String, Object> params) {
Resp response = request(method, path, body, params);
Object payload = null;
try {
payload = Json.parse(response.text());
} catch (RuntimeException ignored) {
payload = null;
}
if (!response.ok()) {
Map<String, Object> map = Json.asObj(payload);
String message = Json.str(map, "message", Json.str(map, "error", "unknown error"));
throw new ApiError(response.status, message, map);
}
return Json.get(payload, "data");
}
/** Use the server flag when present, otherwise fall back to the status code. */
public static boolean isTerminal(Map<String, Object> record) {
Boolean flag = Json.boolOrNull(record, "isTerminal");
if (flag != null) return flag;
Integer status = Json.intOrNull(record, "status");
return status != null && TERMINAL.contains(status);
}// innorix-client.js
const BASE_URL = (process.env.INNORIX_BASE_URL
|| "https://app.innorix.com").replace(/\/+$/, "");
const TOKEN = process.env.INNORIX_ACCESS_TOKEN;
const WORKSPACE_ID = process.env.INNORIX_WORKSPACE_ID || null;
export const STATUS_COMPLETE = 2;
export const TERMINAL = new Set([2, 4, 5, 9, 99]); // complete / error / cancelled / partial / failed
export const NOT_SUCCEEDED = new Set([4, 5, 9, 99]);
export async function api(method, path, body = null, params = null) {
const url = new URL(BASE_URL + path);
if (params) {
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null) continue;
url.searchParams.set(key, String(value));
}
}
const headers = {
"Content-Type": "application/json",
Authorization: `Bearer ${TOKEN}`,
};
// When omitted the account's current workspace is used.
if (WORKSPACE_ID) headers["x-workspace-id"] = WORKSPACE_ID;
const response = await fetch(url, {
method,
headers,
body: body === null ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(30_000),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload.message || `HTTP ${response.status}`);
}
return payload.data;
}
export function isTerminal(detail) {
return detail.isTerminal !== undefined
? detail.isTerminal
: TERMINAL.has(detail.status);
}// InnorixClient.cs
public static readonly string BaseUrl =
Env("INNORIX_BASE_URL", "https://app.innorix.com").TrimEnd('/');
public static readonly string WorkspaceIdFromEnv = Env("INNORIX_WORKSPACE_ID", null);
public const int StatusComplete = 2;
/// <summary>States the transfer no longer moves out of</summary>
public static readonly HashSet<int> Terminal = new HashSet<int> { 2, 4, 5, 9, 99 };
/// <summary>Terminal states that are not a full success</summary>
public static readonly HashSet<int> NotSucceeded = new HashSet<int> { 4, 5, 9, 99 };
// Applied on every request
request.Headers.TryAddWithoutValidation("Authorization", "Bearer " + Session.AccessToken);
// When omitted the account's current workspace is used.
if (WorkspaceId != null) request.Headers.TryAddWithoutValidation("x-workspace-id", WorkspaceId);
public async Task<JsonNode> ApiAsync(string method, string path, JsonNode body = null,
IDictionary<string, object> parameters = null)
{
Resp response = await RequestAsync(method, path, body, parameters).ConfigureAwait(false);
JsonNode payload = null;
try
{
payload = J.Parse(response.Text());
}
catch (Exception)
{
payload = null;
}
if (!response.Ok)
{
JsonObject map = J.AsObj(payload);
string message = J.Str(map, "message", J.Str(map, "error", "unknown error"));
throw new ApiError(response.Status, message, map);
}
return J.Get(payload, "data");
}
/// <summary>Use the server flag when present, otherwise fall back to the status code.</summary>
public static bool IsTerminal(JsonObject record)
{
bool? flag = J.BoolOrNull(record, "isTerminal");
if (flag != null) return flag.Value;
int? status = J.IntOrNull(record, "status");
return status != null && Terminal.Contains(status.Value);
}import base64
import time
def encode_path(device_id, raw_path):
normalized = str(raw_path or "").replace("\\", "/")
token = base64.b64encode(normalized.encode("utf-8")).decode("ascii")
return f"{device_id}_ino_{token}"
def now_iso():
return time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime())public static String encodePath(String deviceId, String rawPath) {
String normalized = (rawPath == null ? "" : rawPath).replace("\\", "/");
return deviceId + "_ino_"
+ Base64.getEncoder().encodeToString(normalized.getBytes(StandardCharsets.UTF_8));
}
public static String nowIso() {
return Instant.now().truncatedTo(ChronoUnit.SECONDS).toString().replace("Z", ".000Z");
}export function encodePath(deviceId, rawPath) {
const normalized = String(rawPath ?? "").replace(/\\/g, "/");
const token = Buffer.from(normalized, "utf8").toString("base64");
return `${deviceId}_ino_${token}`;
}
export function nowIso() {
return new Date().toISOString().replace(/\.\d{3}Z$/, ".000Z");
}public static string EncodePath(string deviceId, string rawPath)
{
string normalized = (rawPath ?? "").Replace("\\", "/");
return deviceId + "_ino_" + Convert.ToBase64String(Encoding.UTF8.GetBytes(normalized));
}
public static string NowIso()
{
return DateTime.UtcNow.ToString("yyyy-MM-dd'T'HH:mm:ss'.000Z'",
System.Globalization.CultureInfo.InvariantCulture);
}전송 상태는 아래 값으로 판단합니다. 종료 상태는 다섯 개이고 성공에 해당하는 값은 완료(2)입니다.
| 상태 값 | 의미 | 종료 |
|---|---|---|
| 2 | 완료 | 예 |
| 4 | 오류 | 예 |
| 5 | 취소 | 예 |
| 9 | 부분 완료 | 예 |
| 99 | 실패 | 예 |
| 1 · 6 · 12 · 13 | 시작·전송 중·동기화 중·수신 중 | 아니오 |
경로 규칙#
데이터셋 버전과 실행 식별자를 경로에 담기
회차가 쌓이면 어느 결과가 어느 데이터로 나온 것인지 알 수 없게 됩니다. 경로 규칙을 함수로 고정해 두면 코드 곳곳에서 문자열을 조립하지 않아도 됩니다.
DATA_ROOT = "/data"
WORK_ROOT = "/work"
ARCHIVE_ROOT = "/archive"
# source dataset on the storage device
def dataset_path(dataset, version):
return f"{DATA_ROOT}/datasets/{dataset}/{version}"
# path the training job reads on the node
def node_input_path(dataset, version, run_id):
return f"{WORK_ROOT}/{run_id}/input/{dataset}/{version}"
# path the node writes its results to
def node_output_path(run_id):
return f"{WORK_ROOT}/{run_id}/output"
# archive path where results are collected
def archive_path(run_id):
return f"{ARCHIVE_ROOT}/runs/{run_id}"
run_id = f"r-{time.strftime('%Y%m%d-%H%M%S')}"static final String DATA_ROOT = "/data";
static final String WORK_ROOT = "/work";
static final String ARCHIVE_ROOT = "/archive";
// source dataset on the storage device
String datasetPath(String dataset, String version) {
return DATA_ROOT + "/datasets/" + dataset + "/" + version;
}
// path the training job reads on the node
String nodeInputPath(String dataset, String version, String runId) {
return WORK_ROOT + "/" + runId + "/input/" + dataset + "/" + version;
}
// path the node writes its results to
String nodeOutputPath(String runId) {
return WORK_ROOT + "/" + runId + "/output";
}
// archive path where results are collected
String archivePath(String runId) {
return ARCHIVE_ROOT + "/runs/" + runId;
}
String runId = "r-" + DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss")
.withZone(ZoneOffset.UTC).format(Instant.now());const DATA_ROOT = "/data";
const WORK_ROOT = "/work";
const ARCHIVE_ROOT = "/archive";
// source dataset on the storage device
const datasetPath = (dataset, version) => `${DATA_ROOT}/datasets/${dataset}/${version}`;
// path the training job reads on the node
const nodeInputPath = (dataset, version, runId) =>
`${WORK_ROOT}/${runId}/input/${dataset}/${version}`;
// path the node writes its results to
const nodeOutputPath = (runId) => `${WORK_ROOT}/${runId}/output`;
// archive path where results are collected
const archivePath = (runId) => `${ARCHIVE_ROOT}/runs/${runId}`;
const runId = `r-${new Date().toISOString().replace(/[-:T]/g, "").slice(0, 15)}`;const string DataRoot = "/data";
const string WorkRoot = "/work";
const string ArchiveRoot = "/archive";
// source dataset on the storage device
string DatasetPath(string dataset, string version) =>
quot;{DataRoot}/datasets/{dataset}/{version}";
// path the training job reads on the node
string NodeInputPath(string dataset, string version, string runId) =>
quot;{WorkRoot}/{runId}/input/{dataset}/{version}";
// path the node writes its results to
string NodeOutputPath(string runId) => quot;{WorkRoot}/{runId}/output";
// archive path where results are collected
string ArchivePath(string runId) => quot;{ArchiveRoot}/runs/{runId}";
string runId = quot;r-{DateTime.UtcNow:yyyyMMdd-HHmmss}";실행 식별자가 경로 맨 앞에 있으면 회차 단위로 삭제하거나 이동하기 쉽습니다. 날짜만 쓰면 같은 날 두 번 실행했을 때 섞이고, 데이터셋 이름만 쓰면 버전을 올렸을 때 이전 결과와 구분되지 않습니다.
데이터 배포#
데이터셋을 여러 노드로 보내고 전원 도착 확인하기
하나의 전송은 대상 하나로 갑니다. 노드가 여럿이면 전송도 여럿입니다.
def deploy(storage, nodes, dataset, version, run_id):
source_path = dataset_path(dataset, version)
target_path = node_input_path(dataset, version, run_id)
return {
node: api("POST", "/api/transfers/manual", {
"sourceDevice": storage,
"targetDevice": node,
"targetPath": target_path,
"sourcePaths": [source_path],
"sendAllFolder": True,
"checkIntegrity": True,
"transferOptions": {"target-action": "overwrite"},
})["monitorId"]
for node in nodes
}Map<String, String> deploy(String storage, List<String> nodes,
String dataset, String version, String runId) {
String sourcePath = datasetPath(dataset, version);
String targetPath = nodeInputPath(dataset, version, runId);
Map<String, String> transfers = new LinkedHashMap<>();
for (String node : nodes) {
Map<String, Object> transfer = client.apiObj("POST", "/api/transfers/manual", Json.newObj(
"sourceDevice", storage,
"targetDevice", node,
"targetPath", targetPath,
"sourcePaths", List.of(sourcePath),
"sendAllFolder", true,
"checkIntegrity", true,
"transferOptions", Json.newObj("target-action", "overwrite")));
transfers.put(node, Json.str(transfer, "monitorId"));
}
return transfers;
}async function deploy(storage, nodes, dataset, version, runId) {
const sourcePath = datasetPath(dataset, version);
const targetPath = nodeInputPath(dataset, version, runId);
const transfers = {};
for (const node of nodes) {
const transfer = await client.api("POST", "/api/transfers/manual", {
sourceDevice: storage,
targetDevice: node,
targetPath,
sourcePaths: [sourcePath],
sendAllFolder: true,
checkIntegrity: true,
transferOptions: { "target-action": "overwrite" },
});
transfers[node] = transfer.monitorId;
}
return transfers;
}async Task<Dictionary<string, string>> DeployAsync(string storage,
IEnumerable<string> nodes, string dataset, string version, string runId)
{
string sourcePath = DatasetPath(dataset, version);
string targetPath = NodeInputPath(dataset, version, runId);
var transfers = new Dictionary<string, string>();
foreach (string node in nodes)
{
JsonObject transfer = await client.ApiObjAsync("POST", "/api/transfers/manual",
new JsonObject
{
["sourceDevice"] = storage,
["targetDevice"] = node,
["targetPath"] = targetPath,
["sourcePaths"] = new JsonArray { sourcePath },
["sendAllFolder"] = true,
["checkIntegrity"] = true,
["transferOptions"] = new JsonObject { ["target-action"] = "overwrite" },
});
transfers[node] = J.Str(transfer, "monitorId");
}
return transfers;
}def wait(monitor_id, timeout=3600, interval=3):
deadline = time.time() + timeout
while time.time() < deadline:
detail = api("GET", f"/api/transfers/{monitor_id}")
if is_terminal(detail):
return detail
time.sleep(interval)
raise TimeoutError(monitor_id)
def failed_files(monitor_id):
result = api("GET", f"/api/transfers/{monitor_id}/files", params={
"state": "any", "size": 500,
}) or {}
return [r for r in (result.get("children") or [])
if r.get("status") in NOT_SUCCEEDED]
def retry_failed(monitor_id):
rows = failed_files(monitor_id)
if not rows:
return 0
api("POST", f"/api/transfers/{monitor_id}/retry", {
"filesRetry": [
{"filePath": r["sourceFilePath"], "isDir": bool(r.get("isFolder"))}
for r in rows
]
})
return len(rows)public Map<String, Object> wait(String monitorId, int timeoutSeconds, int intervalSeconds) {
long deadline = System.currentTimeMillis() + timeoutSeconds * 1000L;
while (System.currentTimeMillis() < deadline) {
Map<String, Object> detail = apiObj("GET", "/api/transfers/" + monitorId);
if (isTerminal(detail)) return detail;
sleep(intervalSeconds * 1000L);
}
throw new ApiError(0, "timeout waiting for " + monitorId);
}
public List<Map<String, Object>> failedFiles(String monitorId) {
List<Map<String, Object>> failed = new ArrayList<>();
for (Map<String, Object> row : transferFiles(monitorId)) {
Integer status = Json.intOrNull(row, "status");
if (status != null && NOT_SUCCEEDED.contains(status)) failed.add(row);
}
return failed;
}
public int retryFailed(String monitorId) {
List<Map<String, Object>> rows = failedFiles(monitorId);
if (rows.isEmpty()) return 0;
List<Object> filesRetry = new ArrayList<>();
for (Map<String, Object> row : rows) {
String path = Json.str(row, "sourceFilePath");
if (path == null) continue;
filesRetry.add(Json.newObj("filePath", path, "isDir", Json.bool(row, "isFolder", false)));
}
api("POST", "/api/transfers/" + monitorId + "/retry", Json.newObj("filesRetry", filesRetry));
return rows.size();
}const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export async function wait(monitorId, { timeout = 3600, interval = 3 } = {}) {
const deadline = Date.now() + timeout * 1000;
while (Date.now() < deadline) {
const detail = await api("GET", `/api/transfers/${monitorId}`);
if (isTerminal(detail)) return detail;
await sleep(interval * 1000);
}
throw new Error(`timeout waiting for ${monitorId}`);
}
export async function failedFiles(monitorId) {
const result = (await api("GET", `/api/transfers/${monitorId}/files`, null, {
state: "any", size: 500,
})) || {};
return (result.children || []).filter((row) => NOT_SUCCEEDED.has(row.status));
}
export async function retryFailed(monitorId) {
const rows = await failedFiles(monitorId);
if (rows.length === 0) return 0;
await api("POST", `/api/transfers/${monitorId}/retry`, {
filesRetry: rows
.filter((row) => row.sourceFilePath)
.map((row) => ({ filePath: row.sourceFilePath, isDir: Boolean(row.isFolder) })),
});
return rows.length;
}public async Task<JsonObject> WaitAsync(string monitorId, int timeoutSeconds = 3600,
int intervalSeconds = 3)
{
long deadline = Environment.TickCount64 + timeoutSeconds * 1000L;
while (Environment.TickCount64 < deadline)
{
JsonObject detail = await ApiObjAsync("GET", "/api/transfers/" + monitorId)
.ConfigureAwait(false);
if (IsTerminal(detail)) return detail;
await Task.Delay(intervalSeconds * 1000).ConfigureAwait(false);
}
throw new ApiError(0, "timeout waiting for " + monitorId);
}
public async Task<List<JsonObject>> FailedFilesAsync(string monitorId)
{
List<JsonObject> rows = await TransferFilesAsync(monitorId).ConfigureAwait(false);
return rows.Where(row =>
{
int? status = J.IntOrNull(row, "status");
return status != null && NotSucceeded.Contains(status.Value);
}).ToList();
}
/// <summary>Only callable once the transfer has reached a terminal state.</summary>
public async Task<int> RetryFailedAsync(string monitorId)
{
List<JsonObject> rows = await FailedFilesAsync(monitorId).ConfigureAwait(false);
if (rows.Count == 0) return 0;
var filesRetry = new JsonArray();
foreach (JsonObject row in rows)
{
string path = J.Str(row, "sourceFilePath");
if (path == null) continue;
filesRetry.Add(new JsonObject
{
["filePath"] = path,
["isDir"] = J.Bool(row, "isFolder", false),
});
}
await ApiAsync("POST", "/api/transfers/" + monitorId + "/retry",
new JsonObject { ["filesRetry"] = filesRetry }).ConfigureAwait(false);
return rows.Count;
}한 노드라도 못 받았으면 학습을 시작하면 안 됩니다. 첫 실패에서 멈추지 말고 모든 노드를 확인해야 재시도 범위를 정할 수 있습니다.
transfers = deploy(STORAGE, NODES, "imagenet", "v3", run_id)
results = {node: wait(mid) for node, mid in transfers.items()}
incomplete = [n for n, d in results.items() if d["status"] != STATUS_COMPLETE]
if incomplete:
raise RuntimeError(f"nodes not fully delivered: {', '.join(incomplete)}")Map<String, String> transfers = deploy(STORAGE, NODES, "imagenet", "v3", runId);
Map<String, Map<String, Object>> results = new LinkedHashMap<>();
for (var entry : transfers.entrySet()) {
results.put(entry.getKey(), client.await(entry.getValue(), 7200, null));
}
List<String> incomplete = new ArrayList<>();
results.forEach((node, detail) -> {
if (Json.intOr(detail, "status", -1) != InnorixClient.STATUS_COMPLETE) {
incomplete.add(node);
}
});
if (!incomplete.isEmpty()) {
throw new RuntimeException("전달 미완료 노드: " + String.join(", ", incomplete));
}const transfers = await deploy(STORAGE, NODES, "imagenet", "v3", runId);
const results = {};
for (const [node, mid] of Object.entries(transfers)) {
results[node] = await wait(mid);
}
const incomplete = Object.entries(results)
.filter(([, d]) => d.status !== STATUS_COMPLETE)
.map(([node]) => node);
if (incomplete.length) {
throw new Error(`전달 미완료 노드: ${incomplete.join(", ")}`);
}Dictionary<string, string> transfers = await DeployAsync(
STORAGE, NODES, "imagenet", "v3", runId);
var results = new Dictionary<string, JsonObject>();
foreach (var (node, mid) in transfers)
{
results[node] = await client.WaitAsync(mid, 7200);
}
var incomplete = results
.Where(kv => J.IntOrNull(kv.Value, "status") != InnorixClient.StatusComplete)
.Select(kv => kv.Key).ToList();
if (incomplete.Count > 0)
{
throw new Exception(quot;전달 미완료 노드: {string.Join(", ", incomplete)}");
}도착 정책은 overwrite를 씁니다. numbering을 쓰면 노드에 사본이 쌓이고 학습 코드가 어느 것을 읽을지 알 수 없게 됩니다.
변경분 전송#
마지막 배포 이후 바뀐 데이터만 보내기
monitor_id = api("POST", "/api/transfers/manual", {
"sourceDevice": STORAGE,
"targetDevice": node,
"targetPath": node_input_path("imagenet", "v3", run_id),
"sourcePaths": [dataset_path("imagenet", "v3")],
"sendAllFolder": True,
"incremental": True,
"transferOptions": {"target-action": "overwrite"},
})["monitorId"]
detail = wait(monitor_id)
print(f"transferred {detail['fileCount']} files, {detail['totalSize']} bytes")Map<String, Object> transfer = client.apiObj("POST", "/api/transfers/manual", Json.newObj(
"sourceDevice", STORAGE,
"targetDevice", node,
"targetPath", nodeInputPath("imagenet", "v3", runId),
"sourcePaths", List.of(datasetPath("imagenet", "v3")),
"sendAllFolder", true,
"incremental", true,
"transferOptions", Json.newObj("target-action", "overwrite")));
String monitorId = Json.str(transfer, "monitorId");
Map<String, Object> detail = client.await(monitorId, 7200, null);
System.out.printf("전송 파일 %s개, %s bytes%n",
Json.str(detail, "fileCount"), Json.str(detail, "totalSize"));const transfer = await client.api("POST", "/api/transfers/manual", {
sourceDevice: STORAGE,
targetDevice: node,
targetPath: nodeInputPath("imagenet", "v3", runId),
sourcePaths: [datasetPath("imagenet", "v3")],
sendAllFolder: true,
incremental: true,
transferOptions: { "target-action": "overwrite" },
});
const monitorId = transfer.monitorId;
const detail = await wait(monitorId);
console.log(`전송 파일 ${detail.fileCount}개, ${detail.totalSize} bytes`);JsonObject transfer = await client.ApiObjAsync("POST", "/api/transfers/manual",
new JsonObject
{
["sourceDevice"] = STORAGE,
["targetDevice"] = node,
["targetPath"] = NodeInputPath("imagenet", "v3", runId),
["sourcePaths"] = new JsonArray { DatasetPath("imagenet", "v3") },
["sendAllFolder"] = true,
["incremental"] = true,
["transferOptions"] = new JsonObject { ["target-action"] = "overwrite" },
});
string monitorId = J.Str(transfer, "monitorId");
JsonObject detail = await client.WaitAsync(monitorId, 7200);
Console.WriteLine(quot;전송 파일 {J.Str(detail, "fileCount")}개, "
+ quot;{J.Str(detail, "totalSize")} bytes");기본값은 꺼짐입니다. 변경분 계산은 노드의 에이전트가 수행하며, 마지막 배포 이후 추가되거나 수정된 파일만 전송합니다.
증분에는 반드시 overwrite를 씁니다. numbering이면 바뀐 파일이 번호가 붙어 쌓이고 학습 코드는 예전 파일을 계속 읽게 됩니다.
무결성 확인#
전송된 데이터가 원본과 같은지 확인하기
무결성 검증은 두 단계로 나뉩니다. POST /api/transfers/{monitorId}/verification으로 검증을 시작하고, 서버가 처리하는 동안 GET으로 결과를 폴링합니다.
def verify(monitor_id, timeout=1800, interval=10):
api("POST", f"/api/transfers/{monitor_id}/verification", {})
deadline = time.time() + timeout
while time.time() < deadline:
result = api("GET", f"/api/transfers/{monitor_id}/verification") or {}
if result.get("verified"):
return result
time.sleep(interval)
raise TimeoutError(f"verification: {monitor_id}")
result = verify(monitor_id)
if result["sourceFileCount"] != result["targetFileCount"]:
raise RuntimeError("file counts differ - part of the transfer is missing")
if not result["checksumMatched"]:
for row in result.get("mismatchedFiles") or []:
print("mismatch:", row)Map<String, Object> result = client.verify(monitorId, 1800, 10);
if (!Json.str(result, "sourceFileCount").equals(Json.str(result, "targetFileCount"))) {
throw new RuntimeException("파일 수가 다릅니다. 전송이 일부 빠졌습니다.");
}
if (!Json.bool(result, "checksumMatched", false)) {
for (Object row : Json.arrOf(result, "mismatchedFiles")) {
System.out.println("불일치: " + row);
}
}const result = await verify(monitorId);
if (result.sourceFileCount !== result.targetFileCount) {
throw new Error("파일 수가 다릅니다. 전송이 일부 빠졌습니다.");
}
if (!result.checksumMatched) {
for (const row of result.mismatchedFiles || []) {
console.log("불일치:", row);
}
}JsonObject result = await client.VerifyAsync(monitorId);
if (J.Str(result, "sourceFileCount") != J.Str(result, "targetFileCount"))
{
throw new Exception("파일 수가 다릅니다. 전송이 일부 빠졌습니다.");
}
if (!J.Bool(result, "checksumMatched", false))
{
foreach (JsonNode row in J.ArrOf(result, "mismatchedFiles"))
{
Console.WriteLine(quot;불일치: {row}");
}
}| 응답 항목 | 내용 |
|---|---|
checksumAlgorithm |
사용된 체크섬 알고리즘 |
sourceFileCount · targetFileCount |
소스와 대상의 파일 수 |
checksumMatched |
체크섬 일치 여부 |
mismatchedCount · mismatchedFiles |
불일치 건수와 목록 |
파일 수가 다르면 전송이 덜 된 것이고, 수는 같은데 불일치가 있으면 내용이 깨진 것입니다. 전자는 재전송으로 해결되지만 후자는 원인을 찾아야 합니다.
결과 수집#
노드별 출력을 겹치지 않게 보관 장비로 모으기
모든 노드가 model.pt처럼 같은 이름을 씁니다. 한 경로로 모으면 서로 덮어씁니다.
def collect(nodes, archive, run_id):
transfers = {}
for node in nodes:
target_path = f"{archive_path(run_id)}/{node}"
transfers[node] = api("POST", "/api/transfers/manual", {
"sourceDevice": node,
"targetDevice": archive,
"targetPath": target_path,
"sourcePaths": [node_output_path(run_id)],
"sendAllFolder": True,
"checkIntegrity": True,
"transferOptions": {"target-action": "overwrite"},
})["monitorId"]
return transfersMap<String, String> collect(List<String> nodes, String archive, String runId) {
Map<String, String> transfers = new LinkedHashMap<>();
for (String node : nodes) {
String targetPath = archivePath(runId) + "/" + node;
Map<String, Object> transfer = client.apiObj("POST", "/api/transfers/manual", Json.newObj(
"sourceDevice", node,
"targetDevice", archive,
"targetPath", targetPath,
"sourcePaths", List.of(nodeOutputPath(runId)),
"sendAllFolder", true,
"checkIntegrity", true,
"transferOptions", Json.newObj("target-action", "overwrite")));
transfers.put(node, Json.str(transfer, "monitorId"));
}
return transfers;
}async function collect(nodes, archive, runId) {
const transfers = {};
for (const node of nodes) {
const targetPath = `${archivePath(runId)}/${node}`;
const transfer = await client.api("POST", "/api/transfers/manual", {
sourceDevice: node,
targetDevice: archive,
targetPath,
sourcePaths: [nodeOutputPath(runId)],
sendAllFolder: true,
checkIntegrity: true,
transferOptions: { "target-action": "overwrite" },
});
transfers[node] = transfer.monitorId;
}
return transfers;
}async Task<Dictionary<string, string>> CollectAsync(
IEnumerable<string> nodes, string archive, string runId)
{
var transfers = new Dictionary<string, string>();
foreach (string node in nodes)
{
string targetPath = quot;{ArchivePath(runId)}/{node}";
JsonObject transfer = await client.ApiObjAsync("POST", "/api/transfers/manual",
new JsonObject
{
["sourceDevice"] = node,
["targetDevice"] = archive,
["targetPath"] = targetPath,
["sourcePaths"] = new JsonArray { NodeOutputPath(runId) },
["sendAllFolder"] = true,
["checkIntegrity"] = true,
["transferOptions"] = new JsonObject { ["target-action"] = "overwrite" },
});
transfers[node] = J.Str(transfer, "monitorId");
}
return transfers;
}/archive/runs/r-20260901-0200/
dev-gpu-01/
model.pt
metrics.json
dev-gpu-02/
model.pt
metrics.json도착 경로에 노드 식별자를 넣는 것이 가장 단순합니다. numbering으로 충돌을 피하면 model_1.pt가 어느 노드 것인지 알 수 없습니다.
모델 파일과 함께 지표와 로그도 수집합니다. 실패한 학습의 원인은 로그에 남는데, 노드가 회수되면 함께 삭제됩니다.
배포와 수집 연결#
배포가 끝나면 수집이 자동으로 시작되게 하기
앞의 코드는 클라이언트가 완료를 기다렸다 다음을 호출합니다. 학습이 몇 시간 걸리면 그동안 프로세스가 살아 있어야 하고, 죽으면 수집이 일어나지 않습니다.
import uuid
flow_id = str(uuid.uuid4())
def build_step(name, source, source_path, target, target_path,
step, trigger_id=None, webhook=None):
schedule = {
"type": "none",
"startDateType": "now",
"startDate": now_iso(),
"timezone": "Asia/Seoul",
}
if trigger_id:
schedule["triggerAutomation"] = {"value": trigger_id}
body = {
"name": name,
"flowName": name,
"flowId": flow_id,
"transferType": "normal",
"timezone": "Asia/Seoul",
"step": step,
"isUpcoming": False,
"details": [
{
"senderId": source,
"receiverId": target,
"sourceItem": [
{
"hash": encode_path(source, source_path),
"filePath": source_path,
"isDir": True,
}
],
"targetPath": encode_path(target, target_path),
"step": step,
"transferOptions": {
"noSchedule": False,
"target-action": "overwrite",
"send-fileoption": {},
},
}
],
"schedules": [schedule],
}
if webhook:
body["processors"] = [{
"category": "run",
"type": "http",
"config": {"url": webhook, "method": "POST"},
}]
return body
deploy_id = api("POST", "/api/automations", build_step(
f"deploy imagenet:v3", STORAGE, dataset_path("imagenet", "v3"),
node, node_input_path("imagenet", "v3", run_id),
step=1, webhook=TRAIN_HOOK))["automationId"]
collect_id = api("POST", "/api/automations", build_step(
f"collect {run_id}", node, node_output_path(run_id),
ARCHIVE, f"{archive_path(run_id)}/{node}",
step=2, trigger_id=deploy_id))["automationId"]String flowId = UUID.randomUUID().toString();
Map<String, Object> buildStep(String name, String source, String sourcePath,
String target, String targetPath, int step,
String triggerId, String webhook) {
Map<String, Object> schedule = new LinkedHashMap<>(Json.newObj(
"type", "none", "startDateType", "now",
"startDate", InnorixClient.nowIso(), "timezone", "Asia/Seoul"));
if (triggerId != null) {
schedule.put("triggerAutomation", Json.newObj("value", triggerId));
}
Map<String, Object> detail = Json.newObj(
"senderId", source, "receiverId", target,
"sourceItem", List.of(Json.newObj(
"hash", InnorixClient.encodePath(source, sourcePath),
"filePath", sourcePath, "isDir", true)),
"targetPath", InnorixClient.encodePath(target, targetPath),
"step", step,
"transferOptions", Json.newObj(
"noSchedule", false, "target-action", "overwrite",
"send-fileoption", Json.newObj()));
Map<String, Object> body = new LinkedHashMap<>(Json.newObj(
"name", name, "flowName", name, "flowId", flowId,
"transferType", "normal", "timezone", "Asia/Seoul",
"step", step, "isUpcoming", false,
"details", List.of(detail),
"schedules", List.of(schedule)));
if (webhook != null) {
body.put("processors", List.of(Json.newObj(
"category", "run", "type", "http",
"config", Json.newObj("url", webhook, "method", "POST"))));
}
return body;
}import { randomUUID } from "node:crypto";
const flowId = randomUUID();
function buildStep(name, source, sourcePath, target, targetPath, {
step, triggerId = null, webhook = null,
}) {
const schedule = {
type: "none", startDateType: "now",
startDate: nowIso(), timezone: "Asia/Seoul",
};
if (triggerId) schedule.triggerAutomation = { value: triggerId };
const body = {
name, flowName: name, flowId,
transferType: "normal", timezone: "Asia/Seoul",
step, isUpcoming: false,
details: [{
senderId: source, receiverId: target,
sourceItem: [{
hash: encodePath(source, sourcePath),
filePath: sourcePath, isDir: true,
}],
targetPath: encodePath(target, targetPath),
step,
transferOptions: {
noSchedule: false, "target-action": "overwrite",
"send-fileoption": {},
},
}],
schedules: [schedule],
};
if (webhook) {
body.processors = [{
category: "run", type: "http",
config: { url: webhook, method: "POST" },
}];
}
return body;
}string flowId = Guid.NewGuid().ToString();
JsonObject BuildStep(string name, string source, string sourcePath,
string target, string targetPath, int step,
string triggerId = null, string webhook = null)
{
var schedule = new JsonObject
{
["type"] = "none", ["startDateType"] = "now",
["startDate"] = InnorixClient.NowIso(), ["timezone"] = "Asia/Seoul",
};
if (triggerId != null)
schedule["triggerAutomation"] = new JsonObject { ["value"] = triggerId };
var detail = new JsonObject
{
["senderId"] = source, ["receiverId"] = target,
["sourceItem"] = new JsonArray
{
new JsonObject
{
["hash"] = InnorixClient.EncodePath(source, sourcePath),
["filePath"] = sourcePath, ["isDir"] = true,
},
},
["targetPath"] = InnorixClient.EncodePath(target, targetPath),
["step"] = step,
["transferOptions"] = new JsonObject
{
["noSchedule"] = false, ["target-action"] = "overwrite",
["send-fileoption"] = new JsonObject(),
},
};
var body = new JsonObject
{
["name"] = name, ["flowName"] = name, ["flowId"] = flowId,
["transferType"] = "normal", ["timezone"] = "Asia/Seoul",
["step"] = step, ["isUpcoming"] = false,
["details"] = new JsonArray { detail },
["schedules"] = new JsonArray { schedule },
};
if (webhook != null)
{
body["processors"] = new JsonArray
{
new JsonObject
{
["category"] = "run", ["type"] = "http",
["config"] = new JsonObject { ["url"] = webhook, ["method"] = "POST" },
},
};
}
return body;
}자동화 요청에서 반드시 지켜야 하는 항목이 네 개 있습니다.
| 항목 | 지정 방식 |
|---|---|
isUpcoming |
반드시 false. 서버 기본값 true는 요청에 담긴 일정을 무시하고 5분짜리 일회성 일정으로 대체합니다. triggerAutomation이 붙은 단계는 서버가 false로 강제하므로, 트리거가 없는 첫 단계에만 직접 지정하면 됩니다 |
step |
최상위와 details 양쪽에 넣습니다. 흐름 안에서의 홉 위치입니다 |
sourceItem |
hash(경로 토큰)와 filePath(평문 경로)를 함께 넣습니다 |
syncType |
transferOptions 안에 넣습니다. 1은 단방향, 2는 양방향입니다 |
네 항목 모두 누락해도 등록은 성공하고 실행 시점에 동작이 달라집니다. 반복 일정을 등록했는데 한 번만 실행되고 끝났다면 isUpcoming부터 확인합니다.
두 요청을 보내고 나면 애플리케이션의 일은 끝납니다. 단계별 식별자는 실행 기록에 함께 저장해 둬야 나중에 상태를 확인할 수 있습니다.
학습 호출과 재전송#
데이터 도착 시점에 학습을 호출하고 실패한 파일만 재전송하기
호출은 전송 완료 후에 오며, 그 요청을 받는 엔드포인트는 다음과 같이 처리합니다.
def on_train_hook(payload):
monitor_id = payload.get("monitorId")
if monitor_id:
detail = wait(monitor_id)
if detail["status"] != STATUS_COMPLETE:
return abort_run(payload)
start_training(payload)void onTrainHook(Map<String, Object> payload) throws Exception {
String monitorId = Json.str(payload, "monitorId");
if (monitorId != null) {
Map<String, Object> detail = client.await(monitorId, 7200, null);
if (Json.intOr(detail, "status", -1) != InnorixClient.STATUS_COMPLETE) {
abortRun(payload);
return;
}
}
startTraining(payload);
}async function onTrainHook(payload) {
const monitorId = payload.monitorId;
if (monitorId) {
const detail = await wait(monitorId);
if (detail.status !== STATUS_COMPLETE) return abortRun(payload);
}
return startTraining(payload);
}async Task OnTrainHookAsync(JsonObject payload)
{
string monitorId = J.Str(payload, "monitorId");
if (monitorId != null)
{
JsonObject detail = await client.WaitAsync(monitorId, 7200);
if (J.Int(detail, "status", -1) != InnorixClient.StatusComplete)
{
await AbortRunAsync(payload);
return;
}
}
await StartTrainingAsync(payload);
}회차별 결과는 실행 이력으로 확인합니다.
runs = api("GET", f"/api/automations/{deploy_id}/executions") or []
latest = runs[0] if runs else None
if latest and latest["status"] != STATUS_COMPLETE:
print("deploy failed:", latest["monitorId"],
"retried", retry_failed(latest["monitorId"]), "files")List<Map<String, Object>> runs = client.executions(deployId);
Map<String, Object> latest = runs.isEmpty() ? null : runs.get(0);
if (latest != null
&& Json.intOr(latest, "status", -1) != InnorixClient.STATUS_COMPLETE) {
int count = client.retryFailed(Json.str(latest, "monitorId"));
System.out.println("배포 실패: " + Json.str(latest, "monitorId")
+ " 재전송 " + count + "건");
}const runs = (await client.executions(deployId)) || [];
const latest = runs[0] || null;
if (latest && latest.status !== STATUS_COMPLETE) {
const count = await client.retryFailed(latest.monitorId);
console.log("배포 실패:", latest.monitorId, "재전송", count, "건");
}List<JsonObject> runs = J.AsList(await client.ExecutionsAsync(deployId));
JsonObject latest = runs.Count > 0 ? runs[0] : null;
if (latest != null
&& J.IntOrNull(latest, "status") != InnorixClient.StatusComplete)
{
int count = await client.RetryFailedAsync(J.Str(latest, "monitorId"));
Console.WriteLine(quot;배포 실패: {J.Str(latest, "monitorId")} 재전송 {count}건");
}데이터셋이 크면 몇 개 파일 때문에 전체를 재전송하는 것이 큰 손실입니다.
| 확인 항목 | 확인 내용 |
|---|---|
| 경로 | 데이터셋 버전과 실행 식별자 |
| 배포 | 노드별 전달 완료 여부 |
| 검증 | 파일 수와 체크섬 일치 여부 |
| 수집 | 노드별 결과 도착 경로 |
| 흐름 | 단계별 회차와 상태 |
| 재전송 | 실패한 파일과 처리 결과 |