Payloads¶
A payload is the bytes of one entity: an SDF, a PDBQT, a pose. Payloads live in
append-only volume files under volumes/, next to project.db. The ms_payloads
table says where each one is. Your tables keep only an id:
CREATE TABLE ligands (
id INTEGER PRIMARY KEY,
name TEXT,
structure_payload_id INTEGER REFERENCES ms_payloads(id)
);
ms_flow never needs your schema. It finds the columns that hold payload ids by
their REFERENCES ms_payloads(id) foreign key. Moving bytes, for example when
compacting, rewrites ms_payloads and never your tables. A payload id never
changes.
Write payloads from a job¶
A task returns Payload objects in place of ids. The sink stores the bytes and
writes the ids, in the same transaction as the rows:
from ms_flow.engine import job, task
from ms_flow.payloads import Payload
from ms_flow.sinks import table_sink
@task()
def prepare(name: str) -> dict:
sdf = build_sdf(name) # your code
return {"name": name, "structure_payload_id": Payload(sdf, kind="sdf", series="ligands")}
@job(task=prepare, output=table_sink("ligands", columns=("name", "structure_payload_id")))
def import_ligands(params, project):
yield from params["names"]
graph_sink works the same way, in any node.
seriesgroups payloads with the same lifetime, so they share volumes and compact well together. Use one series per kind of data ("ligands","poses").codecis"zstd"(the default),"zlib"or"none". Use"none"for data that is already compressed.- The payload is compressed when it is pickled, so the compression happens in the worker. The process that owns the project only appends bytes.
When the sink commits, the bytes are already on disk (fdatasync). A crash never leaves a row that points to missing bytes.
Compression dictionaries¶
A zstd dictionary trained on a series compresses small records much better: 5.9× against 4.3× without it on real SDF records. It is also faster. Train one when a series has enough payloads (10 000 are sampled across the whole series):
project.payloads.train_dictionary("ligands")
A job hands the dictionary to its tasks as a File. Executors without the
project's disk carry it once per job:
@task()
def prepare(element: dict) -> dict:
sdf = build_sdf(element["name"])
return {"name": element["name"], "structure_payload_id":
Payload(sdf, series="ligands", dictionary=element["dictionary"])}
@job(task=prepare, output=table_sink("ligands", columns=("name", "structure_payload_id")))
def import_ligands(params, project):
dictionary = project.payloads.dictionary("ligands") # None until one is trained
return [{"name": name, "dictionary": dictionary} for name in params["names"]]
The dictionary id travels inside each zstd frame, so reading needs nothing else. Payloads compressed with an older dictionary stay readable. Compaction moves them to the series' current dictionary.
Read payloads¶
store = project.payloads
data = store.get(payload_id) # bytes
found = store.get_many(ids) # {id: bytes}, read in disk order
for payload_id, data in store.iter_bytes(ids): # bounded memory, for exports
...
A job whose tasks need the bytes uses with_payloads as its source. Each row
gets the bytes of its payload under the key you name, so a task never sees a
path:
from ms_flow.payloads import with_payloads
from ms_flow.query import QuerySpec, Rows
@job(task=dock, output=...)
def docking(params, project):
rows = Rows(project, QuerySpec(table="ligands"))
return with_payloads(project, rows, {"structure_payload_id": "structure"})
Rows are read 500 at a time in disk order, so memory stays bounded however big the table is. A program that only reads paths gets a file in two ways:
store.materialize(payload_id, directory, suffix)writes the payload once and returns it as aFile. Use it for a receptor shared by every task.scratch_file(data, suffix)is a context manager. It gives a temporary file (in/dev/shmwhen it has room) and deletes it on leaving.
Corrupt or missing bytes raise PayloadCorrupt, which carries the payload id.
Delete payloads¶
Delete your rows, then release the payload ids they held:
with sqlite3.connect(project.db_path) as conn:
ids = [r[0] for r in conn.execute(
"DELETE FROM ligands WHERE name LIKE 'tmp%' RETURNING structure_payload_id")]
project.payloads.release(ids)
release only drops payloads that no foreign key references, so releasing an
id still in use does nothing. A volume left with no live payload is deleted at
once, without copying anything.
Maintenance¶
| Call | What it does |
|---|---|
check(full=False) |
Returns a Report: missing volumes, references past a file's end, unreferenced payloads, empty volumes, dead share per volume, missing dictionaries, orphan files. full=True also reads and verifies every payload. report.ok means every referenced payload is readable; report.clean also means there is nothing to clean up. |
compact(min_dead=0.5, order_by=None) |
Copies the live payloads of sealed volumes that are at least half dead (or small) into new volumes, then deletes the old ones. Ids do not change. order_by is SQL that returns payload ids in the order you want them next to each other on disk (SELECT structure_payload_id FROM ligands ORDER BY score). |
repair() |
Clears what crashes leave behind: unreferenced payloads, orphan volume files, empty volumes. Never deletes a referenced payload. Returns a new check(). |
Compaction can run while other processes read. A reader whose volume disappears reads the new location of the payload. To order compaction with your other jobs, submit it as a job:
from ms_flow.payloads import compaction
flow.submit(compaction())
The compaction job waits for jobs writing payloads to finish, and jobs reading payloads wait for it.
On a 100 000-molecule project (225 MB stored), a cold check(full=True) takes
4.3 s. Compaction copies live data at about 34 MB/s, limited by the disk.
Reference¶
payloads
¶
Per-entity payloads in append-only volumes.
The bytes of each entity (an SDF, a PDBQT, a pose) live in volumes/<id:09d>.vol
next to project.db; a row of ms_payloads says where. Apps keep only
<role>_payload_id INTEGER REFERENCES ms_payloads(id): moving bytes rewrites
ms_payloads and never an app table, and ms_flow never needs the app schema.
Volume: a 32-byte header (MSVOL, version, volume id, series), then needles.
Needle: a 16-byte header (crc32, stored length, raw length, codec, flags), then
the bytes. A volume has one writer and only grows; a sealed volume never changes.
Each payload says its codec (none, zlib, zstd), so volumes mix them. A
Payload compresses itself when it is pickled, i.e. in the worker that made it:
the writer only appends bytes. A PayloadGroup stores several payloads as one needle, which
compresses them better (the poses of one docking, about half); each part keeps its own row and
id, with its place in the needle's raw bytes (part_offset, part_length). A zstd frame names its dictionary (dicts/<id>.zdict,
trained per series with PayloadStore.train_dictionary), so reading needs no table.
Maintenance: check reports, release drops payloads nobody references, compact
copies the live needles of mostly dead volumes to new ones (recompressing them with
the series' current dictionary) and repair clears what crashes leave. Ids never
change: compacting rewrites the location of a row, with compare-and-set (I6).
POSIX only (pread/pwritev). Nothing here imports SQL at module level: tasks
import Payload in workers.
Classes:
| Name | Description |
|---|---|
Payload |
Bytes to store. |
PayloadStore |
Writes and reads the payloads of one project. |
Report |
What |
PayloadCorrupt |
The stored bytes of a payload are missing, truncated or fail their checksum. |
Functions:
| Name | Description |
|---|---|
with_payloads |
A job's source whose rows carry the bytes of their payloads, so a task gets bytes and never a path. |
compaction |
|
scratch_file |
|
Payload
dataclass
¶
Payload(data: bytes, kind: str = '', series: str = 'default', codec: str = DEFAULT_CODEC, dictionary: str | None = None, raw_length: int | None = None)
Bytes to store. series groups payloads with the same lifetime in the same volumes.
codec is none, zlib or zstd; dictionary is the path of a zstd dictionary
(PayloadStore.dictionary(series), a File a job hands its tasks). raw_length is set
once data holds the compressed bytes: pickling compresses, so it happens in the worker.
Methods:
| Name | Description |
|---|---|
packed |
This payload with |
packed
¶
packed() -> Payload
This payload with data compressed by its codec (itself if it already is).
Source code in src/ms_flow/payloads.py
113 114 115 116 117 118 119 120 | |
PayloadStore
¶
PayloadStore(project: Any)
Writes and reads the payloads of one project.
Methods:
| Name | Description |
|---|---|
put |
Store |
referencing_columns |
(table, column) of every app column that holds payload ids. |
get_many |
Bytes by id. Read in (volume, offset) order; unknown ids raise KeyError. |
kinds |
The |
iter_bytes |
|
materialize |
The payload as the file |
train_dictionary |
Train a zstd dictionary on |
dictionary |
The current zstd dictionary of |
check |
What is wrong or left over; |
release |
Delete the payload rows of |
compact |
Copy the live needles of sealed volumes with at least |
repair |
Clear what |
close |
Close files. Call it with no transaction of this store open. |
Source code in src/ms_flow/payloads.py
260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 | |
put
¶
put(session: Any, payloads: Iterable[Payload | PayloadGroup]) -> list[int]
Store payloads inside the caller's transaction; return their ids, in order: one per
Payload, one per part of a PayloadGroup (an empty group stores nothing).
The bytes are on disk (fdatasync) when this returns, so the caller's commit never points to lost bytes. If that transaction does not commit, the volumes go back to the size they had before it.
Source code in src/ms_flow/payloads.py
331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 | |
referencing_columns
¶
referencing_columns() -> list[tuple[str, str]]
(table, column) of every app column that holds payload ids.
Read from the schema (PRAGMA foreign_key_list): an app declares
REFERENCES ms_payloads(id) and registers nothing with ms_flow.
Source code in src/ms_flow/payloads.py
483 484 485 486 487 488 489 490 491 492 493 494 495 496 | |
get_many
¶
get_many(ids: Iterable[int]) -> dict[int, bytes]
Bytes by id. Read in (volume, offset) order; unknown ids raise KeyError.
Source code in src/ms_flow/payloads.py
503 504 505 506 507 508 509 510 511 512 513 514 515 516 | |
kinds
¶
kinds(ids: Iterable[int]) -> dict[int, str]
The kind each payload was stored with (a format: sdf, pdbqt...), by id; unknown ids are left out.
Source code in src/ms_flow/payloads.py
518 519 520 521 522 523 524 525 526 527 | |
iter_bytes
¶
iter_bytes(ids: Iterable[int], window: int = READ_WINDOW)
(id, bytes) for each id, window ids at a time, each window in (volume, offset) order: to export.
Source code in src/ms_flow/payloads.py
529 530 531 532 533 | |
materialize
¶
materialize(payload_id: int, directory: str | PathLike, suffix: str | None = None)
The payload as the file <directory>/<id><suffix>, written once: what a job hands its
tasks as a File (a receptor), which executors without the disk carry once per job.
Without suffix, the payload's kind is the extension (.pdbqt).
Source code in src/ms_flow/payloads.py
535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 | |
train_dictionary
¶
train_dictionary(series: str, samples: int = DICT_SAMPLES, size: int = DICT_SIZE)
Train a zstd dictionary on samples payloads spread over series (the head of a
library is not like the rest of it) and make it the series' current one: a File.
Source code in src/ms_flow/payloads.py
620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 | |
dictionary
¶
dictionary(series: str)
The current zstd dictionary of series as a File, or None if it has none yet.
Source code in src/ms_flow/payloads.py
651 652 653 654 655 656 657 658 | |
check
¶
check(full: bool = False) -> Report
What is wrong or left over; full also reads every needle (crc32, codec, dictionary).
Source code in src/ms_flow/payloads.py
662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 | |
release
¶
release(ids: Iterable[int]) -> int
Delete the payload rows of ids that no app row references (after the app deleted its
rows); a sealed volume left without rows goes, unread. Returns how many rows went.
Source code in src/ms_flow/payloads.py
716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 | |
compact
¶
compact(min_dead: float = 0.5, small: int | None = None, order_by: str | None = None) -> dict
Copy the live needles of sealed volumes with at least min_dead dead bytes (or of two or
more volumes of a series under small bytes, a quarter of VOLUME_SIZE by default) to new volumes, move their rows there and
delete the old ones. Needles are recompressed with the series' current dictionary.
order_by is SQL that gives payload ids in the order wanted next to each other on disk
(SELECT current_payload_id FROM molecules ORDER BY score); the rest keep theirs.
Never touches an active volume, so it can run next to writers and readers (I6, I7).
Source code in src/ms_flow/payloads.py
732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 | |
repair
¶
Clear what check reports as garbage: cut the tails of dead writers, delete unreferenced
rows, empty volumes and orphan files. A referenced row is never deleted. Returns a new check.
Source code in src/ms_flow/payloads.py
825 826 827 828 829 830 831 832 833 834 835 836 837 | |
close
¶
close() -> None
Close files. Call it with no transaction of this store open.
Source code in src/ms_flow/payloads.py
922 923 924 925 926 927 928 929 930 931 932 933 934 | |
Report
dataclass
¶
Report(broken: dict[int, str] = dict(), missing_volumes: list[int] = list(), missing_dicts: list[int] = list(), unreferenced: list[int] = list(), empty: list[int] = list(), orphans: list[int] = list(), dead: dict[int, float] = dict())
PayloadCorrupt
¶
PayloadCorrupt(payload_id: int, reason: str)
Bases: Exception
The stored bytes of a payload are missing, truncated or fail their checksum.
Source code in src/ms_flow/payloads.py
92 93 94 | |
with_payloads
¶
with_payloads(project: Any, rows: Iterable[dict], fields: dict[str, str], window: int = READ_WINDOW, kinds: bool = False)
A job's source whose rows carry the bytes of their payloads, so a task gets bytes and never a path.
fields maps an id column to the key that gets its bytes ({"current_payload_id": "ligand"};
a NULL id gives None). Rows are read window at a time in (volume, offset) order, so at most
that many hold bytes here, and the engine reads only its window ahead (I8). What rows knows
(open, remaining, reads of ms_flow.query.Rows) passes through. With kinds, each key
also gets <key>_kind, the kind its payload was stored with, so a task knows how to parse it.
Source code in src/ms_flow/payloads.py
959 960 961 962 963 964 965 966 967 968 969 970 971 | |
compaction
¶
compaction(min_dead: float = 0.5, order_by: str | None = None)
PayloadStore.compact as a job of ms_flow, flow.submit(compaction()): it waits for the
jobs writing payloads (every table writer says it writes ms_payloads), and a job reading
ms_payloads or ms_volumes waits for it. It runs in the process that owns the project (I4).
Source code in src/ms_flow/payloads.py
974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 | |
scratch_file
¶
scratch_file(data: bytes, suffix: str = '')
data as a temporary file, for a program that only reads paths: in /dev/shm when it is
there and has room for it, in the system's temporary folder if not. Deleted on leaving.
Source code in src/ms_flow/payloads.py
1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 | |