-
Notifications
You must be signed in to change notification settings - Fork 1.7k
Add option to read multiple active event files per directory #1867
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 all commits
81f1385
c2e00a2
ebbee75
29d0504
c494ff3
1be6468
e792ad8
7aa18e7
df5803f
c8ab641
62350b3
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 |
---|---|---|
@@ -0,0 +1,136 @@ | ||
# Copyright 2019 The TensorFlow Authors. All Rights Reserved. | ||
# | ||
# Licensed under the Apache License, Version 2.0 (the "License"); | ||
# you may not use this file except in compliance with the License. | ||
# You may obtain a copy of the License at | ||
# | ||
# http://www.apache.org/licenses/LICENSE-2.0 | ||
# | ||
# Unless required by applicable law or agreed to in writing, software | ||
# distributed under the License is distributed on an "AS IS" BASIS, | ||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
# See the License for the specific language governing permissions and | ||
# limitations under the License. | ||
# ============================================================================== | ||
|
||
"""Implementation for a multi-file directory loader.""" | ||
|
||
from __future__ import absolute_import | ||
from __future__ import division | ||
from __future__ import print_function | ||
|
||
from tensorboard.backend.event_processing import directory_watcher | ||
from tensorboard.backend.event_processing import io_wrapper | ||
from tensorboard.compat import tf | ||
from tensorboard.util import tb_logging | ||
|
||
|
||
logger = tb_logging.get_logger() | ||
|
||
|
||
# Sentinel object for an inactive path. | ||
_INACTIVE = object() | ||
|
||
|
||
class DirectoryLoader(object): | ||
"""Loader for an entire directory, maintaining multiple active file loaders. | ||
|
||
This class takes a directory, a factory for loaders, and optionally a | ||
path filter and watches all the paths inside that directory for new data. | ||
Each file loader created by the factory must read a path and produce an | ||
iterator of (timestamp, value) pairs. | ||
|
||
Unlike DirectoryWatcher, this class does not assume that only one file | ||
receives new data at a time; there can be arbitrarily many active files. | ||
However, any file whose maximum load timestamp fails an "active" predicate | ||
will be marked as inactive and no longer checked for new data. | ||
""" | ||
|
||
def __init__(self, directory, loader_factory, path_filter=lambda x: True, | ||
active_filter=lambda timestamp: True): | ||
"""Constructs a new MultiFileDirectoryLoader. | ||
|
||
Args: | ||
directory: The directory to load files from. | ||
loader_factory: A factory for creating loaders. The factory should take a | ||
path and return an object that has a Load method returning an iterator | ||
yielding (unix timestamp as float, value) pairs for any new data | ||
path_filter: If specified, only paths matching this filter are loaded. | ||
active_filter: If specified, any loader whose maximum load timestamp does | ||
not pass this filter will be marked as inactive and no longer read. | ||
|
||
Raises: | ||
ValueError: If directory or loader_factory are None. | ||
""" | ||
if directory is None: | ||
raise ValueError('A directory is required') | ||
if loader_factory is None: | ||
raise ValueError('A loader factory is required') | ||
self._directory = directory | ||
self._loader_factory = loader_factory | ||
self._path_filter = path_filter | ||
self._active_filter = active_filter | ||
self._loaders = {} | ||
self._max_timestamps = {} | ||
|
||
def Load(self): | ||
"""Loads new values from all active files. | ||
|
||
Yields: | ||
All values that have not been yielded yet. | ||
|
||
Raises: | ||
DirectoryDeletedError: If the directory has been permanently deleted | ||
(as opposed to being temporarily unavailable). | ||
""" | ||
try: | ||
all_paths = io_wrapper.ListDirectoryAbsolute(self._directory) | ||
paths = sorted(p for p in all_paths if self._path_filter(p)) | ||
for path in paths: | ||
for value in self._LoadPath(path): | ||
yield value | ||
except tf.errors.OpError as e: | ||
if not tf.io.gfile.exists(self._directory): | ||
raise directory_watcher.DirectoryDeletedError( | ||
'Directory %s has been permanently deleted' % self._directory) | ||
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. Do we want If this is intended to be 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. The intent was to avoid a behavior change here relative to the existing logic in DirectoryWatcher. I'm a little reluctant to change to 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. SGTM. |
||
else: | ||
logger.info('Ignoring error during file loading: %s' % e) | ||
|
||
def _LoadPath(self, path): | ||
"""Generator for values from a single path's loader. | ||
|
||
Args: | ||
path: the path to load from | ||
|
||
Yields: | ||
All values from this path's loader that have not been yielded yet. | ||
""" | ||
max_timestamp = self._max_timestamps.get(path, None) | ||
if max_timestamp is _INACTIVE or self._MarkIfInactive(path, max_timestamp): | ||
logger.debug('Skipping inactive path %s', path) | ||
return | ||
loader = self._loaders.get(path, None) | ||
if loader is None: | ||
try: | ||
loader = self._loader_factory(path) | ||
except tf.errors.NotFoundError: | ||
# Happens if a file was removed after we listed the directory. | ||
logger.debug('Skipping nonexistent path %s', path) | ||
return | ||
self._loaders[path] = loader | ||
logger.info('Loading data from path %s', path) | ||
for timestamp, value in loader.Load(): | ||
if max_timestamp is None or timestamp > max_timestamp: | ||
max_timestamp = timestamp | ||
yield value | ||
if not self._MarkIfInactive(path, max_timestamp): | ||
self._max_timestamps[path] = max_timestamp | ||
|
||
def _MarkIfInactive(self, path, max_timestamp): | ||
"""If max_timestamp is inactive, returns True and marks the path as such.""" | ||
logger.debug('Checking active status of %s at %s', path, max_timestamp) | ||
if max_timestamp is not None and not self._active_filter(max_timestamp): | ||
self._max_timestamps[path] = _INACTIVE | ||
del self._loaders[path] | ||
return True | ||
return 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.
Bit of a race condition here: if listdir returns an event file that is
subsequently deleted before we invoke
_LoadPath
on it, the exceptionwill break us out of the whole
Load
loop.The race window may be arbitrarily large, because it spans a coroutine
boundary.
Is this the desired behavior? (There might be a case for that.) I could
also see silently skipping the file, or maybe opening all loaders
eagerly—
for loader in [self._LoadPath(path) for path in paths]:
—thoughmaybe we don’t like the performance characteristics of that.
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.
Done. Note that this race across the coroutine boundary existed in DirectoryWatcher too; I think the exception was being swallowed by the except clause below. I went with skipping the file, since opening all loaders eagerly is worse for performance (right now, we close inactive ones when we're done, so when reading an old logdir we only ever have one loader open at a time, which is a nice property to retain) and there's still the non-coroutine race in that case anyway.
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.
SGTM; skipping made the most sense to me, too. Thanks!