Skip to content

API reference

The reference below is generated from the Python sources. Names beginning with an underscore are implementation details.

Workspace

Workspace

Workspace(*, app_id: str, host_namespace: str | None = None, project_resources=None, app_settings=None)

Bases: WorkspaceJobApiMixin, WorkspaceProjectRuntimeMixin

Main core facade.

  • Lightweight global bootstrap.
  • Per-process persistent runtime.
  • Swappable active project.

Methods:

Name Description
app_setting_specs
get_app_setting
update_app_setting
get_project_resource_contract
project_resource_specs
list_project_resources
get_project_resource
get_project_resource_path
get_project_resource_map
list_projects
open_project
create_project
find_project
create_or_open_project
register_task_canceller
get_app_logger
get_executor_logger
get_project_logger
get_entity_loader_context
get_project_data_context
refresh_master_db
close_project

Close the active project. Its jobs that have not ended are interrupted, not lost:

get_project_activity

What is still going on in a project: its jobs that have not ended, and the external

delete_projects
shutdown
executors

One row for each executor kept: name, kind, hosts, default, and state:

activate

Start the executor kept as name, which takes jobs under that name from then on.

deactivate

Stop the executor active as name. Refused with ValueError while a job submitted

save_host

SettingsManager.save_host, refused while an active executor works on that machine.

remove_host
save_executor

SettingsManager.save_executor, refused while that executor is active.

remove_executor
submit

Run a ms_flow.engine.job in the active project and return its id, for engine.status

origin

