Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions python/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,24 @@ This project adheres to [Semantic Versioning](http://semver.org/).

## [Unreleased]

### What's New

#### Interrupted test-result uploads resume

`import-test-result-log <log>` now finishes an upload that was cut short instead of creating a second report. Every upload records what reached the server in a tracking sidecar beside the log (`<log>.jsonl.tracking`), so a re-run reuses the report the earlier attempt created and sends only the entries that are missing. A log that is already fully uploaded is a no-op.

```bash
# Resume, or start fresh if there is nothing to resume
import-test-result-log ./offline-runs/a1b2c3/a1b2c3.jsonl

# Abandon the partial upload and create a new report
import-test-result-log --new-report ./offline-runs/a1b2c3/a1b2c3.jsonl
```

`--new-report` moves the existing sidecar to `<log>.jsonl.tracking.bak` rather than overwriting it, so the abandoned report's ID stays recoverable. Resuming requires that report to still exist; if it was deleted, or the sidecar came from another environment, the command fails and names `--new-report` as the way forward.

`client.test_results.import_log_file(...)` takes the matching `new_report` argument. The recovery hints printed by the pytest plugin drop the `--incremental` flag, which is no longer needed to avoid a duplicate report.

### Bugfixes
- Fix `assets.archive` raising `AttributeError` by reading the correct response field, `archived_run_ids`.

Expand Down
2 changes: 1 addition & 1 deletion python/docs/guides/pytest_plugin/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ so a misconfigured job fails immediately instead of silently producing no report
During the run, every create and update is appended to a JSONL log file.
A background worker uploads new entries to Sift incrementally.
If the connection drops mid-test, the test keeps running and the log keeps writing locally.
The remaining entries can be uploaded afterward by running import-test-result-log, which the plugin prints on exit.
The remaining entries can be uploaded afterward by running import-test-result-log, which the plugin prints on exit. That command resumes into the report the interrupted run created rather than starting a second one.

See [Running Modes](running_modes.md) for the log-file and replay pipeline,
overriding the connection check, and replaying a saved log.
Expand Down
46 changes: 35 additions & 11 deletions python/docs/guides/pytest_plugin/running_modes.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,11 +119,10 @@ get noticed until somebody goes looking for the report, which is usually weeks
later, which is usually too late.

With the JSONL log on by default, create/update calls are written to a log file
in the run's output directory during the run, and an
`import-test-result-log --incremental` worker replays them against Sift in the
background. If the worker crashes mid-session (connection failure, API error) or
is still draining its backlog at session end, the failure is logged at session
end with an `import-test-result-log` command for manual recovery. Test outcomes
in the run's output directory during the run, and a background worker replays
them against Sift as they are written. If the worker crashes mid-session
(connection failure, API error) or is still draining its backlog at session
end, the failure is logged at session end with an `import-test-result-log` command for manual recovery. Test outcomes
are unaffected and the local log file is preserved. Pass `--no-sift-log-file` to
make every create/update synchronous against the API instead.

Expand All @@ -150,8 +149,8 @@ The override is ignored under `--sift-offline` and `--sift-disabled`.
Same fixtures, same `step.measure(...)` semantics as online. The difference is
where the writes go: every create/update lands in a JSONL log file instead of
hitting the Sift API. The session-start ping is skipped, missing `SIFT_*` env
vars are tolerated (placeholders are filled), and the replay worker
(`import-test-result-log --incremental`) does not get spawned at session end.
vars are tolerated (placeholders are filled), and the replay worker does not
get spawned at session end.

```bash
pytest --sift-offline --sift-output-dir=./offline-runs
Expand All @@ -165,8 +164,8 @@ import-test-result-log ./offline-runs/a1b2c3/a1b2c3.jsonl
```

That replay creates the report, steps, and measurements against Sift. See
[Replaying a saved log file](#replaying-a-saved-log-file) for cleanup and the
incremental flag.
[Replaying a saved log file](#replaying-a-saved-log-file) for cleanup and for
what happens when a replay is interrupted.

`--no-sift-log-file` is rejected when offline is set, since the log is the only
sink in offline mode and without it the results are gone.
Expand Down Expand Up @@ -217,5 +216,30 @@ When the worker doesn't finish cleanly the plugin will print a hint mentioning
import-test-result-log <path-to-log.jsonl>
```

That replays the saved JSONL log as a single batch (no `--incremental`) and
deletes the file when it lives under the system temp dir.
That replays the saved JSONL log as a single batch and deletes the file when it
lives under the system temp dir.

`import-test-result-log` runs in one of two modes:

| Mode | How to get it | What it does |
| --- | --- | --- |
| New upload | the default, when nothing has been uploaded yet | Uploads the whole log as a new report |
| Resume | the default, when a tracking sidecar records an interrupted upload | Continues into the report that upload created, sending only what is missing |

Run the same command again if a replay is interrupted. Each upload records what
it created in a tracking sidecar next to the log (`<log>.jsonl.tracking`), so a
second run continues into the report the first one created and sends only what
is missing, rather than leaving a duplicate report behind. A log that is already
fully uploaded is a no-op.

```bash
# Upload as a new report instead, abandoning the partial one
import-test-result-log --new-report <path-to-log.jsonl>
```

The existing sidecar is moved to `<log>.jsonl.tracking.bak` rather than
overwritten, so the abandoned report's ID can still be looked up and cleaned up.

Resuming needs the report the earlier attempt created to still exist. If it was
deleted, or the sidecar came from a different environment, the command fails and
names `--new-report` as the way forward.
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@
map. Written only by the replay subprocess via :meth:`LogTracking.save` using
a temp-file + ``os.replace`` so a crash can't leave a half-written sidecar.
Read once at replay start via :meth:`LogTracking.load`. Never touched by the
test process.
test process. Restarting an upload against a new report moves any existing
sidecar to ``foo.jsonl.tracking.bak`` (see :meth:`LogTracking.archive`) so the
abandoned report's ID stays recoverable.

# Concurrency

Expand Down Expand Up @@ -90,17 +92,26 @@ class LogTracking:
file itself is append-only and stores only API-call data lines.

* ``last_uploaded_line`` is the count of data lines that have been
successfully replayed against the server. Each data line corresponds to a
single API call, so line granularity matches the atomic unit of work: a
line is either fully replayed or must be retried in its entirety. Data
lines are strictly append-only, so this counter is stable across runs.
successfully replayed against the server, in log order. Each data line
corresponds to a single API call, so line granularity matches the atomic
unit of work: a line is either fully replayed or must be retried in its
entirety. Data lines are strictly append-only, so this counter is stable
across runs. A batch upload creates in collapsed order rather than log
order and so leaves it at zero; ``complete`` is what marks that upload
finished.
* ``id_map`` maps simulated response IDs (created during the original test
run) to the real IDs assigned by the server during replay. Subsequent
``Update*`` entries consult this map to translate IDs.
``Update*`` entries consult this map to translate IDs, and a resumed
replay uses it to skip the creates that already reached the server.
* ``complete`` marks the whole log as uploaded, so a stray re-run does
nothing instead of replaying a finished upload. Sidecars written before
this field existed default to False and are treated as resumable, which
costs a no-op walk at worst.
"""

last_uploaded_line: int = 0
id_map: dict[str, str] = field(default_factory=dict)
complete: bool = False
client_version: str = field(default_factory=_client_version)

@staticmethod
Expand All @@ -109,6 +120,28 @@ def sidecar_path(log_path: str | Path) -> Path:
p = Path(log_path)
return p.with_name(p.name + ".tracking")

@staticmethod
def backup_path(log_path: str | Path) -> Path:
"""Return where a sidecar is kept when an upload is restarted (``<log>.tracking.bak``)."""
sidecar = LogTracking.sidecar_path(log_path)
return sidecar.with_name(sidecar.name + ".bak")

@staticmethod
def archive(log_path: str | Path) -> Path | None:
"""Move an existing sidecar aside to ``<log>.tracking.bak``; return its new path.

Used when an upload is deliberately restarted against a new report. The
old sidecar still names the report the abandoned upload created, so it
is preserved rather than overwritten: without it that report is only
findable by hand. Returns None when there was nothing to move.
"""
sidecar = LogTracking.sidecar_path(log_path)
if not sidecar.exists():
return None
backup = LogTracking.backup_path(log_path)
os.replace(sidecar, backup)
return backup

@classmethod
def load(cls, log_path: str | Path) -> LogTracking:
"""Read tracking state for ``log_path``; return a fresh instance if missing or corrupt.
Expand All @@ -126,6 +159,7 @@ def load(cls, log_path: str | Path) -> LogTracking:
return cls(
last_uploaded_line=data.get("lastUploadedLine", 0),
id_map=data.get("idMap", {}),
complete=data.get("complete", False),
client_version=data.get("clientVersion", "unknown"),
)

Expand All @@ -141,6 +175,7 @@ def save(self, log_path: str | Path) -> None:
{
"clientVersion": self.client_version,
"lastUploadedLine": self.last_uploaded_line,
"complete": self.complete,
"idMap": self.id_map,
},
separators=(",", ":"),
Expand Down
Loading
Loading