From bc444ddd506258456f9e1c409fb5b4b9f7250b73 Mon Sep 17 00:00:00 2001 From: Alejandro Ponce Date: Thu, 23 Jan 2025 10:40:43 +0200 Subject: [PATCH 1/3] Adds cache to cli-chat commands Closes: #732 Copilot sends at least 2 requests with the same `last_user_message`. Hence we execute the same command 2 times and reply to the last one. The last behaviour will cause that if we create a Workspace with Copilot the cli will respond that the workspace already exists. This PR implements a workaround to cache the commands and it's outputs. That way we can reply the same to subsequent requests sent by Copilot --- src/codegate/pipeline/cli/commands.py | 62 +++++++++++++++++++++++++-- 1 file changed, 59 insertions(+), 3 deletions(-) diff --git a/src/codegate/pipeline/cli/commands.py b/src/codegate/pipeline/cli/commands.py index da52c42d..f25808b3 100644 --- a/src/codegate/pipeline/cli/commands.py +++ b/src/codegate/pipeline/cli/commands.py @@ -1,7 +1,8 @@ +import datetime from abc import ABC, abstractmethod -from typing import Awaitable, Callable, Dict, List, Tuple +from typing import Awaitable, Callable, Dict, List, Optional, Tuple -from pydantic import ValidationError +from pydantic import BaseModel, ValidationError from codegate import __version__ from codegate.db.connection import AlreadyExistsError @@ -16,6 +17,15 @@ class NoSubcommandError(Exception): pass +class ExecCommand(BaseModel): + + cmd_out: str + exec_time: datetime.datetime + + +command_cache: Dict[str, ExecCommand] = {} + + class CodegateCommand(ABC): @abstractmethod async def run(self, args: List[str]) -> str: @@ -31,10 +41,56 @@ def command_name(self) -> str: def help(self) -> str: pass + async def _get_full_command(self, args: List[str]) -> str: + """ + Get the full command string with the command name and args. + """ + joined_args = " ".join(args) + return f"{self.command_name} {joined_args}" + + async def _record_in_cache(self, args: List[str], cmd_out: str) -> None: + """ + Record the command in the cache. + """ + full_command = await self._get_full_command(args) + command_cache[full_command] = ExecCommand( + cmd_out=cmd_out, exec_time=datetime.datetime.now(datetime.timezone.utc) + ) + + async def _cache_lookup(self, args: List[str]) -> Optional[str]: + """ + Look up the command in the cache. If the command was executed less than 1 second ago, + return the cached output. + """ + full_command = await self._get_full_command(args) + if full_command in command_cache: + exec_command = command_cache[full_command] + time_since_last_exec = ( + datetime.datetime.now(datetime.timezone.utc) - exec_command.exec_time + ) + # 1 second cache. 1 second is to be short enough to not affect UX but long enough to + # reply the same to concurrent requests. Needed for Copilot. + if time_since_last_exec.total_seconds() < 1: + return exec_command.cmd_out + return None + async def exec(self, args: List[str]) -> str: + """ + Execute the command and cache the output. The cache is invalidated after 1 second. + + 1. Check if the command is help. If it is, return the help text. + 2. Check if the command is in the cache. If it is, return the cached output. + 3. Run the command and cache the output. + 4. Return the output. + """ if args and args[0] == "-h": return self.help - return await self.run(args) + cached_out = await self._cache_lookup(args) + if cached_out: + return cached_out + cmd_out = await self.run(args) + await self._record_in_cache(args, cmd_out) + return cmd_out class Version(CodegateCommand): From 49c01877c8817cea9e2ebdc879bf1b2b277a60a9 Mon Sep 17 00:00:00 2001 From: Alejandro Ponce Date: Thu, 23 Jan 2025 11:20:27 +0200 Subject: [PATCH 2/3] Updated cache to use cachetools.TTLCache --- pyproject.toml | 1 + src/codegate/pipeline/cli/commands.py | 30 +++++++-------------------- 2 files changed, 9 insertions(+), 22 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5d74c6a0..9e2d55c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ alembic = "==1.14.1" pygments = "==2.19.1" sqlite-vec-sl-tmp = "==0.0.4" greenlet = "==3.1.1" +cachetools = "==5.5.1" [tool.poetry.group.dev.dependencies] pytest = "==8.3.4" diff --git a/src/codegate/pipeline/cli/commands.py b/src/codegate/pipeline/cli/commands.py index f25808b3..5b101400 100644 --- a/src/codegate/pipeline/cli/commands.py +++ b/src/codegate/pipeline/cli/commands.py @@ -1,8 +1,8 @@ -import datetime from abc import ABC, abstractmethod from typing import Awaitable, Callable, Dict, List, Optional, Tuple -from pydantic import BaseModel, ValidationError +from cachetools import TTLCache +from pydantic import ValidationError from codegate import __version__ from codegate.db.connection import AlreadyExistsError @@ -17,13 +17,9 @@ class NoSubcommandError(Exception): pass -class ExecCommand(BaseModel): - - cmd_out: str - exec_time: datetime.datetime - - -command_cache: Dict[str, ExecCommand] = {} +# 1 second cache. 1 second is to be short enough to not affect UX but long enough to +# reply the same to concurrent requests. Needed for Copilot. +command_cache = TTLCache(maxsize=10, ttl=1) class CodegateCommand(ABC): @@ -53,9 +49,7 @@ async def _record_in_cache(self, args: List[str], cmd_out: str) -> None: Record the command in the cache. """ full_command = await self._get_full_command(args) - command_cache[full_command] = ExecCommand( - cmd_out=cmd_out, exec_time=datetime.datetime.now(datetime.timezone.utc) - ) + command_cache[full_command] = cmd_out async def _cache_lookup(self, args: List[str]) -> Optional[str]: """ @@ -63,16 +57,8 @@ async def _cache_lookup(self, args: List[str]) -> Optional[str]: return the cached output. """ full_command = await self._get_full_command(args) - if full_command in command_cache: - exec_command = command_cache[full_command] - time_since_last_exec = ( - datetime.datetime.now(datetime.timezone.utc) - exec_command.exec_time - ) - # 1 second cache. 1 second is to be short enough to not affect UX but long enough to - # reply the same to concurrent requests. Needed for Copilot. - if time_since_last_exec.total_seconds() < 1: - return exec_command.cmd_out - return None + cmd_out = command_cache.get(full_command) + return cmd_out async def exec(self, args: List[str]) -> str: """ From bf6fd69f4e1b7764fce23c6bc88d603461a29608 Mon Sep 17 00:00:00 2001 From: Alejandro Ponce Date: Thu, 23 Jan 2025 11:21:19 +0200 Subject: [PATCH 3/3] updated lock file --- poetry.lock | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/poetry.lock b/poetry.lock index fa151335..c64f506f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. [[package]] name = "aiohappyeyeballs" @@ -301,6 +301,17 @@ typing = ["build[uv]", "importlib-metadata (>=5.1)", "mypy (>=1.9.0,<1.10.0)", " uv = ["uv (>=0.1.18)"] virtualenv = ["virtualenv (>=20.0.35)"] +[[package]] +name = "cachetools" +version = "5.5.1" +description = "Extensible memoizing collections and decorators" +optional = false +python-versions = ">=3.7" +files = [ + {file = "cachetools-5.5.1-py3-none-any.whl", hash = "sha256:b76651fdc3b24ead3c648bbdeeb940c1b04d365b38b4af66788f9ec4a81d42bb"}, + {file = "cachetools-5.5.1.tar.gz", hash = "sha256:70f238fbba50383ef62e55c6aff6d9673175fe59f7c6782c7a0b9e38f4a9df95"}, +] + [[package]] name = "certifi" version = "2024.12.14" @@ -604,6 +615,7 @@ files = [ {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:761817a3377ef15ac23cd7834715081791d4ec77f9297ee694ca1ee9c2c7e5eb"}, {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3c672a53c0fb4725a29c303be906d3c1fa99c32f58abe008a82705f9ee96f40b"}, {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:4ac4c9f37eba52cb6fbeaf5b59c152ea976726b865bd4cf87883a7e7006cc543"}, + {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:60eb32934076fa07e4316b7b2742fa52cbb190b42c2df2863dbc4230a0a9b385"}, {file = "cryptography-44.0.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ed3534eb1090483c96178fcb0f8893719d96d5274dfde98aa6add34614e97c8e"}, {file = "cryptography-44.0.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f3f6fdfa89ee2d9d496e2c087cebef9d4fcbb0ad63c40e821b39f74bf48d9c5e"}, {file = "cryptography-44.0.0-cp37-abi3-win32.whl", hash = "sha256:eb33480f1bad5b78233b0ad3e1b0be21e8ef1da745d8d2aecbb20671658b9053"}, @@ -614,6 +626,7 @@ files = [ {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:c5eb858beed7835e5ad1faba59e865109f3e52b3783b9ac21e7e47dc5554e289"}, {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f53c2c87e0fb4b0c00fa9571082a057e37690a8f12233306161c8f4b819960b7"}, {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:9e6fc8a08e116fb7c7dd1f040074c9d7b51d74a8ea40d4df2fc7aa08b76b9e6c"}, + {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9abcc2e083cbe8dde89124a47e5e53ec38751f0d7dfd36801008f316a127d7ba"}, {file = "cryptography-44.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d2436114e46b36d00f8b72ff57e598978b37399d2786fd39793c36c6d5cb1c64"}, {file = "cryptography-44.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a01956ddfa0a6790d594f5b34fc1bfa6098aca434696a03cfdbe469b8ed79285"}, {file = "cryptography-44.0.0-cp39-abi3-win32.whl", hash = "sha256:eca27345e1214d1b9f9490d200f9db5a874479be914199194e746c893788d417"}, @@ -3083,4 +3096,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = ">=3.12,<4.0" -content-hash = "479762cfb2027723fecb196828874a9fa5cbfe152e67187f42e07a5181930231" +content-hash = "f1907d1d44405e2e671a9584c7150823dc573468452eea2cc5d66df55f904f03"