What the job recorded under key in the active project was submitted with: job (its

resume

Submit again the job recorded under key (JobSnapshot.key) in the active project,

move

Continue on executor the job recorded under key: resume, for one that may be running.

remote_jobs

What the executor of that name has on another machine, whichever session or project

remove_remote_job

Take one of remote_jobs out of the queue, with what it has there. One that a job of

jobs

The jobs of the active project, oldest first; with limit, the last ones.

job_tasks

The tasks in flight of a job this session runs, by its JobSnapshot.id: see

job_failures

attempt, error (the traceback) and input (a short repr of the element) of the

get_project_store

Attributes:

Name Type Description
app_id
host_namespace
settings_manager
logging_manager
app_logger
master_db
project_manager
project_db ProjectStore | None
project_store ProjectStore | None
active_context Optional[ProjectContext]
project_logger
advanced WorkspaceAdvancedAccess

Explicit access to advanced surfaces outside the public happy path.

artifacts ArtifactRegistry
host_env
engine

The engine behind submit, built at first use: the one that shares out this machine,

Source code in src/ms_flow/main.py
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
def __init__(
    self,
    *,
    app_id: str,
    host_namespace: str | None = None,
    project_resources=None,
    app_settings=None,
):
    self.app_id = str(app_id).strip()
    if not self.app_id:
        raise ValueError("Workspace requires a non-empty app_id.")
    self.host_namespace = str(host_namespace or "").strip() or self.app_id
    self.settings_manager = SettingsManager()
    legacy_specs = coerce_app_setting_specs(app_settings)
    if legacy_specs:
        self.settings_manager.register_app_settings(self.app_id, legacy_specs)
    logging_cfg = self.settings_manager.settings.logging
    self.logging_manager = LoggingManager(
        global_log_dir=self.settings_manager.settings.projects_db.parent / "logs",
        max_bytes=logging_cfg.max_file_size_mb * 1024 * 1024,
        backup_count=logging_cfg.backup_count,
        retention_days=logging_cfg.retention_days,
        queue_size=logging_cfg.queue_size,
        app_level=logging_cfg.app_level,
        executor_level=logging_cfg.executor_level,
        project_level=logging_cfg.project_level,
        console_level=logging_cfg.console_level,
        root_namespace=self.host_namespace,
    )
    self.logging_manager.start()
    self.app_logger = self.logging_manager.get_app_logger("core")

    self._master_db_path = self.settings_manager.settings.projects_db

    self.master_db = MasterDB(self._master_db_path)
    self.project_manager = ProjectManager(self.master_db, app_id_filter=self.app_id)
    if self.settings_manager.settings.general.prune_missing_projects:
        pruned = self.project_manager.repository.delete_missing_projects()
        if pruned:
            self.app_logger.info("Pruned %d registry entries with missing project folders.", pruned)
    self._project_resource_contract = ProjectResourceContract(
        app_id=self.app_id,
        specs=coerce_project_resource_specs(project_resources),
    )

    self._runtime_state = ProjectRuntimeState()
    self._engine = self._flow = None  # see `Workspace.engine` and `Workspace.submit`
    # see `Workspace.activate`: who put the pool of this machine aside, who is starting, one at a time
    self._aside, self._starting, self._activation = None, set(), threading.Lock()
    self.project_logger = None
    self._advanced = WorkspaceAdvancedAccess(self)

app_id instance-attribute

app_id = str(app_id).strip()

host_namespace instance-attribute

host_namespace = str(host_namespace or '').strip() or self.app_id

settings_manager instance-attribute

settings_manager = SettingsManager()

logging_manager instance-attribute

logging_manager = LoggingManager(global_log_dir=self.settings_manager.settings.projects_db.parent / 'logs', max_bytes=logging_cfg.max_file_size_mb * 1024 * 1024, backup_count=logging_cfg.backup_count, retention_days=logging_cfg.retention_days, queue_size=logging_cfg.queue_size, app_level=logging_cfg.app_level, executor_level=logging_cfg.executor_level, project_level=logging_cfg.project_level, console_level=logging_cfg.console_level, root_namespace=self.host_namespace)

app_logger instance-attribute

app_logger = self.logging_manager.get_app_logger('core')

master_db instance-attribute

master_db = MasterDB(self._master_db_path)

project_manager instance-attribute

project_manager = ProjectManager(self.master_db, app_id_filter=self.app_id)

project_db property

project_db: ProjectStore | None

project_store property

project_store: ProjectStore | None

active_context property

active_context: Optional[ProjectContext]

project_logger property writable

project_logger

advanced property

advanced: WorkspaceAdvancedAccess

Explicit access to advanced surfaces outside the public happy path.

artifacts property

artifacts: ArtifactRegistry

host_env class-attribute instance-attribute

host_env = None

engine property

engine

The engine behind submit, built at first use: the one that shares out this machine, with its threads as thread and its pool of processes as local.

Raises ms_flow.engine.Busy when another process of this user already runs one.

app_setting_specs

app_setting_specs()
Source code in src/ms_flow/main.py
85
86
def app_setting_specs(self):
    return self.settings_manager.app_setting_specs(self.app_id)

get_app_setting

get_app_setting(key: str)
Source code in src/ms_flow/main.py
88
89
def get_app_setting(self, key: str):
    return self.settings_manager.get_app_setting(self.app_id, key)

update_app_setting

update_app_setting(key: str, value, *, save_global_too: bool = False) -> None
Source code in src/ms_flow/main.py
91
92
93
94
95
96
97
def update_app_setting(self, key: str, value, *, save_global_too: bool = False) -> None:
    self.settings_manager.update_app_setting(
        self.app_id,
        key,
        value,
        save_global_too=save_global_too,
    )

get_project_resource_contract

get_project_resource_contract()
Source code in src/ms_flow/_api_impl/project_runtime.py
16
17
def get_project_resource_contract(self):
    return self._project_resource_contract

project_resource_specs

project_resource_specs()
Source code in src/ms_flow/_api_impl/project_runtime.py
19
20
def project_resource_specs(self):
    return self._project_resource_contract.specs

list_project_resources

list_project_resources(project_id: str | UUID | None = None) -> dict[str, ProjectResource]
Source code in src/ms_flow/_api_impl/project_runtime.py
30
31
32
33
34
def list_project_resources(self, project_id: str | UUID | None = None) -> dict[str, ProjectResource]:
    project_root = self._resolve_project_root(project_id=project_id)
    if project_root is None:
        return {}
    return self._project_resource_contract.resolve(project_root)

get_project_resource

get_project_resource(key: str, project_id: str | UUID | None = None) -> ProjectResource
Source code in src/ms_flow/_api_impl/project_runtime.py
36
37
38
39
40
def get_project_resource(self, key: str, project_id: str | UUID | None = None) -> ProjectResource:
    project_root = self._resolve_project_root(project_id=project_id)
    if project_root is None:
        raise RuntimeError(f"There is no active project to resolve resource '{key}'.")
    return self._project_resource_contract.resolve_one(project_root, key)

get_project_resource_path

get_project_resource_path(key: str, *parts: str | Path, project_id: str | UUID | None = None, create_parent: bool = False) -> Path
Source code in src/ms_flow/_api_impl/project_runtime.py
42
43
44
45
46
47
48
49
50
51
52
53
def get_project_resource_path(
    self,
    key: str,
    *parts: str | Path,
    project_id: str | UUID | None = None,
    create_parent: bool = False,
) -> Path:
    resource = self.get_project_resource(key, project_id=project_id)
    path = resource.path.joinpath(*(str(part) for part in parts))
    if create_parent:
        path.parent.mkdir(parents=True, exist_ok=True)
    return path

get_project_resource_map

get_project_resource_map(project_id: str | UUID | None = None) -> dict[str, dict[str, str]]
Source code in src/ms_flow/_api_impl/project_runtime.py
55
56
57
58
59
def get_project_resource_map(self, project_id: str | UUID | None = None) -> dict[str, dict[str, str]]:
    return {
        key: resource.to_mapping()
        for key, resource in self.list_project_resources(project_id=project_id).items()
    }

list_projects

list_projects(page: int = 1, items_per_page: int = 20) -> list[Project]
Source code in src/ms_flow/_api_impl/project_runtime.py
102
103
def list_projects(self, page: int = 1, items_per_page: int = 20) -> list[Project]:
    return self.project_manager.repository.get_projects_paginated(page, items_per_page)

open_project

open_project(project_id: str | UUID, *, cancel_running_tasks_on_switch: bool = True, touch_master: bool = True, extra_dirs: list[str] | None = None) -> ProjectContext
Source code in src/ms_flow/_api_impl/project_runtime.py
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
def open_project(
    self,
    project_id: str | UUID,
    *,
    cancel_running_tasks_on_switch: bool = True,
    touch_master: bool = True,
    extra_dirs: list[str] | None = None,
) -> ProjectContext:
    normalized_id = self._normalize_id(project_id)
    self._validate_project_access(normalized_id)

    if self.active_context:
        if self.active_context.id == normalized_id:
            return self.active_context
        self.close_project(cancel_running_tasks=cancel_running_tasks_on_switch)

    context = self.project_manager.load_project_by_id(
        normalized_id,
        sm=self.settings_manager,
        extra_dirs=self._merge_project_dirs(extra_dirs),
    )
    self._activate_context(context, touch_master=touch_master)
    self.app_logger.info(
        "Project activated: id=%s name=%s",
        context.id,
        context.name,
        extra={"project_id": str(context.id)},
    )
    return context

create_project

create_project(name: str, folder: Path | str, description: str = '', tags: list[str] | None = None, base_settings: SettingsProfile = 'global', scope: str = 'full', activate: bool = True, extra_dirs: list[str] | None = None) -> ProjectContext
Source code in src/ms_flow/_api_impl/project_runtime.py
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
def create_project(
    self,
    name: str,
    folder: Path | str,
    description: str = "",
    tags: list[str] | None = None,
    base_settings: SettingsProfile = "global",
    scope: str = "full",
    activate: bool = True,
    extra_dirs: list[str] | None = None,
) -> ProjectContext:
    folder = Path(folder).expanduser().resolve()
    normalized_scope = str(scope).strip()
    effective_scope = normalized_scope or "full"
    if effective_scope == "full":
        effective_scope = self.app_id
    context = self.project_manager.create_project(
        name=name,
        folder=folder,
        sm=self.settings_manager,
        base=base_settings,
        description=description,
        scope=effective_scope,
        app_id=self.app_id,
        tags=tags,
        extra_dirs=self._merge_project_dirs(extra_dirs),
    )
    if not activate:
        return context

    if self.active_context and self.active_context.id != context.id:
        self.close_project(cancel_running_tasks=True)
    self._activate_context(context, touch_master=True)
    self.app_logger.info(
        "Project created and activated: id=%s name=%s",
        context.id,
        context.name,
        extra={"project_id": str(context.id)},
    )
    return context

find_project

find_project(*, name: str | None = None, folder: Path | str | None = None) -> Project | None
Source code in src/ms_flow/_api_impl/project_runtime.py
176
177
178
179
180
181
182
def find_project(
    self,
    *,
    name: str | None = None,
    folder: Path | str | None = None,
) -> Project | None:
    return self.project_manager.find_project(name=name, folder=folder)

create_or_open_project

create_or_open_project(*, name: str, folder: Path | str, description: str = '', tags: list[str] | None = None, base_settings: SettingsProfile = 'global', scope: str = 'full', cancel_running_tasks_on_switch: bool = True, activate: bool = True, extra_dirs: list[str] | None = None) -> ProjectContext
Source code in src/ms_flow/_api_impl/project_runtime.py
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
def create_or_open_project(
    self,
    *,
    name: str,
    folder: Path | str,
    description: str = "",
    tags: list[str] | None = None,
    base_settings: SettingsProfile = "global",
    scope: str = "full",
    cancel_running_tasks_on_switch: bool = True,
    activate: bool = True,
    extra_dirs: list[str] | None = None,
) -> ProjectContext:
    folder_path = Path(folder).expanduser().resolve()
    existing_at_path = self.project_manager.find_project_global(folder=folder_path)
    if existing_at_path is not None:
        existing_app_id = (existing_at_path.app_id or "").strip()
        if existing_app_id != self.app_id:
            raise ValueError(
                f"Path '{folder_path}' already belongs to project '{existing_at_path.name}' "
                f"of app_id='{existing_app_id or '<empty>'}'."
            )
        if activate:
            return self.open_project(
                existing_at_path.id,
                cancel_running_tasks_on_switch=cancel_running_tasks_on_switch,
                touch_master=True,
                extra_dirs=self._merge_project_dirs(extra_dirs),
            )
        return self.project_manager.load_project(
            project=existing_at_path,
            sm=self.settings_manager,
            extra_dirs=self._merge_project_dirs(extra_dirs),
        )
    existing = self.project_manager.find_project(name=name)
    if existing is not None:
        if activate:
            return self.open_project(
                existing.id,
                cancel_running_tasks_on_switch=cancel_running_tasks_on_switch,
                touch_master=True,
                extra_dirs=self._merge_project_dirs(extra_dirs),
            )
        return self.project_manager.load_project(
            project=existing,
            sm=self.settings_manager,
            extra_dirs=self._merge_project_dirs(extra_dirs),
        )
    return self.create_project(
        name=name,
        folder=folder_path,
        description=description,
        tags=tags,
        base_settings=base_settings,
        scope=scope,
        activate=activate,
        extra_dirs=self._merge_project_dirs(extra_dirs),
    )

register_task_canceller

register_task_canceller(project_id: str | UUID, canceller: Callable[[], None])
Source code in src/ms_flow/_api_impl/project_runtime.py
243
244
245
246
247
248
def register_task_canceller(self, project_id: str | UUID, canceller: Callable[[], None]):
    normalized_id = self._normalize_id(project_id)
    runtime = self._require_active_project_runtime()
    if runtime.context.id != normalized_id:
        raise RuntimeError("Cancellers can only be registered for the active project.")
    runtime.task_cancellers.append(canceller)

get_app_logger

get_app_logger(name: str = 'core')
Source code in src/ms_flow/_api_impl/project_runtime.py
250
251
def get_app_logger(self, name: str = "core"):
    return self.logging_manager.get_app_logger(name)

get_executor_logger

get_executor_logger(name: str = 'core')
Source code in src/ms_flow/_api_impl/project_runtime.py
253
254
def get_executor_logger(self, name: str = "core"):
    return self.logging_manager.get_executor_logger(name)

get_project_logger

get_project_logger(name: str = 'core')
Source code in src/ms_flow/_api_impl/project_runtime.py
256
257
def get_project_logger(self, name: str = "core"):
    return self.logging_manager.get_project_logger(name)

get_entity_loader_context

get_entity_loader_context() -> ProjectDataContext
Source code in src/ms_flow/_api_impl/project_runtime.py
259
260
def get_entity_loader_context(self) -> ProjectDataContext:
    return self.advanced.entity_loader_context()

get_project_data_context

get_project_data_context() -> ProjectDataContext
Source code in src/ms_flow/_api_impl/project_runtime.py
262
263
def get_project_data_context(self) -> ProjectDataContext:
    return self.advanced.project_data_context()

refresh_master_db

refresh_master_db()
Source code in src/ms_flow/_api_impl/project_runtime.py
265
266
267
def refresh_master_db(self):
    self.master_db.reconnect()
    self.app_logger.debug("Master DB reconnected")

close_project

close_project(cancel_running_tasks: bool = True)

Close the active project. Its jobs that have not ended are interrupted, not lost: submitted again under the same key in a later session, each continues where it stopped.

With cancel_running_tasks=False, raises instead when one is still going on.

Source code in src/ms_flow/_api_impl/project_runtime.py
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
def close_project(self, cancel_running_tasks: bool = True):
    """Close the active project. Its jobs that have not ended are interrupted, not lost:
    submitted again under the same key in a later session, each continues where it stopped.

    With `cancel_running_tasks=False`, raises instead when one is still going on.
    """
    if not self.active_context:
        self.settings_manager.clear_project()
        self.logging_manager.clear_project_logging()
        return

    context = self.active_context
    if cancel_running_tasks:
        self._cancel_project_tasks(context.id)
    elif self.get_project_activity()["jobs_active"]:
        raise RuntimeError("The project cannot be closed while active jobs remain.")

    self.project_manager.close_project(context)
    self._close_project_runtime()
    self.settings_manager.clear_project()
    self.app_logger.info(
        "Project closed: id=%s name=%s",
        context.id,
        context.name,
        extra={"project_id": str(context.id)},
    )

get_project_activity

get_project_activity(project_id: str | UUID | None = None) -> dict[str, Any]

What is still going on in a project: its jobs that have not ended, and the external tasks registered with register_task_canceller. Only the active project has any.

Source code in src/ms_flow/_api_impl/project_runtime.py
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
def get_project_activity(self, project_id: str | UUID | None = None) -> dict[str, Any]:
    """What is still going on in a project: its jobs that have not ended, and the external
    tasks registered with `register_task_canceller`. Only the active project has any.
    """
    context = self.active_context
    mine = context is not None and (project_id is None or context.id == self._normalize_id(project_id))
    # ponytail: every recorded job of the engine counts, as the project's journal is the
    # only one this facade attaches.
    jobs = [
        job
        for job in (self._engine.jobs() if mine and self._flow is not None else ())
        if job.key is not None and job.status in ("waiting", "running")
    ]
    cancellers = len(self._runtime_state.active_project.task_cancellers) if mine else 0
    active = bool(jobs or cancellers)
    return {
        "project_id": str(project_id or context.id) if project_id or context else None,
        "active": active,
        "can_switch_project": not active,
        "jobs_active": len(jobs),
        "job_ids": [job.id for job in jobs],
        "statuses": sorted({job.status for job in jobs}),
        "external_cancellers": cancellers,
    }

delete_projects

delete_projects(project_ids: list[str | UUID], delete_files: bool = True)
Source code in src/ms_flow/_api_impl/project_runtime.py
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
def delete_projects(self, project_ids: list[str | UUID], delete_files: bool = True):
    normalized_ids = []
    for project_id in project_ids:
        try:
            normalized_ids.append(self._normalize_id(project_id))
        except ValueError:
            continue

    if not normalized_ids:
        return

    if self.active_context and self.active_context.id in normalized_ids:
        self.close_project()

    self.project_manager.delete_projects(normalized_ids, delete_files=delete_files)
    self.app_logger.info("Projects deleted: count=%s delete_files=%s", len(normalized_ids), delete_files)

shutdown

shutdown()
Source code in src/ms_flow/_api_impl/project_runtime.py
374
375
376
377
378
379
380
381
382
383
def shutdown(self):
    try:
        self.close_project(cancel_running_tasks=True)
    except Exception:
        pass
    self._shutdown_runtime_stack()
    try:
        self.logging_manager.stop()
    except Exception:
        pass

executors

executors() -> list[dict]

One row for each executor kept: name, kind, hosts, default, and state: on, starting, off, or blocked when it is off and not to be started now, with why. Safe to poll.

Source code in src/ms_flow/_api_impl/job_api.py
143
144
145
146
147
148
149
150
151
152
153
154
def executors(self) -> list[dict]:
    """One row for each executor kept: `name`, `kind`, `hosts`, `default`, and `state`:
    `on`, `starting`, `off`, or `blocked` when it is off and not to be started now, with
    `why`. Safe to poll."""
    settings, active = self.settings_manager.settings, self._active()
    rows = []
    for name, config in settings.executors.items():
        why = "" if name in active or name in self._starting else blocked(name, settings, active)
        state = "starting" if name in self._starting else "on" if name in active else "blocked" if why else "off"
        rows.append({"name": name, "kind": config.kind, "hosts": list(config.hosts), "state": state, "why": why,
                     "default": name == settings.general.default_executor})
    return rows

activate

activate(name: str, on_line=None) -> None

Start the executor kept as name, which takes jobs under that name from then on.

Refused with ValueError while another active one has one of its machines or is a Ray too (see blocked). A Ray that takes this machine puts the pool of it aside until it is deactivated, so the pool must have no job that has not ended. A host that is not reached or not prepared raises, and leaves everything as it was. on_line hears what is done on a host that is being prepared. It waits for its hosts: not from a thread that cannot.

Source code in src/ms_flow/_api_impl/job_api.py
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
def activate(self, name: str, on_line=None) -> None:
    """Start the executor kept as `name`, which takes jobs under that name from then on.

    Refused with `ValueError` while another active one has one of its machines or is a Ray
    too (see `blocked`). A Ray that takes this machine puts the pool of it aside until it
    is deactivated, so the pool must have no job that has not ended. A host that is not
    reached or not prepared raises, and leaves everything as it was. `on_line` hears what
    is done on a host that is being prepared. It waits for its hosts: not from a thread
    that cannot.
    """
    settings, engine = self.settings_manager.settings, self.engine
    if name not in settings.executors:
        raise ValueError(f"no executor is kept as `{name}`")
    self._starting.add(name)
    try:
        with self._activation:
            if name in engine._executors:
                return
            why = blocked(name, settings, self._active())
            if why:
                raise ValueError(f"`{name}` is not started: {why}")
            config = settings.executors[name]
            aside = config.kind == "ray" and "local" in config.hosts and "local" in engine._executors
            if aside:
                try:
                    engine.unregister("local")
                except ValueError:
                    raise ValueError(f"`{name}` takes this machine, and its pool has jobs that have not ended") from None
            try:
                host_env = self.host_env and (lambda address, busy: self.host_env(address, busy, on_line))
                executor = build_executor(name, settings, host_env)
                if executor is None:
                    raise RuntimeError(_LOGIN_NODE)
                engine.register(name, executor)
            except BaseException:
                if aside:
                    self._local_back()
                raise
            if aside:
                self._aside = name
    finally:
        self._starting.discard(name)

deactivate

deactivate(name: str) -> None

Stop the executor active as name. Refused with ValueError while a job submitted to it has not ended. The pool of this machine comes back if it was aside for this one.

Source code in src/ms_flow/_api_impl/job_api.py
199
200
201
202
203
204
205
206
207
208
def deactivate(self, name: str) -> None:
    """Stop the executor active as `name`. Refused with `ValueError` while a job submitted
    to it has not ended. The pool of this machine comes back if it was aside for this one."""
    if self._engine is None or name not in self._active():
        return
    with self._activation:
        self._engine.unregister(name)
        if self._aside == name:
            self._aside = None
            self._local_back()

save_host

save_host(name: str, **fields) -> None

SettingsManager.save_host, refused while an active executor works on that machine.

Source code in src/ms_flow/_api_impl/job_api.py
227
228
229
def save_host(self, name: str, **fields) -> None:
    """`SettingsManager.save_host`, refused while an active executor works on that machine."""
    self._change("hosts", name, lambda: self.settings_manager.save_host(name, **fields))

remove_host

remove_host(name: str) -> None
Source code in src/ms_flow/_api_impl/job_api.py
231
232
def remove_host(self, name: str) -> None:
    self._change("hosts", name, lambda: self.settings_manager.remove_host(name))

save_executor

save_executor(name: str, **fields) -> None

SettingsManager.save_executor, refused while that executor is active.

Source code in src/ms_flow/_api_impl/job_api.py
234
235
236
def save_executor(self, name: str, **fields) -> None:
    """`SettingsManager.save_executor`, refused while that executor is active."""
    self._change("executors", name, lambda: self.settings_manager.save_executor(name, **fields))

remove_executor

remove_executor(name: str) -> None
Source code in src/ms_flow/_api_impl/job_api.py
238
239
def remove_executor(self, name: str) -> None:
    self._change("executors", name, lambda: self.settings_manager.remove_executor(name))

submit

submit(job, params=None, *, executor: str | None = None, **options) -> int

Run a ms_flow.engine.job in the active project and return its id, for engine.status and engine.wait.

Its results go to the project's store, and the job is recorded in the project's executor.db: closing the project interrupts it, and submitting it again with the same key= continues it. executor is thread or the name of an active one (see activate), by default the one the settings give (general.default_executor); options are those of ms_flow.engine.Flow.submit.

Source code in src/ms_flow/_api_impl/job_api.py
250
251
252
253
254
255
256
257
258
259
260
261
262
263
def submit(self, job, params=None, *, executor: str | None = None, **options) -> int:
    """Run a `ms_flow.engine.job` in the active project and return its id, for `engine.status`
    and `engine.wait`.

    Its results go to the project's store, and the job is recorded in the project's
    `executor.db`: closing the project interrupts it, and submitting it again with the same
    `key=` continues it. `executor` is `thread` or the name of an active one (see
    `activate`), by default the one the settings give (`general.default_executor`);
    `options` are those of `ms_flow.engine.Flow.submit`.
    """
    flow = self._project_flow()
    name = executor or self.settings_manager.settings.general.default_executor
    self._ready(name)
    return flow.submit(job, params, journal="project", executor=name, **options)

origin

origin(key: str) -> dict

What the job recorded under key in the active project was submitted with: job (its module:qualname), params and options. Raises for one resume cannot submit again.

Source code in src/ms_flow/_api_impl/job_api.py
272
273
274
275
def origin(self, key: str) -> dict:
    """What the job recorded under `key` in the active project was submitted with: `job` (its
    `module:qualname`), `params` and `options`. Raises for one `resume` cannot submit again."""
    return self._project_flow().origin(key, journal="project")

resume

resume(key: str, **options) -> int

Submit again the job recorded under key (JobSnapshot.key) in the active project, with the params and options it was submitted with: one that was interrupted (the project or the application was closed) or canceled continues where it was. options say otherwise for this run, and bring what the record cannot keep (on_terminal).

Source code in src/ms_flow/_api_impl/job_api.py
277
278
279
280
281
282
283
284
285
def resume(self, key: str, **options) -> int:
    """Submit again the job recorded under `key` (`JobSnapshot.key`) in the active project,
    with the params and options it was submitted with: one that was interrupted (the project
    or the application was closed) or canceled continues where it was. `options` say
    otherwise for this run, and bring what the record cannot keep (`on_terminal`)."""
    from ms_flow.engine.flow import named

    origin = self.origin(key)
    return self.submit(named(origin["job"]), origin["params"], key=key, **{**origin["options"], **options})

move

move(key: str, executor: str, **options) -> int

Continue on executor the job recorded under key: resume, for one that may be running. A job this session runs is canceled first and waited for, so not from a thread that cannot wait: what its tasks in flight had done is done again there.

Source code in src/ms_flow/_api_impl/job_api.py
287
288
289
290
291
292
293
294
295
296
297
def move(self, key: str, executor: str, **options) -> int:
    """Continue on `executor` the job recorded under `key`: `resume`, for one that may be running.
    A job this session runs is canceled first and waited for, so not from a thread that cannot
    wait: what its tasks in flight had done is done again there."""
    self.origin(key)  # nothing is canceled for a job that cannot be submitted again,
    self._ready(executor)  # or that has nowhere to go
    for job in self._engine.jobs():
        if job.key == key and job.finished_at is None:
            self._engine.cancel(job.id)
            self._engine.wait(job.id)
    return self.resume(key, executor=executor, **options)

remote_jobs

remote_jobs(executor: str) -> list[dict]

What the executor of that name has on another machine, whichever session or project sent it and with or without a project open: see RemoteExecutor.jobs_there. Nothing when it runs its jobs here. It asks that machine, so not from a thread that cannot wait.

Source code in src/ms_flow/_api_impl/job_api.py
299
300
301
302
303
304
def remote_jobs(self, executor: str) -> list[dict]:
    """What the executor of that name has on another machine, whichever session or project
    sent it and with or without a project open: see `RemoteExecutor.jobs_there`. Nothing
    when it runs its jobs here. It asks that machine, so not from a thread that cannot wait."""
    slot = self.engine._executors.get(executor)
    return slot.impl.jobs_there() if hasattr(getattr(slot, "impl", None), "jobs_there") else []

remove_remote_job

remove_remote_job(executor: str, dir: str) -> None

Take one of remote_jobs out of the queue, with what it has there. One that a job of this session waits for is refused: cancel the job.

Source code in src/ms_flow/_api_impl/job_api.py
306
307
308
309
def remove_remote_job(self, executor: str, dir: str) -> None:
    """Take one of `remote_jobs` out of the queue, with what it has there. One that a job of
    this session waits for is refused: cancel the job."""
    self.engine.executor(executor).remove_there(dir)

jobs

jobs(limit: int | None = None) -> list

The jobs of the active project, oldest first; with limit, the last ones.

What a monitor shows: every job on record in the project's journal, the ones of earlier sessions too, each as the engine has it now when it ran it in this session. Reads the journal without owning it and never builds the engine, so it is safe to poll.

Source code in src/ms_flow/_api_impl/job_api.py
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
def jobs(self, limit: int | None = None) -> list:
    """The jobs of the active project, oldest first; with `limit`, the last ones.

    What a monitor shows: every job on record in the project's journal, the ones of earlier
    sessions too, each as the engine has it now when it ran it in this session. Reads the
    journal without owning it and never builds the engine, so it is safe to poll.
    """
    project = self._runtime_state.active_project
    if project is None:
        return []
    from ms_flow.engine import JobSnapshot
    from ms_flow.engine.journal import recorded

    live = {(job.key, job.started_at): job for job in (self._engine.jobs() if self._flow is not None else ())}
    # ponytail: a job of a process that died reads as running until this session's first
    # submit opens the journal, which is what marks it interrupted.
    listed = [live.pop((row["job_id"], row["started_at"]), None)
              or JobSnapshot(None, row["name"], row["status"], row["total"], row["done"], row["failed"], 0,
                             row["error"], 0, row["started_at"], row["finished_at"], row["job_id"], 0,
                             min(1.0, (row["done"] + row["failed"]) / row["total"]) if row["total"] else None)
              for row in recorded(project.context.path / "executor.db", limit)]
    # One just submitted is not in the file until the journal's next write. Only the active
    # project has jobs that have not ended: closing a project interrupts its own.
    return listed + [job for job in live.values() if job.finished_at is None]

job_tasks

job_tasks(job_id: int | None) -> list

The tasks in flight of a job this session runs, by its JobSnapshot.id: see Engine.tasks. Nothing for a job of another session. Never builds the engine.

Source code in src/ms_flow/_api_impl/job_api.py
336
337
338
339
def job_tasks(self, job_id: int | None) -> list:
    """The tasks in flight of a job this session runs, by its `JobSnapshot.id`: see
    `Engine.tasks`. Nothing for a job of another session. Never builds the engine."""
    return self._engine.tasks(job_id) if self._engine is not None and job_id is not None else []

job_failures

job_failures(key: str, limit: int = 200) -> list[dict]

attempt, error (the traceback) and input (a short repr of the element) of the chunks that failed in the job recorded under key (JobSnapshot.key) in the active project, at most limit.

Source code in src/ms_flow/_api_impl/job_api.py
341
342
343
344
345
346
347
def job_failures(self, key: str, limit: int = 200) -> list[dict]:
    """`attempt`, `error` (the traceback) and `input` (a short repr of the element) of the
    chunks that failed in the job recorded under `key` (`JobSnapshot.key`) in the active
    project, at most `limit`."""
    from ms_flow.engine.journal import failures

    return failures(self._require_active_project_runtime().context.path / "executor.db", key, limit)

get_project_store

get_project_store()
Source code in src/ms_flow/_api_impl/job_api.py
349
350
def get_project_store(self):
    return self.advanced.project_store

Tasks and jobs

engine

In-memory job engine.

Classes:

Name Description
JobSnapshot
TaskSnapshot

A task that is with its executor: one chunk, or the several that share it.

Functions:

Name Description
task

Mark a function as a task: what runs on one element, and what it needs to run.

job

Mark a function (params, project) as a job: the elements it gives go to task, the results to output.

report

From a task, on the thread it was called on: how far the element it is working on is,

launched

From a task, on the thread it was called on: a program it started (a subprocess.Popen)

content_key

16 bytes that identify a chunk by what it carries, the same in every run.

writer

A table_sink, a graph_sink or a file_sink as the writer the engine makes of it: for

JobSnapshot dataclass

JobSnapshot(id: int | None, name: str, status: str, chunks_total: int | None, chunks_done: int, chunks_failed: int, chunks_inflight: int, error: str | None, peak_mem_mb: int, started_at: float, finished_at: float | None, key: str | None, chunks_retried: int, progress: float | None)

TaskSnapshot dataclass

TaskSnapshot(input: str, chunks: int, attempt: int, seconds: float, progress: float | None)

A task that is with its executor: one chunk, or the several that share it.

task

task(*, cpu: float = 1, gpu: float = 0, mem_mb: float = 0, retries: int = 0, batch: int | None = None, batched: bool = False, label=None, thread: bool = False)

Mark a function as a task: what runs on one element, and what it needs to run.

batch is how many elements the author wants to travel together; batched says the function takes the list of them and returns one result for each. label is what an element goes by in the monitor and in a failure: a template its keys fill ("dock-R{receptor}-L{ligand}") or a function (element) that returns the text. thread is for a function that only waits for a program it starts (a docking binary): on this machine it runs on a thread of the engine's process and no worker is kept for it. It tells of the program with launched, which is how it is stopped.

Source code in src/ms_flow/engine/flow.py
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
def task(*, cpu: float = 1, gpu: float = 0, mem_mb: float = 0, retries: int = 0, batch: int | None = None,
         batched: bool = False, label=None, thread: bool = False):
    """Mark a function as a task: what runs on one element, and what it needs to run.

    `batch` is how many elements the author wants to travel together; `batched` says the
    function takes the list of them and returns one result for each.
    `label` is what an element goes by in the monitor and in a failure: a template its keys
    fill (`"dock-R{receptor}-L{ligand}"`) or a function `(element)` that returns the text.
    `thread` is for a function that only waits for a program it starts (a docking binary): on
    this machine it runs on a thread of the engine's process and no worker is kept for it. It
    tells of the program with `launched`, which is how it is stopped.
    """
    def mark(fn):
        fn.__task__ = {"cpu": cpu, "gpu": gpu, "mem_mb": mem_mb, "retries": retries, "batch": batch,
                       "batched": batched, "label": label, "thread": thread}
        return fn
    return mark

job

job(*, task, name: str = '', params_model=None, output=None, reads=(), finalize=None)

Mark a function (params, project) as a job: the elements it gives go to task, the results to output.

It is a generator, or returns whatever can be read one element at a time: ms_flow.query.Rows for the rows of a table, which also gives the job its total and the table it reads. output is a table_sink (the task returns rows) or a graph_sink (it returns {node: rows}; a $ref names its row within what the element returned), a file_sink (it returns text or bytes, written as they come, or anything else, written as JSON) or a function (params, project) that returns the job's own writer (see the module's note). reads is the tables the source reads, besides the ones it says itself: the job waits for the jobs writing them. finalize(params, project, snapshot) is called once the job has ended, however it ended: what there is to tidy up when it failed or was canceled.

