Skip to content
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

Add checkhealth endpoint #7

Merged
merged 34 commits into from
Jul 22, 2023
Merged
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
ca68890
Add health endpoint
dakennguyen Jun 23, 2023
195ae8c
Protect function call with circuit breaker
dakennguyen Jun 24, 2023
e727041
Add dynamic accepted exceptions
dakennguyen Jun 24, 2023
458bf24
Add test for protected call
dakennguyen Jun 24, 2023
a8758c0
Add test for checkhealth
dakennguyen Jun 24, 2023
1f9c6ad
Add redis to github workflow test
dakennguyen Jun 24, 2023
b082e8a
Add application-docker config files
dakennguyen Jun 24, 2023
05f15e3
Call successfully will update the cache
dakennguyen Jun 24, 2023
a7737d7
chore: move clear cache command inside wrapper
dakennguyen Jun 25, 2023
b8cd82f
Add cmd to attach server container
dakennguyen Jun 25, 2023
e11ad73
Merge branch 'main' into feature/checkhealth-endpoint
dakennguyen Jun 25, 2023
2a6be44
Add docs and tests
dakennguyen Jul 6, 2023
6c9a425
Remove redis
dakennguyen Jul 6, 2023
0cb68d9
Add in memory cache store
dakennguyen Jul 6, 2023
1ceb704
chore: rename param
dakennguyen Jul 6, 2023
def7e26
Add unit for the constants
dakennguyen Jul 10, 2023
ded2ff7
Add docstring for cache interface
dakennguyen Jul 10, 2023
12d9c74
Set type for cache methods
dakennguyen Jul 10, 2023
296a245
Use dict for cache value
dakennguyen Jul 10, 2023
45edb61
Specify return type for get and incr
dakennguyen Jul 10, 2023
6b65ec2
chore: rename ttl to expired_at
dakennguyen Jul 10, 2023
1f719f2
Add log
dakennguyen Jul 11, 2023
548a205
Merge remote-tracking branch 'origin/main' into feature/checkhealth-e…
dakennguyen Jul 12, 2023
6692b9f
Install hazelcast
dakennguyen Jul 15, 2023
6115a35
Use hazelcast
dakennguyen Jul 15, 2023
9bdc3fb
Add hazelcast to docker production
dakennguyen Jul 16, 2023
3f1da3a
Fix cache key
dakennguyen Jul 16, 2023
70c96fe
Add response type for health endpoint
dakennguyen Jul 16, 2023
7f3869d
Merge remote-tracking branch 'origin/main' into feature/checkhealth-e…
dakennguyen Jul 18, 2023
55fb836
Merge remote-tracking branch 'origin/main' into feature/checkhealth-e…
dakennguyen Jul 18, 2023
d7baeca
Remove redundant key
dakennguyen Jul 18, 2023
47ffc59
Remove redundant key
dakennguyen Jul 18, 2023
191b793
Use Optional typing
dakennguyen Jul 19, 2023
114f661
Assert error message
dakennguyen Jul 19, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,5 +44,8 @@ jobs:
run: |
poetry run flake8 .

- name: Start cache service
run: docker compose up -d --build cache

- name: Test with pytest
run: poetry run pytest
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ venv.bak/

# Config files
application.yml
application-docker.yml

# Spyder project settings
.spyderproject
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

## With docker
- (optional) Install **[Task](https://taskfile.dev)**
- Copy `application.example.yml` to `application-docker.yml` and change necessary values
- Build docker: `docker compose build` or `task build`
- Run server: `docker compose up -d` or `task up`
- Stop server: `docker compose down` or `task down`
Expand Down
5 changes: 5 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,8 @@ tasks:
logs:
cmds:
- docker compose logs -f server

console:
aliases: [ c ]
cmds:
- docker attach pyris-server # using <ctrl-p><ctrl-q> to detach
14 changes: 12 additions & 2 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,29 @@
from pyaml_env import parse_config


class CacheSettings(BaseModel):
class CacheParams(BaseModel):
host: str
port: int

hazelcast: CacheParams


class Settings(BaseModel):
class PyrisSettings(BaseModel):
api_key: str
cache: CacheSettings
llm: dict

pyris: PyrisSettings

@classmethod
def get_settings(cls):
postfix = "-docker" if "DOCKER" in os.environ else ""
if "RUN_ENV" in os.environ and os.environ["RUN_ENV"] == "test":
file_path = "application.test.yml"
file_path = f"application{postfix}.test.yml"
else:
file_path = "application.yml"
file_path = f"application{postfix}.yml"

return Settings.parse_obj(parse_config(file_path))

Expand Down
9 changes: 9 additions & 0 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,16 @@
from fastapi.responses import ORJSONResponse

from app.routes.messages import router as messages_router
from app.routes.health import router as health_router
from app.services.hazelcast_client import hazelcast_client

app = FastAPI(default_response_class=ORJSONResponse)


@app.on_event("shutdown")
async def shutdown():
hazelcast_client.shutdown()


app.include_router(messages_router)
app.include_router(health_router)
11 changes: 11 additions & 0 deletions app/models/dtos.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ class LLMModel(str, Enum):
GPT35_TURBO_0613 = "GPT35_TURBO_0613"


class LLMStatus(str, Enum):
UP = "UP"
DOWN = "DOWN"
NOT_AVAILABLE = "NOT_AVAILABLE"


class ContentType(str, Enum):
TEXT = "text"

Expand Down Expand Up @@ -37,3 +43,8 @@ class Message(BaseModel):

used_model: LLMModel = Field(..., alias="usedModel")
message: Message


class ModelStatus(BaseModel):
model: LLMModel
status: LLMStatus
27 changes: 27 additions & 0 deletions app/routes/health.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from fastapi import APIRouter, Depends

from app.dependencies import PermissionsValidator
from app.models.dtos import LLMModel, LLMStatus, ModelStatus
from app.services.guidance_wrapper import GuidanceWrapper
from app.services.circuit_breaker import CircuitBreaker

router = APIRouter()


@router.get("/api/v1/health", dependencies=[Depends(PermissionsValidator())])
def checkhealth() -> list[ModelStatus]:
result = []

for model in LLMModel:
circuit_status = CircuitBreaker.get_status(
checkhealth_func=GuidanceWrapper(model=model).is_up,
cache_key=model,
)
status = (
LLMStatus.UP
if circuit_status == CircuitBreaker.Status.CLOSED
else LLMStatus.DOWN
)
result.append(ModelStatus(model=model, status=status))

return result
7 changes: 6 additions & 1 deletion app/routes/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
)
from app.dependencies import PermissionsValidator
from app.models.dtos import SendMessageRequest, SendMessageResponse, LLMModel
from app.services.circuit_breaker import CircuitBreaker
from app.services.guidance_wrapper import GuidanceWrapper

