Skip to content

Make thread-safe - #1346

Merged
AdrianAtZyte merged 3 commits into
scrapinghub:masterfrom
AdrianAtZyte:thread-safety
Aug 24, 2026
Merged

AdrianAtZyte merged 3 commits into
scrapinghub:masterfrom
AdrianAtZyte:thread-safety

Conversation

@AdrianAtZyte

@AdrianAtZyte AdrianAtZyte commented Jun 29, 2026 •

Copy link
Copy Markdown
Member

Fixes #441, fixes #1291.

@codecov

codecov Bot commented Jun 29, 2026 •

Copy link
Copy Markdown

Codecov Report

βœ… All modified and coverable lines are covered by tests.
βœ… Project coverage is 97.18%. Comparing base (de7b986) to head (dd25b8d).

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1346      +/-   ##
==========================================
+ Coverage   97.10%   97.18%   +0.07%     
==========================================
  Files         236      236              
  Lines        3008     3053      +45     
==========================================
+ Hits         2921     2967      +46     
+ Misses         87       86       -1     

β˜” View full report in Codecov by Harness.
πŸ“’ Have feedback on the report? Share it here.

πŸš€ New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@AdrianAtZyte
AdrianAtZyte marked this pull request as ready for review June 29, 2026 11:15
@serhii73
serhii73 self-requested a review August 24, 2026 08:48
@serhii73

Copy link
Copy Markdown
Collaborator

Thanks for this. I verified the branch empirically rather than just reading it, and the locking holds up. My verdict: this is mergeable β€” approve with comments. Nothing I found is a regression introduced by this PR, but two of the "Fixes #X" claims don't hold up, one of the four new tests doesn't test anything, and there's a second non-race bug in the same function that keeps #1291 partly open.

Setup: master = de7b986, PR head = 099cc78, merge base = 273eb23, Python 3.11, each tree installed editable in its own venv, sys.setswitchinterval(1e-7) throughout.

What I confirmed works

The races are real and this fixes them. To isolate the race from the logic bug discussed below, I made every settings object use the same CACHE_SIZE_LIMIT (so eviction is well-behaved) and hammered the dictionary caches β€” 24 registry keys, 24 threads, 48 000 cache calls:

KeyErrors, 3 consecutive runs master this PR
run 1 / 2 / 3 3 / 3 / 4 0 / 0 / 0

Both master failure modes disappear: the check-then-read TOCTOU in the _get_*_from_cache methods, and _add_to_cache's cache.pop(list(cache.keys())[0]) racing against itself.

No deadlock. Since the lock is reentrant and taken on nested calls, I checked for it explicitly with a watchdog that samples a progress counter and dumps every thread's stack via faulthandler if progress stalls for >30 s. 48 threads Γ— 20 000 calls completed in 380 s (53 calls/s) and the watchdog never fired. An earlier run of mine that I'd killed at 120 s was simply slow scaling, not a hang. Lock ordering is one-directional (Locale._lock β†’ Dictionary._cache_lock, and Dictionary never calls back into Locale), and only Settings uses @registry, so I don't see an inversion or self-deadlock path.

Realistic mixed usage is clean. Heterogeneous stress across four entry points (parse, parse with explicit languages, a DateDataParser deliberately shared by all threads, and search_dates), varying languages, DATE_ORDER, PREFER_DATES_FROM, RELATIVE_BASE, NORMALIZE, RETURN_AS_TIMEZONE_AWARE and PREFER_LOCALE_DATE_ORDER across concurrently running calls, with valid, localized, relative and malformed input: 24 threads Γ— 6000 calls β†’ 0 errors (10 m 56 s).

No regressions, and it merges cleanly. Full suite: master 24158 passed / 0 failed, this branch 24055 passed / 0 failed (all skips are environment noise β€” missing ruamel/hijridate/convertdate/pytest-codspeed, missing fr_FR.UTF-8). Despite the 10 commits master has gained since the merge base, master merges into this branch with no conflicts, and the merged tree runs 24162 passed / 0 failed β€” master's 24158 plus your four new tests.

Three of the four new tests are genuine regression tests. Running tests/test_thread_safety.py against unpatched master: 3 failed, 1 passed.

  • test_dictionary_cache_eviction_concurrently β€” KeyError in dictionary.py (also 3/3 in separate repeat runs)
  • test_settings_date_order_not_mutated_while_parsing β€” AssertionError: Items in the first set but not the second: 'DMY'
  • test_search_does_not_mutate_shared_relative_base

I also checked the hunks that aren't pure locking: the inverted early return in get_wordchars_for_detection is equivalent; _bind_settings' shallow copy correctly stops the shared cached Dictionary being mutated per call; dropping settings.NORMALIZE = True in _get_split_dictionary removes a real side effect on the caller's settings object; and removing parser._settings = Settings() in search.py is safe because the parser is constructed per call at search.py:182 (three successive search_dates calls give identical results on both branches).

1. A second, non-race bug in the same function keeps #1291 partly open

_add_to_cache can evict the entry it just wrote, raising the same KeyError as #1291 β€” with a single thread. The lock can't fix it, so it survives this PR unchanged.

# dictionary.py L190-197
cache.setdefault(self._settings.registry_key, {})[self.info["name"]] = value
if self._settings.CACHE_SIZE_LIMIT and len(cache) > self._settings.CACHE_SIZE_LIMIT:
    cache.pop(list(cache.keys())[0])      # oldest key β€” which may BE registry_key

The five caches are class-level and shared by every Dictionary, but the eviction limit comes from the calling settings. When two callers disagree about CACHE_SIZE_LIMIT, len(cache) can already exceed one caller's limit while that caller's own key is the oldest β€” so list(cache.keys())[0] is its own key. It gets deleted, and the read on the next line raises.

Three lines, no threads, public API only:

import dateparser
dateparser.parse("1 day ago",     languages=["en"], settings={"CACHE_SIZE_LIMIT": 1})
dateparser.parse("1 day ago",     languages=["en"], settings={"CACHE_SIZE_LIMIT": 1000})
dateparser.parse("il y a 1 jour", languages=["fr"], settings={"CACHE_SIZE_LIMIT": 1})  # raises
  File "dateparser/languages/dictionary.py", line 177, in split
    split_relative_regex = self._get_split_relative_regex_cache()
  File "dateparser/languages/dictionary.py", line 325, in _get_split_relative_regex_cache
    self._construct_split_relative_regex()
  File "dateparser/languages/dictionary.py", line 332, in _construct_split_relative_regex
    self._get_sorted_relative_strings_from_cache()
  File "dateparser/languages/dictionary.py", line 314, in _get_sorted_relative_strings_from_cache
    return self._sorted_relative_strings_cache[self._settings.registry_key][
KeyError: '57e9646db7926bdbf278f7631d51773c'

Same terminal frame as the traceback in #1291. It raises identically on master (dictionary.py:347) and on this branch (dictionary.py:314) β€” the path is untouched.

Scaled up (dateparser.parse, mixed CACHE_SIZE_LIMIT, no languages= filter, 2000 calls):

master this PR this PR + the patch below
1 thread 66 (3.30%) 66 (3.30%) 0
16 threads 1176 (58.80%) 846 (42.30%) 0

The single-threaded numbers are identical because this path is untouched; the 59% β†’ 42% drop under threads is the race component being removed, and the residual is this logic bug leaking through. All five caches are affected β€” at 48 threads Γ— 20 000 calls I get KeyError from _get_sorted_relative_strings_from_cache (2975), _get_split_relative_regex_cache (2094), _get_match_relative_regex_cache (1777), _get_sorted_words_from_cache (1460) and _get_split_regex_cache (886).

To pin down when it can fire, I model-checked the eviction rule over random operation sequences: uniform CACHE_SIZE_LIMIT β†’ 0 failures / 1500 trials; mixed CACHE_SIZE_LIMIT β†’ 1500 / 1500. So it needs two callers in the same process to disagree about CACHE_SIZE_LIMIT. Since the default is 1000 and most people never touch it, the common case is safe β€” which is also why I read #1291 itself ("once in a while… run the same process again and it works", and the reporter calls dateparser.parse(legend_date[0]) with no settings at all) as the race, i.e. the thing this PR does fix. But anyone who sets CACHE_SIZE_LIMIT on some call sites and not others hits this deterministically, with the identical traceback, so I'd rather it didn't stay open behind a "Fixes #1291".

Suggested fix β€” re-insert the just-written key so it becomes the newest; then len(cache) > limit >= 1 implies len(cache) >= 2, so the oldest key provably isn't the one just written, and no skip condition or scan is needed:

def _add_to_cache(self, value, cache):
    with self._cache_lock:
        key = self._settings.registry_key
        # Re-insert so the entry just written is the newest. dicts keep
        # insertion order, so the oldest key can then never be this one --
        # which matters because callers may disagree about CACHE_SIZE_LIMIT,
        # so len(cache) can already exceed *this* caller's limit while its
        # own key is the oldest, and the caller reads that entry back
        # immediately.
        entry = cache.pop(key, {})
        entry[self.info["name"]] = value
        cache[key] = entry
        limit = self._settings.CACHE_SIZE_LIMIT
        if limit and len(cache) > limit:
            cache.pop(next(iter(cache)))

Applied on top of your branch this takes every reproduction above to 0 errors (including 16 threads Γ— 2000, previously 846), keeps the existing suite green (24055 passed / 0 failed, unchanged), and your four thread-safety tests still pass.

One note if you'd rather drain to the limit in a loop instead: don't. I measured that variant β€” with a burst of 1000 adds from a big-limit caller followed by one add from a CACHE_SIZE_LIMIT: 1 caller, it performs 1000 pops inside a single _add_to_cache, while holding _cache_lock, wiping the other caller's entire cache and leaving the two callers thrashing. The version above, and a "pop the oldest key that isn't mine" variant, both bound it to one pop per add.

2. test_relative_dates_concurrently passes on unpatched master, so it isn't testing this PR

It passes 1/1 in my combined run and 5/5 standalone on master. Its docstring attributes the crash to self.now being shared in the freshness parser, but that was removed in 12cfa6f ("remove self.now from freshness_date_parser (#885)", Feb 2021) β€” self.now no longer exists in freshness_date_parser.py β€” and this PR doesn't touch that file. As written the test will pass no matter what we break; worth re-pointing or dropping.

Relatedly, I'd soften the "Fixes #276 / #441" wording. I ran both reporters' original code against unpatched master:

The TypeError: unsupported operand type(s) for -: 'NoneType' and 'relativedelta' in both reports was already fixed by #885, and the IndexError: pop from empty list from the #441 thread came from dateparser/languages/detection.py, which no longer exists. What this PR does fix from #441 is the shared DATE_ORDER mutation and the RELATIVE_BASE leak out of search_dates β€” both real, both proven by your own tests failing on master. That's a stronger and more accurate claim than the original tracebacks.

3. Latent: two check-then-read races on the search_dates singleton (not this PR's job)

Flagging these for a follow-up issue rather than as merge blockers, because I could not make either fire under natural scheduling. Both live on the process-wide _search_with_detection created at search/__init__.py:3, and this PR touches neither.

# search.py:268-272, DateSearchWithDetection.detect_language
self.language_detector = FullTextLanguageDetector(languages=languages)   # STORE
detected_language = self.language_detector._best_language(text) or ...   # LOAD

# search.py:25-31, _ExactLanguageSearch
def get_current_language(self, shortname):
    if self.language is None or self.language.shortname != shortname:
        self.language = self.loader.get_locale(shortname)                # STORE
def search(self, shortname, text, settings):
    self.get_current_language(shortname)
    result = self.language.translate_search(text, settings=settings)     # LOAD

Worth noting detect_language is called unconditionally from search_dates (search.py:315) β€” also when languages is given β€” so the first store happens on every call, not only on the autodetect path.

Forcing the interleaving the code permits (a barrier parks each thread after its store, so both store before either loads), two concurrent search_dates calls with different languages:

  • detector race β†’ the ru call returns None instead of its date
  • locale race β†’ the ru text is parsed with the en locale: expected [('19 ΠΌΠ°Ρ€Ρ‚Π° 2001 Π³ΠΎΠ΄Π° Π²', datetime(2001, 3, 19))], got [('2001', datetime(2001, 8, 24))] β€” a plausible-looking but wrong date

Under natural scheduling I could not hit either: 12 threads × 600 and 24 threads × 5000 mixed-languages calls both gave 0 wrong results and 0 exceptions. Both store→load gaps are only a couple of bytecodes wide, which explains it. So: real and deterministically demonstrable, but not something I can show affecting realistic workloads — a follow-up, not a blocker.

4. CI cost

The new file roughly quadruples the suite's wall-clock: on the same machine tests/ runs in 24.3 s on master and 93.5 s on this branch, and tests/test_thread_safety.py alone takes 73.7 s. Its four tests are also the four slowest in the entire 24 000-test suite β€” 43.3 s, 20.3 s, 4.1 s and 3.9 s, where the 5th-slowest test overall is 1.0 s. Could the iteration counts come down, or the file be marked (e.g. a slow marker) so it can be run separately from the default suite?


To summarize: the synchronization work here is correct and well-tested, and I'd merge it. I'd like the _add_to_cache self-eviction fixed either here or in an immediate follow-up before #1291 is closed, test_relative_dates_concurrently re-pointed or dropped, and the #276/#441 attribution reworded. Happy to push the _add_to_cache patch to this branch if you'd prefer.

@codspeed

codspeed Bot commented Aug 24, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

βœ… 7 untouched benchmarks


Comparing AdrianAtZyte:thread-safety (dd25b8d) with master (de7b986)

Open in CodSpeed

@AdrianAtZyte
AdrianAtZyte merged commit cd9bb13 into scrapinghub:master Aug 24, 2026
16 checks passed
@serhii73

Copy link
Copy Markdown
Collaborator

Post-merge follow-up on my review above. dd25b8d addressed all four points; I re-ran the relevant checks on the merged tree (cd9bb13, CPython 3.11.15) rather than assuming, so the review doesn't stand as the last word. Summary: it all holds, with one small gap worth closing and one item still open.

1. _add_to_cache self-eviction β€” fixed, and #1291 is now fully closed by this PR. Both trees, same machine, back to back, nothing else running:

pre-merge de7b986 merged cd9bb13
three-line repro (mismatched CACHE_SIZE_LIMIT, single thread) KeyError at dictionary.py:347 no error
dateparser.parse, mixed limits, no languages=, 1 thread Γ— 2000 66 (3.30%) 0
dateparser.parse, mixed limits, no languages=, 16 threads Γ— 2000 1165 (58.25%) 0
uniform-limit harness, 24 threads Γ— 48 000 cache calls 12 (0.03%) 0
mixed-limit harness, 24 threads Γ— 48 000 cache calls β€” 0

Both causes are gone: the check-then-read race and the self-eviction. One detail I hadn't isolated before β€” on pre-merge master, 10 of those 12 KeyErrors were raised by cache.pop(list(cache.keys())[0]) itself, i.e. two threads racing to evict the same key, rather than by the read. The lock closes that too.

2. Cutting the iteration counts did not cost coverage. This was the one change I wanted to verify, because 40β†’24 dictionaries / 300β†’50 inner iterations / 3000β†’100 parses / 150β†’40 searches is a 10–30Γ— reduction, and a regression test that can no longer fail is worse than a slow one. They still fail. Merged tests/test_thread_safety.py run against pre-merge de7b986, five runs each:

test on de7b986 time
test_dictionary_cache_eviction_concurrently 5/5 failed (KeyError) 5.6–7.5 s
test_settings_date_order_not_mutated_while_parsing 5/5 failed (AssertionError: … 'DMY') ~3.1 s
test_search_does_not_mutate_shared_relative_base 5/5 failed ~8.9 s

And the CI cost is essentially gone. Full pytest tests/: 24.16 s on de7b986 (24158 passed) β†’ 32.88 s on cd9bb13 (24161 passed, 29 skipped, 1 xfailed), with tests/test_thread_safety.py alone at 8.47 s. At review time it was 24.3 β†’ 93.5 s with the file at 73.7 s, and its tests were the four slowest in the suite; now only two of them are in the top ten (6.14 s and 1.47 s, with the third at 0.35 s). No slow marker needed as far as I'm concerned.

3. One gap: nothing locks in the _add_to_cache fix. test_dictionary_cache_eviction_concurrently uses a single uniform CACHE_SIZE_LIMIT=1, and with a uniform limit the self-eviction cannot fire β€” so it covers the race, not the reinsert. I checked by reverting only that hunk on merged master and keeping the lock: the three-line repro raises KeyError again, but the whole suite stays green (24161 passed, and all three thread-safety tests pass). So a future tidy-up of _add_to_cache back to setdefault(...) + pop(list(cache.keys())[0]) would reintroduce #1291's traceback silently.

The natural regression test is single-threaded, needs no locks and costs milliseconds:

def test_add_to_cache_does_not_evict_the_entry_it_just_wrote(self):
    # Issue #1291: the caches are shared but CACHE_SIZE_LIMIT comes from the
    # calling settings, so len(cache) can already exceed *this* caller's limit
    # while its own key is the oldest. Evicting the oldest key would then drop
    # the entry the caller reads back on the next line.
    dateparser.parse("1 day ago", languages=["en"], settings={"CACHE_SIZE_LIMIT": 1})
    dateparser.parse("1 day ago", languages=["en"], settings={"CACHE_SIZE_LIMIT": 1000})
    self.assertIsNotNone(
        dateparser.parse("il y a 1 jour", languages=["fr"], settings={"CACHE_SIZE_LIMIT": 1})
    )

That raises KeyError without the fix and passes with it. Happy to open a PR for it if you'd like.

4. The search_dates singleton races are the one item still open β€” filed as #1369. Both sites are verbatim on merged master (search.py:272-281 and search.py:34-46, neither in this diff). Two things I know now that I didn't when I wrote the review:

  • FullTextLanguageDetector is single-use: _best_language narrows self.languages in place, which is exactly why detect_language builds a fresh one per call. So a call that reads another thread's detector isn't using a different-but-equivalent object, it's using one already locked to somebody else's language. That is the mechanism behind the wrong results, and it's why the fix is "use a local", not "cache it better".
  • Instrumenting the store/load pair so cross-thread reads are visible (the wrapper adds a handful of bytecodes, so the window is somewhat wider than production's), 16 threads alternating languages=["ru"]/["en"] gave 19 cross-thread reads and 14 wrong results in 7921 calls β€” and not only None: an English call came back as [('1957', datetime(1957, 8, 25))] instead of 1957-10-04. With completely unmodified code I still get 0 wrong results in 20 912 and 21 220 calls, so I can't put a frequency on it for real workloads; details and both reproductions are in Concurrent search_dates calls race on the _search_with_detection singleton and return wrong datesΒ #1369.

Nothing here needs anything from this PR β€” it merged in good shape.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Getting KeyError issues Library is not thread safe

2 participants