Source code in src/ms_flow/engine/flow.py
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
def job(*, task, name: str = "", params_model=None, output=None, reads=(), finalize=None):
    """Mark a function `(params, project)` as a job: the elements it gives go to `task`, the results to `output`.

    It is a generator, or returns whatever can be read one element at a time: `ms_flow.query.Rows`
    for the rows of a table, which also gives the job its total and the table it reads.
    `output` is a `table_sink` (the task returns rows) or a `graph_sink` (it returns
    `{node: rows}`; a `$ref` names its row within what the element returned), a `file_sink`
    (it returns text or bytes, written as they come, or anything else, written as JSON) or a
    function `(params, project)` that returns the job's own writer (see the module's note).
    `reads` is the tables the source reads, besides the ones it says itself: the job waits for
    the jobs writing them.
    `finalize(params, project, snapshot)` is called once the job has ended, however it ended:
    what there is to tidy up when it failed or was canceled.
    """
    if not hasattr(task, "__task__"):
        raise TypeError(f"{task!r} is not a task: decorate it with @task(...)")
    if output is not None and not (hasattr(output, "table") or hasattr(output, "fmt") or callable(output)):
        raise TypeError("output is a table_sink, a graph_sink, a file_sink or a function that returns a writer")

    def mark(source):
        source.__job__ = {"task": task, "name": name or source.__name__, "params_model": params_model,
                          "output": output, "reads": frozenset(reads), "finalize": finalize}
        return source
    return mark

