1
0
mirror of https://git.savannah.gnu.org/git/coreutils.git synced 2026-10-24 02:08:57 +03:00

tail: improve scalability of follow-by-name directory events

Replace the O(N) scan of watched names on each directory inotify event
with a Hash_table keyed by (parent_wd, basename), addressing the
long-standing FIXME in tail_forever_inotify.

Keep ownership consistent with wd_to_name: parent_by_name is created
only for Follow_name and returned to the caller, so fallback paths can
return without local hash cleanup.

Hash basenames with hash_string() and mix in parent_wd, matching the
existing gnulib helper rather than a hand-rolled string hash.

Matching cost for N directory events that each compare against N names
(illustrative host timings of the scan vs hash lookup alone):

  N=1000:  ~0.018s -> ~0.000s
  N=5000:  ~0.39s  -> ~0.000s
  N=10000: ~1.5s   -> ~0.000s

End-to-end recreate storms remain largely file-system bound on this
host; the hash removes the quadratic name-matching CPU from that path.

* src/tail.c (parent_name_hasher, parent_name_comparator): New.
(tail_forever_inotify): Build and use parent_by_name; return it to the
caller.
(main): Free parent_ht when non-NULL.
* tests/tail/inotify-parent-hash.sh: Require notification mode; cover
multiple names in one directory and the same basename in two
directories.
* tests/local.mk: Reference the new test.
* NEWS: Mention the improvement.
This commit is contained in:
Iván Ezequiel Rodriguez
2026-08-31 13:34:39 -03:00
committed by Pádraig Brady
parent 951dd1891c
commit 6ab90e2010
4 changed files with 190 additions and 18 deletions

3
NEWS
View File

@@ -83,6 +83,9 @@ GNU coreutils NEWS -*- outline -*-
initial reads finish, but subsequent reads might block, which was seen
e.g. with fifos on NetBSD 10.
'tail -F' is more responsive to changes when following many file names,
when using inotify.
'wc -m' counts ASCII up to 14x faster, and UTF-8 up to 7x faster.
** Build-related

View File

