From d8aa735a550a15ef80d7c1697ea8c75047326a5c Mon Sep 17 00:00:00 2001 From: Tom Villette Date: Tue, 3 Oct 2023 18:03:41 +0200 Subject: [PATCH] sde_types_market & rework infra --- .env.template | 16 ++++++++ Dockerfile.dev | 24 ----------- docker-compose.dev.yml | 62 ++++++++++++++++++++++++++++ eveal/database.py | 9 +++-- eveal/esi.py | 32 +++++++++++++++ eveal/main.py | 32 ++++++++++----- eveal/models_esi.py | 24 +++++++++++ eveal/models_sde.py | 82 ++++++++++++++++++------------------- import_sde.py | 92 +++++++++++++++++++++--------------------- requirements.txt | 4 +- 10 files changed, 251 insertions(+), 126 deletions(-) create mode 100644 .env.template delete mode 100644 Dockerfile.dev create mode 100644 docker-compose.dev.yml create mode 100644 eveal/esi.py create mode 100644 eveal/models_esi.py diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..2aeaa25 --- /dev/null +++ b/.env.template @@ -0,0 +1,16 @@ +ESI_CLIENT_ID= +ESI_SECRET_KEY= +ESI_CALLBACK_URL= +ESI_USER_AGENT= + +REDIS_URL= +REDIS_PORT= + +REDIS_USER= +REDIS_PASSWORD= +REDIS_SSL= + +SQLITE_DB_PATH= +POSTGRES_PASSWORD= +POSTGRES_USER= +POSTGRES_DB= diff --git a/Dockerfile.dev b/Dockerfile.dev deleted file mode 100644 index ef0c538..0000000 --- a/Dockerfile.dev +++ /dev/null @@ -1,24 +0,0 @@ -FROM python:3.11-slim as sde_import -WORKDIR /app -COPY requirements.txt . -RUN python -m pip install --no-cache-dir --upgrade -r requirements.txt -COPY --chown=appuser:appuser eveal /app/eveal -COPY --chown=appuser:appuser static_eve /app/static_eve -COPY --chown=appuser:appuser import_sde.py /app/import_sde.py -RUN python import_sde.py - -FROM python:3.11-slim - -ENV PYTHONUNBUFFERED=1 -ENV PYTHONDONTWRITEBYTECODE=1 - -RUN adduser -u 5678 --disabled-password --gecos "" appuser - -WORKDIR /app -COPY --from=sde_import --chown=appuser:appuser /app/sde.db /app/sde.db -COPY requirements.txt . -RUN python -m pip install --no-cache-dir --upgrade -r requirements.txt -COPY --chown=appuser:appuser eveal /app/eveal - -USER appuser -CMD ["uvicorn", "eveal.main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..337c274 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,62 @@ +version: '3' +services: + eveal: + image: eveal:latest + build: + context: . + dockerfile: Dockerfile + env_file: + - .env + ports: + - 8000:8000 + volumes: + - ./eveal:/app/eveal + - ./eveal.db:/app/eveal.db + command: ["uvicorn", "eveal.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] + depends_on: + redis: + condition: service_healthy +# db: +# condition: service_healthy +# elasticsearch: +# condition: service_healthy + + redis: + image: redis:latest + ports: + - 6379:6379 + healthcheck: + test: redis-cli ping + interval: 3s + +# db: +# image: postgres:13-alpine +# ports: +# - 5432:5432 +# volumes: +# - ./dump_sde.sql:/docker-entrypoint-initdb.d/init_sde.sql +# - mabras_dbdata:/var/lib/postgresql/data +# environment: +# - POSTGRES_PASSWORD +# - POSTGRES_USER +# - POSTGRES_DB +# healthcheck: +# test: pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB} +# interval: 10s +# timeout: 3s +# retries: 3 + +# elasticsearch: +# image: elasticsearch:latest +# ports: +# - 9200:9200 +# environment: +# - discovery.type=single-node +# - cluster.name=elasticsearch +# - bootstrap.memory_lock=true +# - "ES_JAVA_OPTS=-Xms512m -Xmx512m" +# healthcheck: +# test: curl -s http://elasticsearch:9200 >/dev/null || exit 1 +# interval: 10s +# timeout: 3s +# retries: 3 diff --git a/eveal/database.py b/eveal/database.py index a9a6c47..96e955e 100644 --- a/eveal/database.py +++ b/eveal/database.py @@ -1,12 +1,13 @@ +import os from sqlmodel import create_engine, Session from eveal import models_sde -sqlite_sde_file_name = "sde.db" -sde_engine = create_engine(f"sqlite:///{sqlite_sde_file_name}", echo=True, future=True, connect_args={"check_same_thread": False}) +sqlite_file_name = os.getenv("SQLITE_DB_PATH", "eveal.db") +engine = create_engine(f"sqlite:///{sqlite_file_name}", echo=True, future=True, connect_args={"check_same_thread": False}) -def get_sde_session(): - db = Session(sde_engine) +def get_session(): + db = Session(engine) try: yield db finally: diff --git a/eveal/esi.py b/eveal/esi.py new file mode 100644 index 0000000..aeebe2e --- /dev/null +++ b/eveal/esi.py @@ -0,0 +1,32 @@ +import datetime +import os +import redis +import pickle +from esy.client import ESIClient +from esy.auth import ESIAuthenticator + + +class ESICache(object): + def __init__(self, redis_url: str, redis_port: int, db: str): + self._r = redis.Redis(host=redis_url, port=redis_port, db=db) + # self._r = redis.StrictRedis(host=redis_url, port=redis_port, db=db) + + def get(self, key): + # return pickle.loads(self._r[key]) + return pickle.loads(self._r.get(key)) + + def set(self, key, data, cached_until: datetime.datetime): + self._r.set(key, pickle.dumps(data), ex=cached_until - datetime.datetime.now(datetime.timezone.utc)) + + def __contains__(self, item): + # return item in self._r + return self._r.exists(item) + + +esi_client_id = os.getenv('ESI_CLIENT_ID') +esi_secret_key = os.getenv('ESI_SECRET_KEY') + +esi_cache = ESICache(redis_url=os.getenv("REDIS_URL"), redis_port=int(os.getenv("REDIS_PORT")), db="0") + +esi_client = ESIClient.get_client(user_agent=os.getenv('ESI_USER_AGENT'), cache=esi_cache) +esi_auth = ESIAuthenticator() diff --git a/eveal/main.py b/eveal/main.py index 6a83926..5c0addd 100644 --- a/eveal/main.py +++ b/eveal/main.py @@ -6,10 +6,10 @@ from typing import List, Annotated, Tuple, Literal from sqlmodel import SQLModel, Session, select from eveal.schemas import Evepraisal, PriceReprocess -from eveal.database import sde_engine, get_sde_session -from eveal import models_sde +from eveal.database import engine, get_session +from eveal import models_sde, esi -SQLModel.metadata.create_all(sde_engine) # remove? db should be created by import_sde.py +SQLModel.metadata.create_all(engine) # use alembic? app = FastAPI() app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"]) @@ -21,13 +21,13 @@ async def root(): @app.post("/reprocess/") -async def reprocess(ep_items: Evepraisal, ep_mat: Evepraisal, efficiency: float = .55, sde: Session = Depends(get_sde_session)) -> List[PriceReprocess]: +async def reprocess(ep_items: Evepraisal, ep_mat: Evepraisal, efficiency: float = .55, db: Session = Depends(get_session)) -> List[PriceReprocess]: matprices = {item.typeID: {'sell': item.prices.sell.min, 'buy': item.prices.buy.max} for item in ep_mat.items} item_reprocess: List[PriceReprocess] = [] for rawitem in ep_items.items: # item = sde.exec(select(models_sde.Type).where(models_sde.Type.id == rawitem.typeID)).one() - item = sde.get(models_sde.Type, rawitem.typeID) + item = db.get(models_sde.SDEType, rawitem.typeID) buy_reprocess = sell_reprocess = 0.0 for mat in item.materials.all(): buy_reprocess += matprices[mat.type.id]['buy'] * mat.quantity * efficiency @@ -43,17 +43,29 @@ async def reprocess(ep_items: Evepraisal, ep_mat: Evepraisal, efficiency: float @app.get("/sde/types/{sde_type}/") -async def sde_types(sde_type: int | str, sde: Session = Depends(get_sde_session)) -> models_sde.Type: +async def sde_types(sde_type: int | str, db: Session = Depends(get_session)) -> models_sde.SDEType: try: - item = sde.get(models_sde.Type, int(sde_type)) + item = db.get(models_sde.SDEType, int(sde_type)) except ValueError: - item = sde.exec(select(models_sde.Type).where(models_sde.Type.name == sde_type)).one() + item = db.exec(select(models_sde.SDEType).where(models_sde.SDEType.name == sde_type)).one() return item @app.post("/sde/types/search") -async def sde_types_search(query: List[Tuple[Literal["id", "name"], int | str]], sde: Session = Depends(get_sde_session)) -> List[models_sde.Type]: +async def sde_types_search(query: List[Tuple[Literal["id", "name"], int | str]], db: Session = Depends(get_session)) -> List[models_sde.SDEType]: items = [] for key, val in query: - items.extend(sde.exec(select(models_sde.Type).where(getattr(models_sde.Type, key) == val)).all()) + items.extend(db.exec(select(models_sde.SDEType).where(getattr(models_sde.SDEType, key) == val)).all()) return items + + +@app.get("/esi/types/{sde_type}/market/{region_id}/") +async def sde_types_market(sde_type: int | str, region_id: int | str, db: Session = Depends(get_session)): + """Get market orders for a type in a region. example: /esi/types/22291/market/10000002/""" + """TODO: use ESIMarketOrder""" + return list(esi.esi_client.Market.get_markets_region_id_orders(order_type="all", type_id=sde_type, region_id=region_id)) + + +if __name__ == "__main__": + import uvicorn + uvicorn.run("eveal.main:app", host="0.0.0.0", port=8000, reload=True) diff --git a/eveal/models_esi.py b/eveal/models_esi.py new file mode 100644 index 0000000..0b26ed6 --- /dev/null +++ b/eveal/models_esi.py @@ -0,0 +1,24 @@ +from datetime import datetime +from typing import Optional, List +from sqlmodel import SQLModel, Field, Relationship + + +class ESIMarketOrder(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + timestamp: datetime = Field(default_factory=datetime.utcnow, nullable=False) + + region_id: int + type_id: int # TODO: link to SDE + location_id: int # TODO: link to SDE + volume_total: int + volume_remain: int + min_volume: int + order_id: int # TODO: use this as PK ? (will lose volume_remain history) + price: float + is_buy_order: bool + duration: int + issued: datetime + range: str # TODO: enum? + system_id: int # TODO: link to SDE + + diff --git a/eveal/models_sde.py b/eveal/models_sde.py index e1be4a1..767ba96 100644 --- a/eveal/models_sde.py +++ b/eveal/models_sde.py @@ -2,107 +2,107 @@ from typing import Optional, List from sqlmodel import SQLModel, Field, Relationship -class Icon(SQLModel, table=True): +class SDEIcon(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) description: Optional[str] = None iconFile: str - categories: List['Category'] = Relationship(back_populates="icon") - groups: List['Group'] = Relationship(back_populates="icon") - marketgroups: List['MarketGroup'] = Relationship(back_populates="icon") - types: List['Type'] = Relationship(back_populates="icon") + categories: List['SDECategory'] = Relationship(back_populates="icon") + groups: List['SDEGroup'] = Relationship(back_populates="icon") + marketgroups: List['SDEMarketGroup'] = Relationship(back_populates="icon") + types: List['SDEType'] = Relationship(back_populates="icon") -class Category(SQLModel, table=True): +class SDECategory(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) - icon_id: Optional[int] = Field(default=None, foreign_key="icon.id") - icon: Optional[Icon] = Relationship(back_populates="categories") + icon_id: Optional[int] = Field(default=None, foreign_key="sdeicon.id") + icon: Optional[SDEIcon] = Relationship(back_populates="categories") name: str published: bool - groups: List['Group'] = Relationship(back_populates="category") + groups: List['SDEGroup'] = Relationship(back_populates="category") -class Group(SQLModel, table=True): +class SDEGroup(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) anchorable: bool anchored: bool - category_id: Optional[int] = Field(default=None, foreign_key="category.id") - category: Optional[Category] = Relationship(back_populates="groups") + category_id: Optional[int] = Field(default=None, foreign_key="sdecategory.id") + category: Optional[SDECategory] = Relationship(back_populates="groups") fittableNonSingletion: bool - icon_id: Optional[int] = Field(default=None, foreign_key="icon.id") - icon: Optional[Icon] = Relationship(back_populates="groups") + icon_id: Optional[int] = Field(default=None, foreign_key="sdeicon.id") + icon: Optional[SDEIcon] = Relationship(back_populates="groups") name: str published: bool useBasePrice: bool - types: List['Type'] = Relationship(back_populates="group") + types: List['SDEType'] = Relationship(back_populates="group") -class MarketGroup(SQLModel, table=True): +class SDEMarketGroup(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) description: Optional[str] = None hasTypes: bool - icon_id: Optional[int] = Field(default=None, foreign_key="icon.id") - icon: Optional[Icon] = Relationship(back_populates="marketgroups") + icon_id: Optional[int] = Field(default=None, foreign_key="sdeicon.id") + icon: Optional[SDEIcon] = Relationship(back_populates="marketgroups") name: str - parent_marketgroup_id: Optional[int] = Field(default=None, foreign_key="marketgroup.id") - parent_marketgroup: Optional['MarketGroup'] = Relationship(back_populates="children_marketgroups", - sa_relationship_kwargs={"remote_side": 'MarketGroup.id'}) # workaround for self reference: https://github.com/tiangolo/sqlmodel/issues/127#issuecomment-1224135123 - children_marketgroups: List['MarketGroup'] = Relationship(back_populates="parent_marketgroup") + parent_marketgroup_id: Optional[int] = Field(default=None, foreign_key="sdemarketgroup.id") + parent_marketgroup: Optional['SDEMarketGroup'] = Relationship(back_populates="children_marketgroups", + sa_relationship_kwargs={"remote_side": 'SDEMarketGroup.id'}) # workaround for self reference: https://github.com/tiangolo/sqlmodel/issues/127#issuecomment-1224135123 + children_marketgroups: List['SDEMarketGroup'] = Relationship(back_populates="parent_marketgroup") - types: List['Type'] = Relationship(back_populates="marketgroup") + types: List['SDEType'] = Relationship(back_populates="marketgroup") -class Type(SQLModel, table=True): +class SDEType(SQLModel, table=True): id: int = Field(primary_key=True) - group_id: Optional[int] = Field(default=None, foreign_key="group.id") - group: Optional[Group] = Relationship(back_populates="types") + group_id: Optional[int] = Field(default=None, foreign_key="sdegroup.id") + group: Optional[SDEGroup] = Relationship(back_populates="types") - marketgroup_id: Optional[int] = Field(default=None, foreign_key="marketgroup.id") - marketgroup: Optional[MarketGroup] = Relationship(back_populates="types") + marketgroup_id: Optional[int] = Field(default=None, foreign_key="sdemarketgroup.id") + marketgroup: Optional[SDEMarketGroup] = Relationship(back_populates="types") name: str published: bool = False description: Optional[str] = None basePrice: Optional[float] = None - icon_id: Optional[int] = Field(default=None, foreign_key="icon.id") - icon: Optional[Icon] = Relationship(back_populates="types") + icon_id: Optional[int] = Field(default=None, foreign_key="sdeicon.id") + icon: Optional[SDEIcon] = Relationship(back_populates="types") volume: Optional[float] = None portionSize: int - materials: List['TypeMaterial'] = Relationship(back_populates="type", - sa_relationship_kwargs={"foreign_keys": '[TypeMaterial.type_id]'}) # https://github.com/tiangolo/sqlmodel/issues/10#issuecomment-1537445078 - material_of: List['TypeMaterial'] = Relationship(back_populates="material_type", - sa_relationship_kwargs={"foreign_keys": '[TypeMaterial.material_type_id]'}) # https://github.com/tiangolo/sqlmodel/issues/10#issuecomment-1537445078 + materials: List['SDETypeMaterial'] = Relationship(back_populates="type", + sa_relationship_kwargs={"foreign_keys": '[SDETypeMaterial.type_id]'}) # https://github.com/tiangolo/sqlmodel/issues/10#issuecomment-1537445078 + material_of: List['SDETypeMaterial'] = Relationship(back_populates="material_type", + sa_relationship_kwargs={"foreign_keys": '[SDETypeMaterial.material_type_id]'}) # https://github.com/tiangolo/sqlmodel/issues/10#issuecomment-1537445078 -class TypeMaterial(SQLModel, table=True): +class SDETypeMaterial(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) - type_id: Optional[int] = Field(default=None, foreign_key="type.id") - type: Optional[Type] = Relationship(back_populates="materials", - sa_relationship_kwargs={"primaryjoin": 'TypeMaterial.type_id==Type.id', + type_id: Optional[int] = Field(default=None, foreign_key="sdetype.id") + type: Optional[SDEType] = Relationship(back_populates="materials", + sa_relationship_kwargs={"primaryjoin": 'SDETypeMaterial.type_id==SDEType.id', 'lazy': 'joined'}) # workaround: https://github.com/tiangolo/sqlmodel/issues/10#issuecomment-1002835506 - material_type_id: Optional[int] = Field(default=None, foreign_key="type.id") - material_type: Optional[Type] = Relationship(back_populates="material_of", - sa_relationship_kwargs={"primaryjoin": 'TypeMaterial.material_type_id==Type.id', + material_type_id: Optional[int] = Field(default=None, foreign_key="sdetype.id") + material_type: Optional[SDEType] = Relationship(back_populates="material_of", + sa_relationship_kwargs={"primaryjoin": 'SDETypeMaterial.material_type_id==SDEType.id', 'lazy': 'joined'}) # workaround: https://github.com/tiangolo/sqlmodel/issues/10#issuecomment-1002835506 quantity: int diff --git a/import_sde.py b/import_sde.py index f1f24e1..f3d7965 100644 --- a/import_sde.py +++ b/import_sde.py @@ -1,14 +1,14 @@ import yaml -from eveal.database import sde_engine, sqlite_sde_file_name +from eveal.database import engine, sqlite_file_name from sqlmodel import Session, SQLModel from eveal import models_sde import os try: - os.remove(sqlite_sde_file_name) + os.remove(sqlite_file_name) except: pass -SQLModel.metadata.create_all(sde_engine) +SQLModel.metadata.create_all(engine) print("Importing SDE data...") @@ -16,9 +16,9 @@ print("Importing icons...") with open("static_eve/sde/fsd/iconIDs.yaml", "r", encoding="utf-8") as f: icons = yaml.safe_load(f) -with Session(sde_engine) as db: +with Session(engine) as db: for id, icon in icons.items(): - db.add(models_sde.Icon(id=id, **icon)) + db.add(models_sde.SDEIcon(id=id, **icon)) db.commit() @@ -26,14 +26,14 @@ print("Importing categories...") with open("static_eve/sde/fsd/categoryIDs.yaml", "r", encoding="utf-8") as f: categories = yaml.safe_load(f) -with Session(sde_engine) as db: +with Session(engine) as db: for id, category in categories.items(): if category["published"] == False: continue - db.add(models_sde.Category(id=id, - icon_id=category['iconID'] if 'iconID' in category else None, - name=category['name']['en'], - published=category['published'])) + db.add(models_sde.SDECategory(id=id, + icon_id=category['iconID'] if 'iconID' in category else None, + name=category['name']['en'], + published=category['published'])) db.commit() @@ -41,20 +41,20 @@ print("Importing groups...") with open("static_eve/sde/fsd/groupIDs.yaml", "r", encoding="utf-8") as f: groups = yaml.safe_load(f) -with Session(sde_engine) as db: +with Session(engine) as db: for id, group in groups.items(): if group["published"] == False: continue - db.add(models_sde.Group(id=id, - anchorable=group['anchorable'], - anchored=group['anchored'], - category_id=group['categoryID'], - fittableNonSingletion=group['fittableNonSingleton'], - icon_id=group['iconID'] if 'iconID' in group else None, - name=group['name']['en'], - published=group['published'], - useBasePrice=group['useBasePrice'] - )) + db.add(models_sde.SDEGroup(id=id, + anchorable=group['anchorable'], + anchored=group['anchored'], + category_id=group['categoryID'], + fittableNonSingletion=group['fittableNonSingleton'], + icon_id=group['iconID'] if 'iconID' in group else None, + name=group['name']['en'], + published=group['published'], + useBasePrice=group['useBasePrice'] + )) db.commit() @@ -62,15 +62,15 @@ print("Importing marketgroups...") with open("static_eve/sde/fsd/marketGroups.yaml", "r", encoding="utf-8") as f: marketgroups = yaml.safe_load(f) -with Session(sde_engine) as db: +with Session(engine) as db: for id, marketgroup in marketgroups.items(): - db.add(models_sde.MarketGroup(id=id, - description=marketgroup['descriptionID']['en'] if 'descriptionID' in marketgroup else None, - hasTypes=marketgroup['hasTypes'], - icon_id=marketgroup['iconID'] if 'iconID' in marketgroup else None, - name=marketgroup['nameID']['en'], - parent_marketgroup_id=marketgroup['parentGroupID'] if 'parentGroupID' in marketgroup else None, - )) + db.add(models_sde.SDEMarketGroup(id=id, + description=marketgroup['descriptionID']['en'] if 'descriptionID' in marketgroup else None, + hasTypes=marketgroup['hasTypes'], + icon_id=marketgroup['iconID'] if 'iconID' in marketgroup else None, + name=marketgroup['nameID']['en'], + parent_marketgroup_id=marketgroup['parentGroupID'] if 'parentGroupID' in marketgroup else None, + )) db.commit() @@ -78,21 +78,21 @@ print("Importing types...") with open("static_eve/sde/fsd/typeIDs.yaml", "r", encoding="utf-8") as f: types = yaml.safe_load(f) -with Session(sde_engine) as db: +with Session(engine) as db: for id, type in types.items(): if type["published"] == False: continue - db.add(models_sde.Type(id=id, - group_id=type['groupID'], - marketgroup_id=type['marketGroupID'] if 'marketGroupID' in type else None, - name=type['name']['en'], - published=type['published'], - basePrice=type['basePrice'] if 'basePrice' in type else None, - description=type['description']['en'] if 'description' in type else None, - icon_id=type['iconID'] if 'iconID' in type else None, - portionSize=type['portionSize'], - volume=type['volume'] if 'volume' in type else None, - )) + db.add(models_sde.SDEType(id=id, + group_id=type['groupID'], + marketgroup_id=type['marketGroupID'] if 'marketGroupID' in type else None, + name=type['name']['en'], + published=type['published'], + basePrice=type['basePrice'] if 'basePrice' in type else None, + description=type['description']['en'] if 'description' in type else None, + icon_id=type['iconID'] if 'iconID' in type else None, + portionSize=type['portionSize'], + volume=type['volume'] if 'volume' in type else None, + )) db.commit() @@ -100,13 +100,13 @@ print("Importing materials...") with open("static_eve/sde/fsd/typeMaterials.yaml", "r", encoding="utf-8") as f: materials = yaml.safe_load(f) -with Session(sde_engine) as db: +with Session(engine) as db: for id, material in materials.items(): for mat in material['materials']: - db.add(models_sde.TypeMaterial(type_id=id, - material_id=mat['materialTypeID'], - quantity=mat['quantity'] - )) + db.add(models_sde.SDETypeMaterial(type_id=id, + material_id=mat['materialTypeID'], + quantity=mat['quantity'] + )) db.commit() diff --git a/requirements.txt b/requirements.txt index 5760613..d0e1083 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,6 @@ fastapi httpx uvicorn[standard] -sqlmodel \ No newline at end of file +sqlmodel +esy +redis