Writing results safely¶
Three small modules that everything else depends on being correct. None is part of the user interface; they exist because of failure modes that are invisible until they have already cost someone their results, or their afternoon.
Atomic writes¶
Analysis steps stream output row by row, and steps skip work whose output already exists — that is what makes a long pipeline resumable. Together those two facts make an interrupted run dangerous: a half-written file keeps its final name, and the next run accepts it as finished.
Writing under a scratch name and renaming at the end removes that. A rename is indivisible as far as the filesystem is concerned, so the real name only ever refers to a complete file.
taters.helpers.atomic ¶
Write a file under a scratch name, and give it its real name only when finished.
Analysis steps stream their output row by row, which means the output file exists -- with its final name -- from the first row onwards. Anything that interrupts the run leaves that half-written file behind looking exactly like a completed one.
That would be survivable on its own. What makes it a real hazard is the other half of the design: steps skip work whose output already exists, because that is what makes a long pipeline resumable. So a truncated file is not merely wrong, it is sticky -- the next run sees it, decides the step is done, and returns it. You get 3 rows where you asked for 300,000, with no error.
The fix is to write to <name>.part and rename it at the end. A rename is
indivisible as far as the filesystem is concerned: there is no moment at which
the destination exists half-renamed. Every other part of the write can be
interrupted; that step cannot. So the real name only ever refers to a complete
file, and an interrupted run leaves nothing for the next one to mistake for
finished work.
This covers interruption generally -- a cancelled run, a full disk, a power cut, Ctrl-C -- not just any one of them.
atomic_write ¶
atomic_write(path, mode='w', **open_kwargs)
Open a file for writing that only appears at path once complete.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
str or Path
|
Where the finished file should end up. Parent directories are created. |
required |
mode
|
str
|
As :func: |
"w"
|
**open_kwargs
|
Any
|
Passed straight through to :func: |
{}
|
Yields:
| Type | Description |
|---|---|
IO
|
The handle to write to. It refers to the scratch file, not to |
Notes
On an exception the scratch file is removed and path is left exactly as
it was -- which for a first run means absent, so the step is retried rather
than resumed from a partial file.
Not safe for two processes writing the same destination at once: they would share a scratch name. Nothing in Taters does that, since a GLOBAL step runs once and ITEM steps write per-input paths.
Source code in src\taters\helpers\atomic.py
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 84 85 86 87 88 89 90 | |
Progress reporting¶
A GLOBAL pipeline step is a single call, so the runner cannot count it from
outside. A step that can count itself says so by declaring an on_progress
parameter, which the runner then injects automatically — a signature check
rather than a registry, so a new analyzer opts in without anything else needing
to know.
taters.helpers.progress ¶
Row-level progress reporting for analysis steps.
A GLOBAL pipeline step is a single call: the runner hands over and gets control
back at the end, so it cannot count anything from outside. A step that can
count itself says so by declaring an on_progress parameter, which the
pipeline runner then injects automatically.
The contract
on_progress(done, total, message=None, unit=None)
totalis an int once the size of the job is known, anddoneis a position within it.totalisNonewhile the size is still being worked out.doneis then a running tally, andmessagesays which pass is running -- reading the input, counting its rows. On a large file those passes take long enough to look like a hang, and a number climbing is the only visible proof that anything is happening.unitnames what is being counted when it is not rows. The only value with a meaning today is"seconds", used by transcription, where the numbers are a position in the recording:252/771would be read as segments, which is not what it is, while4:12/12:51cannot be misread.
The last two arguments are optional in both directions -- a sink that predates them still works, because nothing is obliged to send them.
This module exists so the five text analyzers share one implementation of that pattern rather than five copies that drift.
Ticker ¶
Ticker(on_progress, total=0)
Counts work as it happens and reports it.
Safe to use unconditionally: with no on_progress every method is a
no-op, so the analysis code reads the same whether anyone is watching.
Source code in src\taters\helpers\progress.py
101 102 103 104 105 106 | |
tick ¶
tick(n=1, message=None)
Record n more units of work done.
message names the unit -- "scoring
Source code in src\taters\helpers\progress.py
108 109 110 111 112 113 114 115 116 117 118 119 120 | |
FlightReporter ¶
FlightReporter(on_progress, total, message)
Progress for a phase whose work is spread over parallel workers.
Reports the usual (done, total, message) plus -- to sinks that accept it
-- inflight: the names currently being worked on, so a display can
show one sub-bar per file the way item steps do for ffmpeg or Whisper.
start/finish may be called from executor threads; consumed
is the parent's in-order tally. All three re-report immediately.
Source code in src\taters\helpers\progress.py
156 157 158 159 160 161 162 163 164 165 166 167 168 | |
announce ¶
announce(on_progress, message)
Name the phase that is about to run, with no size known yet.
Source code in src\taters\helpers\progress.py
45 46 47 48 | |
count_rows ¶
count_rows(
path,
*,
on_progress=None,
encoding="utf-8-sig",
every=1000
)
Count the data records in a CSV, reporting progress as it goes.
Records, not lines: document text carries embedded newlines inside its quoted field, and counting physical lines told a 2,300-paper run it had 3.2 million rows to do -- a denominator so wrong the bar read as broken.
Always counts, and only reports when something is watching. It used
to return 0 with no sink, on the theory that the count was only ever a
progress bar's denominator -- but :func:taters.helpers.row_map.map_text_rows
sizes its worker pool from it, so every direct API call (no sink) ran
on one worker while the same call from the wizard ran on twelve. One
csv.reader sweep is far cheaper than the work it sizes.
The header is not counted. A file that cannot be read comes back as 0 rather than raising -- a progress bar is not worth failing a run over.
Source code in src\taters\helpers\progress.py
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 84 85 86 87 88 | |
The run log¶
A run used to record one line when a step failed: the exception's type and its message. That is enough when the message is the reason, and useless when it is a wrapper around the reason — a library reporting "could not import module 'RobertaModel'" while the exception it was raised from, naming the module that was actually missing, went unrecorded. Nothing captured the environment either, so a failure caused by the installed build of a dependency looked identical to a failure in Taters.
So every run now writes a log: the environment, what was asked and answered, each step, everything printed — including from child processes — and the full chain of causes behind anything that raised. The manifest keeps the short structured reason; this keeps the rest.
Deliberately not built on atomic_write above. That helper's contract is
that nothing appears at the destination unless the write finished, and it
unlinks its scratch file when an exception passes through — which is exactly
the moment a log has to survive. This writes plainly and flushes per line.
taters.helpers.runlog ¶
Everything a run printed, in one file, so a failure can be read rather than reconstructed.
Why this exists
A run failed with one line in the manifest::
potato.text.extract_transformer_embeddings failed: ModuleNotFoundError:
Could not import module 'RobertaModel'. Are this object's requirements
defined correctly?
That message is a wrapper's, not the cause's. The library raised it from e,
so the real reason was one attribute away -- and it was discarded, because every
failure site in the runner flattens an exception to type(e).__name__ plus
str(e) and lets the object go. Nothing recorded the Python version, the
build of torch that was installed, or a single line of what the step printed
before it died. Working out what happened took an hour of picking through the
filesystem, and the environment that produced it had been replaced by then.
So: one log file per run, holding what the run printed, the full traceback chain when something raises, and the versions it all ran against.
This is not a replacement for the run manifest. The manifest stays the structured record -- which step ran, which input failed, where the outputs landed -- and is the thing to read first. The log is the verbatim one, for when the manifest's summary is not enough.
What it is not
Not :func:~taters.helpers.atomic.atomic_write. Every other artifact in Taters
is written that way, and it would be wrong here: that helper unlinks its
scratch file on BaseException and re-raises, which is exactly the moment a
log has to survive. This writes plainly and flushes as it goes, the way the run
manifest does, so that an interrupted run still leaves behind whatever had been
recorded.
Not a transcript of the screen either. Live progress bars are deliberately absent; what is kept is the structure of the run and everything that was printed.
RunLog ¶
RunLog(*, enabled=True, console=None)
A run's log file, plus the machinery for capturing what is printed into it.
Lines can be recorded before there is anywhere to put them: a session that
starts in the menus buffers what it says until :meth:open_run is given a
folder, at which point the buffer is flushed into the new file ahead of the
run's own output. That is what makes the answers someone gave three screens
back part of the record of the run they led to.
Nothing here raises. A log that breaks a run would be worse than no log at all, and the most likely moment for it to be writing is while something else has already gone wrong -- so the first write failure switches the sink off for good and the run carries on without it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
enabled
|
bool
|
|
True
|
console
|
Console
|
The console a live display draws on, pinned to the real terminal for
as long as output is being captured. See :meth: |
None
|
Source code in src\taters\helpers\runlog.py
361 362 363 364 365 366 367 368 369 370 371 372 373 | |
line ¶
line(channel, text)
Record one line on one channel. Thread-safe, and never raises.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
channel
|
str
|
A short category -- |
required |
text
|
str
|
The message. Newlines in it are handled, but :meth: |
required |
Source code in src\taters\helpers\runlog.py
394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 | |
block ¶
block(channel, text)
Record a multi-line blob -- a traceback, or a captured chunk.
The first line is timestamped like any other; the rest are indented under a gutter rather than each carrying its own clock, because forty identical timestamps down the side of a traceback help nobody.
Source code in src\taters\helpers\runlog.py
415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 | |
event ¶
event(name, payload)
Record one of the runner's progress events.
Not all of them. item_progress fires many times a second and says
nothing worth reading afterwards, so it is dropped. step_progress
is kept to one heartbeat every ten seconds, because "still going" is
the one thing that tells a slow step apart from a hung one when you are
reading this hours later.
Source code in src\taters\helpers\runlog.py
434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 | |
stream ¶
stream(channel, raw)
Record captured terminal output, one log line per output line.
Captured output is the one channel with no natural limit -- a step that prints per row prints as long as there are rows -- so it is the one that gets a budget. Structured lines are unaffected when it runs out.
Source code in src\taters\helpers\runlog.py
460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 | |
open_run ¶
open_run(work_dir, **context)
Start a file for this run and flush anything said before it.
The name carries a timestamp so that re-running keeps the evidence of the run that failed, rather than overwriting it with the run that was meant to prove the fix.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
work_dir
|
str or Path
|
The run's folder. The log lands in |
required |
**context
|
Passed to :func: |
{}
|
Returns:
| Type | Description |
|---|---|
Path or None
|
Where the log is being written, or |
Source code in src\taters\helpers\runlog.py
503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 | |
close_run ¶
close_run()
Finish the current file. Lines said afterwards buffer again.
The pumps are stopped before the lock is taken, and that ordering is load-bearing: stopping one joins its thread, and that thread wants this same lock to record what it has just drained. Holding the lock across the join would be waiting on a thread that is waiting on us.
Source code in src\taters\helpers\runlog.py
558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 | |
write_buffer_to ¶
write_buffer_to(path, **context)
Dump whatever is buffered to path.
This is the safety net for a session that never got as far as a run -- a crash in the menus still leaves its answers somewhere readable.
Source code in src\taters\helpers\runlog.py
581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 | |
capture_streams ¶
capture_streams(*, console=None)
Tee everything printed into the log, for the duration of the block.
Two layers, because neither is enough on its own. sys.stdout is
replaced so that Python-level printing is recorded, and it writes to a
held-open copy of the real terminal rather than through the descriptor,
so nothing is logged twice. Underneath that, descriptors 1 and 2 are
redirected into pipes, which catches what never passes through
sys.stdout at all: extension modules, raw writes, and the output of
child processes.
Only ever wrap work. While a descriptor is a pipe isatty is false,
and the interactive prompts render themselves differently when they
believe they are not on a terminal -- so this must not be held open
across a question.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
console
|
Console
|
The live display's console, which gets pinned to the real terminal
for the duration. This is not optional in practice for anything
with a progress display: a bare |
None
|
Source code in src\taters\helpers\runlog.py
607 608 609 610 611 612 613 614 615 616 617 618 619 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 650 651 652 653 654 655 656 657 658 659 660 661 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 | |
stop_capture ¶
stop_capture()
Put the descriptors back. Safe to call more than once.
Source code in src\taters\helpers\runlog.py
695 696 697 698 699 | |
logging_wanted ¶
logging_wanted()
Whether logging is switched on for this process.
Source code in src\taters\helpers\runlog.py
120 121 122 | |
clean_stream_text ¶
clean_stream_text(raw)
Turn a chunk of captured terminal output into plain log lines.
Escape sequences are removed, and a carriage return is treated as the
redraw it is: only the last frame of \r-separated output survives. A
progress bar that repainted two thousand times therefore contributes one
line showing where it finished, instead of two thousand near-identical ones.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
raw
|
str
|
Captured output, which may contain escape sequences, carriage returns and newlines. |
required |
Returns:
| Type | Description |
|---|---|
list of str
|
One entry per line, right-stripped. Blank entries are kept so that a caller can decide whether spacing matters. |
Source code in src\taters\helpers\runlog.py
129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 | |
describe_event ¶
describe_event(name, payload)
One line for one of the runner's progress events.
Source code in src\taters\helpers\runlog.py
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 | |
session_fallback_path ¶
session_fallback_path()
Where a session that never started a run leaves its record.
A crash in the menus has no work folder to write to, so it falls back to the same home folder the rest of Taters already uses for state.
Source code in src\taters\helpers\runlog.py
200 201 202 203 204 205 206 207 208 209 210 | |
exception_text ¶
exception_text(exc)
The whole traceback for an exception, chained causes and all.
This is the function the incident needed. A library that wraps an import
failure re-raises from e, so its own message says only that something
could not be imported -- while the exception it was raised from names the
module that was actually missing. :func:traceback.format_exception walks
__cause__ and __context__ for us and writes the "The above
exception was the direct cause of the following exception" separators, so
the entire chain comes back as one string.
A string is also the only shape that survives the trip out of a worker: an item step is caught inside a thread or a spawned process, and a traceback object neither pickles nor outlives the frame it came from.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
exc
|
BaseException
|
The exception to render. |
required |
Returns:
| Type | Description |
|---|---|
str
|
The formatted traceback, ending in a newline. |
Source code in src\taters\helpers\runlog.py
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 | |
environment_lines ¶
environment_lines(
*,
work_dir=None,
preset=None,
workers=None,
command=None,
started=None
)
The header block: when, where, on what, against which versions.
Every value here was missing from the incident that prompted this module, and between them they answer "was this even the environment I think it was?" before any of the run's own output is read.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
work_dir
|
str or Path
|
The folder the run owns. |
None
|
preset
|
str
|
Name of the preset being run. |
None
|
workers
|
int
|
The resolved parallelism budget. |
None
|
command
|
str
|
A terminal command that reproduces the run. |
None
|
started
|
datetime
|
Overrides the clock, for tests. |
None
|
Returns:
| Type | Description |
|---|---|
list of str
|
Comment lines, followed by one blank line. |
Source code in src\taters\helpers\runlog.py
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 | |