@@ -1432,6 +1432,29 @@ wd_comparator (const void *e1, const void *e2)
return spec1->wd == spec2->wd;
}
/* Hash Follow_name watches by parent directory wd + basename so directory
inotify events can be matched in expected O(1) instead of scanning all
files. */
static size_t
parent_name_hasher (const void *entry, size_t tabsize)
{
const struct File_spec *spec = entry;
char const *name = spec->name + spec->basename_start;
/* hash_string already reduces modulo TABSIZE; mix in parent_wd so the
same basename under different directories does not collide by design. */
return (hash_string (name, tabsize) + (size_t) spec->parent_wd) % tabsize;
}
static bool
parent_name_comparator (const void *e1, const void *e2)
{
const struct File_spec *a = e1;
const struct File_spec *b = e2;
return (a->parent_wd == b->parent_wd
&& streq (a->name + a->basename_start,
b->name + b->basename_start));
}
/* Output (new) data for FSPEC->fd.
PREV_FSPEC records the last File_spec for which we output. */
static void
@@ -1480,10 +1503,17 @@ check_fspec (struct File_spec *fspec, struct File_spec **prev_fspec)
/* Attempt to tail N_FILES files forever, or until killed.
Check modifications using the inotify events system.
Exit if finished or on fatal error; return to revert to polling. */
Exit if finished or on fatal error; return to revert to polling.
On return (to revert to polling), *WD_TO_NAMEP and *PARENT_BY_NAMEP
hold hash tables the caller must free. *PARENT_BY_NAMEP is non-NULL
only when follow_mode == Follow_name; otherwise it is set to NULL.
Like WD_TO_NAME, PARENT_BY_NAME is owned by the caller so return
paths need not free it (and must not call hash_free (NULL)). */
static void
tail_forever_inotify (int wd, struct File_spec *f, int n_files,
double sleep_interval, Hash_table **wd_to_namep)
double sleep_interval, Hash_table **wd_to_namep,
Hash_table **parent_by_namep)
{
# if TAIL_TEST_SLEEP
/* Delay between open() and inotify_add_watch()
@@ -1495,6 +1525,10 @@ tail_forever_inotify (int wd, struct File_spec *f, int n_files,
/* Map an inotify watch descriptor to the name of the file it's watching. */
Hash_table *wd_to_name;
/* Map (parent_wd, basename) to File_spec for Follow_name
directory events. */
Hash_table *parent_by_name = NULL;
bool found_watchable_file = false;
bool tailed_but_unwatchable = false;
bool found_unwatchable_dir = false;
@@ -1504,12 +1538,23 @@ tail_forever_inotify (int wd, struct File_spec *f, int n_files,
char *evbuf;
idx_t evbuf_off = 0;
*parent_by_namep = NULL;
wd_to_name = hash_initialize (n_files, NULL, wd_hasher, wd_comparator,
NULL);
if (! wd_to_name)
xalloc_die ();
*wd_to_namep = wd_to_name;
if (follow_mode == Follow_name)
{
parent_by_name = hash_initialize (n_files, NULL, parent_name_hasher,
parent_name_comparator, NULL);
if (! parent_by_name)
xalloc_die ();
*parent_by_namep = parent_by_name;
}
/* The events mask used with inotify on files (not directories). */
uint32_t inotify_wd_mask = IN_MODIFY;
/* TODO: Perhaps monitor these events in Follow_descriptor mode also,
@@ -1559,6 +1604,9 @@ tail_forever_inotify (int wd, struct File_spec *f, int n_files,
of the inotify API will still be diagnosed. */
break;
}
if (hash_insert (parent_by_name, &(f[i])) == NULL)
xalloc_die ();
}
f[i].wd = inotify_add_watch (wd, f[i].name, inotify_wd_mask);
@@ -1737,21 +1785,19 @@ tail_forever_inotify (int wd, struct File_spec *f, int n_files,
if (ev->len) /* event on ev->name in watched directory. */
{
int j;
for (j = 0; j < n_files; j++)
{
/* With N=hundreds of frequently-changing files, this O(N^2)
process might be a problem. FIXME: use a hash table? */
if (f[j].parent_wd == ev->wd
&& streq (ev->name, f[j].name + f[j].basename_start))
break;
}
/* It is not a watched file. */
if (j == n_files)
struct File_spec key;
if (! parent_by_name)
continue;
fspec = &(f[j]);
/* Probe key: basename is the whole string at KEY.NAME. */
key.parent_wd = ev->wd;
key.name = ev->name;
key.basename_start = 0;
fspec = hash_lookup (parent_by_name, &key);
/* It is not a watched file. */
if (! fspec)
continue;
int new_wd = -1;
bool deleting = !! (ev->mask & IN_DELETE);
@@ -1759,7 +1805,7 @@ tail_forever_inotify (int wd, struct File_spec *f, int n_files,
if (! deleting)
{
/* Adding the same inode again will look up any existing wd. */
new_wd = inotify_add_watch (wd, f[j].name, inotify_wd_mask);
new_wd = inotify_add_watch (wd, fspec->name, inotify_wd_mask);
}
if (! deleting && new_wd < 0)
@@ -1772,7 +1818,7 @@ tail_forever_inotify (int wd, struct File_spec *f, int n_files,
else
{
/* Can get ENOENT for a dangling symlink for example. */
error (0, errno, _("cannot watch %s"), quoteaf (f[j].name));
error (0, errno, _("cannot watch %s"), quoteaf (fspec->name));
}
/* We'll continue below after removing the existing watch. */
}
@@ -2521,8 +2567,12 @@ main (int argc, char **argv)
write_error ();
Hash_table *ht;
tail_forever_inotify (wd, F, n_files, sleep_interval, &ht);
Hash_table *parent_ht = NULL;
tail_forever_inotify (wd, F, n_files, sleep_interval,
&ht, &parent_ht);
hash_free (ht);
if (parent_ht)
hash_free (parent_ht);
close (wd);
errno = 0;
}

View File

@@ -196,6 +196,7 @@ all_tests = \
tests/tail/basic-seek.sh \
tests/tail/inotify-hash-abuse.sh \
tests/tail/inotify-hash-abuse2.sh \
tests/tail/inotify-parent-hash.sh \
tests/tail/F-vs-missing.sh \
tests/tail/F-vs-rename.sh \
tests/tail/F-headers.sh \

118
tests/tail/inotify-parent-hash.sh Executable file
View File

@@ -0,0 +1,118 @@
#!/bin/sh
# Exercise Follow_name directory-event lookup keyed by (parent_wd, basename).
# Covers several names in one directory and the same basename in two dirs.
# Copyright (C) 2026 Free Software Foundation, Inc.
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
. "${srcdir=.}/tests/init.sh"; path_prepend_ ./src
print_ver_ tail
require_inotify_supported_
check_tail_output()
{
local delay="$1"
grep -E "$tail_re" out > /dev/null ||
{ sleep $delay; return 1; }
}
cleanup_() { kill $pid 2>/dev/null && wait $pid; }
cleanup_fail()
{
cat out
warn_ "$1"
cleanup_
fail=1
}
# Ensure this run actually uses inotify (not a silent fallback to polling).
assert_notification_mode_()
{
inotify_failed_re='inotify (resources exhausted|cannot be used)'
tail_re="using notification mode|$inotify_failed_re" \
retry_delay_ check_tail_output .1 7 ||
cleanup_fail 'tail did not use notification mode'
if grep -E "$inotify_failed_re" out; then
skip_ "inotify can't be used"
fi
}
# --- Several basenames in the same parent directory. ---
mkdir d || framework_failure_
touch d/a d/b d/c || framework_failure_
rm -f out
timeout 60 tail --debug -qF d/a d/b d/c > out 2>&1 & pid=$!
assert_notification_mode_
echo a1 > d/a || framework_failure_
tail_re='^a1$' retry_delay_ check_tail_output .1 7 ||
cleanup_fail 'missing a1'
echo b1 > d/b || framework_failure_
tail_re='^b1$' retry_delay_ check_tail_output .1 7 ||
cleanup_fail 'missing b1'
echo c1 > d/c || framework_failure_
tail_re='^c1$' retry_delay_ check_tail_output .1 7 ||
cleanup_fail 'missing c1'
# Recreate each name so directory CREATE events must resolve the right
# File_spec via (parent_wd, basename), not a linear scan of the wrong peer.
rm -f d/a d/b d/c || framework_failure_
echo a2 > d/a || framework_failure_
tail_re='^a2$' retry_delay_ check_tail_output .1 7 ||
cleanup_fail 'missing a2'
echo b2 > d/b || framework_failure_
tail_re='^b2$' retry_delay_ check_tail_output .1 7 ||
cleanup_fail 'missing b2'
echo c2 > d/c || framework_failure_
tail_re='^c2$' retry_delay_ check_tail_output .1 7 ||
cleanup_fail 'missing c2'
kill -0 $pid || fail=1
cleanup_
# --- Same basename in two different parent directories. ---
mkdir e1 e2 || framework_failure_
touch e1/log e2/log || framework_failure_
rm -f out
timeout 60 tail --debug -qF e1/log e2/log > out 2>&1 & pid=$!
assert_notification_mode_
echo e1x > e1/log || framework_failure_
tail_re='^e1x$' retry_delay_ check_tail_output .1 7 ||
cleanup_fail 'missing e1x'
echo e2x > e2/log || framework_failure_
tail_re='^e2x$' retry_delay_ check_tail_output .1 7 ||
cleanup_fail 'missing e2x'
rm -f e1/log e2/log || framework_failure_
echo e1y > e1/log || framework_failure_
tail_re='^e1y$' retry_delay_ check_tail_output .1 7 ||
cleanup_fail 'missing e1y'
echo e2y > e2/log || framework_failure_
tail_re='^e2y$' retry_delay_ check_tail_output .1 7 ||
cleanup_fail 'missing e2y'
kill -0 $pid || fail=1
cleanup_
Exit $fail