-
Notifications
You must be signed in to change notification settings - Fork 9
fix: Retry after partial file reads. #218
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 51 commits
d1f90f1
53d2f62
f724861
2859064
f9eb455
d1add2a
9924c10
a13e6da
e35db5d
ce9dd18
8151abf
c04a01d
1baf7ec
a458d00
81cf9ac
d330eee
98c80ec
907e9bc
50215b5
75ab3bf
9ca9b0e
9d4913b
0066cd9
99a9cd0
b0b31e1
764e7d3
dc58101
22c836c
72cc40a
f90b545
ee1bf7e
824e929
ba42d22
fc32955
5bc5f9b
956bd47
97bacd2
2de17ef
0a14226
c709ee4
5a3af9e
12ec3a2
32918b3
5f8ed89
9f9a802
c2b6c41
8119872
6025222
7610048
068fd1a
e187c91
64cb514
b9fcb63
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -22,12 +22,24 @@ internal sealed class FileDataSource : IDataSource | |
| private readonly FlagFileDataMerger _dataMerger; | ||
| private readonly FileDataTypes.IFileReader _fileReader; | ||
| private readonly bool _skipMissingPaths; | ||
| private readonly bool _autoUpdate; | ||
| private readonly Logger _logger; | ||
| private volatile bool _started; | ||
| private volatile bool _loadedValidData; | ||
| private volatile bool _disposed; | ||
| private volatile int _lastVersion; | ||
| private object _updateLock = new object(); | ||
|
|
||
| private const int MaxParseAttempts = 5; | ||
| private static readonly TimeSpan RetryDelay = TimeSpan.FromMilliseconds(600); | ||
| // Consecutive parse failures per path within the current failure episode. An externally | ||
| // triggered load (Start or a file-change notification) starts a new episode and clears this, | ||
| // so the retry budget is per-episode, not per-lifetime. Only touched inside _updateLock. | ||
| private readonly Dictionary<string, int> _parseFailureCounts = new Dictionary<string, int>(); | ||
| // Whether a delayed retry is already scheduled; at most one retry chain exists at a time, | ||
| // since each retry re-reads every path anyway. Only touched inside _updateLock. | ||
| private bool _retryPending; | ||
|
|
||
|
kinyoklion marked this conversation as resolved.
|
||
| public FileDataSource(IDataSourceUpdates dataSourceUpdates, FileDataTypes.IFileReader fileReader, | ||
| List<string> paths, bool autoUpdate, Func<string, object> alternateParser, bool skipMissingPaths, | ||
| FileDataTypes.DuplicateKeysHandling duplicateKeysHandling, | ||
|
|
@@ -40,6 +52,7 @@ public FileDataSource(IDataSourceUpdates dataSourceUpdates, FileDataTypes.IFileR | |
| _dataMerger = new FlagFileDataMerger(duplicateKeysHandling); | ||
| _fileReader = fileReader; | ||
| _skipMissingPaths = skipMissingPaths; | ||
| _autoUpdate = autoUpdate; | ||
| _lastVersion = 0; | ||
| if (autoUpdate) | ||
| { | ||
|
|
@@ -62,7 +75,7 @@ public FileDataSource(IDataSourceUpdates dataSourceUpdates, FileDataTypes.IFileR | |
| public Task<bool> Start() | ||
| { | ||
| _started = true; | ||
| LoadAll(); | ||
| LoadAll(isRetry: false); | ||
|
|
||
| // We always complete the start task regardless of whether we successfully loaded data or not; | ||
| // if the data files were bad, they're unlikely to become good within the short interval that | ||
|
|
@@ -83,14 +96,26 @@ private void Dispose(bool disposing) | |
| { | ||
| if (disposing) | ||
| { | ||
| _disposed = true; | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| _reloader?.Dispose(); | ||
| } | ||
| } | ||
|
|
||
| private void LoadAll() | ||
| private void LoadAll(bool isRetry) | ||
| { | ||
| lock (_updateLock) | ||
| { | ||
| if (_disposed) | ||
| { | ||
| return; | ||
| } | ||
| if (!isRetry) | ||
| { | ||
| // An externally triggered load starts a new failure episode: parse failures | ||
| // observed from here on get a fresh retry budget, and any state left over | ||
| // from a previous episode is discarded. | ||
| _parseFailureCounts.Clear(); | ||
| } | ||
| var version = Interlocked.Increment(ref _lastVersion); | ||
| var flags = new Dictionary<string, ItemDescriptor>(); | ||
| var segments = new Dictionary<string, ItemDescriptor>(); | ||
|
|
@@ -100,7 +125,21 @@ private void LoadAll() | |
| { | ||
| var content = _fileReader.ReadAllText(path); | ||
| _logger.Debug("file data: {0}", content); | ||
| var data = _parser.Parse(content, version); | ||
| FullDataSet<ItemDescriptor> data; | ||
| try | ||
| { | ||
| data = _parser.Parse(content, version); | ||
| } | ||
| catch (Exception e) | ||
| { | ||
| // A file-change notification can fire while the file is mid-write, so a parse | ||
| // failure may just mean we read an empty or partially written file. This applies | ||
| // to any configured parser (JSON or alternate), so we treat every failure of | ||
| // Parse — as opposed to reading the file — as potentially transient. | ||
| HandleParseFailure(path, e); | ||
| return; | ||
| } | ||
| _parseFailureCounts.Remove(path); | ||
|
cursor[bot] marked this conversation as resolved.
Outdated
|
||
| _dataMerger.AddToData(data, flags, segments); | ||
| } | ||
| catch (FileNotFoundException) when (_skipMissingPaths) | ||
|
|
@@ -124,12 +163,94 @@ private void LoadAll() | |
| } | ||
| } | ||
|
|
||
| // Called under _updateLock when parsing a path's content fails. Since an externally | ||
| // triggered load clears _parseFailureCounts before reading, any existing count for the | ||
| // path belongs to the current episode. | ||
| private void HandleParseFailure(string path, Exception e) | ||
| { | ||
| if (!_autoUpdate) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I know it is probably more complex to handle, but should it be willing to retry even when auto update is off? I guess this is the distinction between "load once (w failure)" vs "load once (success)". Which is "loaded once" ? In the customer's shoes, I'd rather it retry if there was a race condition with whatever was updating the file (maybe an external sync process) even if I had auto update off.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would rather not change that contract right now. Not that I know if anyone depends on it, but if someone had a problem, then I think we could add the option. Though there isn't really much of a reason to not just use autoUpdate in that case. |
||
| { | ||
| // With auto-update off, files are documented to be loaded only once, so we don't | ||
| // retry in the background — Start()'s result stays final. | ||
| LogHelpers.LogException(_logger, "Failed to parse " + path, e); | ||
| return; | ||
| } | ||
|
|
||
| var attempts = _parseFailureCounts.TryGetValue(path, out var previousAttempts) | ||
| ? previousAttempts + 1 | ||
| : 1; | ||
|
kinyoklion marked this conversation as resolved.
Outdated
|
||
|
|
||
| if (attempts < MaxParseAttempts) | ||
| { | ||
| _parseFailureCounts[path] = attempts; | ||
|
kinyoklion marked this conversation as resolved.
Outdated
|
||
| _logger.Warn("{0}: Failed to parse file ({1}); will retry in {2} ms in case it was incompletely written", | ||
| path, LogValues.ExceptionSummary(e), RetryDelay.TotalMilliseconds); | ||
| _logger.Debug("{0}", LogValues.ExceptionTrace(e)); | ||
| ScheduleRetry(); | ||
| } | ||
| else | ||
| { | ||
| // This path kept failing, so the retry chain ends until the next external trigger. | ||
| // Every attempt stopped at this path, so any other paths with recorded failures | ||
| // were never re-attempted; their episodes end here too. | ||
| _parseFailureCounts.Remove(path); | ||
| foreach (var abandoned in _parseFailureCounts.Keys) | ||
| { | ||
| _logger.Error("{0}: Will not be retried because {1} repeatedly failed to parse; both will be re-read on the next detected file change", | ||
| abandoned, path); | ||
| } | ||
| _parseFailureCounts.Clear(); | ||
| LogHelpers.LogException(_logger, | ||
| string.Format("{0}: Failed to parse file after {1} attempts", path, MaxParseAttempts), e); | ||
| } | ||
| } | ||
|
|
||
| // Called under _updateLock. | ||
| private void ScheduleRetry() | ||
| { | ||
| if (_retryPending) | ||
| { | ||
| return; // the already-scheduled retry will re-read every path | ||
| } | ||
| _retryPending = true; | ||
| Task.Run(async () => | ||
| { | ||
| await Task.Delay(RetryDelay).ConfigureAwait(false); | ||
| try | ||
| { | ||
| RetryLoadAll(); | ||
| } | ||
| catch (Exception e) | ||
| { | ||
| // Nothing observes this task, so any escaping exception would otherwise vanish. | ||
| LogHelpers.LogException(_logger, "Unexpected error while retrying file data load", e); | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| private void RetryLoadAll() | ||
|
kinyoklion marked this conversation as resolved.
Outdated
|
||
| { | ||
| lock (_updateLock) | ||
| { | ||
| _retryPending = false; | ||
| if (_disposed || _parseFailureCounts.Count == 0) | ||
| { | ||
| // Disposed, or the failure state was cleared in the meantime (an externally | ||
| // triggered load succeeded, or the chain gave up) — a reload would be redundant | ||
| // and would re-Init identical data at bumped versions, firing spurious change | ||
| // events. | ||
| return; | ||
| } | ||
| LoadAll(isRetry: true); | ||
| } | ||
| } | ||
|
|
||
| private void TriggerReload() | ||
| { | ||
| if (_started) | ||
| { | ||
| _logger.Info("detected file modification, reloading"); | ||
| LoadAll(); | ||
| LoadAll(isRetry: false); | ||
| } | ||
| } | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does this take the probability of the parsing incomplete data failure to 0 or just reduce it a bunch and nearly to 0?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There isn't really any way to make it 0. But for practical purposes it will effectively make it 0. It is an arbitrary number.
If you are writing a file, then probably the first retry would always fix it. But if you just continually stream JSON into a file, then there isn't any point at which it would be safely parse-able.