report

report(fraction: float) -> None

From a task, on the thread it was called on: how far the element it is working on is, from 0 to 1.

Does nothing unless the task is watched: any on the process pool; elsewhere one of a job of few elements, or of a job submitted with progress=True.

Source code in src/ms_flow/engine/executors.py
386
387
388
389
390
391
392
393
394
395
def report(fraction: float) -> None:
    """From a task, on the thread it was called on: how far the element it is working on is,
    from 0 to 1.

    Does nothing unless the task is watched: any on the process pool; elsewhere one of a job of few
    elements, or of a job submitted with `progress=True`.
    """
    at = getattr(_task, "at", None)
    if at is not None:
        _slots[1][at[0]] = at[1] + fraction * at[2]

launched

launched(program) -> None

From a task, on the thread it was called on: a program it started (a subprocess.Popen) and waits for.

A task on a thread (thread=True) cannot be stopped, only what it runs can: when the engine stops what is running (a cancel, a time limit) the programs it told of are killed, and one it tells of after that is killed at once. A task that tells of none runs to its end. Does nothing in a worker process, which is killed with everything it started.

Source code in src/ms_flow/engine/executors.py
398
399
400
401
402
403
404
405
406
407
408
409
410
411
def launched(program) -> None:
    """From a task, on the thread it was called on: a program it started (a `subprocess.Popen`)
    and waits for.

    A task on a thread (`thread=True`) cannot be stopped, only what it runs can: when the engine
    stops what is running (a cancel, a time limit) the programs it told of are killed, and one it
    tells of after that is killed at once. A task that tells of none runs to its end. Does nothing
    in a worker process, which is killed with everything it started.
    """
    started = getattr(_task, "started", None)
    if started is not None:
        started.programs.append(program)
        if started.killed:
            _stop(program)

content_key

content_key(payload) -> bytes

16 bytes that identify a chunk by what it carries, the same in every run.

Equal keys mean equal payloads. The reverse is not promised: a payload built another way (dict order, a set) gets another key, so on resume it is repeated, never skipped.

Source code in src/ms_flow/engine/engine.py
70
71
72
73
74
75
76
77
78
79
80
def content_key(payload) -> bytes:
    """16 bytes that identify a chunk by what it carries, the same in every run.

    Equal keys mean equal payloads. The reverse is not promised: a payload built another
    way (dict order, a set) gets another key, so on resume it is repeated, never skipped.
    """
    try:
        data = pickle.dumps(payload, protocol=4)
    except Exception:
        return uuid.uuid4().bytes  # nothing to recognise it by in another run
    return hashlib.blake2b(data, digest_size=16).digest()

writer

writer(output, project)

A table_sink, a graph_sink or a file_sink as the writer the engine makes of it: for a job's own writer that changes what its tasks returned and leaves the writing to a sink.

Source code in src/ms_flow/engine/flow.py
218
219
220
221
def writer(output, project):
    """A `table_sink`, a `graph_sink` or a `file_sink` as the writer the engine makes of it: for
    a job's own writer that changes what its tasks returned and leaves the writing to a sink."""
    return _Table(output, project) if hasattr(output, "table") else _File(output, project)

