Repository: aio-libs/async-lru

Issue:
`KeyError` is raised while using both: TTL and maxsize params.
**Description**
`_LRUCacheWrapper._task_done_callback()` fails with `KeyError` during concurrent processing when using `ttl` and `maxsize`. The latter is set to 128 by default so the problem occurs without `maxsize` explicitly set.

This seems to happening due to the fact that cached item has already been removed from `self.__cache` at the time when `self._task_done_callback()` is triggered. If this happens `cache_item = self.__cache[key]` fails.

**Module version**
```
$ python3 -m pip freeze | grep async-lru
async-lru==2.0.2
```

**How to reproduce:**
```python3
import asyncio
from async_lru import alru_cache


@alru_cache(ttl=60, maxsize=5)
async def cached_func(uuid: int):
    return uuid


async def main():
    tasks = [cached_func(i) for i in range(20)]
    cached_results = await asyncio.gather(*tasks)


if __name__ == "__main__":
    asyncio.run(main())
```

**Expected behavior**
`cached_results == list(range(20))`

**Actual behavior**
Bunch of `KeyError`s raised.

**Proposed solution**
Couldn't push it out as PR since there is no write access to the repo.
```
@@ -161,10 +161,12 @@ class _LRUCacheWrapper(Generic[_R]):
         if self.__ttl is not None:
             loop = asyncio.get_running_loop()
-            cache_item = self.__cache[key]
-            cache_item.later_call = loop.call_later(
-                self.__ttl, self.__cache.pop, key, None
-            )
+            # using safe `.get()` here since cached item might have been removed from cache due to maxsize already
+            cache_item = self.__cache.get(key)
+            if cache_item is not None:
+                cache_item.later_call = loop.call_later(
+                    self.__ttl, self.__cache.pop, key, None
+                )
```

Discussion:

Patch:
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 99d0255..b9fb8a6 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -1,12 +1,11 @@
 version: 2
 updates:
-- package-ecosystem: pip
-  directory: "/"
-  schedule:
-    interval: daily
-  open-pull-requests-limit: 10
-- package-ecosystem: github-actions
-  directory: /
-  schedule:
-    interval: daily
-  open-pull-requests-limit: 10
+  - package-ecosystem: pip
+    directory: "/"
+    schedule:
+      interval: daily
+
+  - package-ecosystem: "github-actions"
+    directory: "/"
+    schedule:
+      interval: "monthly"
diff --git a/.github/workflows/auto-merge.yaml b/.github/workflows/auto-merge.yaml
new file mode 100644
index 0000000..525458a
--- /dev/null
+++ b/.github/workflows/auto-merge.yaml
@@ -0,0 +1,22 @@
+name: Dependabot auto-merge
+on: pull_request_target
+
+permissions:
+  pull-requests: write
+  contents: write
+
+jobs:
+  dependabot:
+    runs-on: ubuntu-latest
+    if: ${{ github.actor == 'dependabot[bot]' }}
+    steps:
+      - name: Dependabot metadata
+        id: metadata
+        uses: dependabot/fetch-metadata@v1.5.1
+        with:
+          github-token: "${{ secrets.GITHUB_TOKEN }}"
+      - name: Enable auto-merge for Dependabot PRs
+        run: gh pr merge --auto --squash "$PR_URL"
+        env:
+          PR_URL: ${{github.event.pull_request.html_url}}
+          GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 72260ed..c22519d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -10,8 +10,6 @@ on:
     branches:
       - master
       - '[0-9].[0-9]+'
-  schedule:
-    - cron:  '0 6 * * *'  # Daily 6AM UTC build
 
 
 jobs:
@@ -23,24 +21,21 @@ jobs:
     steps:
     - name: Checkout
       uses: actions/checkout@v3
-    - name: Setup Python 3.8
+    - name: Setup Python
       uses: actions/setup-python@v4
       with:
         python-version: 3.8
-    - name: Cache PyPI
-      uses: actions/cache@v3
-      with:
-        key: pip-lint-${{ hashFiles('requirements.txt') }}
-        path: ~/.cache/pip
-        restore-keys: |
-            pip-lint-
+        cache: 'pip'
+        cache-dependency-path: '**/requirements*.txt'
+    - name: Pre-Commit hooks
+      uses: pre-commit/action@v3.0.0
     - name: Install dependencies
       uses: py-actions/py-dependency-install@v4.0.0
       with:
-        path: requirements.txt
+        path: requirements-dev.txt
     - name: Install itself
       run: |
