Repository navigation
[#160] Save each XML change atomically before the call returns - #198
maximthomas merged 9 commits into
Conversation
…ns and undo a change that is not saved
…orTests signature
…copy's removal after a failed rename
…tWriterTests signature
…relative link chains, unwritable directories and the save log
…tor.sync, which an interrupted thread does not fail
vharseko
left a comment
There was a problem hiding this comment.
Reviewed the head 20d1450d against the description. The copy-and-rename write, the undo of a failed save and the stamp handling all hold up. I found one small thing.
XMLHandlerImpl.saveChange() (line 582): guard addSuppressed against self-suppression. Once a JVM has thrown four heap OutOfMemoryErrors, HotSpot stops handing out preallocated errors. From then on it throws one shared instance with no stack trace. On JDK 21, OOMs 5 to 8 were the same object. Suppose the save runs out of heap and the reload does too, which is likely because the reload parses the whole store. Then save() and revert() throw that same instance, and e.addSuppressed(reloadFailure) throws IllegalArgumentException: Self-suppression not permitted from inside the catch block. The caller gets an IllegalArgumentException, with the OOM only as its cause, instead of the Error, and throw e is never reached. C2's preallocated fast-throw exceptions can collide the same way (-XX:+OmitStackTraceInFastThrow is on by default). errorWhileTheFileIsReadAgainIsAddedToTheSaveError uses two distinct errors, so it does not catch this.
} catch (RuntimeException | Error reloadFailure) {
if (reloadFailure != e) {
e.addSuppressed(reloadFailure);
}
}…eload throws the same instance again Once its preallocated OutOfMemoryErrors are used up, HotSpot throws one shared instance. If both the save and the reload run out of heap, e.addSuppressed(e) threw IllegalArgumentException: Self-suppression not permitted, which replaced the error. The reload's failure is now added only when it is another instance.
|
Fixed in I checked the premise first. With The new test
|
vharseko
left a comment
There was a problem hiding this comment.
Reviewed 626793cb against my previous round. The fix holds.
saveChange() now adds the reload's failure as suppressed only when it is a different instance (XMLHandlerImpl.java:582), so a shared OutOfMemoryError thrown by both the save and the reload reaches the caller as it is, instead of IllegalArgumentException: Self-suppression not permitted.
I ran the new saveErrorThrownAgainByTheReloadIsThrownAsItIs locally on JDK 21. It is green at 626793cb. With the guard removed it fails with Expected OutOfMemoryError to be thrown, but IllegalArgumentException was thrown. The two-instance tests errorWhileTheFileIsReadAgainIsAddedToTheSaveError and saveFailureIsThrownWhenTheFileCannotBeReadAgain still pass and pin the other side. The line references in the description match the head, and CI is green.
Approving.
The XML connector no longer writes its store in place.
XmlDocumentWriter.writewrites a copy next to the store, syncs it to disk and renames it over the store, so after a save the store is either as it was or complete.create,updateanddeletenow save before they return: a save that fails makes the call throw, and the change is undone.Before, the store was saved only in
dispose(), which the framework calls after every operation, throughnew FileOutputStream(file). That truncated the store before Saxon wrote it again, so a crash, a kill or a full disk in that window left a truncated store, and every later operation failed withSAXParseExceptionuntil someone repaired the file. A save that failed was only logged by the framework (it logs whatdispose()throws): the call returned success, and the change was lost when the next call loaded the file again.createwhose save fails after it has started (a full disk; in the tests, a value Saxon cannot encode,"x\uD800y")SAXParseExceptionConnectorException(Saxon reports a full disk the same way as a value it cannot encode, see below); the store is unchanged, no copy is left, and the entry is gone from memory toocreate,updateordeleteon a read-only store fileConnectorIOExceptioncaused byAccessDeniedException: <store>: the file is read-only; the store reads as before<store>.<digits>.tmpcopy may be left next to itdispose()keeps the file and drops the changeConnectorException: <path> appeared before the new document was saved; the change is not saved, and the file is loaded insteadHow the store is written (
XmlDocumentWriter.write):Files.createTempFile(<store's directory>, <store's name> + ".", ".tmp"): a new name in the store's directory, readable by its owner only on POSIX file systems while it is written.FileDescriptor.sync()(anfsync). UnlikeFileChannel.force, it does not fail on a thread whose interrupt flag is set, so a save on such a thread succeeds, as it did before. The copy then takes the store's group when the process may set it; otherwise it keeps the group it was created with (the process's group, or the directory's on BSD and macOS and in a setgid directory) and gets no group permissions. It also takes the store's mode, the wayCSVFileConnector.finishRewritedoes (Rewrite the CSV file through a private copy next to it and never replace it with a fragment #131). ThenFiles.move(copy, store, REPLACE_EXISTING, ATOMIC_MOVE), withoutATOMIC_MOVEwhere the file system cannot do it, and a best-effort sync of the directory.ATOMIC_MOVEgivesAtomicMoveNotSupportedException, which a local file system never does within one directory). There the store is replaced by a plain move, which removes it first, so a failure in that window loses the store. Such a file system could save before, and the fallback keeps it saving.Files.isWritablerejects is refused withAccessDeniedExceptionbefore anything is written. Without this check the rename would replace a read-only file, whichFileOutputStreamrefused to open.FileOutputStreamfollowed it: the file is created behind the link, and a link into a missing directory fails withNoSuchFileException. After 40 such links (a cycle, for one) the save fails withFileSystemException: <path>: Too many levels of symbolic links.rw-------) on POSIX file systems; before, it got the process's umask (usuallyrw-r--r--). Elsewhere it takes the directory's inherited permissions.writereturns the CRC-32 of the bytes now in the file.How a change is saved (
XMLHandlerImpl):create,updateanddeletesave at their end, insideConcurrentXMLHandler's write lock. A call that changed nothing (anupdatewith no attributes) does not write the file.IOExceptionfrom the steps around Saxon's serializer throwsConnectorIOException: the store cannot be opened, created, synced or renamed (a read-only store, a directory that cannot be written). A failure inside the serializer throwsConnectorException, as before, and so does a full disk on APFS: Saxon reports it as aSAXExceptioncaused byIOException: No space left on device, the same way it reports a value it cannot encode. Linux file systems were not probed.Errorsuch as anOutOfMemoryError, so another caller that holds the handler at the same time sees the file's content, not the refused change. If that load fails too (a directory at the path), its exception is added to the save's as suppressed; when both are one instance (HotSpot throws one sharedOutOfMemoryErroronce its preallocated ones are used up), that instance is thrown as it is. The followingdispose()has nothing to save, and the next call loads the file again.dispose()still saves what is left, as before, and only logs a failure: a new empty store (createFileIfNotExists), and the change of anupdatethat failed midway (XML connector: update, delete and authenticate break on an entry below another element #182).fileAppearedOverNewDocument()is nowunsavedNewFile && file.isFile().What operators may notice:
AccessDeniedExceptionnaming<store>.<digits>.tmp. Before, a writable store file in such a directory was written in place.EBUSY. Mount the directory that holds the store instead.FILE_SHARE_DELETE(ajava.ioreader in another JVM, some editors, backup or antivirus tools)..tmp), past the usual limit of 255 bytes.ConcurrentXMLHandlerwith more than one invoker), each change now writes the whole file before it returns; before, the file was written once, when the last caller left. See the second table below.What a save costs
XmlPerf(one store of N accounts: 2.9 MB at 10,000, 11.7 MB at 40,000), 5 rounds of one JVM per build after a discarded warm-up round, builds alternating in each round; a cell is the median (min-max) of the 5 JVMs, each value the median of 5 calls.nosyncis this branch with the copy'sFileDescriptor.sync()removed (the directory sync kept). macOS 15.7, Intel i7-4850HQ, internal SSD (APFS), JDK 26; load average 2.6-5.4.facade.create(init, create, dispose; one write of the file per call in every build), ms:facade.createwhile another connector instance holds the store, ms:The copy's sync costs 2-12 ms per save here (the medians of this branch and nosync), 1-9% of a create, and the ranges overlap. This branch is 22 ms (10,000) and 59 ms (40,000 entries) slower than master; nosync is still 15 and 47 ms slower, in the other steps of the replace that it keeps: a new file, the group and mode copy, the rename and the directory sync. The cost of an fsync depends on the disk and the file system; this is one SSD under APFS. Under overlapping callers a create now writes the whole store before it returns, where master wrote nothing until the last caller left.
Not fixed here
__PASSWORD__values in clear text. On POSIX it is readable by its owner only, or has the store's mode if the process died after the copy took it.Data file does not existsuntil the last one leaves.updatethat fails midway (XML connector: update, delete and authenticate break on an entry below another element #182) throws before it saves, butdispose()still saves its partial change, as before.fsyncdoes not flush the drive's cache (F_FULLFSYNCdoes, andFileChannel.forceissues it there), so a save there is not guaranteed to survive a power loss. On LinuxFileDescriptor.sync()andFileChannel.force(true)are bothfsync.Not pinned by any test:
ATOMIC_MOVE;if (dirty)branch ofdropNewDocument(reachable only ifappendChildor the index update increatethrows);Tests
Nine tests of
XMLHandlerReloadTestspinned behaviour that no longer exists, and are removed:retryAfterAFailedSaveDoesNotReportItsOwnPartialWrite,ownPartialWriteOfANewDocumentIsSavedAgain,ownPartialWriteOfAnUnchangedNewDocumentIsNotLoaded,ownPartialWriteBehindAnUnreadableStampIsSavedAgain(with theirStatFailingFilefixture). A failed save no longer writes at the path;failedSaveOfANewDocumentWritesNoFilepins that.dispose()did with a change that waited in memory: kept and retried after a failed save, or dropped for a file that appeared:failedSaveKeepsTheChangeInMemoryAndRetriesIt,changedNewDocumentGivesWayToAFileThatAppears,fileThatAppearsDuringAChangeToANewDocumentIsKept,changeToANewDocumentIsSavedOnceThePathIsFree,changesDroppedForAFileThatAppearsStayDroppedWhenItGoes. A change no longer waits fordispose().outsideEditDuringAChangeIsReportedAndOverwrittennow expects theUPDATE COLLISIONat the change, not atdispose().XMLConnectorTests#failedSaveIsLoggedAndThrownnow expects ajava.nio.fileexception in the log line, notFileNotFoundException.XmlDocumentWriter.java:166the copy, in the store's directoryXmlDocumentWriterTests#failedWriteLeavesTheFileAsItWas(an unencodable value: the store's bytes unchanged, no copy left);#copyIsPrivateAndNextToTheFileWhileItIsWritten;#newFileIsReadableByItsOwnerOnlyjava.io.tmpdir; a copy created with the umask's mode:166a store in a directory that cannot be written is refused#storeInADirectoryThatCannotBeWrittenIsRefusedAndLeftAlone:191-193,:231the store's mode, after the writecopyIsPrivateAndNextToTheFileWhileItIsWritten(storerw-r-----:rw-------while written,rw-r-----after):161a link to the store is resolved#fileReachedThroughALinkIsReplacedBehindTheLinktoRealPath()FileOutputStreamfollows links:153-159a dangling link is followed, at most 40 hops#danglingLinkKeepsTheLinkAndCreatesTheFileBehindIt;#danglingLinkIntoAMissingDirectoryFailsAndKeepsTheLink;#linkCycleIsRefused(10 s timeout);#relativeDanglingLinksAreFollowedFromEachLinksDirectory(a chain of two links with relative content)FileNotFoundException); the other three keep master's behaviour:162-164a read-only store is refused#readOnlyFileIsRefusedAndLeftAlone;XMLConnectorTests#changeToAReadOnlyStoreFailsAndLosesNothing(create, update, delete):202-206the copy is deleted on failurefailedWriteLeavesTheFileAsItWas(the write fails);#failedRenameLeavesNoCopy(a directory at the store's path: the rename fails)replaced = truebefore the rename:177,:189the copy is written and synced without a channel#writeOnAnInterruptedThreadSucceedsAndKeepsTheFlag(the file written, the flag still set)FileChannel.open(copy, WRITE).force(true)after the write; the stream'sgetChannel().force(true)(both throwClosedByInterruptException)FileOutputStreamXMLHandlerImpl.java:286,:397,:418save before the call returnsXMLHandlerReloadTests#createIsInTheFileBeforeTheCallReturns,#updateIsInTheFileBeforeTheCallReturns,#deleteIsInTheFileBeforeTheCallReturns;changeToAReadOnlyStoreFailsAndLosesNothing:569nothing changed, nothing written#updateWithNoAttributesLeavesTheFileAlone(kept test: the mtime):578-585a failed save undoes the changeXMLConnectorTests#failedSaveFailsTheCreateAndLosesNothing;changeToAReadOnlyStoreFailsAndLosesNothing;#failedSaveUndoesTheChangeForEveryUser(another caller holds the handler);#failedFirstSaveOfANewDocumentDropsTheChange;#changeToANewDocumentFailsWhenAFileAppeared;#saveFailureIsThrownWhenTheFileCannotBeReadAgain:578anErrorfrom the save undoes the change too#errorDuringASaveUndoesTheChange(anOutOfMemoryErrorfrom the save: the entry is gone from memory,dispose()writes nothing)catch (RuntimeException e):591-599the undo's stepsdirty = false:failedSaveFailsTheCreateAndLosesNothing,failedFirstSaveOfANewDocumentDropsTheChange,failedSaveUndoesTheChangeForEveryUser,saveFailureIsThrownWhenTheFileCannotBeReadAgain;unsavedNewFile = false:failedFirstSaveOfANewDocumentDropsTheChange;buildDocument():changeToANewDocumentFailsWhenAFileAppeared,failedSaveUndoesTheChangeForEveryUser,saveFailureIsThrownWhenTheFileCannotBeReadAgain:579-585a failed load is added as suppressedsaveFailureIsThrownWhenTheFileCannotBeReadAgain;#errorWhileTheFileIsReadAgainIsAddedToTheSaveError(both throw anError)catch (RuntimeException reloadFailure)(the second test):582the save's own exception is not added to itself#saveErrorThrownAgainByTheReloadIsThrownAsItIs(the save and the load throw oneOutOfMemoryErrorinstance: the caller gets that instance, with nothing suppressed, anddispose()writes nothing);saveFailureIsThrownWhenTheFileCannotBeReadAgain,errorWhileTheFileIsReadAgainIsAddedToTheSaveError(two instances)IllegalArgumentException: Self-suppression not permittedreplaces the error); a guard that is never true (the last two):556IOException->ConnectorIOExceptionchangeToAReadOnlyStoreFailsAndLosesNothing;saveFailureIsThrownWhenTheFileCannotBeReadAgainConnectorException.wrap:555the log line names the causeXMLConnectorTests#failedSaveIsLoggedAndThrownjava.io.FileNotFoundException):573-576a file that appeared over a new document refuses the changechangeToANewDocumentFailsWhenAFileAppeared:530dispose()'s failed save of a new document writes no file#failedSaveOfANewDocumentWritesNoFile:541-543a collision is reported at the change#outsideEditDuringAChangeIsReportedAndOverwrittensaveChange()removed fromcreate"Red at master": the branch's tests run against master's main code (4648e38): 136 tests of the three classes, 23 failures (21 tests;
changeToAReadOnlyStoreFailsAndLosesNothinghas 3 rows). Every mutant was run against the branch's tip with the test classes of its row; each was killed.mvn -pl OpenICF-xml-connector verify: 266 unit tests (248 − 9 + 27) and 1 IT, green on JDK 26 and JDK 11.Fixes #160