Sinks

sinks

Functions:

Name Description
table_sink

Simple helper to persist results into a SQLite table.

graph_sink

Helper to persist a multi-table relational graph into SQLite.

file_sink

Simple helper to persist results to a file. A relative path is under the project.

table_sink

table_sink(table: str = '', *, model: type[SQLModel] | None = None, columns: Iterable[str] = (), write_mode: str = 'bulk', conflict_keys: Iterable[str] = (), validate_model: bool = False, db_role: str = 'project', db_path: str | Path = '', schema: str = '', meta: Optional[dict[str, Any]] = None) -> DbOutputSpec

Simple helper to persist results into a SQLite table.

write_mode="upsert" updates existing rows via INSERT ... ON CONFLICT (conflict_keys) DO UPDATE. Each emitted row only updates the columns it carries (SET is built per payload), so it needs model and conflict_keys to be the PK or a UNIQUE constraint of the table.

Source code in src/ms_flow/sinks.py
 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
def table_sink(
    table: str = "",
    *,
    model: type[SQLModel] | None = None,
    columns: Iterable[str] = (),
    write_mode: str = "bulk",
    conflict_keys: Iterable[str] = (),
    validate_model: bool = False,
    db_role: str = "project",
    db_path: str | Path = "",
    schema: str = "",
    meta: Optional[dict[str, Any]] = None,
) -> DbOutputSpec:
    """Simple helper to persist results into a SQLite table.

    `write_mode="upsert"` updates existing rows via `INSERT ... ON CONFLICT
    (conflict_keys) DO UPDATE`. Each emitted row only updates the columns it carries
    (SET is built per payload), so it needs `model` and `conflict_keys` to be the PK
    or a UNIQUE constraint of the table.
    """
    resolved_table = _model_table_name(model) if model is not None else str(table)
    # For upsert we keep explicit columns when given so the SET clause is scoped;
    # otherwise default to the model's writable columns.
    if columns:
        resolved_columns: tuple[str, ...] = tuple(str(col) for col in columns)
    elif model is not None:
        resolved_columns = _model_output_columns(model)
    else:
        resolved_columns = ()
    return DbOutputSpec(
        table=resolved_table,
        columns=resolved_columns,
        model_path=_model_path(model) if model is not None else "",
        write_mode=str(write_mode or "bulk"),
        conflict_keys=tuple(str(key) for key in conflict_keys),
        validate_model=bool(validate_model),
        db_role=str(db_role or "project"),
        db_path=str(db_path or ""),
        schema=str(schema or ""),
        meta=dict(meta or {}),
    )

graph_sink

graph_sink(*, nodes: Iterable[Mapping[str, Any]], relations: Iterable[Mapping[str, Any]] = (), db_role: str = 'project', db_path: str | Path = '', schema: str = '', meta: Optional[dict[str, Any]] = None) -> DbOutputSpec

Helper to persist a multi-table relational graph into SQLite.

Source code in src/ms_flow/sinks.py
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
def graph_sink(
    *,
    nodes: Iterable[Mapping[str, Any]],
    relations: Iterable[Mapping[str, Any]] = (),
    db_role: str = "project",
    db_path: str | Path = "",
    schema: str = "",
    meta: Optional[dict[str, Any]] = None,
) -> DbOutputSpec:
    """Helper to persist a multi-table relational graph into SQLite."""
    resolved_meta = dict(meta or {})
    resolved_meta["graph"] = {
        "nodes": [_coerce_graph_node(node) for node in nodes],
        "relations": [_coerce_graph_relation(relation) for relation in relations],
    }
    return DbOutputSpec(
        table="",
        columns=(),
        write_mode="bulk",
        db_role=str(db_role or "project"),
        db_path=str(db_path or ""),
        mode="graph",
        schema=str(schema or ""),
        meta=resolved_meta,
    )

file_sink

file_sink(path: str | Path, *, root: str = 'project', fmt: str = 'json', encoding: str = 'utf-8', ensure_parent: bool = True, append: bool = False, per_batch: bool = False, schema: str = '', meta: Optional[dict[str, Any]] = None) -> FileOutputSpec

Simple helper to persist results to a file. A relative path is under the project.

What a task returns is written as it comes when it is text or bytes, and as a JSON line otherwise; with fmt="json" the file is one JSON list of what the tasks returned.

append=True (text/binary fmt only) is what a long job needs: each group of results is added to the file as it arrives. per_batch=True is the other bounded option: one numbered file (name_00001.ext) for each group. With neither, everything is kept in memory and the file is written once, when the job completes.

Source code in src/ms_flow/sinks.py
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
def file_sink(
    path: str | Path,
    *,
    root: str = "project",
    fmt: str = "json",
    encoding: str = "utf-8",
    ensure_parent: bool = True,
    append: bool = False,
    per_batch: bool = False,
    schema: str = "",
    meta: Optional[dict[str, Any]] = None,
) -> FileOutputSpec:
    """Simple helper to persist results to a file. A relative `path` is under the project.

    What a task returns is written as it comes when it is text or bytes, and as a JSON line
    otherwise; with `fmt="json"` the file is one JSON list of what the tasks returned.

    `append=True` (text/binary fmt only) is what a long job needs: each group of results is
    added to the file as it arrives. `per_batch=True` is the other bounded option: one numbered
    file (`name_00001.ext`) for each group. With neither, everything is kept in memory and the
    file is written once, when the job completes.
    """
    return FileOutputSpec(
        path=str(path),
        root=str(root or "project"),
        fmt=str(fmt or "json"),
        encoding=str(encoding or "utf-8"),
        ensure_parent=bool(ensure_parent),
        append=bool(append),
        per_batch=bool(per_batch),
        schema=str(schema or ""),
        meta=dict(meta or {}),
    )

Queries

query

Classes:

Name Description
QuerySpec

Declarative query specification for project paths and DB transport.

Rows

The rows of a table as the source of a job, read in pages by key.

Functions:

Name Description
db_pages

The same rows as db_stream, but in keyset pages.

QuerySpec dataclass

QuerySpec(table: str = '', fields: tuple[str, ...] = (), filters: dict[str, Any] = dict(), order: tuple[str, ...] = (), limit: Optional[int] = None, offset: int = 0, query: str = '', params: tuple[Any, ...] = (), db_role: str = 'project', db_path: str = '', schema: str = '', meta: dict[str, Any] = dict())

Declarative query specification for project paths and DB transport.

Rows

Rows(source: Any, spec: QuerySpec, *, key: str = 'id', page_size: int = 500)

The rows of a table as the source of a job, read in pages by key.

It stops at the highest key the table had when the job first read it, and the job keeps that: rows that arrive later stay out, also when the job continues. It knows how many rows it has left, which gives the job its total.

Source code in src/ms_flow/query.py
263
264
265
266
267
268
269
270
271
272
def __init__(self, source: Any, spec: QuerySpec, *, key: str = "id", page_size: int = 500):
    self._key = normalize_identifier(key, label="key")
    if (not spec.table or spec.offset or spec.limit is not None
            or (spec.fields and self._key not in spec.fields)
            or (spec.order and tuple(spec.order) != (self._key,))):
        raise ValueError("Rows reads a whole table in the order of its key: no query, limit, offset or "
                         "other order, and the key among the fields")
    self.reads = (spec.table,)
    self._source, self._spec, self._size = source, spec, page_size
    self._state = self._last = None

db_pages

db_pages(source: Any, spec: QuerySpec, *, key: str = 'id', page_size: int = 500) -> Iterator[dict[str, Any]]

The same rows as db_stream, but in keyset pages.

Each page is its own short read (WHERE key > last ORDER BY key LIMIT n) instead of a single cursor held open for the whole feed. Feeding a million-ligand screen takes hours, and a read snapshot held open that long prevents SQLite from checkpointing the WAL: project.db-wal grows without bound while the job itself writes its results.

Without a usable key (raw SQL, offset, or an order other than key) no keyset is possible: it falls back to db_stream, which returns exactly the same rows.

Source code in src/ms_flow/query.py
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
def db_pages(
    source: Any,
    spec: QuerySpec,
    *,
    key: str = "id",
    page_size: int = 500,
) -> Iterator[dict[str, Any]]:
    """The same rows as `db_stream`, but in keyset pages.

    Each page is its own short read (`WHERE key > last ORDER BY key LIMIT n`) instead of a single
    cursor held open for the whole feed. Feeding a million-ligand screen takes hours, and a read
    snapshot held open that long prevents SQLite from checkpointing the WAL: `project.db-wal`
    grows without bound while the job itself writes its results.

    Without a usable key (raw SQL, `offset`, or an order other than `key`) no keyset is possible:
    it falls back to `db_stream`, which returns exactly the same rows.
    """
    key_name = str(key or "").strip()
    pageable = (
        bool(spec.table)
        and bool(key_name)
        and not spec.offset
        and (not spec.fields or key_name in spec.fields)
        and (not spec.order or tuple(spec.order) == (key_name,))
    )
    if not pageable:
        yield from db_stream(source, spec, batch_size=page_size)
        return

    backend = resolve_project_store(source)
    size = max(1, int(page_size))
    remaining = None if spec.limit is None else int(spec.limit)
    cursor: Any = None
    while remaining is None or remaining > 0:
        take = size if remaining is None else min(size, remaining)
        filters = dict(spec.filters)
        if cursor is not None:
            filters[f"{key_name}__gt"] = cursor
        page = replace(spec, filters=filters, order=(key_name,), limit=take, offset=0)
        rows = backend.read_rows(_as_project_read_query(page))
        if not rows:
            return
        yield from rows
        if len(rows) < take:
            return
        cursor = rows[-1].get(key_name)
        if cursor is None:
            return  # no cursor, no progress; stopping beats repeating the page forever
        if remaining is not None:
            remaining -= len(rows)

The engine

engine

In-memory job engine.

Classes:

Name Description
Engine
ProcessExecutor

loky process pool. A dead worker costs the pool, never the executor: it is rebuilt.

ThreadExecutor
RayExecutor

Tasks on a Ray cluster, which shares out its own CPUs, GPUs and memory.

Flow

Submits jobs for one project: project is what a source receives and where the sinks write.

Engine

Engine(cpus: int | None = None, gpus: int = 0, mem_mb: int | None = None, state_dir=None)

mem_mb is the memory budget for chunks; by default physical RAM minus a reserve.

With state_dir, this is the one engine that shares out the local machine: it owns engine.lock there, and a second process gets Busy instead of the same CPUs.

Methods:

Name Description
register

Add an executor (see executors for the contract), in the place of the one that had

unregister

Shut down the executor registered as name, if there is one.

executor

The executor registered as name.

attach

Open the journal at path; jobs submitted with journal=name are recorded in it.

detach

Interrupt the jobs recorded in this journal, wait until it is written and close it.

submit

Run fn(payload) for every payload in chunks on executor. Returns the job id.

recorded

The jobs on record in an attached journal, oldest first, as rows of its jobs table.

discard

Drop the job recorded as key, which must not be running: what it had done is forgotten

status

Where the job is. An ended job is forgotten once _REMEMBERED others ended after it;

tasks

The tasks of the job that are with its executor now, the oldest first: what a monitor

wait

Block until the job is terminal and its callbacks have run, or timeout passes.

close

Interrupt what is running, let pending callbacks finish and stop the executors.

Source code in src/ms_flow/engine/engine.py
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
def __init__(self, cpus: int | None = None, gpus: int = 0, mem_mb: int | None = None, state_dir=None):
    """`mem_mb` is the memory budget for chunks; by default physical RAM minus a reserve.

    With `state_dir`, this is the one engine that shares out the local machine: it owns
    `engine.lock` there, and a second process gets `Busy` instead of the same CPUs.
    """
    self._lock = own(Path(state_dir) / "engine.lock") if state_dir is not None else None
    self.tokens = Tokens(cpus or usable_cpus(), gpus,
                         memory_budget_mb() if mem_mb is None else mem_mb)
    self._executors: dict[str, _Slot] = {}
    self._journals: dict[str, Journal] = {}
    self._jobs: dict[int, _Job] = {}  # the ones that have not ended
    self._ended: dict[int, JobSnapshot] = {}  # the last _REMEMBERED that have, oldest first
    self._active: list[_Job] = []  # running jobs, by priority and then arrival
    self._waiting: list[_Job] = []
    self._ids = itertools.count(1)
    self._timers: list = []  # (when, tie-break, fn, args), a heap; the loop's own
    self._ticks = itertools.count()
    self._events: queue.SimpleQueue = queue.SimpleQueue()  # into the loop
    self._deliveries: queue.SimpleQueue = queue.SimpleQueue()  # user callbacks, out of the loop
    self._closed = False
    self._threads = [threading.Thread(target=self._loop, name="msflow-engine", daemon=True),
                     threading.Thread(target=self._deliver, name="msflow-delivery", daemon=True)]
    for thread in self._threads:
        thread.start()

register

register(name: str, executor) -> None

Add an executor (see executors for the contract), in the place of the one that had the name: see unregister.

Source code in src/ms_flow/engine/engine.py
195
196
197
198
199
200
201
202
def register(self, name: str, executor) -> None:
    """Add an executor (see `executors` for the contract), in the place of the one that had
    the name: see `unregister`."""
    self.unregister(name)
    executor.bind(self._chunk_done)
    if hasattr(executor, "bind_parts"):
        executor.bind_parts(self._part_done)
    self._executors = {**self._executors, name: _Slot(executor)}  # the loop iterates the old one

unregister

unregister(name: str) -> None

Shut down the executor registered as name, if there is one.

Raises ValueError while a job submitted to it has not ended: an executor changes between jobs, never under one.

Source code in src/ms_flow/engine/engine.py
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
def unregister(self, name: str) -> None:
    """Shut down the executor registered as `name`, if there is one.

    Raises `ValueError` while a job submitted to it has not ended: an executor changes
    between jobs, never under one.
    """
    slot = self._executors.get(name)
    if slot is None:
        return
    # ponytail: whoever changes executors is whoever submits; a submit from another thread
    # at this very moment could still land on the one going away.
    if any(job.slot is slot for job in list(self._jobs.values())):
        raise ValueError(f"executor {name!r} has jobs that have not ended")
    self._executors = {key: kept for key, kept in self._executors.items() if key != name}
    slot.impl.shutdown()

executor

executor(name: str)

The executor registered as name.

Source code in src/ms_flow/engine/engine.py
220
221
222
def executor(self, name: str):
    """The executor registered as `name`."""
    return self._executors[name].impl

attach

attach(name: str, path) -> None

Open the journal at path; jobs submitted with journal=name are recorded in it.

Raises Busy when another process owns that file.

Source code in src/ms_flow/engine/engine.py
224
225
226
227
228
229
230
231
def attach(self, name: str, path) -> None:
    """Open the journal at `path`; jobs submitted with `journal=name` are recorded in it.

    Raises `Busy` when another process owns that file.
    """
    if name in self._journals:
        raise ValueError(f"a journal is already attached as {name!r}")
    self._journals = {**self._journals, name: Journal(path)}

detach

detach(name: str) -> None

Interrupt the jobs recorded in this journal, wait until it is written and close it.

Source code in src/ms_flow/engine/engine.py
233
234
235
236
237
238
239
240
241
242
243
244
245
def detach(self, name: str) -> None:
    """Interrupt the jobs recorded in this journal, wait until it is written and close it."""
    if self._closed:
        return
    journal = self._journals[name]
    self._journals = {k: v for k, v in self._journals.items() if k != name}
    told = threading.Event()
    self._events.put((self._on_detach, journal, told))
    told.wait()
    told.clear()
    self._events.put((told.set,))  # behind the loop taking note of what those callbacks took
    told.wait()
    journal.close()

submit

submit(executor: str, fn, chunks, *, cpu: float = 1, gpu: float = 0, mem_mb: float = 0, window: int | None = None, max_cpu: float | None = None, priority: int = 0, total: int | None = None, name: str = '', journal: str | None = None, key: str | None = None, after=(), retries: int = 0, retry_wait: float = 1.0, timeout: float | None = None, batch: int | None = None, batched: bool = False, progress: bool | None = None, origin=None, label=None, thread: bool = False, on_result=None, on_results=None, on_error=None, on_finish=None, on_terminal=None) -> int

Run fn(payload) for every payload in chunks on executor. Returns the job id.

cpu, gpu and mem_mb are what one chunk needs; a dict payload may say otherwise for itself with _cpu_required, _gpu_required and _mem_mb, which fn never sees. max_cpu caps the job as a whole; a chunk above the cap runs alone in its job. chunks is read on its own thread. window is how many chunks may be between dispatch and delivered result, and how many more may be read ahead. Chunks that take well under a task's worth of time travel several to a task where the executor allows it; each still succeeds, fails and is recorded on its own. Left to itself the window grows with them, since such chunks are small by construction. batch settles it instead: that many chunks to a task, fewer only in the last one and where chunks with different needs meet. With batched, fn takes the list of payloads of a task and returns one result for each, in order; if it raises, all of them failed. A chunk's key is content_key(payload), or the _key of a dict payload. on_result(key, result), on_error(key, error) and on_terminal(snapshot) run on the delivery thread, in order; a chunk counts as done once on_result returns. on_results(pairs) takes its place for whoever writes results somewhere: it gets every (key, result) of the job that is waiting, up to _DELIVER_MAX, and all of them count as done once it returns. When it raises, each pair is offered again alone and only the ones that raise again fail, so it must leave nothing behind when it raises: one transaction. on_finish() is the job's last word: called once every chunk has ended, on the delivery thread, before the job counts as completed and the jobs after it start. If it raises, the job fails with that error. A job that failed, was canceled or interrupted never gets there. chunks may know more than how to be read. With remaining(), how many chunks it has yet to give, the job has a total while it runs: counted at its first chunk and every _RECOUNT seconds after, exact once the source is read. With open(state), it is called before the first read and what it returns (JSON) is kept with the job in its journal and passed back as state when the job continues; state is None the first time. progress says whether the tasks of the job are watched, for JobSnapshot.progress to count how far the running chunks are and not only the ones that ended (see executors.report). By default every task is on an executor that watches for nothing (the process pool); on another, a task of several chunks, and a task of one when the job has at most two for each worker. thread says that fn spends its time waiting for a program it starts, not in Python: an executor that keeps a worker for each task and can spare it (the process pool) runs it on a thread of this process, so no worker holds memory for it. Such a chunk is stopped through the programs it tells of (see executors.launched). label is what a chunk goes by in tasks and in a failure, in place of a short repr of its payload: a template the payload fills ("dock-R{receptor}-L{ligand}", the keys of a dict payload) or a function (payload) that returns the text. It runs here, never in a worker, and only for a chunk that is shown. journal names an attached journal; without it nothing about the job reaches the disk. key is what the job is called there. Submitting an interrupted or canceled job again under its key continues it: the chunks it had done are recognised by their own keys and not run again. discard is how such a job is given up instead. origin is whatever whoever submits wants kept with the job there (JSON): recorded gives it back in spec. after is the ids of the jobs that must end first; chunks is not read until they do, and the job fails if one of them fails, is canceled or is interrupted. A chunk that fails runs again up to retries times, retry_wait seconds after the first failure and twice as long after each one that follows. Failing is raising, running past timeout seconds or killing its worker; falling with somebody else's worker is not, and costs the chunk nothing. on_error hears of a chunk only once it has failed for good. timeout needs an executor that can stop a running chunk; the process pool stops all of its chunks to stop one, and the others start again.