-        python setup.py install
+        pip install .
     - name: Run linter
       run: |
         make lint
@@ -54,13 +49,12 @@ jobs:
 
   test:
     name: Test
-    needs: lint
     strategy:
       matrix:
         pyver: ['3.8', '3.9', '3.10', '3.11']
         os: [ubuntu, macos, windows]
         include:
-          - pyver: pypy3.9
+          - pyver: pypy-3.9
             os: ubuntu
     runs-on: ${{ matrix.os }}-latest
     timeout-minutes: 15
@@ -71,56 +65,62 @@ jobs:
       uses: actions/setup-python@v4
       with:
         python-version: ${{ matrix.pyver }}
-    - name: Get pip cache dir
-      id: pip-cache
-      run: |
-        echo "::set-output name=dir::$(pip cache dir)"    # - name: Cache
-    - name: Cache PyPI
-      uses: actions/cache@v3
-      with:
-        key: pip-ci-${{ runner.os }}-${{ matrix.pyver }}-${{ hashFiles('requirements/*.txt') }}
-        path: ${{ steps.pip-cache.outputs.dir }}
-        restore-keys: |
-            pip-ci-${{ runner.os }}-${{ matrix.pyver }}-
+        cache: 'pip'
+        cache-dependency-path: '**/requirements*.txt'
     - name: Install dependencies
       uses: py-actions/py-dependency-install@v4.0.0
       with:
         path: requirements.txt
     - name: Run unittests
+      run: make test
       env:
         COLOR: 'yes'
-      run: |
-        make test-only
-        python -m coverage xml
+    - run: python -m coverage xml
     - name: Upload coverage
       uses: codecov/codecov-action@v3
       with:
         file: ./coverage.xml
         flags: unit
-        fail_ci_if_error: false
+
+  check:  # This job does nothing and is only used for the branch protection
+    if: always()
+
+    needs: [lint, test]
+
+    runs-on: ubuntu-latest
+
+    steps:
+    - name: Decide whether the needed jobs succeeded or failed
+      uses: re-actors/alls-green@release/v1
+      with:
+        jobs: ${{ toJSON(needs) }}
 
   deploy:
     name: Deploy
+    environment: release
     runs-on: ubuntu-latest
-    needs: test
-    # Run only on pushing a tag
+    needs: [check]
     if: github.event_name == 'push' && contains(github.ref, 'refs/tags/')
     steps:
     - name: Checkout
       uses: actions/checkout@v3
-    - name: Setup Python 3.8
+    - name: Setup Python
       uses: actions/setup-python@v4
       with:
         python-version: 3.8
     - name: Install dependencies
       run:
-        python -m pip install -U pip wheel twine build
-    - name: Make dists
-      run:
-        python -m build
-    - name: PyPI upload
-      env:
-        TWINE_USERNAME: __token__
-        TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
+        python -m pip install -U pip wheel setuptools build twine
+    - name: Build dists
       run: |
-        twine upload dist/*
+        python -m build
+    - name: Make Release
+      uses: aio-libs/create-release@v1.6.6
+      with:
+        changes_file: CHANGES.rst
+        name: async-lru
+        version_file: async_lru/__init__.py
+        github_token: ${{ secrets.GITHUB_TOKEN }}
+        dist_dir: dist
+        fix_issue_regex: "`#(\\d+) <https://github.com/aio-libs/async-lru/issues/\\1>`"
+        fix_issue_repl: "(#\\1)"
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
new file mode 100644
index 0000000..42088c6
--- /dev/null
+++ b/.github/workflows/codeql.yml
@@ -0,0 +1,63 @@
+name: "CodeQL"
+
+on:
+  push:
+    branches: [ 'master' ]
+  pull_request:
+    # The branches below must be a subset of the branches above
+    branches: [ 'master' ]
+  schedule:
+    - cron: '5 1 * * 4'
+
+jobs:
+  analyze:
+    name: Analyze
+    runs-on: ubuntu-latest
+    permissions:
+      actions: read
+      contents: read
+      security-events: write
+
+    strategy:
+      fail-fast: false
+      matrix:
+        language: [ 'python' ]
+        # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
+        # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
+
+    steps:
+    - name: Checkout repository
+      uses: actions/checkout@v3
+
+    # Initializes the CodeQL tools for scanning.
+    - name: Initialize CodeQL
+      uses: github/codeql-action/init@v2
+      with:
+        languages: ${{ matrix.language }}
+        # If you wish to specify custom queries, you can do so here or in a config file.
+        # By default, queries listed here will override any specified in a config file.
+        # Prefix the list here with "+" to use these queries and those in the config file.
+
+        # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
+        # queries: security-extended,security-and-quality
+
+
+    # Autobuild attempts to build any compiled languages  (C/C++, C#, Go, or Java).
+    # If this step fails, then you should remove it and run the build manually (see below)
+    - name: Autobuild
+      uses: github/codeql-action/autobuild@v2
+
+    # ℹ️ Command-line programs to run using the OS shell.
+    # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
+
+    #   If the Autobuild fails above, remove it and uncomment the following three lines.
+    #   modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
+
+    # - run: |
+    #   echo "Run, Build Application using script"
+    #   ./location_of_script_within_repo/buildscript.sh
+
+    - name: Perform CodeQL Analysis
+      uses: github/codeql-action/analyze@v2
+      with:
+        category: "/language:${{matrix.language}}"
diff --git a/.gitignore b/.gitignore
index 1ce6d24..586c72e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -47,9 +47,6 @@ cover
 *.mo
 *.pot
 
-# Django stuff:
-*.log
-
 # Sphinx documentation
 docs/_build/
 
@@ -62,4 +59,4 @@ target/
 # rope
 .ropeproject
 
-.python-version
\ No newline at end of file
+.python-version
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..242ef9b
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,63 @@
+repos:
+- repo: https://github.com/pre-commit/pre-commit-hooks
+  rev: 'v4.4.0'
+  hooks:
+  - id: check-merge-conflict
+- repo: https://github.com/asottile/yesqa
+  rev: v1.4.0
+  hooks:
+  - id: yesqa
+- repo: https://github.com/PyCQA/isort
+  rev: '5.12.0'
+  hooks:
+  - id: isort
+- repo: https://github.com/psf/black
+  rev: '23.1.0'
+  hooks:
+    - id: black
+      language_version: python3 # Should be a command that runs python
+- repo: https://github.com/pre-commit/pre-commit-hooks
+  rev: 'v4.4.0'
+  hooks:
+  - id: end-of-file-fixer
+    exclude: >-
+      ^docs/[^/]*\.svg$
+  - id: requirements-txt-fixer
+  - id: trailing-whitespace
+  - id: file-contents-sorter
+    files: |
+      CONTRIBUTORS.txt|
+      docs/spelling_wordlist.txt|
+      .gitignore|
+      .gitattributes
+  - id: check-case-conflict
+  - id: check-json
+  - id: check-xml
+  - id: check-executables-have-shebangs
+  - id: check-toml
+  - id: check-xml
+  - id: check-yaml
+  - id: debug-statements
+  - id: check-added-large-files
+  - id: check-symlinks
+  - id: debug-statements
+  - id: detect-aws-credentials
+    args: ['--allow-missing-credentials']
+  - id: detect-private-key
+    exclude: ^examples/
+- repo: https://github.com/PyCQA/flake8
+  rev: '6.0.0'
+  hooks:
+  - id: flake8
+    exclude: "^docs/"
+- repo: https://github.com/asottile/pyupgrade
+  rev: 'v3.3.1'
+  hooks:
+  - id: pyupgrade
+    args: ['--py36-plus']
+- repo: https://github.com/Lucas-C/pre-commit-hooks-markup
+  rev: v1.0.1
+  hooks:
+  - id: rst-linter
+    files: >-
+      ^[^/]+[.]rst$
diff --git a/.pyup.yml b/.pyup.yml
deleted file mode 100644
index 6ea2065..0000000
--- a/.pyup.yml
+++ /dev/null
@@ -1,4 +0,0 @@
-# autogenerated pyup.io config file 
-# see https://pyup.io/docs/configuration/ for all available options
-
-schedule: every week
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 6c5ce77..0000000
--- a/.travis.yml
+++ /dev/null
@@ -1,31 +0,0 @@
-dist: xenial
-language: python
-python:
-  - "3.5"
-  - "3.6"
-  - "3.7"
-install:
-  - pip install -U setuptools
-  - pip install -U pip
-  - pip install -U wheel
-  - pip install -U tox
-script:
-  - export TOXENV=py`python -c 'import sys; print("".join(map(str, sys.version_info[:2])))'`
-  - echo "$TOXENV"
-
-  - tox
-after_success:
-  - pip install codecov
-  - codecov
-cache: pip
-
-deploy:
-  provider: pypi
-  user: hellysmile
-  password:
-    secure: "SLZbjWCCtlsS+q3iZ7Ay6l96kASV27e+Ih3Sa6YAQ/u9Vr7pEp6EAiL6IJnBsxgJFU0Msd7mdmouGPQsUp2GTxmrD7O4kmamtVymB0DcKEaUhpQ12+Mo92qi4FcYil7aHa6C+uPynBC2PMgTw+1g2WoDBIrKKQT11/t8xYsl905yM1gpZR25BkpJsrqDoKb+iZTphc7ZB/3TE0RK+W+RrqgAumLmGl9r3t3P8o8hdLiUIMjckMeegM1d1TRHVtnApuyJB2P+LeljKTp1uzS3CEOkKupRzWVh4UDzp5gahsTxGWGg9tueol2VfCn+BxXMCJ0uYDY36IP6zsN4nEtn7Ja8H7f3tZoA1xGNNgfE4yHsf2fECGYb8HivOHIsj/HSOyGrTpKNGfJLg649e+BydVAaaiOuAviag3gw/7Fi7dFZI9hVnbHcPtQfYGbyCNCfJ0O5EI86lEo8V6F7wlnwDcc3M5euunP1+QWgKcGjlnMy3KWFnX3X2vHFHwF/9rPG/WytniPJJS0jdxzpsRKTdYYNhkepVFKQMOu8iE69X7z4VXMRKzBHJ7rO/zu1WiT/jMOhtH88UbP9KGpOgONVtyRlDcbY4FQGPFUKRXfk14WpE4rbmKHrNjdtjgqrtRC/3ZxooVRZwpIDnNifKjGuZpXkHiMWSF65gYc1tP3iH9M="
-  distributions: "sdist bdist_wheel"
-  on:
-    tags: true
-    all_branches: true
-    python: 3.7
diff --git a/CHANGES.rst b/CHANGES.rst
new file mode 100644
index 0000000..0bff76c
--- /dev/null
+++ b/CHANGES.rst
@@ -0,0 +1,10 @@
+=======
+CHANGES
+=======
+
+.. towncrier release notes start
+
+2.x (2023-xx-xx)
+==================
+
+- xxx
diff --git a/Makefile b/Makefile
index 02ec289..392af3f 100644
--- a/Makefile
+++ b/Makefile
@@ -1,26 +1,18 @@
-SOURCES = setup.py async_lru.py tests
+# Some simple testing tasks (sorry, UNIX only).
 
-test: lint test-only
-
-test-only:
-	pytest tests
-
-
-lint: black flake8 mypy
+.PHONY: init setup
+init setup:
+	pip install -r requirements-dev.txt
+	pre-commit install
 
+.PHONY: fmt
+fmt:
+	python -m pre_commit run --all-files --show-diff-on-failure
 
-mypy:
+.PHONY: lint
+lint: fmt
 	mypy
 
-
-black:
-	isort -c .
-	black --check .
-
-flake8:
-	flake8 async_lru tests setup.py
-
-
-fmt:
-	isort .
-	black .
+.PHONY: test
+test:
+	pytest -s ./tests/
diff --git a/README.rst b/README.rst
index 35bb83c..33474e8 100644
--- a/README.rst
+++ b/README.rst
@@ -22,7 +22,7 @@ Installation
 Usage
 -----
 
-This package is 100% port of Python built-in function `functools.lru_cache <https://docs.python.org/3/library/functools.html#functools.lru_cache>`_ for `asyncio <https://docs.python.org/3/library/asyncio.html>`_
+This package is a port of Python's built-in `functools.lru_cache <https://docs.python.org/3/library/functools.html#functools.lru_cache>`_ function for `asyncio <https://docs.python.org/3/library/asyncio.html>`_. To better handle async behaviour, it also ensures multiple concurrent calls will only result in 1 call to the wrapped function, with all ``await``\s receiving the result of that call when it completes.
 
 .. code-block:: python
 
diff --git a/async_lru/__init__.py b/async_lru/__init__.py
index 9048b50..4ccc348 100644
--- a/async_lru/__init__.py
+++ b/async_lru/__init__.py
@@ -1,5 +1,6 @@
 import asyncio
 import dataclasses
+import sys
 from asyncio.coroutines import _is_coroutine  # type: ignore[attr-defined]
 from functools import _CacheInfo, _make_key, partial, partialmethod
 from typing import (
@@ -20,7 +21,11 @@ from typing import (
     overload,
 )
 
-from typing_extensions import Self
+
+if sys.version_info >= (3, 11):
+    from typing import Self
+else:
+    from typing_extensions import Self
 
 
 __version__ = "2.0.2"
@@ -135,7 +140,7 @@ class _LRUCacheWrapper(Generic[_R]):
         return _CacheInfo(
             self.__hits,
             self.__misses,
-            self.__maxsize,  # type: ignore[arg-type]
+            self.__maxsize,
             len(self.__cache),
         )
 
@@ -159,9 +164,9 @@ class _LRUCacheWrapper(Generic[_R]):
     ) -> None:
         self.__tasks.remove(task)
 
-        if self.__ttl is not None:
+        cache_item = self.__cache.get(key)
+        if self.__ttl is not None and cache_item is not None:
             loop = asyncio.get_running_loop()
-            cache_item = self.__cache[key]
             cache_item.later_call = loop.call_later(
                 self.__ttl, self.__cache.pop, key, None
             )
@@ -179,7 +184,7 @@ class _LRUCacheWrapper(Generic[_R]):
 
     async def __call__(self, /, *fn_args: Any, **fn_kwargs: Any) -> _R:
         if self.__closed:
-            raise RuntimeError("alru_cache is closed for {}".format(self))
+            raise RuntimeError(f"alru_cache is closed for {self}")
 
         loop = asyncio.get_running_loop()
 
@@ -297,7 +302,7 @@ def _make_wrapper(
             origin = origin.func
 
         if not asyncio.iscoroutinefunction(origin):
-            raise RuntimeError("Coroutine function is required, got {!r}".format(fn))
+            raise RuntimeError(f"Coroutine function is required, got {fn!r}")
 
         # functools.partialmethod support
         if hasattr(fn, "_make_unbound_method"):
@@ -340,4 +345,4 @@ def alru_cache(
         if callable(fn) or hasattr(fn, "_make_unbound_method"):
             return _make_wrapper(128, False, None)(fn)
 
-        raise NotImplementedError("{!r} decorating is not supported".format(fn))
+        raise NotImplementedError(f"{fn!r} decorating is not supported")
diff --git a/async_lru/py.typed b/async_lru/py.typed
index f457233..e69de29 100644
--- a/async_lru/py.typed
+++ b/async_lru/py.typed
@@ -1,1 +0,0 @@
-# placeholder
\ No newline at end of file
diff --git a/requirements-dev.txt b/requirements-dev.txt
new file mode 100644
index 0000000..ef5638e
--- /dev/null
+++ b/requirements-dev.txt
@@ -0,0 +1,8 @@
+-r requirements.txt
+
+flake8==6.0.0
+flake8-bandit==4.1.1
+flake8-bugbear==23.5.9
+flake8-import-order==0.18.2
+flake8-requirements==1.7.8
+mypy==1.3.0; implementation_name=="cpython"
diff --git a/requirements.txt b/requirements.txt
index b7ad5fe..14f629c 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,9 +1,7 @@
-flake8==6.0.0
-pytest==7.2.1
-pytest-asyncio==0.20.3
-pytest-cov==4.0.0
-pytest-timeout==2.1.0
-isort==5.12.0
-black==23.1.0
-mypy==1.0.1
 -e .
+
+coverage==7.2.7
+pytest==7.3.1
+pytest-asyncio==0.21.0
+pytest-cov==4.1.0
+pytest-timeout==2.1.0
diff --git a/setup.cfg b/setup.cfg
index c72a476..8804273 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -40,7 +40,7 @@ python_requires = >=3.8
 packages = find:
 
 install_requires =
-  typing_extensions>=4.0.0
+  typing_extensions>=4.0.0; python_version<"3.11"
 
 [options.package_data]
 * =
@@ -79,4 +79,4 @@ timeout=15
 [mypy]
 strict=True
 pretty=True
-packages=async_lru, tests
\ No newline at end of file
+packages=async_lru, tests


Test patch:
diff --git a/tests/test_ttl.py b/tests/test_ttl.py
index 3b5acf8..8159fd4 100644
--- a/tests/test_ttl.py
+++ b/tests/test_ttl.py
@@ -58,3 +58,12 @@ async def test_ttl_with_explicit_invalidation(check_lru: Callable[..., None]) ->
     # cache is not cleared after ttl expires because invalidate also should clear
     # the invalidation by timeout
     check_lru(coro, hits=0, misses=2, cache=1, tasks=0, maxsize=None)
+
+
+async def test_ttl_concurrent() -> None:
+    @alru_cache(maxsize=1, ttl=1)
+    async def coro(val: int) -> int:
+        return val
+
+    results = await asyncio.gather(*(coro(i) for i in range(2)))
+    assert results == list(range(2))