router = APIRouter(tags=["messages"])
Expand All @@ -32,7 +33,11 @@ def send_message(body: SendMessageRequest) -> SendMessageResponse:
)

try:
content = guidance.query()
content = CircuitBreaker.protected_call(
func=guidance.query,
cache_key=model,
accepted_exceptions=(KeyError, SyntaxError, IncompleteParseError),
)
except KeyError as e:
raise MissingParameterException(str(e))
except (SyntaxError, IncompleteParseError) as e:
Expand Down
154 changes: 154 additions & 0 deletions app/services/cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
from abc import ABC, abstractmethod
from datetime import datetime, timedelta
from typing import Union, Any
from pydantic import BaseModel
from app.services.hazelcast_client import hazelcast_client


class CacheStoreInterface(ABC):
@abstractmethod
def get(self, name: str):
"""
Get the value at key ``name``

Returns:
The value at key ``name``, or None if the key doesn't exist
"""
pass

@abstractmethod
def set(self, name: str, value, ex: Union[int, None] = None):
dakennguyen marked this conversation as resolved.
Show resolved Hide resolved
"""
Set the value at key ``name`` to ``value``

``ex`` sets an expire flag on key ``name`` for ``ex`` seconds.
"""
pass

@abstractmethod
def expire(self, name: str, ex: int):
"""
Set an expire flag on key ``name`` for ``ex`` seconds
"""
pass

@abstractmethod
def incr(self, name: str) -> int:
"""
Increase the integer value of a key ``name`` by 1.
If the key does not exist, it is set to 0
before performing the operation.

Returns:
The value of key ``name`` after the increment

Raises:
TypeError: if cache value is not an integer
"""
pass

@abstractmethod
def flushdb(self):
"""
Delete all keys in the current database
"""
pass

@abstractmethod
def delete(self, name: str):
"""
Delete the key specified by ``name``
"""
pass


class InMemoryCacheStore(CacheStoreInterface):
class CacheValue(BaseModel):
value: Any
expired_at: Union[datetime, None]

def __init__(self):
self._cache: dict[str, Union[InMemoryCacheStore.CacheValue, None]] = {}

def get(self, name: str) -> Union[Any, None]:
current_time = datetime.now()
data = self._cache.get(name)
if data is None:
return None
if data.expired_at and current_time > data.expired_at:
del self._cache[name]
return None
return data.value

def set(self, name: str, value, ex: Union[int, None] = None):
self._cache[name] = InMemoryCacheStore.CacheValue(
value=value, expired_at=None
)
if ex is not None:
self.expire(name, ex)

def expire(self, name: str, ex: int):
current_time = datetime.now()
expired_at = current_time + timedelta(seconds=ex)
data = self._cache.get(name)
if data is None:
return
data.expired_at = expired_at

def incr(self, name: str) -> int:
value = self.get(name)
if value is None:
self._cache[name] = InMemoryCacheStore.CacheValue(
value=1, expired_at=None
)
return 1
if isinstance(value, int):
value += 1
self.set(name, value)
return value
raise TypeError("value is not an integer")

def flushdb(self):
self._cache = {}

def delete(self, name: str):
if name in self._cache:
del self._cache[name]


class HazelcastCacheStore(CacheStoreInterface):
def __init__(self):
self._cache = hazelcast_client.get_map("cache_store").blocking()

def get(self, name: str) -> Union[Any, None]:
return self._cache.get(name)

def set(self, name: str, value, ex: Union[int, None] = None):
self._cache.put(name, value, ex)

def expire(self, name: str, ex: int):
self._cache.set_ttl(name, ex)

def incr(self, name: str) -> int:
flag = False
value = 0
while not flag:
value = self._cache.get(name)
if value is None:
self._cache.set(name, 1)
return 1
if not isinstance(value, int):
raise TypeError("value is not an integer")

flag = self._cache.replace_if_same(name, value, value + 1)

return value + 1

def flushdb(self):
self._cache.clear()

def delete(self, name: str):
self._cache.remove(name)


cache_store = HazelcastCacheStore()
Loading
Loading