Source code in src/ms_flow/engine/engine.py
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
def submit(self, executor: str, fn, chunks, *, cpu: float = 1, gpu: float = 0, mem_mb: float = 0,
           window: int | None = None, max_cpu: float | None = None, priority: int = 0,
           total: int | None = None, name: str = "", journal: str | None = None, key: str | None = None,
           after=(), retries: int = 0, retry_wait: float = 1.0, timeout: float | None = None,
           batch: int | None = None, batched: bool = False, progress: bool | None = None, origin=None,
           label=None, thread: bool = False,
           on_result=None, on_results=None, on_error=None, on_finish=None, on_terminal=None) -> int:
    """Run `fn(payload)` for every payload in `chunks` on `executor`. Returns the job id.

    `cpu`, `gpu` and `mem_mb` are what one chunk needs; a dict payload may say otherwise
    for itself with `_cpu_required`, `_gpu_required` and `_mem_mb`, which `fn` never sees.
    `max_cpu` caps the job as a whole; a chunk above the cap runs alone in its job.
    `chunks` is read on its own thread. `window` is how many chunks may be between dispatch
    and delivered result, and how many more may be read ahead.
    Chunks that take well under a task's worth of time travel several to a task where the
    executor allows it; each still succeeds, fails and is recorded on its own. Left to
    itself the window grows with them, since such chunks are small by construction.
    `batch` settles it instead: that many chunks to a task, fewer only in the last one and
    where chunks with different needs meet. With `batched`, `fn` takes the list of payloads
    of a task and returns one result for each, in order; if it raises, all of them failed.
    A chunk's key is `content_key(payload)`, or the `_key` of a dict payload.
    `on_result(key, result)`, `on_error(key, error)` and `on_terminal(snapshot)` run on
    the delivery thread, in order; a chunk counts as done once `on_result` returns.
    `on_results(pairs)` takes its place for whoever writes results somewhere: it gets every
    `(key, result)` of the job that is waiting, up to `_DELIVER_MAX`, and all of them count as
    done once it returns. When it raises, each pair is offered again alone and only the ones
    that raise again fail, so it must leave nothing behind when it raises: one transaction.
    `on_finish()` is the job's last word: called once every chunk has ended, on the delivery
    thread, before the job counts as completed and the jobs after it start. If it raises, the
    job fails with that error. A job that failed, was canceled or interrupted never gets there.
    `chunks` may know more than how to be read. With `remaining()`, how many chunks it has yet
    to give, the job has a total while it runs: counted at its first chunk and every
    `_RECOUNT` seconds after, exact once the source is read. With `open(state)`, it is called
    before the first read and what it returns (JSON) is kept with the job in its journal and
    passed back as `state` when the job continues; `state` is None the first time.
    `progress` says whether the tasks of the job are watched, for `JobSnapshot.progress` to
    count how far the running chunks are and not only the ones that ended (see
    `executors.report`). By default every task is on an executor that watches for nothing (the
    process pool); on another, a task of several chunks, and a task of one when the job has at
    most two for each worker.
    `thread` says that `fn` spends its time waiting for a program it starts, not in Python:
    an executor that keeps a worker for each task and can spare it (the process pool) runs it
    on a thread of this process, so no worker holds memory for it. Such a chunk is stopped
    through the programs it tells of (see `executors.launched`).
    `label` is what a chunk goes by in `tasks` and in a failure, in place of a short repr of
    its payload: a template the payload fills (`"dock-R{receptor}-L{ligand}"`, the keys of a
    dict payload) or a function `(payload)` that returns the text. It runs here, never in a
    worker, and only for a chunk that is shown.
    `journal` names an attached journal; without it nothing about the job reaches the disk.
    `key` is what the job is called there. Submitting an interrupted or canceled job again
    under its key continues it: the chunks it had done are recognised by their own keys and
    not run again. `discard` is how such a job is given up instead.
    `origin` is whatever whoever submits wants kept with the job there (JSON): `recorded`
    gives it back in `spec`.
    `after` is the ids of the jobs that must end first; `chunks` is not read until they do,
    and the job fails if one of them fails, is canceled or is interrupted.
    A chunk that fails runs again up to `retries` times, `retry_wait` seconds after the first
    failure and twice as long after each one that follows. Failing is raising, running past
    `timeout` seconds or killing its worker; falling with somebody else's worker is not, and
    costs the chunk nothing. `on_error` hears of a chunk only once it has failed for good.
    `timeout` needs an executor that can stop a running chunk; the process pool stops all
    of its chunks to stop one, and the others start again.
    """
    if self._closed:
        raise RuntimeError("engine is closed")
    slot = self._executors.get(executor)
    if slot is None:
        raise ValueError(f"unknown executor {executor!r}; registered: {sorted(self._executors)}")
    if on_result is not None and on_results is not None:
        raise ValueError("on_result or on_results, not both")
    batch = batch or getattr(slot.impl, "batch", None)
    if (batch or batched) and not slot.packs:
        raise ValueError(f"executor {executor!r} takes one chunk to a task")
    if timeout is not None and not slot.kills:
        raise ValueError(f"executor {executor!r} cannot stop a running chunk, so its chunks cannot have a timeout")
    job = _Job()
    job.journal = job.n = job.uid = job.spec = job.skip = job.source = job.key = None
    job.retries, job.retry_wait, job.timeout, job.retried = retries, retry_wait, timeout, 0
    job.after = [self._jobs.get(job_id) or self._ended[job_id] for job_id in after]
    if journal is not None:
        job.journal = self._journals.get(journal)
        if job.journal is None or job.journal.failed:
            raise ValueError(f"no usable journal {journal!r}; attached: {sorted(self._journals)}")
        job.uid, job.key = key or uuid.uuid4().hex, key  # `key`: what another run can ask for it by
        job.spec = json.dumps({
            "executor": executor, "fn": f"{getattr(fn, '__module__', '')}:{getattr(fn, '__qualname__', repr(fn))}",
            "cpu": cpu, "gpu": gpu, "mem_mb": mem_mb, "window": window, "max_cpu": max_cpu, "priority": priority,
            "retries": retries, "retry_wait": retry_wait, "timeout": timeout, "batch": batch, "origin": origin})
    job.cost = (cpu, gpu, mem_mb) if slot.impl.uses_tokens else None
    job.needs = (cpu, gpu, mem_mb)
    if job.cost is not None and not self.tokens.ever_fits(job.cost):
        raise ValueError(f"a chunk needing (cpu, gpu, mem_mb)={job.cost} can never run: "
                         f"the engine has {self.tokens.total}")
    job.id, job.name, job.slot, job.fn, job.max_cpu = next(self._ids), name, slot, fn, max_cpu
    job.batch, job.batched, job.fine, job.label = batch or 0, batched, progress, label
    job.thread = thread and slot.threads
    job.window = max(window or 2 * slot.impl.capacity * (batch or 1), batch or 1)
    job.pace, job.pack, job.elastic = 0.0, batch or 1, window is None
    job.priority, job.on_result, job.on_error, job.on_terminal = priority, on_result, on_error, on_terminal
    job.on_results, job.on_finish, job.finishing = on_results, on_finish, False
    job.status, job.chunks = WAITING if job.after else RUNNING, chunks
    job.total = len(chunks) if total is None and hasattr(chunks, "__len__") else total
    job.done = job.failed = job.open = job.seq = job.peak_mem_mb = 0
    if key is not None and job.journal is not None:
        resumed = job.journal.claim(key)
        if resumed is not None:
            job.n, job.skip, job.source = resumed  # `skip`: how many chunks it had done
            job.done = job.skip
    job.flying, job.cpu_used, job.fatal, job.error = set(), 0, None, None
    job.ready, job.room, job.hungry, job.fed_all = deque(), threading.Event(), False, False
    job.started_at, job.finished_at, job.finished = time.time(), None, threading.Event()
    self._jobs[job.id] = job
    self._events.put((self._on_submit, job))
    return job.id

recorded

recorded(journal: str) -> list[dict]

The jobs on record in an attached journal, oldest first, as rows of its jobs table.

Source code in src/ms_flow/engine/engine.py
362
363
364
def recorded(self, journal: str) -> list[dict]:
    """The jobs on record in an attached journal, oldest first, as rows of its `jobs` table."""
    return self._journals[journal].jobs()

discard

discard(journal: str, key: str) -> None

Drop the job recorded as key, which must not be running: what it had done is forgotten and the key is free again. An interrupted or canceled job keeps a row per done chunk until it is continued or discarded.

Source code in src/ms_flow/engine/engine.py
366
367
368
369
370
371
def discard(self, journal: str, key: str) -> None:
    """Drop the job recorded as `key`, which must not be running: what it had done is forgotten
    and the key is free again. An interrupted or canceled job keeps a row per done chunk until
    it is continued or discarded.
    """
    self._journals[journal].discard(key)

status

status(job_id: int) -> JobSnapshot

Where the job is. An ended job is forgotten once _REMEMBERED others ended after it; what a journal recorded of it stays in recorded().

Source code in src/ms_flow/engine/engine.py
373
374
375
376
377
def status(self, job_id: int) -> JobSnapshot:
    """Where the job is. An ended job is forgotten once `_REMEMBERED` others ended after it;
    what a journal recorded of it stays in `recorded()`."""
    job = self._jobs.get(job_id)
    return self._ended[job_id] if job is None else job.snapshot()

tasks

tasks(job_id: int) -> list[TaskSnapshot]

The tasks of the job that are with its executor now, the oldest first: what a monitor unfolds under a running job. At most a window of them, and none once the job ended.

Source code in src/ms_flow/engine/engine.py
379
380
381
382
383
384
385
386
387
388
389
390
391
def tasks(self, job_id: int) -> list[TaskSnapshot]:
    """The tasks of the job that are with its executor now, the oldest first: what a monitor
    unfolds under a running job. At most a window of them, and none once the job ended."""
    job = self._jobs.get(job_id)
    flying = sorted(tuple(job.flying), key=lambda chunk: chunk.started) if job is not None else ()
    told = job.slot.impl.progress(job.id) if flying and job.slot.tells else {}
    now, tasks = time.perf_counter(), []
    for chunk in flying:
        pack, said = chunk.pack, told.get(chunk)  # `pack` once: the loop empties it as the task ends
        n = len(pack) if pack else 1
        tasks.append(TaskSnapshot(_called(job, chunk.payload), n, chunk.attempt, now - chunk.started,
                                  None if said is None else min(1.0, said / n)))
    return tasks

wait

wait(job_id: int, timeout: float | None = None) -> JobSnapshot

Block until the job is terminal and its callbacks have run, or timeout passes.

Source code in src/ms_flow/engine/engine.py
397
398
399
400
401
402
403
def wait(self, job_id: int, timeout: float | None = None) -> JobSnapshot:
    """Block until the job is terminal and its callbacks have run, or `timeout` passes."""
    job = self._jobs.get(job_id)
    if job is None:
        return self._ended[job_id]
    job.finished.wait(timeout)
    return job.snapshot()

close

close() -> None

Interrupt what is running, let pending callbacks finish and stop the executors.

