Skip to content

fix(core): guard job-record decode and return in-memory copies#59

Merged
williaby merged 2 commits into
mainfrom
fix/pr53-followup-hardening
May 31, 2026
Merged

fix(core): guard job-record decode and return in-memory copies#59
williaby merged 2 commits into
mainfrom
fix/pr53-followup-hardening

Conversation

@williaby
Copy link
Copy Markdown
Contributor

@williaby williaby commented May 31, 2026

Summary

Follow-up hardening surfaced by the /pr-review of #53. Almost all review
findings were already addressed on main via concurrent pre-merge work
(SecretStr API keys + auth validator, constant-time key comparison, rate-limit
hard cap and hashed identifier, enqueue-failure handling, timestamp parse guard,
broadened artifact-failure handling). This PR adds the one remaining net-new
item that did not land: JobStore robustness.

Changes

  • RedisJobStore._decode_hash converts a corrupt or legacy record (a
    non-JSON hash field value) into a typed, logged DatabaseError instead of
    letting a raw JSONDecodeError propagate uncaught. Previously one poisoned
    record could 500 a GET route or wedge the worker's per-update decode loop.
  • InMemoryJobStore.get / update return copies so callers cannot mutate
    stored state out of band, matching RedisJobStore (which always returns a
    freshly decoded record). This prevents in-memory-only aliasing bugs from
    hiding behind passing tests.
  • RedisJobStore TTL uses an explicit is not None check so an accidental
    0/negative is no longer silently coerced to the default.

Tests

  • test_get_raises_on_corrupt_record: a non-JSON field value surfaces as a
    DatabaseError, not an uncaught crash.
  • test_get_returns_a_copy: mutating a fetched in-memory record does not alter
    stored state.
  • Full suite: 470 passed, 93.28% coverage (gate 80%). ruff clean,
    basedpyright 0 errors.

Why

These are defensive-robustness improvements to the shared job store that the
API and ARQ worker both depend on as the single source of truth; a corrupt or
aliased record there has outsized blast radius.

Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Job store now gracefully handles corrupted or malformed data with typed error logging instead of crashes.
    • TTL configuration validation prevents jobs from being immediately deleted due to invalid settings.
    • Job records are now isolated from caller mutations to ensure data integrity.

Copilot AI review requested due to automatic review settings May 31, 2026 14:29
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 31, 2026

Review Change Stack

Walkthrough

The PR hardens the shared job store across InMemoryJobStore and RedisJobStore by adding deep-copy isolation to prevent state mutation, validating positive TTL values in the constructor, and gracefully handling corrupted Redis records via typed errors instead of raw exceptions.

Changes

Job Store Hardening

Layer / File(s) Summary
Changelog entry summarizing job store hardening
CHANGELOG.md
Documents the three hardening improvements: deep-copy isolation in InMemoryJobStore, TTL validation in RedisJobStore constructor, and corrupted field handling in Redis decoding.
InMemoryJobStore deep-copy isolation
src/audio_processor/core/job_store.py, tests/unit/test_job_store.py
InMemoryJobStore now deep-copies records during create, get, and update operations (including nested dicts) to prevent out-of-band mutation. Module imports are updated and tests verify proper shallow and deep copy isolation; external mutations to provided input objects do not affect stored state.
RedisJobStore TTL validation
src/audio_processor/core/job_store.py, tests/unit/test_job_store.py
RedisJobStore constructor now explicitly validates that resolved ttl_seconds is positive, raising ConfigurationError for non-positive values to prevent Redis EXPIRE from immediately deleting newly written jobs.
RedisJobStore JSON decode error handling
src/audio_processor/core/job_store.py, tests/unit/test_job_store.py
_decode_hash now catches ValueError from json.loads on corrupted or legacy fields, logs job_record_decode_failed, and raises typed DatabaseError(operation="decode") instead of propagating raw JSON exceptions.

🎯 3 (Moderate) | ⏱️ ~20 minutes

python, tests, breaking-change

