Skip to content

feat: stop PictoPy's own file writes from triggering a folder resync - #1482

Open
rohan-pandeyy wants to merge 3 commits into
AOSSIE-Org:mainfrom
rohan-pandeyy:fix/watcher-self-write-echo
Open

feat: stop PictoPy's own file writes from triggering a folder resync#1482
rohan-pandeyy wants to merge 3 commits into
AOSSIE-Org:mainfrom
rohan-pandeyy:fix/watcher-self-write-echo

Conversation

@rohan-pandeyy

@rohan-pandeyy rohan-pandeyy commented Aug 10, 2026

Copy link
Copy Markdown
Member

The sync microservice treats any change under a watched folder as a user edit. That's fine today, because nothing in PictoPy writes to a photo — but the moment something does, the write comes straight back as a sync-folder call and re-runs the whole pipeline on the folder it lives in.

This adds the mechanism to tell our own writes apart, so that work can land safely.

How it works

self_write_util_replace() writes to a sibling temp file, records that file's size and mtime, then renames it into place. A rename carries both across unchanged, so the ledger row is committed before the new bytes are visible at the watched path — there's no window where our own write can read as a user edit.

The watcher claims matching entries and drops those changes before any folder is marked affected.

Notes

  • Entries are claimed once. A second event for the same write therefore reads as a real change — one redundant rescan, rather than risking a genuine edit being swallowed. Failing open is the right direction here.
  • Unclaimed rows expire after an hour, pruned on the next write, so no scheduler is needed.
  • An unreadable ledger suppresses nothing, for the same reason.
  • The temp file lands in the watched folder (it has to, for the rename to stay atomic), so the watcher recognises and ignores it by name.
  • Path normalisation is a contract between the two services; both key on normcase(abspath(path)) and both files say so.

Verification

15 new tests on the backend side, including that the ledger row survives a rename that fails midway — that's the ordering guarantee, so it's worth pinning down.

The watcher half was checked end to end against a real database: our own write plus its temp file produced no sync call; a genuine edit produced one; the two mixed in a single batch still let the real edit through; and a repeat event for a claimed write correctly fell back to syncing.

Worth flagging for review: CI doesn't run tests for sync-microservice (there's no suite there), so that side is covered by the manual check above rather than automated tests. Happy to add a suite and a CI job separately if that's wanted.

Full backend suite passes (1103), pre-commit clean, both services import cleanly.

Summary by CodeRabbit

  • Bug Fixes

    • Prevented PictoPy’s own file updates from being incorrectly treated as external changes.
    • Reduced duplicate synchronization events caused by temporary files and self-generated writes.
    • Preserved deletion handling while filtering recognized self-write events.
  • Reliability

    • Added safer atomic file replacement with cleanup and failure handling.
    • Improved tracking cleanup for expired write records and graceful handling of storage errors.
    • Ensured failed file replacements do not overwrite or remove the original file.

The watcher treats any change under a watched folder as a user edit, so a file
PictoPy writes comes straight back as a full resync. Records each write before
the bytes land and lets the watcher claim it instead.
@github-actions

Copy link
Copy Markdown
Contributor

⚠️ No issue was linked in the PR description.
Please make sure to link an issue (e.g., 'Fixes #issue_number')

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Important

Review skipped

No new commits to review since the last review.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 9d63c40d-a389-4516-a399-3411d8bbda48

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Walkthrough

The PR adds SQLite-backed self-write tracking, atomic file replacement, application initialization, and watcher filtering. Matching events are claimed and skipped based on normalized paths, file size, and modification time.

Changes

Self-write suppression flow

Layer / File(s) Summary
Backend self-write recording and replacement
backend/app/database/self_writes.py, backend/app/utils/self_write.py, backend/main.py, backend/tests/conftest.py, backend/tests/test_self_write.py
The backend records expected file states, performs atomic replacements through temporary files, initializes the ledger, and tests recording, claiming, cleanup, and failure behavior.
Sync-service ledger matching
sync-microservice/app/database/self_writes.py
The sync service matches observed files by normalized path, size, and modification time. It removes claimed entries and fails open on database errors.
Watcher event filtering
sync-microservice/app/utils/watcher.py
The watcher excludes .pictopy-write- temporary files and skips matching self-write events before folder synchronization. Deletion handling remains active.

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

Sequence Diagram(s)

sequenceDiagram
  participant PictoPy
  participant SelfWriteUtility
  participant SQLiteLedger
  participant Watcher
  participant FolderSynchronization
  PictoPy->>SelfWriteUtility: replace file data
  SelfWriteUtility->>SQLiteLedger: record expected path, size, and mtime
  SelfWriteUtility-->>PictoPy: return replacement result
  Watcher->>SQLiteLedger: match observed file events
  SQLiteLedger-->>Watcher: return and claim self-write paths
  Watcher->>FolderSynchronization: synchronize remaining events
Loading

Suggested labels: Python

Poem

I’m a rabbit with bytes in my burrow,
I log every write in a hurry.
Temp files hop past,
Old entries don’t last,
And watchers now skip what they worry.

🚥 Pre-merge checks | ✅ 4
✅ 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 clearly and concisely describes the main change: preventing PictoPy file writes from triggering folder resynchronization.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@github-actions

Copy link
Copy Markdown
Contributor

⚠️ No issue was linked in the PR description.
Please make sure to link an issue (e.g., 'Fixes #issue_number')

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 3

🧹 Nitpick comments (4)
sync-microservice/app/utils/watcher.py (1)

68-84: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Rename watcher_util_drop_self_writes to match what it returns.

The function drops nothing from its input. It returns the subset of paths that match a recorded self-write, and the caller decides what to skip. A name such as watcher_util_find_self_writes or watcher_util_claim_self_writes states that, and "claim" matches the wording used in db_take_matching_self_writes.

🤖 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 `@sync-microservice/app/utils/watcher.py` around lines 68 - 84, Rename
watcher_util_drop_self_writes to watcher_util_find_self_writes (or
watcher_util_claim_self_writes) and update all call sites to match its behavior
of returning matching self-write paths without modifying the input.
backend/app/utils/self_write.py (1)

50-63: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Consider preserving the target file mode.

tempfile.mkstemp creates the temp file with mode 0600, and os.replace keeps that mode. After a replacement, the photo is readable only by the owner. If the library sits on a shared or group-readable location, other users lose access.

♻️ Optional fix
         stats = os.stat(temp_path)
+        try:
+            os.chmod(temp_path, os.stat(target).st_mode & 0o7777)
+        except OSError:
+            # New target, or a filesystem without modes: keep the default.
+            pass
         db_record_self_write(target, stats.st_size, int(stats.st_mtime))

Call os.stat(temp_path) after os.chmod so the recorded mtime stays accurate.

🤖 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 `@backend/app/utils/self_write.py` around lines 50 - 63, Preserve the existing
target file permissions in the self-write replacement flow by reading the target
mode and applying it to temp_path with os.chmod before the os.stat(temp_path)
call and os.replace operation. Keep db_record_self_write using the post-chmod
temp-file metadata so the recorded mtime remains accurate.
backend/app/database/self_writes.py (1)

16-16: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Deprecated typing aliases in the new modules. Ruff reports UP035 on each new file because the code uses typing.List, typing.Set, typing.Tuple, and typing.Iterator. Builtin generics work at runtime from Python 3.9 onward, which matches the declared floor for this repository, so the replacements are safe.

  • backend/app/database/self_writes.py#L16-L16: replace the import with from typing import Set removal and use list[ObservedFile], set[str], and ObservedFile = tuple[str, int, int].
  • sync-microservice/app/database/self_writes.py#L11-L11: apply the same replacement for List, Set, and Tuple.
  • backend/tests/test_self_write.py#L11-L11: import Iterator from collections.abc.

Run ruff check --fix on the three files; do not run ruff format.

As per coding guidelines: "Ruff may be used as a linter and with --fix."

🤖 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 `@backend/app/database/self_writes.py` at line 16, Replace deprecated typing
aliases with Python 3.9+ builtin generics in backend/app/database/self_writes.py
(line 16) and sync-microservice/app/database/self_writes.py (line 11), including
list, set, and tuple annotations and removing unused typing imports. In
backend/tests/test_self_write.py (line 11), import Iterator from
collections.abc. Run ruff check --fix on all three files, without running ruff
format.

Sources: Coding guidelines, Linters/SAST tools

backend/tests/test_self_write.py (1)

44-92: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add one case for duplicate observed paths.

The suite does not cover a batch where two observed entries normalize to the same key. That is the input that breaks db_take_matching_self_writes today (see the comment on backend/app/database/self_writes.py lines 118-127). One assertion pins the fix.

💚 Proposed test
     def test_nothing_observed_queries_nothing(self, test_db):
         assert db_take_matching_self_writes([]) == set()
+
+    def test_repeated_observations_of_one_path_still_match(self, test_db, tmp_path):
+        """One batch can carry an add and a modify for the same file."""
+        photo = tmp_path / "a.jpg"
+        photo.write_bytes(b"x" * 100)
+        observed = _observe(str(photo))
+        db_record_self_write(*observed)
+
+        assert db_take_matching_self_writes([observed, observed]) == {observed[0]}

Also applies to: 102-136

🤖 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 `@backend/tests/test_self_write.py` around lines 44 - 92, Add a test method in
TestLedger covering a batch with duplicate observed entries for the same path
and metadata, verifying db_take_matching_self_writes handles the normalized
duplicate key correctly with one expected claim. Use the existing _observe and
db_record_self_write helpers and assert the precise returned path set.
🤖 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 `@backend/app/database/self_writes.py`:
- Around line 118-127: Update the placeholder construction in the self-write
query to derive its count from the unique keys in by_key, matching the list of
bound parameters. Keep the existing deduplication and query behavior unchanged
so duplicate observed paths do not cause a binding-count error.
- Around line 37-38: Update the _connect() helper to enable SQLite foreign-key
enforcement with PRAGMA foreign_keys = ON immediately after creating the
connection, matching the behavior of other _connect() helpers while still
returning the configured connection.

In `@backend/tests/test_self_write.py`:
- Around line 93-100: Update test_lookup_survives_a_differently_spelled_path to
assert the platform-independent abspath normalization on all systems, and guard
the path.upper() case-folding scenario with a Windows-only condition. Keep the
db_take_matching_self_writes expectation for the normalized path, while avoiding
the uppercase comparison on POSIX.

---

Nitpick comments:
In `@backend/app/database/self_writes.py`:
- Line 16: Replace deprecated typing aliases with Python 3.9+ builtin generics
in backend/app/database/self_writes.py (line 16) and
sync-microservice/app/database/self_writes.py (line 11), including list, set,
and tuple annotations and removing unused typing imports. In
backend/tests/test_self_write.py (line 11), import Iterator from
collections.abc. Run ruff check --fix on all three files, without running ruff
format.

In `@backend/app/utils/self_write.py`:
- Around line 50-63: Preserve the existing target file permissions in the
self-write replacement flow by reading the target mode and applying it to
temp_path with os.chmod before the os.stat(temp_path) call and os.replace
operation. Keep db_record_self_write using the post-chmod temp-file metadata so
the recorded mtime remains accurate.

In `@backend/tests/test_self_write.py`:
- Around line 44-92: Add a test method in TestLedger covering a batch with
duplicate observed entries for the same path and metadata, verifying
db_take_matching_self_writes handles the normalized duplicate key correctly with
one expected claim. Use the existing _observe and db_record_self_write helpers
and assert the precise returned path set.

In `@sync-microservice/app/utils/watcher.py`:
- Around line 68-84: Rename watcher_util_drop_self_writes to
watcher_util_find_self_writes (or watcher_util_claim_self_writes) and update all
call sites to match its behavior of returning matching self-write paths without
modifying the input.
🪄 Autofix

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: d929ef08-5f25-4b0e-b41a-cfe045c597dc

📥 Commits

Reviewing files that changed from the base of the PR and between d1ccba1 and 13c9dda.

📒 Files selected for processing (7)
  • backend/app/database/self_writes.py
  • backend/app/utils/self_write.py
  • backend/main.py
  • backend/tests/conftest.py
  • backend/tests/test_self_write.py
  • sync-microservice/app/database/self_writes.py
  • sync-microservice/app/utils/watcher.py

