Skip to content

fix!: do not count downtime towards crawler_runtime after migration or resurrection - #2204

Open
Pijukatel wants to merge 3 commits into
v2from
claude/actor-resurrection-time-tracking-bixxkl
Open

Pijukatel wants to merge 3 commits into
v2from
claude/actor-resurrection-time-tracking-bixxkl

Conversation

@Pijukatel

@Pijukatel Pijukatel commented Aug 31, 2026 •

Copy link
Copy Markdown
Collaborator
  • After an Actor migration or resurrection, the first periodic statistics log reported crawler_runtime including the downtime between the runs (e.g. 38.01s for a ~0.5 s run); later logs corrected themselves. Now the first log reports exactly the runtime accumulated so far (0 for a fresh run).
  • Also covers two rarer edge cases: a state persisted mid-run and never finished cleanly baked the downtime into the runtime permanently, and repeated resurrections kept only the last run's segment of the accumulated runtime.
  • StatisticsState.accumulated_runtime now stores the runtime of the previous runs; the read-only crawler_runtime property adds the run in progress, and every persist checkpoints the live total.
  • Breaking (v2): crawler_runtime_for_serialization is removed, and crawlerRuntimeMillis is persisted as a number of milliseconds instead of an ISO 8601 duration string. Documented in docs/upgrading/upgrading_to_v2.md.
  • Runtime tracking matches Crawlee JS (accumulation across resumes, checkpoint on every persist, downtime excluded).
  • Related to Remove deprecated StatisticsState.crawler_runtime setter #1567.

@github-actions github-actions Bot added this to the 148th sprint - Tooling team milestone Aug 31, 2026
@github-actions github-actions Bot added t-tooling Issues with this label are in the ownership of the tooling team. tested Temporary label used only programatically for some analytics. labels Aug 31, 2026
@codecov

codecov Bot commented Aug 31, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
⚠️ Please upload report for BASE (v2@51efb37). Learn more about missing BASE report.

Additional details and impacted files
@@          Coverage Diff          @@
##             v2    #2204   +/-   ##
=====================================
  Coverage      ?   94.20%           
=====================================
  Files         ?      185           
  Lines         ?    13752           
  Branches      ?        0           
=====================================
  Hits          ?    12955           
  Misses        ?      797           
  Partials      ?        0           
Flag Coverage Δ
unit 94.20% <100.00%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@Pijukatel

Copy link
Copy Markdown
Collaborator Author

Waiting for V2 as this might be breaking change and will inlcude removal of deprecated fields

@Pijukatel Pijukatel changed the title fix: do not count downtime towards crawler_runtime after migration or resurrection fix!: do not count downtime towards crawler_runtime after migration or resurrection Sep 3, 2026
@Pijukatel
Pijukatel force-pushed the claude/actor-resurrection-time-tracking-bixxkl branch from 4b9f3e5 to 8af9ee3 Compare October 20, 2026 07:47
@Pijukatel
Pijukatel changed the base branch from master to v2 October 20, 2026 07:48
@Pijukatel
Pijukatel force-pushed the claude/actor-resurrection-time-tracking-bixxkl branch 6 times, most recently from de270b1 to 7ca479e Compare October 21, 2026 06:50
…r resurrection

The `crawler_runtime` statistic no longer counts the downtime between
the runs of a resumed crawler, and it accumulates correctly across any
number of persist/resume cycles:

- The first periodic statistics log reports exactly the runtime
  accumulated so far: zero for a fresh start, the previously persisted
  total for a resumed run.
- The accumulated runtime is stored in the new `accumulated_runtime`
  field, persisted under `crawlerRuntimeMillis`. Every persist writes
  the live total, so a state persisted mid-run (migration, abort)
  carries the runtime up to that moment and a resumed run continues
  from the last checkpoint.
- The timestamps of the previous run are cleared before the new run
  starts being measured, so they cannot leak the downtime into the
  runtime, and a backwards clock jump during a run cannot push the
  runtime below the accumulated total.

BREAKING CHANGE: `StatisticsState.crawler_runtime_for_serialization`
is removed; the `crawler_runtime` property is backed by the new
`accumulated_runtime` field. `crawlerRuntimeMillis` is serialized as
a number of milliseconds instead of an ISO 8601 duration string.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VMHwDx87jV4rnu5LkbpNxy
@Pijukatel
Pijukatel force-pushed the claude/actor-resurrection-time-tracking-bixxkl branch from 7ca479e to fbab2a8 Compare October 21, 2026 06:59
@Pijukatel
Pijukatel requested a review from vdusek October 21, 2026 07:04
@Pijukatel
Pijukatel marked this pull request as ready for review October 21, 2026 07:04

@apify-factory apify-factory left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, just one note.