Source code in src/ms_flow/engine/engine.py
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
def close(self) -> None:
    """Interrupt what is running, let pending callbacks finish and stop the executors."""
    if self._closed:
        return
    self._closed = True
    self._events.put((self._on_close,))
    self._events.put(None)
    self._threads[0].join()
    self._deliveries.put(None)
    self._threads[1].join()
    while not self._events.empty():  # what the last callbacks took, with the loop gone
        event = self._events.get()
        if event is not None and event[0] == self._on_delivered:
            event[0](*event[1:])
    for slot in self._executors.values():
        slot.impl.shutdown()
    for journal in self._journals.values():
        journal.close()
    if self._lock is not None:
        self._lock.close()

ProcessExecutor

ProcessExecutor(max_workers: int, idle: float | None = 60.0)

loky process pool. A dead worker costs the pool, never the executor: it is rebuilt.

The tasks of a job submitted with thread=True run on threads of this process instead, and no worker is started or kept for them: they count against the same capacity. A worker with nothing to do for idle seconds leaves, with the memory of what it imported, and one is started again when there is work for it; None keeps them all.

Source code in src/ms_flow/engine/executors.py
119
120
121
122
123
124
125
126
127
128
129
def __init__(self, max_workers: int, idle: float | None = 60.0):
    from loky.process_executor import BrokenProcessPool, ProcessPoolExecutor

    self.capacity = max_workers
    self._lost = (BrokenProcessPool,)
    self._new_pool = partial(ProcessPoolExecutor, max_workers, timeout=idle, initializer=_die_with_parent,
                             initargs=(os.getpid(),))
    self._pool = self._new_pool()
    self._slots = _Slots()
    self._threads = ThreadPoolExecutor(max_workers, thread_name_prefix="msflow-task")  # started as they are used
    self._flying = set()  # the `_Started` of each task on a thread

ThreadExecutor

ThreadExecutor(max_workers: int, uses_tokens: bool = False)
Source code in src/ms_flow/engine/executors.py
85
86
87
88
def __init__(self, max_workers: int, uses_tokens: bool = False):
    self.capacity = max_workers
    self.uses_tokens = uses_tokens
    self._pool = ThreadPoolExecutor(max_workers, thread_name_prefix="msflow-chunk")

RayExecutor

RayExecutor(address: str | None = None, capacity: int | None = None, shared: bool | None = None, **init)

Tasks on a Ray cluster, which shares out its own CPUs, GPUs and memory.

address=None starts a cluster on this machine and stops it at shutdown; "auto" or host:port joins one that is running, on this machine or on others. What ray.init takes goes through. The functions and what they import have to be installed on every node.

shared says that every node sees the disk of this machine, as a cluster started here does. Without it each File of an element goes once to Ray's object store and a node keeps its copy in its temporary directory, and what a result names under a Dir comes back with it.

Source code in src/ms_flow/engine/executors.py
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
def __init__(self, address: str | None = None, capacity: int | None = None, shared: bool | None = None, **init):
    import ray

    self._ray, self.shared = ray, address is None if shared is None else shared
    # (path, when it changed, size) -> _Carried, for as long as a task that reads it is with
    # Ray: after the last one nothing holds it and the object store lets it go.
    self._carried = weakref.WeakValueDictionary()
    self._flying, self._killed = set(), set()  # the tasks Ray has, and those of them `kill` stopped
    # How far each watched task is: the nodes tell an actor, and what it holds is asked for
    # whenever `progress` is, so that one answers with what the time before brought.
    self._tally, self._watched, self._said, self._asking = None, {}, {}, False
    self._ids = itertools.count()
    # What Ray starts on this machine dies with the thread that started it: this one stays
    # until `shutdown`, so whoever makes the executor may do it from a thread that ends.
    self._over, started, failed = threading.Event(), threading.Event(), []

    def keep():
        try:
            ray.init(address=address, **{"include_dashboard": False, "log_to_driver": False, **init})
        except BaseException as error:  # raised below, in the thread that asked
            failed.append(error)
            return
        finally:
            started.set()
        self._over.wait()

    threading.Thread(target=keep, name="msflow-ray", daemon=True).start()
    started.wait()
    if failed:
        raise failed[0]
    # How many tasks are handed to Ray at once: it queues those it has no room for.
    self.capacity = capacity or int(ray.cluster_resources().get("CPU", 1))
    self._lost = (ray.exceptions.WorkerCrashedError, ray.exceptions.NodeDiedError,
                  ray.exceptions.ObjectLostError, ray.exceptions.OutOfMemoryError)
    # max_retries=0: the engine is the one that runs a chunk again
    self._run = ray.remote(max_retries=0)(_apply if self.shared else _apply_far)
    self._sized = {}  # cost -> `_run` asking Ray for that much

Flow

Flow(engine, project)

Submits jobs for one project: project is what a source receives and where the sinks write.

Methods:

Name Description
submit

Start job and return its id. batch, cpu and thread override the task's; after

origin

What the job recorded as key in journal was submitted with: job (as named reads

resume

Submit again the job recorded as key in journal, with what it was submitted with:

Source code in src/ms_flow/engine/flow.py
277
278
279
280
def __init__(self, engine, project):
    self.engine, self.project = engine, project
    self._writing: dict[int, frozenset] = {}  # the tables each job that has not ended writes
    self._lock = threading.Lock()

submit

submit(job, params=None, *, executor: str = 'process', batch: int | None = None, cpu: float | None = None, thread: bool | None = None, after=(), on_terminal=None, **engine) -> int

Start job and return its id. batch, cpu and thread override the task's; after adds jobs to wait for.

What else Engine.submit takes (key, journal, priority, total...) passes through. The sink is written on the engine's delivery thread, one transaction for each group of results, and an element is done once its rows are in. A job that goes to a journal always has a key, its own or one made here, and is recorded with what it was submitted with: see resume.

Source code in src/ms_flow/engine/flow.py
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
def submit(self, job, params=None, *, executor: str = "process", batch: int | None = None,
           cpu: float | None = None, thread: bool | None = None, after=(), on_terminal=None, **engine) -> int:
    """Start `job` and return its id. `batch`, `cpu` and `thread` override the task's; `after`
    adds jobs to wait for.

    What else `Engine.submit` takes (`key`, `journal`, `priority`, `total`...) passes through.
    The sink is written on the engine's delivery thread, one transaction for each group of
    results, and an element is done once its rows are in.
    A job that goes to a journal always has a key, its own or one made here, and is recorded
    with what it was submitted with: see `resume`.
    """
    spec = job.__job__
    fn, output = spec["task"], spec["output"]
    # How many elements to a task: whoever submits, then the executor (a job in a queue takes
    # what its settings say), then the author of the task.
    own = getattr(self.engine.executor(executor), "batch", None)
    needs = {**fn.__task__, "batch": batch or own or fn.__task__["batch"], "cpu": cpu or fn.__task__["cpu"],
             "thread": fn.__task__.get("thread", False) if thread is None else thread}
    if spec["params_model"] is not None:
        params = spec["params_model"].model_validate(params or {})
    if engine.get("journal") is not None:
        engine["key"] = engine.get("key") or uuid.uuid4().hex
        engine["origin"] = _origin(job, params, {"executor": executor, "batch": batch, "cpu": cpu, "thread": thread, **{
            name: value for name, value in engine.items() if name not in ("journal", "key")}})
    if "params" in inspect.signature(fn).parameters:
        fn = functools.partial(fn, params=params)  # pickled with the function: once for each batch

    writer = None if output is None else _writer(output, params, self.project)

    def write(pairs):
        results = [result for _, result in pairs if result]
        if results:
            writer.write(results)

    def ended(snapshot):
        with self._lock:
            self._writing.pop(snapshot.id, None)
        try:
            if spec["finalize"] is not None:
                spec["finalize"](params, self.project, snapshot)
        finally:
            if on_terminal is not None:
                on_terminal(snapshot)

    source = job(params, self.project)
    reads = spec["reads"] | frozenset(getattr(source, "reads", ()))
    with self._lock:  # so `ended` cannot run before the job is in `_writing`
        after = [*after, *(job_id for job_id, tables in self._writing.items() if tables & reads)]
        job_id = self.engine.submit(executor, fn, source, name=spec["name"], after=after,
                                    on_results=write if writer is not None else None,
                                    on_finish=getattr(writer, "finish", None), on_terminal=ended,
                                    **needs, **engine)
        self._writing[job_id] = frozenset(getattr(writer, "tables", ()))
    return job_id

origin

origin(key: str, journal: str) -> dict

What the job recorded as key in journal was submitted with: job (as named reads it), params and options.

Source code in src/ms_flow/engine/flow.py
337
338
339
340
341
342
343
344
345
def origin(self, key: str, journal: str) -> dict:
    """What the job recorded as `key` in `journal` was submitted with: `job` (as `named` reads
    it), `params` and `options`."""
    spec = next((row["spec"] for row in self.engine.recorded(journal) if row["job_id"] == key), None)
    origin = json.loads(spec or "{}").get("origin")
    if origin is None:
        raise ValueError(f"job {key!r} is not on record with what it takes to submit it again: "
                         "its function has to be importable and its params JSON")
    return origin

resume

resume(key: str, *, journal: str, **options) -> int

Submit again the job recorded as key in journal, with what it was submitted with: one that was interrupted or canceled continues. options say otherwise for this run, and bring what a record cannot keep (on_terminal, after).

Source code in src/ms_flow/engine/flow.py
347
348
349
350
351
352
353
def resume(self, key: str, *, journal: str, **options) -> int:
    """Submit again the job recorded as `key` in `journal`, with what it was submitted with:
    one that was interrupted or canceled continues. `options` say otherwise for this run,
    and bring what a record cannot keep (`on_terminal`, `after`)."""
    origin = self.origin(key, journal)
    return self.submit(named(origin["job"]), origin["params"], key=key, journal=journal,
                       **{**origin["options"], **options})

Hosts and executors

HostConfig

Bases: BaseModel

A machine that runs jobs, kept by name. The one named local is this machine.

ExecutorConfig

Bases: BaseModel

Who runs jobs, kept by name. hosts are names in Settings.hosts.

loky is the pool of processes of this machine. ray is a Ray cluster started from this machine over its hosts: with local among them this machine works in it too. queue is the queue of an HPC, and its one host the login node (local when this runs on it).