Skip to content

fix: make configuration writes strict and atomic#367

Open
yyiilluu wants to merge 3 commits into
mainfrom
codex/strict-atomic-config-writes
Open

fix: make configuration writes strict and atomic#367
yyiilluu wants to merge 3 commits into
mainfrom
codex/strict-atomic-config-writes

Conversation

@yyiilluu

@yyiilluu yyiilluu commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Reject unknown or malformed configuration fields before storage validation or persistence.
  • Prepare full replacements and top-level shallow patches without mutating configurator state.
  • Serialize local-file config writes with a non-blocking lock so concurrent writers fail immediately instead of overwriting newer data.
  • Preserve existing API success payloads while returning HTTP 409 for write contention; read paths never surface a write conflict.

Changes

Configuration write contract

  • Added PreparedConfigWrite for side-effect-free validation and a storage-level read/transform/write result contract.
  • Re-merge partial updates against the latest persisted document while holding the backend lock.
  • Run no-op updates through the locked path and invalidate the Reflexio cache only when the document changes; the "Configuration unchanged" sentinel is a shared constant (CONFIG_UNCHANGED_MESSAGE) instead of an inline string match.

Local-file storage

  • Added non-blocking file locking around config mutations.
  • Kept atomic temporary-file replacement under the lock.
  • Preserved tolerant reads when a schema-cleanup rewrite contends with another writer, and a first load under a held lock now serves the default config instead of raising ConfigWriteConflict.

API and documentation

  • Map ConfigWriteConflict to HTTP 409 with Configuration update already in progress.
  • Document strict writes, shallow patch behavior, and concurrency semantics.

Review follow-ups

  • Dropped the unused PreparedConfigWrite.changed field — storage owns the authoritative changed result.
  • Added a contract test proving concurrent first loads degrade to defaults instead of failing.

Test Plan

  • uv run pytest tests/lib/test_config_unit.py tests/server/api_endpoints/test_api_routes.py tests/server/services/configurator/test_config_storage_contract.py -q -o 'addopts=' — 56 passed
  • Ruff checks and formatting checks for every changed Python file
  • Targeted Pyright for the configurator, config routes, and config mixin — 0 errors
  • uv run python -c "import reflexio"
  • Full non-E2E OSS suite — 4,737 passed, 73 skipped; 8 existing vector-search failures while the embedding service was unavailable
  • OSS E2E suite — 41 passed, 49 skipped; 2 search failures while the embedding service at 127.0.0.1:8099 was unavailable

Summary by CodeRabbit

  • New Features
    • Config writes now follow a prepared/validated write flow, with full replacement validated before saving.
    • Partial updates are merged and validated as a complete resulting configuration.
    • Concurrent config writers now return a conflict response (HTTP 409).
  • Bug Fixes
    • Cache invalidation is now driven by “changed vs unchanged” messaging rather than only success.
    • Storage configuration is preserved/restored correctly when omitted or platform-managed.
    • Local file storage now uses payload-level updates with non-blocking lock handling and safer concurrent behavior.
  • Documentation
    • API docs updated to clarify validation timing, replacement vs patch semantics, and error codes.
  • Tests
    • Updated unit and server route tests for prepared-write objects, conflict propagation, and partial-merge correctness.

@coderabbitai

coderabbitai Bot commented Jul 18, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@yyiilluu, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 53 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 2d66a162-a3d4-4ad5-a6f6-a1a137adfada

📥 Commits

Reviewing files that changed from the base of the PR and between fb9d0ad and c34a915.

📒 Files selected for processing (2)
  • reflexio/server/services/configurator/local_file_config_storage.py
  • tests/server/services/configurator/test_config_storage_contract.py
📝 Walkthrough

Walkthrough

Configuration writes now use prepared, validated payloads and conflict-aware storage updates. Local file storage adds non-blocking locking and canonical payload persistence. API routes, library handling, tests, and documentation cover partial writes, no-op detection, storage preservation, and HTTP 409 conflicts.

Changes

Configuration write flow

Layer / File(s) Summary
Payload storage and locking
reflexio/server/services/configurator/config_storage.py, reflexio/server/services/configurator/local_file_config_storage.py, tests/server/services/configurator/test_config_storage_contract.py
Payload transforms are validated and persisted under non-blocking file locks, with explicit change results and conflict handling.
Prepared configuration workflow
reflexio/server/services/configurator/base_configurator.py, tests/server/services/configurator/test_config_storage_contract.py
PreparedConfigWrite separates validation from commit, supports partial writes, canonicalizes payloads, and updates in-memory configuration after persistence.
Library and API integration
reflexio/lib/_config.py, reflexio/server/routes/config.py, reflexio/models/api_schema/retriever_schema.py, reflexio/server/README.md
Configuration endpoints and ConfigMixin use prepared writes, preserve storage configuration, report no-ops, map validation and conflict errors, and conditionally invalidate cache.
Behavior validation
tests/lib/test_config_unit.py, tests/server/api_endpoints/test_api_routes.py, tests/server/services/configurator/test_config_storage_contract.py
Tests cover prepared-write arguments, storage preservation, partial updates, no-op responses, conflict propagation, and HTTP 409 behavior.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant ConfigRoutes
  participant Configurator
  participant ConfigMixin
  participant ConfigStorage
  Client->>ConfigRoutes: submit configuration write
  ConfigRoutes->>Configurator: prepare_config_write(payload, partial)
  ConfigRoutes->>ConfigMixin: set_config(prepared)
  ConfigMixin->>ConfigStorage: commit_config_write(prepared)
  ConfigStorage-->>ConfigMixin: changed or unchanged
  ConfigMixin-->>Client: configuration response
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 65.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: stricter, atomic configuration writes with lock-based concurrency handling.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/strict-atomic-config-writes

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.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 4

🤖 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 `@reflexio/server/services/configurator/local_file_config_storage.py`:
- Around line 81-84: Update the missing-file branch in the configuration read
flow to tolerate a ConfigWriteConflict from save_config() when another process
initializes the file concurrently. Catch the conflict and read and return the
existing winner’s configuration, while preserving the current default-creation
path when no competing writer intervenes.
- Around line 113-116: Update the schema-cleanup branch around save_config so
normalization uses update_config_payload with a transform callback rather than
replacing the previously read payload via save_config. The transform must
re-read the current payload under the update lock, apply the same config
normalization, and preserve newer concurrent writes; retain the existing OSError
and ConfigWriteConflict handling.
- Around line 160-168: Update the transformed-payload flow in the local-file
storage override before _save_payload_to_local_dir: validate updated through the
same Config payload validation and canonicalization used by the base
implementation, then compare and persist the canonical result. Ensure malformed,
unknown, invalid, or non-JSON-compatible transform output is rejected rather
than written, while preserving the unchanged-payload result behavior.

In `@tests/server/services/configurator/test_config_storage_contract.py`:
- Around line 218-234: Strengthen
test_schema_cleanup_conflict_returns_valid_stored_config by assigning a
non-default value to a persisted field such as agent_context_prompt before
writing the payload. Assert the loaded configuration preserves that custom
value, while retaining the existing retired_field assertion to verify cleanup
was not performed during the lock conflict.
🪄 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: CHILL

Plan: Pro Plus

Run ID: 52e95da3-73a2-40aa-9392-29af66296e70

📥 Commits

Reviewing files that changed from the base of the PR and between eb18b48 and edc5a41.

📒 Files selected for processing (9)
  • reflexio/lib/_config.py
  • reflexio/server/README.md
  • reflexio/server/routes/config.py
  • reflexio/server/services/configurator/base_configurator.py
  • reflexio/server/services/configurator/config_storage.py
  • reflexio/server/services/configurator/local_file_config_storage.py
  • tests/lib/test_config_unit.py
  • tests/server/api_endpoints/test_api_routes.py
  • tests/server/services/configurator/test_config_storage_contract.py

Comment thread reflexio/server/services/configurator/local_file_config_storage.py
Comment thread reflexio/server/services/configurator/local_file_config_storage.py Outdated
Comment thread reflexio/server/services/configurator/local_file_config_storage.py
Comment thread tests/server/services/configurator/test_config_storage_contract.py
yyiilluu added 2 commits July 18, 2026 17:51
- serve reads instead of raising ConfigWriteConflict when the config lock
  is held during a first load; write paths still fail fast
- share CONFIG_UNCHANGED_MESSAGE between ConfigMixin and the config routes
  instead of string-matching the message inline
- drop the unused PreparedConfigWrite.changed field (storage owns the
  authoritative changed result)
@yyiilluu
yyiilluu force-pushed the codex/strict-atomic-config-writes branch from 9394b06 to fb9d0ad Compare July 19, 2026 00:51
The cleanup rewrite previously persisted the pre-lock snapshot, so a
writer completing between the read and the lock could be silently
overwritten. Re-run the normalization inside update_config_payload
against the payload re-read under the lock instead, and pin the race
plus user-value survival with contract tests.

@coderabbitai coderabbitai Bot 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.

♻️ Duplicate comments (3)
reflexio/server/services/configurator/local_file_config_storage.py (2)

172-178: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Validate and canonicalize transformed payloads before persistence.

Unlike the base implementation, this override writes the arbitrary transform output directly. A malformed transform can therefore persist unknown fields, invalid values, or non-JSON-compatible objects despite the storage contract requiring canonical Config payloads.

🛡️ Proposed fix
-                if updated == current:
+                config = Config.model_validate(updated)
+                canonical = config.model_dump(mode="json")
+                if canonical == current:
                     return ConfigPayloadUpdateResult(
                         payload=current,
                         changed=False,
                     )
-                self._save_payload_to_local_dir(updated)
-                return ConfigPayloadUpdateResult(payload=updated, changed=True)
+                self._save_payload_to_local_dir(canonical)
+                return ConfigPayloadUpdateResult(payload=canonical, changed=True)
🤖 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 `@reflexio/server/services/configurator/local_file_config_storage.py` around
lines 172 - 178, Update the local storage update flow around
_save_payload_to_local_dir so transformed payloads are first validated and
canonicalized using the same Config payload contract as the base implementation.
Compare the canonical result with current, persist only that canonical payload,
and return it in ConfigPayloadUpdateResult while preserving the unchanged fast
path.

124-127: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Preserve newer concurrent writes during schema cleanup.

The current implementation replaces the entire file via save_config(config) using the payload read before locking. A writer completing between the read and lock acquisition will have its newer configuration silently overwritten.

Perform normalization as an update_config_payload transform so it re-reads and cleans the latest payload while holding the lock.

♻️ Proposed fix to preserve concurrent writes
                 if config.model_dump(mode="json") != original_payload:
+                    def _cleanup_transform(curr: dict[str, Any]) -> dict[str, Any]:
+                        st = curr.get("storage_config") if isinstance(curr, dict) else None
+                        if isinstance(st, dict) and st.get("type") == "disk":
+                            curr = dict(curr)
+                            curr["storage_config"] = self._default_storage_config().model_dump()
+                        return validate_stored_config(curr).model_dump(mode="json")
+
                     try:
-                        self.save_config(config=config)
+                        self.update_config_payload(_cleanup_transform)
                     except (OSError, ConfigWriteConflict):
🤖 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 `@reflexio/server/services/configurator/local_file_config_storage.py` around
lines 124 - 127, Update the schema-cleanup flow around save_config and replace
the full-file save with an update_config_payload transform. Make the transform
normalize the payload loaded while holding the lock, so concurrent writes
completed after the initial read are re-read and preserved; retain the existing
OSError and ConfigWriteConflict handling.
tests/server/services/configurator/test_config_storage_contract.py (1)

234-246: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Assert that non-default user values survive the cleanup conflict.

This fixture is otherwise identical to the default config, so the test would still pass if load_config() were to catch an unexpected error and silently fall back to returning defaults. Add a custom persisted field to guarantee the file was actually loaded.

🧪 Proposed test strengthening
         payload = storage.get_default_config().model_dump(mode="json")
+        payload["agent_context_prompt"] = "preserve me"
         payload["retired_field"] = "ignored"
         config_path = Path(storage.config_file)
         config_path.parent.mkdir(parents=True, exist_ok=True)
         config_path.write_text(json.dumps(payload), encoding="utf-8")
         lock_path = config_path.with_suffix(".json.lock")
 
         with lock_path.open("a+b") as lock_file:
             fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
             loaded = storage.load_config()
 
         assert loaded.storage_config == storage.get_default_config().storage_config
+        assert loaded.agent_context_prompt == "preserve me"
         assert json.loads(config_path.read_text())["retired_field"] == "ignored"
🤖 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/server/services/configurator/test_config_storage_contract.py` around
lines 234 - 246, Strengthen the load_config test around
storage.get_default_config() by setting a non-default persisted user value
before writing the payload, then assert that value is present in loaded. Keep
the retired_field preservation assertion and lock-conflict setup unchanged so
the test verifies the file was loaded rather than silently returning defaults.
🤖 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.

Duplicate comments:
In `@reflexio/server/services/configurator/local_file_config_storage.py`:
- Around line 172-178: Update the local storage update flow around
_save_payload_to_local_dir so transformed payloads are first validated and
canonicalized using the same Config payload contract as the base implementation.
Compare the canonical result with current, persist only that canonical payload,
and return it in ConfigPayloadUpdateResult while preserving the unchanged fast
path.
- Around line 124-127: Update the schema-cleanup flow around save_config and
replace the full-file save with an update_config_payload transform. Make the
transform normalize the payload loaded while holding the lock, so concurrent
writes completed after the initial read are re-read and preserved; retain the
existing OSError and ConfigWriteConflict handling.

In `@tests/server/services/configurator/test_config_storage_contract.py`:
- Around line 234-246: Strengthen the load_config test around
storage.get_default_config() by setting a non-default persisted user value
before writing the payload, then assert that value is present in loaded. Keep
the retired_field preservation assertion and lock-conflict setup unchanged so
the test verifies the file was loaded rather than silently returning defaults.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 5ef7caca-5452-4544-a150-82270206985e

📥 Commits

Reviewing files that changed from the base of the PR and between 9394b06 and fb9d0ad.

📒 Files selected for processing (10)
  • reflexio/lib/_config.py
  • reflexio/models/api_schema/retriever_schema.py
  • reflexio/server/README.md
  • reflexio/server/routes/config.py
  • reflexio/server/services/configurator/base_configurator.py
  • reflexio/server/services/configurator/config_storage.py
  • reflexio/server/services/configurator/local_file_config_storage.py
  • tests/lib/test_config_unit.py
  • tests/server/api_endpoints/test_api_routes.py
  • tests/server/services/configurator/test_config_storage_contract.py
🚧 Files skipped from review as they are similar to previous changes (8)
  • reflexio/models/api_schema/retriever_schema.py
  • reflexio/server/README.md
  • reflexio/server/services/configurator/config_storage.py
  • reflexio/lib/_config.py
  • tests/lib/test_config_unit.py
  • reflexio/server/services/configurator/base_configurator.py
  • reflexio/server/routes/config.py
  • tests/server/api_endpoints/test_api_routes.py

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.

1 participant