Outside the diff:

  • src/crawlee/statistics/_models.py: Note: crawlerRuntimeMillis is serialized as the total (including the last run's segment) but validated back into accumulated_runtime alongside the unchanged crawlerLastStartTimestamp/crawlerFinishedAt, so StatisticsState.model_validate(state.model_dump(by_alias=True)).crawler_runtime counts the last run twice. Only Statistics.__aenter__ clears the timestamps, so any consumer that parses a persisted statistics record with StatisticsState gets an inflated runtime.

🏭 Factory review — ℹ️ not auto-approved

This change was reviewed but cannot be auto-approved: a gate failed, so the review cannot end in an approval.

check result
Static gates ❌ 13/16 — base-branch, max-lines, pr-title
Review findings ❌ not clean
Reviewer escalation ❌ Changes the persisted statistics format (crawlerRuntimeMillis type and meaning) and removes a public model field, a breaking public contract that downstream consumers like the Apify SDK may read.
Juror 1 — claude-sonnet-5-5, adversarial ⏭️ skipped — a gate failed
Why it was not auto-approved

Failed gates:

  • base-branch: base is v2, required master
  • max-lines: 217 changed lines (+194/-23), limit 200
  • pr-title: breaking changes are never auto-approved

The comments above are the review itself; this section only explains the approval decision. Push a change and request @apify-factory again to re-run it, or ask a human to review.

Reviewed fc873682f · workflow run

@Pijukatel
Pijukatel marked this pull request as draft October 21, 2026 07:38
@Pijukatel

Pijukatel commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator Author

src/crawlee/statistics/_models.py: Note: crawlerRuntimeMillis is serialized as the total (including the last run's segment) but validated back into accumulated_runtime alongside the unchanged crawlerLastStartTimestamp/crawlerFinishedAt, so StatisticsState.model_validate(state.model_dump(by_alias=True)).crawler_runtime counts the last run twice. Only Statistics.aenter clears the timestamps, so any consumer that parses a persisted statistics record with StatisticsState gets an inflated runtime.

That is not how the StatisticsState should be used, and no Crawlee or SDK code is doing that.

@Pijukatel
Pijukatel marked this pull request as ready for review October 21, 2026 07:57
Comment on lines +118 to +121
if self.crawler_last_started_at is None:
return self.accumulated_runtime
finished_at = self.crawler_finished_at or datetime.now(UTC)
return self.accumulated_runtime + max(timedelta(), finished_at - self.crawler_last_started_at)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important: A finished run is counted twice when the persisted state is loaded outside Statistics. crawlerRuntimeMillis already includes the last run, but crawlerLastStartTimestamp and crawlerFinishedAt are persisted next to it, so StatisticsState.model_validate(...) adds finished - last_started again. A 10s run reads back as 20s. If __aexit__ folds the run into accumulated_runtime (see the comment there), this property can ignore finished runs:

Suggested change
if self.crawler_last_started_at is None:
return self.accumulated_runtime
finished_at = self.crawler_finished_at or datetime.now(UTC)
return self.accumulated_runtime + max(timedelta(), finished_at - self.crawler_last_started_at)
if self.crawler_last_started_at is None or self.crawler_finished_at is not None:
return self.accumulated_runtime
return self.accumulated_runtime + max(timedelta(), datetime.now(UTC) - self.crawler_last_started_at)

✍️ Drafted by Claude Code

Comment on lines +207 to 209
# Stop logging; the finish timestamp freezes the measured run before the final persist.
await self._periodic_logger.stop()
self.state.crawler_finished_at = datetime.now(UTC)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important: The other half of the double-count fix in _models.py:

Suggested change
# Stop logging; the finish timestamp freezes the measured run before the final persist.
await self._periodic_logger.stop()
self.state.crawler_finished_at = datetime.now(UTC)
# Stop logging and fold the finished run into the accumulated runtime before the final persist.
await self._periodic_logger.stop()
self.state.accumulated_runtime = self.state.crawler_runtime
self.state.crawler_finished_at = datetime.now(UTC)

✍️ Drafted by Claude Code

def model_post_init(self, /, __context: Any) -> None:
self._runtime_offset = self.crawler_runtime or self._runtime_offset
accumulated_runtime: Annotated[timedelta_ms, Field(alias='crawlerRuntimeMillis')] = timedelta()
"""The runtime accumulated by the previous runs of the crawler, excluding the run in progress."""

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: The serialized crawlerRuntimeMillis includes the run in progress, so a mid-run persist loaded with model_validate outside Statistics still counts the time since crawler_last_started_at twice. __aenter__ clears the timestamps, so the crawler itself is fine. I would leave the logic alone, but the docstring could mention that the persisted value differs.

✍️ Drafted by Claude Code

async with Statistics.with_default_state(persistence_enabled=True, persist_state_key=key) as statistics:
pass

assert previous_runtime <= statistics.state.crawler_runtime < previous_runtime + timedelta(minutes=1)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: Two cases have no test: the double-count above, and restoring a runtime that v1 persisted as an ISO 8601 duration string.

Suggested change
assert previous_runtime <= statistics.state.crawler_runtime < previous_runtime + timedelta(minutes=1)
assert previous_runtime <= statistics.state.crawler_runtime < previous_runtime + timedelta(minutes=1)
async def test_persisted_finished_state_validates_to_same_runtime() -> None:
"""A state persisted after a finished run reports the same runtime when validated back into the model."""
async with Statistics.with_default_state() as statistics:
assert statistics.state.crawler_last_started_at
statistics.state.crawler_last_started_at -= timedelta(seconds=10)
serialized = statistics.state.model_dump(mode='json', by_alias=True)
restored = StatisticsState.model_validate(serialized)
assert abs(restored.crawler_runtime - statistics.state.crawler_runtime) < timedelta(milliseconds=1)
async def test_runtime_restored_from_v1_iso_duration() -> None:
"""A runtime persisted by crawlee v1 as an ISO 8601 duration string is restored on resume."""
key = 'statistics_v1_runtime'
previous_runtime = timedelta(seconds=10)
kvs = await KeyValueStore.open()
await kvs.set_value(key, {'crawlerRuntimeMillis': 'PT10S'})
async with Statistics.with_default_state(persistence_enabled=True, persist_state_key=key) as statistics:
runtime = statistics.state.crawler_runtime
assert previous_runtime <= runtime < previous_runtime + timedelta(minutes=1)

✍️ Drafted by Claude Code

@@ -17,6 +17,10 @@ We drop support for Python 3.10, which reached its end of life in October 2026.

`StatisticsState.crawler_runtime` is computed from the crawler's start and finish timestamps. In v1, assigning to it only emitted a `DeprecationWarning` and the value was ignored. In v2, the property has no setter, so an assignment raises `AttributeError`. Remove any such assignments.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: With this PR, crawler_runtime no longer comes from the start and finish timestamps.

Suggested change
`StatisticsState.crawler_runtime` is computed from the crawler's start and finish timestamps. In v1, assigning to it only emitted a `DeprecationWarning` and the value was ignored. In v2, the property has no setter, so an assignment raises `AttributeError`. Remove any such assignments.
`StatisticsState.crawler_runtime` is computed from the runtime accumulated by previous runs and the start timestamp of the run in progress. In v1, assigning to it only emitted a `DeprecationWarning` and the value was ignored. In v2, the property has no setter, so an assignment raises `AttributeError`. Remove any such assignments.

✍️ Drafted by Claude Code

Comment on lines +124 to +125
def _serialize_accumulated_runtime(self, _value: timedelta) -> float:
"""Serialize the total runtime. Previously accumulated and current runtime."""

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: round() returns an int, and the annotation feeds the serialization JSON schema. The docstring reads a bit awkwardly too.

Suggested change
def _serialize_accumulated_runtime(self, _value: timedelta) -> float:
"""Serialize the total runtime. Previously accumulated and current runtime."""
def _serialize_accumulated_runtime(self, _value: timedelta) -> int:
"""Serialize the total runtime including the run in progress, so that every persist checkpoints it."""

✍️ Drafted by Claude Code

Comment on lines +8 to +16
from pydantic import (
BaseModel,
ConfigDict,
Field,
PlainSerializer,
PlainValidator,
computed_field,
field_serializer,
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Fits on one line (116 chars).

Suggested change
from pydantic import (
BaseModel,
ConfigDict,
Field,
PlainSerializer,
PlainValidator,
computed_field,
field_serializer,
)
from pydantic import BaseModel, ConfigDict, Field, PlainSerializer, PlainValidator, computed_field, field_serializer

✍️ Drafted by Claude Code

Comment on lines +177 to +180
# The restored timestamps belong to the previous run and must not measure this one: left in place,
# they would count the downtime between the runs (e.g. after a migration or resurrection) towards
# the runtime. Clear them, so that the initial periodic log reports exactly the runtime accumulated
# by the previous runs (zero for a fresh start).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Could be shorter.

Suggested change
# The restored timestamps belong to the previous run and must not measure this one: left in place,
# they would count the downtime between the runs (e.g. after a migration or resurrection) towards
# the runtime. Clear them, so that the initial periodic log reports exactly the runtime accumulated
# by the previous runs (zero for a fresh start).
# The restored timestamps belong to the previous run. Clear them, so that the downtime since then (e.g. a
# migration or resurrection) doesn't count towards the runtime.

✍️ Drafted by Claude Code



async def test_runtime_stops_growing_after_run_finishes() -> None:
"""The runtime measured by a finished run stays frozen instead of following the clock."""

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Simpler wording.

Suggested change
"""The runtime measured by a finished run stays frozen instead of following the clock."""
"""The runtime of a finished run stays fixed as time passes."""

✍️ Drafted by Claude Code


def model_post_init(self, /, __context: Any) -> None:
self._runtime_offset = self.crawler_runtime or self._runtime_offset
accumulated_runtime: Annotated[timedelta_ms, Field(alias='crawlerRuntimeMillis')] = timedelta()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really want to have the crawlerRuntimeMillis alias? Why? For serialization? Validation? Or both?

@vdusek

vdusek commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator

The title is 84 characters long. Could we use something more concise?

E.g.:

  • "fix!: exclude downtime from crawler_runtime after a resume"
    • resume should cover both migration and ressurrection
  • "fix!: report correct crawler runtime after Actor migration or resurrection"
    • this points more at the user visible sympton rather than the internal field

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

t-tooling Issues with this label are in the ownership of the tooling team. tested Temporary label used only programatically for some analytics.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Remove deprecated StatisticsState.crawler_runtime setter

5 participants