Run semantics¶
AsyncETLPipeline.run makes the guarantees below. Each one is numbered, and
each has a test in tests/semantics/test_run_rules.py whose name starts with
the rule's number, such as test_r1_cursor_waits_for_settled_page. Changing a
rule is a breaking change: it changes the rule's test, and it gets a
MIGRATION.md entry.
A record is settled once it is processed, marked irrelevant, or skipped as
quarantined. A record is durable once the exporter holds its entities
durably (R20). A page is stalled when records on it failed and none was
processed, or when the exporter's flush failed for it. A page ends the listing when it has no next cursor, has no
entries, or is truncated.
| Rule | Guarantee | Tests | Source |
|---|---|---|---|
| R1 | The saved cursor never moves forward past a page with an unsettled record: once a record fails, is deferred by total_limit, or was written but not made durable by a failed flush, the cursor stays at the start of that page for the rest of the run. Resetting it to the first page (R15, R16) is not moving forward. A record skipped as quarantined counts as settled (R18). |
test_r1_* |
_listing_position.py:46-57 |
| R2 | total_limit is exact. A relevant record reserves a slot before its full text is fetched, and a record that fails releases its slot for another record. |
test_r2_* |
pipeline_async.py:81-98, :802-821 |
| R3 | An irrelevant record is marked processed at once, without fetching its full text. | test_r3_* |
pipeline_async.py:805-807 |
| R4 | A memory-ingest exception in MEMORY_FAULTS is logged and counted in RunMetrics.memory_faults, and the record's entities are still exported. |
test_r4_* |
pipeline_async.py:832-840 |
| R5 | Two stalled pages, with no page that processed a record between them, abort the run with PipelineAborted. A single stalled page is tolerated. Records skipped as processed or quarantined make a page neither stalled nor processing. A page whose exporter flush failed is stalled (R21). |
test_r5_* |
pipeline_async.py:46, :562-574 |
| R6 | A stalled page that ends the listing, or is followed by a page that does, aborts the run before the end is saved. This takes precedence over R15. | test_r6_* |
pipeline_async.py:575-576 |
| R7 | A page with no next cursor, or with no entries, ends the run "completed" once the page settles. An OffsetListing then saves the offset past the last entry, so entries appended later are found by the next run; any other extractor saves no cursor, so the next run starts from the first page. |
test_r7_* |
_listing_position.py:46-75 |
| R8 | A page made up entirely of already-processed records is not the end; paging moves past it. | test_r8_* |
pipeline_async.py:601-613 |
| R9 | A shutdown request finishes the records in flight, flushes the exporter, marks the records that flush made durable, leaves the rest of the page for the next run, keeps the saved cursor before that page, closes the exporter, flushes state, and raises PipelineInterrupted. |
test_r9_* |
pipeline_async.py:515-531, :767-785, :803-804 |
| R10 | The exporter is flushed and closed, and then state is flushed, however the run ends. A fault in either after the run failed is logged, so it never hides the original error; an exporter close fault after a successful run is raised once state is flushed. | test_r10_* |
pipeline_async.py:451-469, :698-724 |
| R11 | A record whose record_id is missing or blank is skipped, logged, and reported as a RecordFinished event with outcome "skipped". |
test_r11_* |
pipeline_async.py:740-745 |
| R12 | sleep_between is waited between pages, never after the page that reaches total_limit or ends the listing. |
test_r12_* |
pipeline_async.py:582-587 |
| R13 | A listing page that cannot be fetched or parsed aborts the run with PipelineAborted carrying the count processed so far. |
test_r13_* |
pipeline_async.py:726-734 |
| R14 | For an OffsetListing, a newest_first=True run pages from offset 0 until it finds the records saved at the head of the listing, then continues from the saved offset moved down by the number of new entries. Each offset cursor comes from cursor_for_offset, and the listed ids come from the page's records, so each page is fetched and parsed once. |
test_r14_* |
_listing_position.py:77-134 |
| R15 | A truncated page that is not a stall page ends the run "completed", reports the truncation in RunMetrics.listing_truncated, PageFetched.truncated, and one log line per run, resets the saved cursor to the first page even when a page did not settle, and sets PipelineMetadata.truncated. The flag is cleared by the first run that reaches the end of the listing without a cap; a run that aborts, is interrupted, or reaches total_limit leaves it unchanged. |
test_r15_* |
pipeline_async.py:544-548, :578-581 |
| R16 | A StaleCursorError restarts the listing from the first page once per run; a second one in the same run raises PipelineAborted. |
test_r16_* |
pipeline_async.py:520-529 |
| R17 | newest_first=True, or a start_index above 0, with an extractor that is not an OffsetListing raises ValueError before any request. |
test_r17_* |
pipeline_async.py:443-446 |
| R18 | With max_attempts set, failures are counted through record_failure only from pages that processed a record, or from stalled pages that a later page of the same run cleared; failures held by a stalled page that is never cleared are discarded. A listed record whose counted attempts reach max_attempts is skipped as quarantined, counted once per run in RunMetrics.quarantined, and logged once. No record is quarantined in the run in which its last attempt failed. |
test_r18_* |
pipeline_async.py:124-141, :564-567, :601-631 |
| R19 | A record is settled only on an answer. A relevance fault in a filter built with default_on_error=False, and an LLM response that holds no entity list, because the completion is empty or has several keys and none is result_key, fail the record: it stays unmarked, its attempt is counted under R18, and it is retried on the next run. |
test_r19_* |
llm/openai_compatible_async.py:206-207, llm/extraction_async.py:281-288, llm/relevance_async.py:96-109, pipeline_async.py:802-821 |
| R20 | A record is marked processed only once its entities are durable: right after write on an exporter with durable_writes = True, and after the page's flush otherwise. A crash between write and flush repeats the record on the next run and loses none. |
test_r20_* |
pipeline_async.py:767-785, :814-817 |
| R21 | A write fault fails the record and counts one attempt under R18. A flush fault leaves the records written since the last successful flush unsettled, counts no attempt against them, and makes the page a stalled page, so two failed flushes in a row abort the run under R5. |
test_r21_* |
pipeline_async.py:773-781, :814 |
| R22 | A fault in the exporter's open aborts the run with PipelineAborted before any listing request. |
test_r22_* |
pipeline_async.py:453-457, :704-708 |
| R23 | Every processed record is written to the exporter, including one with no entities; an irrelevant record is not. | test_r23_* |
pipeline_async.py:805-814 |
The source column cites the code behind each rule. A change that moves that code updates its citation here.
Capped listings¶
PubMed serves the first 9,999 results of a query and Semantic Scholar's
relevance search the first 1,000. Their extractors mark the page that reaches
the cap as truncated, and R15 applies: the run completes, and the next run
pages the reachable results again. Processed records are skipped by id, so the
rescan costs listing requests, not LLM calls: at page_size=100, up to 10
requests for Semantic Scholar and 100 for PubMed. The rescan also finds
records that entered the reachable window since the last run, which matters
for a listing sorted by relevance. To avoid the rescan, narrow the query, for
example by date range. OpenAlex is not capped: AsyncOpenAlexExtractor pages
with OpenAlex cursors.