A rabbit hops through the store with care,
Deep-copying dreams to keep mutations fair,
TTL validated, JSON errors caught with grace,
Job records hardened—no corruption left to trace! 🐰✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: guarding job-record decode (DatabaseError handling) and returning in-memory copies (deep-copying to prevent mutation), which are the core hardening fixes.
Docstring Coverage ✅ Passed Docstring coverage is 92.86% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/pr53-followup-hardening

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 31, 2026

✅ FIPS Compatibility Check

Metric Count
Errors 0
Warnings 0
Info 1

Status: ✅ PASSED

What is FIPS?

FIPS 140-2/140-3 is a US government standard for cryptographic modules.
Systems running Ubuntu LTS with fips-updates or similar configurations
restrict cryptographic algorithms to NIST-approved ones.

Common issues:

  • Using hashlib.md5() without usedforsecurity=False
  • Dependencies using non-approved algorithms (bcrypt, DES, RC4)
  • Weak cipher configurations

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 31, 2026

⚠️ Deprecation Warning: The deny-licenses option is deprecated for possible removal in the next major release. For more information, see issue 997.

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR hardens the shared job-store abstraction used by the API and worker by improving corrupt Redis record handling and reducing in-memory store aliasing.

Changes:

  • Wraps Redis job-record JSON decode failures in a typed DatabaseError.
  • Changes in-memory get/update to return copied records.
  • Adds unit tests for corrupt Redis records and in-memory fetch mutation behavior.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
src/audio_processor/core/job_store.py Updates job-store copy semantics, Redis decode error handling, logging, and TTL fallback behavior.
tests/unit/test_job_store.py Adds regression tests for corrupt Redis hash values and in-memory record copy behavior.

Comment thread src/audio_processor/core/job_store.py Outdated
Comment on lines +125 to +134
# Return a copy so callers cannot mutate stored state out of band; this
# matches RedisJobStore, which always returns a freshly decoded record.
record = self._jobs.get(job_id)
return dict(record) if record is not None else None

async def update(self, job_id: str, **fields: object) -> JobRecord:
"""See :meth:`JobStore.update`."""
record = self._jobs.setdefault(job_id, {})
return _merge_fields(record, fields)
_merge_fields(record, fields)
return dict(record)
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 93144e3: InMemoryJobStore.get/update/create now use copy.deepcopy instead of a shallow dict(), so nested progress/input/result dicts are no longer aliased to stored state. update also deep-copies incoming field values. New tests test_get_returns_a_deep_copy and test_update_isolates_incoming_nested_fields cover nested mutation. Thanks for catching this.

Comment on lines +183 to +187
# Use ``is not None`` so an explicit 0/negative is not silently coerced
# to the default (a caller passing such a value likely has a bug).
self._ttl = (
ttl_seconds if ttl_seconds is not None else settings.job_result_ttl_seconds
)
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 93144e3: RedisJobStore.__init__ now raises ConfigurationError when the resolved TTL is <= 0 instead of letting EXPIRE delete the key immediately. New test test_non_positive_ttl_rejected covers 0 and -1. Good call: the previous behavior silently dropped newly written jobs.

@williaby
Copy link
Copy Markdown
Contributor Author

PR Review (Claude Code /pr-review)

Merge blocker: mergeStateStatus is DIRTY — rebase on main to resolve the conflict before merge.

Status: Copilot 2 comments (both confirmed real) · CodeRabbit rate-limited (no review) · SonarCloud gate passed, 1 MINOR issue · CI all green.

No Critical code defects. 4 Important findings:

1. Shallow copy leaves nested values aliased (job_store.py#L134)
dict(record) copies only the top level; nested progress/input/result dicts stay shared, so fetched["progress"]["pct"]=... still mutates stored state. Defeats the stated goal and breaks parity with RedisJobStore (which returns freshly decoded objects). Fix: copy.deepcopy(record). (Copilot)

2. Explicit 0/negative TTL deletes the job (job_store.py#L187)
is not None correctly stops coercion, but EXPIRE key 0 (or negative) drops the key immediately, so a newly created job silently vanishes instead of surfacing the caller bug. Fix: validate in __init__ and raise on ttl <= 0. (Copilot)

3. CHANGELOG.md not updated for a fix: PR (CLAUDE.md / OpenSSF baseline). Add a ### Fixed entry.

4. test_get_returns_a_copy only checks top-level mutation — it reassigns a scalar key, which a shallow copy already protects, so the nested-aliasing gap (finding 1) goes untested. Add an assertion that mutating a nested value does not alter stored state.

Suggested

  • SonarCloud python:S5713 (MINOR) job_store.py#L224: except (json.JSONDecodeError, ValueError) is redundant — JSONDecodeError subclasses ValueError. Keep ValueError alone.
  • PR description overstates the copy protection / Redis parity; tighten once finding 1 lands.

Findings 1, 2, 4, 5 all live in job_store.py / its test and can be fixed in one commit.

🤖 Generated with Claude Code

williaby and others added 2 commits May 31, 2026 08:06
Address pr-review findings on PR #53:

- RedisJobStore._decode_hash converts a corrupt or legacy record
  (non-JSON field value) into a typed, logged DatabaseError instead of
  letting a raw JSONDecodeError propagate uncaught, which could 500 a
  GET route or wedge the worker's per-update decode loop.
- InMemoryJobStore.get/update return copies so callers cannot mutate
  stored state out of band, matching RedisJobStore's detached records.
- RedisJobStore TTL uses an explicit None check so an accidental 0 or
  negative is no longer silently coerced to the default.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Follow-up to the /pr-review of #59.

- InMemoryJobStore.get/update/create now deep-copy records so callers
  cannot mutate stored state out of band, including nested progress/
  input/result dicts. The prior shallow dict() copy left those aliased
  and diverged from RedisJobStore, which decodes fresh objects per
  field. (Copilot)
- RedisJobStore.__init__ rejects a non-positive ttl_seconds with
  ConfigurationError instead of letting Redis EXPIRE delete a newly
  written job immediately. (Copilot)
- _decode_hash catches ValueError alone; json.JSONDecodeError is a
  ValueError subclass, so the tuple was redundant. (SonarCloud S5713)
- Add tests for nested-copy isolation (get and update) and TTL
  rejection; document the change in CHANGELOG.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@williaby williaby force-pushed the fix/pr53-followup-hardening branch from 1a78f67 to 93144e3 Compare May 31, 2026 15:16
@williaby
Copy link
Copy Markdown
Contributor Author

PR Fix Summary

Rebased onto main (resolved the job_store.py docstring conflict from the #56/#58 pydoclint migration) and addressed all actionable review findings in 93144e3.

Review comments (Copilot)

  • Shallow-copy nested aliasing: InMemoryJobStore.get/update/create now deepcopy records (and incoming update fields), matching RedisJobStore's per-field decode.
  • Non-positive TTL: RedisJobStore.__init__ raises ConfigurationError on ttl <= 0 instead of letting EXPIRE delete the key.

SonarQube

  • python:S5713: dropped redundant json.JSONDecodeError from the except tuple (ValueError already covers it).

Tests / docs

  • Added test_get_returns_a_deep_copy, test_update_isolates_incoming_nested_fields, test_non_positive_ttl_rejected.
  • Added a ### Fixed CHANGELOG entry.

Local gates: ruff format + lint clean, basedpyright 0 errors, pydoclint no violations, bandit 0 issues, 475 passed / 93.33% coverage. CI re-run triggered by the push.

🤖 Generated with Claude Code

@sonarqubecloud
Copy link
Copy Markdown

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@CHANGELOG.md`:
- Line 20: The changelog bullet exceeds the 120-character Markdown line length;
edit the single long bullet so it wraps to multiple lines under 120 chars while
preserving content and inline code/backticks — split after logical phrases
(e.g., after the mention of RedisJobStore._decode_hash, after
InMemoryJobStore.get/update/create, and after ttl_seconds/ConfigurationError) so
the line breaks keep the sentence readable and the bullet formatting intact.

In `@tests/unit/test_job_store.py`:
- Around line 76-124: Add a unit test that verifies InMemoryJobStore.create()
isolates/makes a deep copy of the input record: construct a mutable record
(include a nested dict like "progress"), call store.create("j1", record) on an
InMemoryJobStore instance, mutate the original top-level and nested fields of
record after create, then fetch the stored record with store.get("j1") and
assert the stored values (including nested dict) remain the original values;
reference the InMemoryJobStore class and its create and get methods when
locating where to add this test.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 1a4ee3c2-0e41-4bb2-bc2a-fac04e6819a7

📥 Commits

Reviewing files that changed from the base of the PR and between 4758077 and 93144e3.

📒 Files selected for processing (3)
  • CHANGELOG.md
  • src/audio_processor/core/job_store.py
  • tests/unit/test_job_store.py

Comment thread CHANGELOG.md
- fix(jobs): correct field paths for `duration_ms` and `language` in `process_audio_job` result assembly; both now read from `TranscriptionResult.metadata` where they actually live, preventing `AttributeError` at runtime
- fix(api): guard `content-length` header parsing against malformed values; `int()` conversion is now wrapped in a `ValueError` handler so a non-numeric header no longer raises an unhandled exception
- fix(tests): restore `tmp_path` fixture in `test_custom_initialization` for `AudioConverter`, `AudioConditioner`, and `VADProcessor`; hardcoded `/custom/temp` caused `PermissionError` on systems without root access
- fix(core): harden the shared job store. `RedisJobStore._decode_hash` now converts a corrupt or legacy (non-JSON) field value into a typed, logged `DatabaseError` instead of letting a raw `JSONDecodeError` propagate and 500 a `GET` route or wedge the worker decode loop. `InMemoryJobStore.get`/`update`/`create` deep-copy records so callers cannot mutate stored state out of band (including nested `progress`/`input`/`result` dicts), matching `RedisJobStore`. `RedisJobStore` now rejects a non-positive `ttl_seconds` with `ConfigurationError` rather than letting Redis `EXPIRE` delete newly written jobs immediately
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Wrap this changelog bullet to the Markdown line-length limit.

This line exceeds the 120-character Markdown limit and should be split across continuation lines for consistency and lint compliance.

✂️ Proposed formatting fix
-- fix(core): harden the shared job store. `RedisJobStore._decode_hash` now converts a corrupt or legacy (non-JSON) field value into a typed, logged `DatabaseError` instead of letting a raw `JSONDecodeError` propagate and 500 a `GET` route or wedge the worker decode loop. `InMemoryJobStore.get`/`update`/`create` deep-copy records so callers cannot mutate stored state out of band (including nested `progress`/`input`/`result` dicts), matching `RedisJobStore`. `RedisJobStore` now rejects a non-positive `ttl_seconds` with `ConfigurationError` rather than letting Redis `EXPIRE` delete newly written jobs immediately
+- fix(core): harden the shared job store. `RedisJobStore._decode_hash` now converts a corrupt or
+  legacy (non-JSON) field value into a typed, logged `DatabaseError` instead of letting a raw
+  `JSONDecodeError` propagate and 500 a `GET` route or wedge the worker decode loop.
+  `InMemoryJobStore.get`/`update`/`create` deep-copy records so callers cannot mutate stored state
+  out of band (including nested `progress`/`input`/`result` dicts), matching `RedisJobStore`.
+  `RedisJobStore` now rejects a non-positive `ttl_seconds` with `ConfigurationError` rather than
+  letting Redis `EXPIRE` delete newly written jobs immediately.

As per coding guidelines **/*.md: Code Quality: Use 120 character line length for Markdown files.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- fix(core): harden the shared job store. `RedisJobStore._decode_hash` now converts a corrupt or legacy (non-JSON) field value into a typed, logged `DatabaseError` instead of letting a raw `JSONDecodeError` propagate and 500 a `GET` route or wedge the worker decode loop. `InMemoryJobStore.get`/`update`/`create` deep-copy records so callers cannot mutate stored state out of band (including nested `progress`/`input`/`result` dicts), matching `RedisJobStore`. `RedisJobStore` now rejects a non-positive `ttl_seconds` with `ConfigurationError` rather than letting Redis `EXPIRE` delete newly written jobs immediately
- fix(core): harden the shared job store. `RedisJobStore._decode_hash` now converts a corrupt or
legacy (non-JSON) field value into a typed, logged `DatabaseError` instead of letting a raw
`JSONDecodeError` propagate and 500 a `GET` route or wedge the worker decode loop.
`InMemoryJobStore.get`/`update`/`create` deep-copy records so callers cannot mutate stored state
out of band (including nested `progress`/`input`/`result` dicts), matching `RedisJobStore`.
`RedisJobStore` now rejects a non-positive `ttl_seconds` with `ConfigurationError` rather than
letting Redis `EXPIRE` delete newly written jobs immediately.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CHANGELOG.md` at line 20, The changelog bullet exceeds the 120-character
Markdown line length; edit the single long bullet so it wraps to multiple lines
under 120 chars while preserving content and inline code/backticks — split after
logical phrases (e.g., after the mention of RedisJobStore._decode_hash, after
InMemoryJobStore.get/update/create, and after ttl_seconds/ConfigurationError) so
the line breaks keep the sentence readable and the bullet formatting intact.

Comment on lines +76 to +124
@pytest.mark.asyncio
async def test_get_returns_a_copy(self) -> None:
"""Mutating a fetched record must not alter stored state.

Matches RedisJobStore, which always returns a freshly decoded record,
so tests on the in-memory backend cannot mask aliasing bugs.
"""
store = InMemoryJobStore()
await store.create("j1", {"status": "queued"})
fetched = await store.get("j1")
assert fetched is not None
fetched["status"] = "tampered"
again = await store.get("j1")
assert again is not None
assert again["status"] == "queued"

@pytest.mark.asyncio
async def test_get_returns_a_deep_copy(self) -> None:
"""Mutating a *nested* value of a fetched record must not leak.

A shallow ``dict`` copy would leave nested dicts (progress/input/result)
aliased to stored state; only a deep copy gives the same isolation as
RedisJobStore, which decodes fresh nested objects per field.
"""
store = InMemoryJobStore()
await store.create("j1", {"status": "queued", "progress": {"pct": 0}})
fetched = await store.get("j1")
assert fetched is not None
progress = fetched["progress"]
assert isinstance(progress, dict)
progress["pct"] = 99
again = await store.get("j1")
assert again is not None
assert again["progress"] == {"pct": 0}

@pytest.mark.asyncio
async def test_update_isolates_incoming_nested_fields(self) -> None:
"""Mutating a nested object passed to update must not alter stored state.

Matches RedisJobStore, which JSON-encodes incoming values on write.
"""
store = InMemoryJobStore()
await store.create("j1", {"status": "queued"})
progress = {"pct": 10}
await store.update("j1", progress=progress)
progress["pct"] = 99
again = await store.get("j1")
assert again is not None
assert again["progress"] == {"pct": 10}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Add a direct test for create() input-mutation isolation.

You validate get() and update() isolation well, but there’s no explicit test that mutating the original record after create() does not affect stored state.

✅ Proposed test addition
+    `@pytest.mark.asyncio`
+    async def test_create_isolates_incoming_nested_fields(self) -> None:
+        """Mutating the input record after create must not alter stored state."""
+        store = InMemoryJobStore()
+        record = {"status": "queued", "progress": {"pct": 1}}
+        await store.create("j1", record)
+        record["progress"]["pct"] = 99
+        again = await store.get("j1")
+        assert again is not None
+        assert again["progress"] == {"pct": 1}

As per coding guidelines tests/**/*.py: Ensure tests verify behavior not just execution, edge cases and error conditions are tested, and tests would catch regressions in the feature being tested.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/test_job_store.py` around lines 76 - 124, Add a unit test that
verifies InMemoryJobStore.create() isolates/makes a deep copy of the input
record: construct a mutable record (include a nested dict like "progress"), call
store.create("j1", record) on an InMemoryJobStore instance, mutate the original
top-level and nested fields of record after create, then fetch the stored record
with store.get("j1") and assert the stored values (including nested dict) remain
the original values; reference the InMemoryJobStore class and its create and get
methods when locating where to add this test.

@williaby williaby merged commit f5f0c15 into main May 31, 2026
46 checks passed
@williaby williaby deleted the fix/pr53-followup-hardening branch May 31, 2026 15:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants