Skip to content

fix(conversion): stop touching converted copies on serve - #5242

Open
sx4im wants to merge 2 commits into
rommapp:masterfrom
sx4im:fix/5239-converted-download-etag
Open

sx4im wants to merge 2 commits into
rommapp:masterfrom
sx4im:fix/5239-converted-download-etag

Conversation

@sx4im

@sx4im sx4im commented Oct 14, 2026

Copy link
Copy Markdown

Description

Converted downloads (GET /api/roms/{id}/content/{file_name}?format=cso) could not be resumed. Every serve of a cached conversion ran os.utime on the copy so TTL cleanup could measure demand, but nginx builds its ETag from mtime and size, so the ETag changed on every request and If-Range never matched. A client that lost its connection halfway had to download the whole file again.

_serve_cached() no longer touches the copy it serves, the same approach zip_cache.py already uses. The touch parameter is removed from _serve_cached, get_cached_converted, and resolve_format_download. TTL cleanup now measures from conversion time rather than last serve.

Checklist

  • I've tested the changes locally
  • I've updated relevant comments
  • I've assigned reviewers for this PR
  • I've added unit tests that cover the changes

Fixes #5239

Testing

backend/tests/utils/test_conversion_cache.py: 70 passed. Added test_serving_leaves_mtime_untouched and reworked test_cache_hit_returns_path_without_touching_mtime; both fail on the old code and pass with the fix. The tests that pinned the old touch-on-serve behavior were removed.

The full suite was not run here: it needs MariaDB/PostgreSQL through Docker, which is not available in this environment. CI will cover it.

AI assistance disclosure

Per CONTRIBUTING.md: this PR was written primarily by Muse, an AI coding agent, under the contributor's direction. The agent reproduced the bug, wrote the fix and the tests, and drafted this description.

Every served converted download refreshed the copy's mtime so TTL
cleanup could measure demand. nginx builds its ETag from mtime+size,
so the ETag changed on every request and If-Range never matched,
making converted downloads unresumable.

Serve the cached copy without touching it, like the ZIP cache does.
TTL cleanup now measures from conversion time instead of last serve.

Fixes rommapp#5239
@greptile-apps

greptile-apps Bot commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 3/5

[Medium impact] Stops refreshing file timestamps when serving cached conversions.

Hold merging until requested copies are protected from cleanup, the endpoint tests are updated, and the docstring meets the repository rule.

Fix All in Claude CodeFindings

  1. P1 Cleanup can delete requested downloads ▶
  2. P1 Endpoint tests require removed behavior ▶
  3. P2 Docstring breaks the comment rule ▶
  4. P2 Cache descriptions promise old behavior ▶
Fix with agent prompt
### Issue 1
backend/utils/conversion_cache.py:142-146
Removing the mtime refresh also removes the download's protection from cleanup. For a cached copy older than `SERVE_GRACE_SECONDS`, `_serve_cached` releases its lock before nginx opens the file. If another conversion runs `cleanup_stale_conversions` to make room during that gap, cleanup can delete the just-requested copy and the download fails. Keep a separate last-served timestamp or equivalent protection without changing the copy's mtime.

### Issue 2
backend/tests/utils/test_conversion_cache.py:133-134
The unit tests now expect unchanged mtime, but `test_get_refreshes_a_cached_copy_but_head_does_not` still requires GET to refresh it. Its fixture sets mtime one hour back, so all three GET cases fail at line 307 with the new serving behavior. Update that endpoint test's mtime and cleanup expectations too.

### Issue 3
backend/utils/conversion_cache.py:143-146
The new `_serve_cached` docstring has a second paragraph. The repository's comment rule requires one sentence, followed only by an `Args:` or `Returns:` block when needed. Keep the non-obvious nginx mtime requirement in one sentence. This repository requirement must be satisfied before merging.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

