Skip to content

[#171] Skip CSV snapshot rows without a uid and delete the snapshots a failed sync copied - #183

Merged
maximthomas merged 5 commits into
OpenIdentityPlatform:masterfrom
maximthomas:issues/171-csv-uidless-snapshot
Oct 13, 2026
Merged

maximthomas merged 5 commits into
OpenIdentityPlatform:masterfrom
maximthomas:issues/171-csv-uidless-snapshot

Conversation

@maximthomas

@maximthomas maximthomas commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #171.

CSVFileConnector.sync() no longer fails on a snapshot row without a uid, and a failed sync no longer leaves behind the snapshots it copied.

  • The delete pass skips a snapshot row whose uid cell is empty or blank and logs a warning with the line and the file. Such a row was never reported: a live row without a uid fails the sync before any delta. A sync from a snapshot that has one now reaches handleResult. The token moves with the next change; until then every poll logs the warning again.
  • sync() copies the live file before the diff when the token is null or names a missing snapshot, and again after the diff when it reports changes. When the sync fails before the handler accepts the token, it now deletes the copies it made, under the write lock. A kept copy could be returned by getLatestSyncToken, and it stopped a retry on the unchanged live file: sync(null) failed with "Unable to copy CSV file for sync operation", and a sync from the old snapshot reported the same changes again without moving the token. A snapshot named by the caller's token is never deleted, and neither is the copy made before the diff once the handler has returned from a delta: every delta carries that copy's token, and a consumer that stores the token of each handled delta resumes from it.

getLatestSyncToken takes only the read lock, so a call that overlaps the failing sync can still return a copy's token before the copy is deleted. A sync from that token then starts over from the live file, as it does for any token without a snapshot, and never reports the changes made since that token. A sync from that token that takes the write lock before the copy is deleted runs from the copy, but if it reports no change it hands back the same token, and the sync after it starts over.

Test strength

arm case mutant killed red at
CSVFileConnector.java:577 delete pass, null uid → skip snapshotRowWithoutUidIsSkipped[",Jane,Doe,pw1"] no guard ccb33e9
same, blank uid snapshotRowWithoutUidIsSkipped["\" \",Jane,Doe,pw1"] uid == null instead of StringUtil.isBlank ccb33e9
same, warning skippedSnapshotRowIsLogged warning removed ccb33e9
CSVFileConnector.java:681 finally deletes the copy failedSyncDeletesTheSnapshotItCopied no cleanup ac832fe
CSVFileConnector.java:497 same when the sync fails before it holds the read lock syncThatCannotListItsSnapshotsDeletesTheSnapshotItCopied (POSIX, non-root) no cleanup in the write-locked block cb7965d
keep: token's snapshot failedSyncKeepsTheSnapshotOfItsToken deletes syncOrigin that mutant only (green at ac832fe)
keep: completed sync successfulSyncKeepsTheSnapshotItCopied no !completed that mutant only (green at ac832fe)
completed after handleResult syncWhoseTokenIsNotDeliveredDeletesTheSnapshotItCopied completed set before the token block ac832fe
CSVFileConnector.java:670 the copy made after the diff is tracked syncWhoseTokenIsNotDeliveredDeletesTheSnapshotItCopiedAfterTheDiff that copy not added to copies ac832fe
CSVFileConnector.java:638 a handled delta keeps the copy failedSyncKeepsTheSnapshotOfADeltaItHandled no copies.clear() 0390dc5
same, only after handle returns syncWhoseFirstDeltaFailsDeletesTheSnapshotItCopied copies.clear() before handle that mutant only (green at 0390dc5)
CSVFileConnector.java:588 same in the delete pass failedSyncKeepsTheSnapshotOfADeleteItHandled (relies on the header cached at init: the live columns are permuted after it) no copies.clear() 0390dc5
deleteSnapshot warning snapshotThatCannotBeDeletedIsLogged (POSIX, non-root) result of delete() ignored ac832fe
deleteSnapshot write lock — unpinnable by construction: another sync in this JVM can enter its read section only between readLock().unlock() and writeLock().lock(), a window a test cannot open or hold without the private static lock map. The lock keeps a copy from being deleted while another sync reads it; it does not stop getLatestSyncToken from returning the copy's token earlier —

…e sync delete pass

A snapshot row without a uid was never reported, because a live row without one fails the sync before any delta. The delete pass now skips it with a warning instead of failing after earlier DELETEs were sent, so a sync from such a snapshot reaches handleResult and the token moves.
sync() copies the live file before the diff when the token is null or names a missing snapshot, and again after the diff when it reports changes. When the sync fails before the handler accepts the token, it now deletes the copies it made, under the write lock. A kept copy could be returned by getLatestSyncToken, and it stopped a retry on the unchanged live file: sync(null) failed to copy over it, and a sync from the old snapshot reported the same changes again without moving the token. A snapshot named by the caller's token is never deleted.

getLatestSyncToken takes only the read lock, so a call that overlaps the failing sync can still return a copy's token before the copy is deleted. A sync from that token then starts over from the live file, as it does for any token without a snapshot.
…ng its token was handled

Every delta of a sync carries the token of the snapshot copied before the diff. A consumer that stores the token of each handled delta resumes from that snapshot after a failure, so deleting it made the next sync start over from the live file and silently skip the changes in between. The copy is now kept as soon as the handler returns from a delta; it is still deleted when the handler fails on the first one.
@maximthomas
maximthomas marked this pull request as draft October 12, 2026 10:43
@maximthomas maximthomas added bug Something isn't working connector:csvfile CSV file connector java Pull requests that update java code tests Test additions or fixes labels Oct 12, 2026
…efore its read section

scrubSyncFiles() runs after the pre-diff copy, still under the write lock,
while the cleanup that deletes a failed sync's copies belongs to the read
section after it. When it threw (File.list() returns null on an I/O error),
the copy stayed, and the next sync(null) on the unchanged file failed with
"Unable to copy CSV file for sync operation".

Also pin the delete-pass copies.clear(): a live file whose columns are
permuted after init yields DELETEs from a fresh copy, because the connector
reads the live file with the header cached at init.
@maximthomas
maximthomas marked this pull request as ready for review October 12, 2026 11:29
@maximthomas
maximthomas requested a review from vharseko October 12, 2026 11:30

@vharseko vharseko left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nit (non-blocking): SyncOpDiffTest.java:337 — syncThatCannotListItsSnapshotsDeletesTheSnapshotItCopied expects exactly NullPointerException, which is what the unchecked dataDir.list() in scrubSyncFiles happens to throw today. The test is about the cleanup, not the exception type: once #174 reworks scrubSyncFiles (e.g. a ConnectorIOException on an unlistable directory), this test goes red although the copy is still deleted. expectThrows(RuntimeException.class, ...) would keep it pinned to the cleanup only.

Not raised here, as they are already tracked: the start-copy collision and the token kept when <csv>.<mtime> exists (#174), snapshot retention (#174), the handler-stop semantics (#175), and the external-writer race (#172).

…le-directory sync test

syncThatCannotListItsSnapshotsDeletesTheSnapshotItCopied expected the
NullPointerException that scrubSyncFiles() happens to throw on an unlistable
directory, so a rework of that method (OpenIdentityPlatform#174) would turn it red although the
copy is still deleted. It now accepts any RuntimeException.

A RuntimeException alone also accepts a sync that fails before the copy is
made, where the empty directory proves nothing. The test sets the
directory's mtime to 0 before the sync and asserts that it moved, which only
creating or deleting an entry does.
@maximthomas

Copy link
Copy Markdown
Contributor Author

Re review 5493443471: agreed, the test is about the cleanup and the NPE is incidental. Changing only the expected type would open another gap, though: RuntimeException also matches the ConnectorException("Unable to copy CSV file for sync operation") that a sync throws before any copy exists, and the empty-directory assertion then holds with nothing to clean up.

535f9c4 widens the expectation to RuntimeException and adds a witness that the copy was made. Before the sync the test sets the directory's mtime to 0; afterwards it asserts that the mtime moved. Only creating or deleting an entry in the directory moves it.

syncThatCannotListItsSnapshotsDeletesTheSnapshotItCopied, run alone on macOS against four mutants of CSVFileConnector (green = the mutant survives):

expectation no cleanup in the write-locked block copy fails first directory listed before the copy ConnectorIOException on an unlistable directory (#174-like)
NullPointerException (8bbbe4c) red red green red
RuntimeException only red green green green
RuntimeException + mtime witness (535f9c4) red red red green

Without mutants the test is green, and so is the whole module (122 tests, 0 skipped). The "red at cb7965d" cell in the description still holds: at cb7965d the test fails on the leftover copy.

@vharseko vharseko left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Re 6140592007: thanks — the mtime witness closes the gap I missed in my own suggestion; RuntimeException alone would indeed have accepted the pre-copy ConnectorException with nothing to clean up.

Checked 535f9c4 locally (macOS, JDK 21):

  • SyncOpDiffTest: 34 tests, 0 failures, 0 skipped (the POSIX test ran).
  • ConnectorException thrown before Files.copy ("copy fails first"): red, on the new assertion The sync failed before it copied the CSV file.
  • ConnectorIOException on an unlistable directory in scrubSyncFiles (#174-like): green.

Both match your table. No further findings.

@maximthomas
maximthomas merged commit 659e8b0 into OpenIdentityPlatform:master Oct 13, 2026
15 of 26 checks passed
@maximthomas
maximthomas deleted the issues/171-csv-uidless-snapshot branch October 13, 2026 08:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working connector:csvfile CSV file connector java Pull requests that update java code tests Test additions or fixes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CSVFile connector: a snapshot row without a uid fails sync() after DELETEs were sent, and a leftover snapshot can leave the sync token stuck

2 participants