Comment thread backend/app/database/self_writes.py Outdated
Comment thread backend/app/database/self_writes.py
Comment thread backend/tests/test_self_write.py Outdated
Comment on lines +93 to +100
def test_lookup_survives_a_differently_spelled_path(self, test_db, tmp_path):
"""The watcher reports whatever the OS hands it; the key has to absorb that."""
photo = tmp_path / "a.jpg"
photo.write_bytes(b"x" * 100)
path, size, mtime = _observe(str(photo))
db_record_self_write(path.upper(), size, mtime)

assert db_take_matching_self_writes([(path, size, mtime)]) == {path}

@coderabbitai coderabbitai Bot Aug 10, 2026

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.

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

This test only passes on Windows; CI fails on Linux.

os.path.normcase lowercases the path on Windows and returns it unchanged on POSIX. The test records path.upper() and expects a match, so the keys differ on the Linux runner and db_take_matching_self_writes returns an empty set. The pipeline reports exactly this failure at line 100.

The production code is correct. Assert the normalization that os.path.abspath performs on every platform, and keep the case-folding assertion behind a Windows guard.

💚 Proposed fix
     def test_lookup_survives_a_differently_spelled_path(self, test_db, tmp_path):
         """The watcher reports whatever the OS hands it; the key has to absorb that."""
         photo = tmp_path / "a.jpg"
         photo.write_bytes(b"x" * 100)
         path, size, mtime = _observe(str(photo))
-        db_record_self_write(path.upper(), size, mtime)
+        # Redundant "." and separator components resolve the same way on every
+        # platform; case folding only applies on Windows.
+        noisy = os.path.join(str(tmp_path), ".", "", "a.jpg")
+        db_record_self_write(noisy, size, mtime)
 
         assert db_take_matching_self_writes([(path, size, mtime)]) == {path}
+
+    `@pytest.mark.skipif`(os.name != "nt", reason="normcase folds case only on Windows")
+    def test_lookup_survives_a_differently_cased_path(self, test_db, tmp_path):
+        photo = tmp_path / "a.jpg"
+        photo.write_bytes(b"x" * 100)
+        path, size, mtime = _observe(str(photo))
+        db_record_self_write(path.upper(), size, mtime)
+
+        assert db_take_matching_self_writes([(path, size, mtime)]) == {path}

As per pipeline failures: "Expected db_take_matching_self_writes([(path, size, mtime)]) to return the observed path, but it returned an empty set when the recorded path was uppercase".

📝 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
def test_lookup_survives_a_differently_spelled_path(self, test_db, tmp_path):
"""The watcher reports whatever the OS hands it; the key has to absorb that."""
photo = tmp_path / "a.jpg"
photo.write_bytes(b"x" * 100)
path, size, mtime = _observe(str(photo))
db_record_self_write(path.upper(), size, mtime)
assert db_take_matching_self_writes([(path, size, mtime)]) == {path}
def test_lookup_survives_a_differently_spelled_path(self, test_db, tmp_path):
"""The watcher reports whatever the OS hands it; the key has to absorb that."""
photo = tmp_path / "a.jpg"
photo.write_bytes(b"x" * 100)
path, size, mtime = _observe(str(photo))
# Redundant "." and separator components resolve the same way on every
# platform; case folding only applies on Windows.
noisy = os.path.join(str(tmp_path), ".", "", "a.jpg")
db_record_self_write(noisy, size, mtime)
assert db_take_matching_self_writes([(path, size, mtime)]) == {path}
`@pytest.mark.skipif`(os.name != "nt", reason="normcase folds case only on Windows")
def test_lookup_survives_a_differently_cased_path(self, test_db, tmp_path):
photo = tmp_path / "a.jpg"
photo.write_bytes(b"x" * 100)
path, size, mtime = _observe(str(photo))
db_record_self_write(path.upper(), size, mtime)
assert db_take_matching_self_writes([(path, size, mtime)]) == {path}
🧰 Tools
🪛 GitHub Actions: PR Check / 1_Backend Tests.txt

[error] 100-100: Pytest test_lookup_survives_a_differently_spelled_path failed: db_take_matching_self_writes returned an empty set instead of the expected path when the recorded path used different casing. Command 'cd backend && pytest' failed with exit code 1.