### Issue 4
backend/utils/conversion_cache.py:381
Cleanup now uses conversion age, but `cleanup_stale_conversions` still says it evicts the least recently served copies. The [conversion guide](https://github.com/rommapp/docs/blob/HEAD/docs/administration/library-conversion.md#the-conversion-cache) and [configuration reference](https://github.com/rommapp/docs/blob/HEAD/docs/reference/configuration-file.md#convertocache_ttl_hours) also describe expiry from the last serve and eviction by last use. Update these descriptions to conversion age so admins know that frequent downloads no longer keep a copy cached.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

Converted downloads no longer refresh the cached copy's mtime, keeping nginx's ETag stable for resume requests.

  • Cached converted downloads keep the same ETag when they are served.

Reviews (1) · Last reviewed commit: "fix(conversion): stop touching converted..." · Reviewed by Greptile

Comment thread backend/utils/conversion_cache.py Outdated
Comment on lines +142 to +146
def _serve_cached(final_path: Path) -> Path | None:
"""The cached copy, or None while cleanup or a conversion holds its dir.

Never touches the copy: its mtime feeds nginx's ETag, and refreshing it
would break resumable downloads.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Cleanup can delete requested downloads

Removing the mtime refresh also removes the download's protection from cleanup. For a cached copy older than SERVE_GRACE_SECONDS, _serve_cached releases its lock before nginx opens the file. If another conversion runs cleanup_stale_conversions to make room during that gap, cleanup can delete the just-requested copy and the download fails. Keep a separate last-served timestamp or equivalent protection without changing the copy's mtime.

Prompt To Fix With AI
This is a comment left during a code review.
Path: backend/utils/conversion_cache.py
Line: 142-146

Comment:
**Cleanup can delete requested downloads**

Removing the mtime refresh also removes the download's protection from cleanup. For a cached copy older than `SERVE_GRACE_SECONDS`, `_serve_cached` releases its lock before nginx opens the file. If another conversion runs `cleanup_stale_conversions` to make room during that gap, cleanup can delete the just-requested copy and the download fails. Keep a separate last-served timestamp or equivalent protection without changing the copy's mtime.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code

Comment on lines 133 to +134
assert await get_or_convert(1, f, "psp", "chd") == final
assert final.stat().st_mtime > time.time() - 60
assert final.stat().st_mtime == before

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Endpoint tests require removed behavior

The unit tests now expect unchanged mtime, but test_get_refreshes_a_cached_copy_but_head_does_not still requires GET to refresh it. Its fixture sets mtime one hour back, so all three GET cases fail at line 307 with the new serving behavior. Update that endpoint test's mtime and cleanup expectations too.

Prompt To Fix With AI
This is a comment left during a code review.
Path: backend/tests/utils/test_conversion_cache.py
Line: 133-134

Comment:
**Endpoint tests require removed behavior**

The unit tests now expect unchanged mtime, but `test_get_refreshes_a_cached_copy_but_head_does_not` still requires GET to refresh it. Its fixture sets mtime one hour back, so all three GET cases fail at line 307 with the new serving behavior. Update that endpoint test's mtime and cleanup expectations too.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code

Comment thread backend/utils/conversion_cache.py Outdated
Comment on lines +143 to +146
"""The cached copy, or None while cleanup or a conversion holds its dir.

Never touches the copy: its mtime feeds nginx's ETag, and refreshing it
would break resumable downloads.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Docstring breaks the comment rule

The new _serve_cached docstring has a second paragraph. The repository's comment rule requires one sentence, followed only by an Args: or Returns: block when needed. Keep the non-obvious nginx mtime requirement in one sentence. This repository requirement must be satisfied before merging.

Rule Used: Comment discipline. A comment is one or two lines; a docstring is one sentence plus an Args:/Returns: block when the signature needs it. Flag: multi-paragraph rationale; change history or migration notes ("replaces the manual pattern", "this was drop... (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: backend/utils/conversion_cache.py
Line: 143-146

Comment:
**Docstring breaks the comment rule**

The new `_serve_cached` docstring has a second paragraph. The repository's comment rule requires one sentence, followed only by an `Args:` or `Returns:` block when needed. Keep the non-obvious nginx mtime requirement in one sentence. This repository requirement must be satisfied before merging.

**Rule Used:** Comment discipline. A comment is one or two lines; a docstring is one sentence plus an Args:/Returns: block when the signature needs it. Flag: multi-paragraph rationale; change history or migration notes ("replaces the manual pattern", "this was drop... ([source](https://app.greptile.com/romm/-/custom-context?memory=43fcb307-60fc-4cf1-9be8-07a4b8f33a24))

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code

expired_before = now - converto.cache_ttl_hours * SECONDS_PER_HOUR
deleted = 0
# (last served, size) of the final copies in each dir that survives the TTL pass.
# (newest mtime, size) of the final copies in each dir that survives the TTL pass.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Cache descriptions promise old behavior

Cleanup now uses conversion age, but cleanup_stale_conversions still says it evicts the least recently served copies. The conversion guide and configuration reference also describe expiry from the last serve and eviction by last use. Update these descriptions to conversion age so admins know that frequent downloads no longer keep a copy cached.

Prompt To Fix With AI
This is a comment left during a code review.
Path: backend/utils/conversion_cache.py
Line: 381

Comment:
**Cache descriptions promise old behavior**

Cleanup now uses conversion age, but `cleanup_stale_conversions` still says it evicts the least recently served copies. The [conversion guide](https://github.com/rommapp/docs/blob/HEAD/docs/administration/library-conversion.md#the-conversion-cache) and [configuration reference](https://github.com/rommapp/docs/blob/HEAD/docs/reference/configuration-file.md#convertocache_ttl_hours) also describe expiry from the last serve and eviction by last use. Update these descriptions to conversion age so admins know that frequent downloads no longer keep a copy cached.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code

Serving a converted copy no longer refreshes the copy's mtime (it feeds
nginx's ETag), but that also removed the copy's protection from
cleanup: a just-served copy could be evicted before nginx opened it.
Record serves in a `.last_served` sidecar instead; cleanup already keys
off file mtimes, so the sidecar restores the protection without
touching the copy.

Addresses Greptile P1 findings on rommapp#5242 (cleanup race, stale endpoint
test) and the docstring rule P2.

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Converted downloads (?format=) can't be resumed: the ETag changes on every request

1 participant