🪛 GitHub Actions: PR Check / Backend Tests

[error] 100-100: pytest failed in tests/test_self_write.py::TestLedger::test_lookup_survives_a_differently_spelled_path. Expected db_take_matching_self_writes([(path, size, mtime)]) to return the observed path, but it returned an empty set when the recorded path was uppercase.

🤖 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 `@backend/tests/test_self_write.py` around lines 93 - 100, Update
test_lookup_survives_a_differently_spelled_path to assert the
platform-independent abspath normalization on all systems, and guard the
path.upper() case-folding scenario with a Windows-only condition. Keep the
db_take_matching_self_writes expectation for the normalized path, while avoiding
the uppercase comparison on POSIX.

Source: Pipeline failures

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

review

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.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '=== Changed files ==='
git diff --name-status "$(git merge-base HEAD origin/main)" HEAD 2>/dev/null || git diff --name-status HEAD~1 HEAD

printf '%s\n' '=== Python outlines ==='
fd -a 'self_writes\.py|self_write\.py|watcher\.py|test_self_write\.py|main\.py|conftest\.py' . \
  | while IFS= read -r file; do
      printf '\n--- %s ---\n' "$file"
      ast-grep outline "$file" --items all 2>/dev/null || true
    done

printf '%s\n' '=== Relevant source ==='
fd -a 'self_writes\.py|self_write\.py|watcher\.py|test_self_write\.py|main\.py|conftest\.py' . \
  | while IFS= read -r file; do
      printf '\n--- %s ---\n' "$file"
      cat -n "$file"
    done

Length of output: 68973


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '=== self_write_util_replace definitions and calls ==='
ast-grep run \
  --pattern 'self_write_util_replace($$$ARGS)' \
  --lang python \
  .

printf '%s\n' '=== Imports of self_write ==='
rg -n -C 3 --type py \
  '^\s*(from\s+app\.utils\.self_write\s+import|import\s+app\.utils\.self_write\b)' \
  .

printf '%s\n' '=== Candidate watched-file mutation operations ==='
rg -n -C 4 --type py \
  '(piexif\.insert\s*\(|exiftool|Image\.save\s*\(|\.save\s*\(|open\s*\(.*["'\''](?:wb|ab|\+b)["'\'']|write_bytes\s*\()' \
  backend sync-microservice 2>/dev/null || true

printf '%s\n' '=== Watcher and self-write test coverage ==='
fd -a -e py . backend sync-microservice \
  | xargs -r rg -n -C 3 \
      '(watcher_util_handle_file_changes\s*\(|watcher_util_drop_self_writes\s*\(|watcher_util_is_own_temp_file\s*\(|SELF_WRITE_TEMP_PREFIX)' \
  || true

Length of output: 37623


@rohan-pandeyy, I found one blocking issue.

🔴 backend/app/utils/self_write.py — Line 32

self_write_util_replace() has no production caller. The only imports and calls are in backend/tests/test_self_write.py.

As a result, PictoPy’s existing writes do not create ledger entries and do not use the atomic replacement path. The watcher therefore cannot identify those writes as self-writes. The PR does not yet prevent production file writes from triggering folder synchronization.

Call self_write_util_replace() from each production path that rewrites a file inside a watched folder. Add an integration test that performs that production write path and verifies that watcher_util_handle_file_changes() does not call the folder-sync API.

The prior path-normalization test finding is fixed. The test now verifies absolute-path normalization without assuming case folding on POSIX.

You are interacting with an AI system.

normcase folds case on Windows and is the identity on Linux, where paths
really are case-sensitive, so the test asserted Windows behaviour as if it
were universal. Absolute-vs-relative is the part that holds everywhere.
The table stands alone, but every other _connect() in this package sets the
pragma and there is no reason for this one to differ.
@github-actions

Copy link
Copy Markdown
Contributor

⚠️ No issue was linked in the PR description.
Please make sure to link an issue (e.g., 'Fixes #issue_number')

1 similar comment
@github-actions

Copy link
Copy Markdown
Contributor

⚠️ No issue was linked in the PR description.
Please make sure to link an issue (e.g., 'Fixes #issue_number')

@rohan-pandeyy

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor
⚠️ Action not completed

Already reviewed.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

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