From 9f814e7271d600837f67c4121deb4b520dd46c12 Mon Sep 17 00:00:00 2001 From: wowlikon Date: Thu, 29 Jan 2026 00:42:30 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=B2=D0=B5=D0=BA=D1=82=D0=BE=D1=80=D0=BD=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=20=D0=BF=D0=BE=D0=B8=D1=81=D0=BA=D0=B0=20=D0=B8=20?= =?UTF-8?q?=D1=80=D0=B5=D0=BF=D0=BB=D0=B8=D0=BA=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 10 +- Dockerfile | 1 - docker-compose.yml | 57 ++++++++- example-docker.env | 46 +++++++ example-local.env | 43 +++++++ init-replication.sql | 4 + library_service/main.py | 9 ++ library_service/models/db/book.py | 2 + library_service/routers/books.py | 121 ++++++++++-------- library_service/settings.py | 4 +- library_service/static/page/create_book.js | 4 +- library_service/static/page/edit_book.js | 4 +- library_service/templates/api.html | 3 +- library_service/templates/edit_genre.html | 2 +- migrations/script.py.mako | 2 +- .../6c616cc9d1f0_book_vector_search.py | 38 ++++++ pyproject.toml | 4 +- setup-replication.sh | 77 +++++++++++ uv.lock | 92 ++++++++++++- 19 files changed, 450 insertions(+), 73 deletions(-) create mode 100644 example-docker.env create mode 100644 example-local.env create mode 100644 init-replication.sql create mode 100644 migrations/versions/6c616cc9d1f0_book_vector_search.py create mode 100644 setup-replication.sh diff --git a/.env b/.env index f8d4afa..98674b8 100644 --- a/.env +++ b/.env @@ -1,15 +1,21 @@ # Postgres -POSTGRES_HOST="db" +POSTGRES_HOST="localhost" POSTGRES_PORT="5432" POSTGRES_USER="postgres" POSTGRES_PASSWORD="postgres" POSTGRES_DB="lib" +# Ollama +OLLAMA_URL="http://localhost:11434" +OLLAMA_MAX_LOADED_MODELS=1 +OLLAMA_NUM_THREADS=4 +OLLAMA_KEEP_ALIVE=5m + # Default admin account # DEFAULT_ADMIN_USERNAME="admin" # DEFAULT_ADMIN_EMAIL="admin@example.com" # DEFAULT_ADMIN_PASSWORD="password-is-generated-randomly-on-first-launch" -# SECRET_KEY="your-secret-key-change-in-production" +SECRET_KEY="your-secret-key-change-in-production" # JWT ALGORITHM="HS256" diff --git a/Dockerfile b/Dockerfile index d527fc4..1ad2e77 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,7 +19,6 @@ RUN uv sync --group dev --no-install-project COPY ./library_service /code/library_service COPY ./alembic.ini /code/ -COPY ./data.py /code/ RUN useradd app && \ chown -R app:app /code && \ diff --git a/docker-compose.yml b/docker-compose.yml index f970ca5..48953cd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: db: - image: postgres:17 + image: pgvector/pgvector:pg17 container_name: db restart: unless-stopped logging: @@ -9,8 +9,10 @@ services: max-file: "3" volumes: - ./data/db:/var/lib/postgresql/data - networks: - - proxy + # networks: + # - proxy + ports: + - 5432:5432 env_file: - ./.env healthcheck: @@ -19,6 +21,49 @@ services: timeout: 5s retries: 5 + replication-setup: + image: postgres:17-alpine + container_name: replication-setup + restart: "no" + networks: + - proxy + env_file: + - ./.env + volumes: + - ./setup-replication.sh:/setup-replication.sh + entrypoint: ["/bin/sh", "/setup-replication.sh"] + depends_on: + api: + condition: service_started + db: + condition: service_healthy + + llm: + image: ollama/ollama:latest + container_name: llm + restart: unless-stopped + logging: + options: + max-size: "10m" + max-file: "3" + volumes: + - ./data/llm:/root/.ollama + # networks: + # - proxy + ports: + - 11434:11434 + env_file: + - ./.env + healthcheck: + test: ["CMD-SHELL", "curl http://localhost:11434"] + interval: 10s + timeout: 5s + retries: 5 + deploy: + resources: + limits: + memory: 5g + api: build: . container_name: api @@ -28,8 +73,8 @@ services: options: max-size: "10m" max-file: "3" - networks: - - proxy + # networks: + # - proxy ports: - 8000:8000 env_file: @@ -39,6 +84,8 @@ services: depends_on: db: condition: service_healthy + llm: + condition: service_healthy networks: proxy: # Рекомендуется использовать через реверс-прокси diff --git a/example-docker.env b/example-docker.env new file mode 100644 index 0000000..4517192 --- /dev/null +++ b/example-docker.env @@ -0,0 +1,46 @@ +# Postgres +POSTGRES_HOST="db" +POSTGRES_PORT="5432" +POSTGRES_USER="postgres" +POSTGRES_PASSWORD="postgres" +POSTGRES_DB="lib" +REMOTE_HOST= +REMOTE_PORT= +NODE_ID= + +# Ollama +OLLAMA_URL="http://llm:11434" +OLLAMA_MAX_LOADED_MODELS=1 +OLLAMA_NUM_THREADS=4 +OLLAMA_KEEP_ALIVE=5m + +# Default admin account +DEFAULT_ADMIN_USERNAME="admin" +DEFAULT_ADMIN_EMAIL="admin@example.com" +DEFAULT_ADMIN_PASSWORD="Password12345" +SECRET_KEY="your-secret-key-change-in-production" + +# JWT +ALGORITHM="HS256" +REFRESH_TOKEN_EXPIRE_DAYS="7" +ACCESS_TOKEN_EXPIRE_MINUTES="15" +PARTIAL_TOKEN_EXPIRE_MINUTES="5" + +# Hash +ARGON2_TYPE="id" +ARGON2_TIME_COST="3" +ARGON2_MEMORY_COST="65536" +ARGON2_PARALLELISM="4" +ARGON2_SALT_LENGTH="16" +ARGON2_HASH_LENGTH="48" + +# Recovery codes +RECOVERY_CODES_COUNT="10" +RECOVERY_CODE_SEGMENTS="4" +RECOVERY_CODE_SEGMENT_BYTES="2" +RECOVERY_MIN_REMAINING_WARNING="3" +RECOVERY_MAX_AGE_DAYS="365" + +# TOTP_2FA +TOTP_ISSUER="LiB" +TOTP_VALID_WINDOW="1" diff --git a/example-local.env b/example-local.env new file mode 100644 index 0000000..6d034ee --- /dev/null +++ b/example-local.env @@ -0,0 +1,43 @@ +# Postgres +POSTGRES_HOST="localhost" +POSTGRES_PORT="5432" +POSTGRES_USER="postgres" +POSTGRES_PASSWORD="postgres" +POSTGRES_DB="lib" + +# Ollama +OLLAMA_URL="http://localhost:11434" +OLLAMA_MAX_LOADED_MODELS=1 +OLLAMA_NUM_THREADS=4 +OLLAMA_KEEP_ALIVE=5m + +# Default admin account +DEFAULT_ADMIN_USERNAME="admin" +DEFAULT_ADMIN_EMAIL="admin@example.com" +DEFAULT_ADMIN_PASSWORD="Password12345" +SECRET_KEY="your-secret-key-change-in-production" + +# JWT +ALGORITHM="HS256" +REFRESH_TOKEN_EXPIRE_DAYS="7" +ACCESS_TOKEN_EXPIRE_MINUTES="15" +PARTIAL_TOKEN_EXPIRE_MINUTES="5" + +# Hash +ARGON2_TYPE="id" +ARGON2_TIME_COST="3" +ARGON2_MEMORY_COST="65536" +ARGON2_PARALLELISM="4" +ARGON2_SALT_LENGTH="16" +ARGON2_HASH_LENGTH="48" + +# Recovery codes +RECOVERY_CODES_COUNT="10" +RECOVERY_CODE_SEGMENTS="4" +RECOVERY_CODE_SEGMENT_BYTES="2" +RECOVERY_MIN_REMAINING_WARNING="3" +RECOVERY_MAX_AGE_DAYS="365" + +# TOTP_2FA +TOTP_ISSUER="LiB" +TOTP_VALID_WINDOW="1" diff --git a/init-replication.sql b/init-replication.sql new file mode 100644 index 0000000..b2b3ee8 --- /dev/null +++ b/init-replication.sql @@ -0,0 +1,4 @@ +CREATE PUBLICATION all_tables_pub FOR ALL TABLES; + +ALTER SYSTEM SET password_encryption = 'scram-sha-256'; +SELECT pg_reload_conf(); diff --git a/library_service/main.py b/library_service/main.py index 582688a..ea0c513 100644 --- a/library_service/main.py +++ b/library_service/main.py @@ -11,6 +11,7 @@ from alembic import command from alembic.config import Config from fastapi import FastAPI, Depends, Request, Response, status from fastapi.staticfiles import StaticFiles +from ollama import Client, ResponseError from sqlmodel import Session from library_service.auth import run_seeds @@ -21,6 +22,7 @@ from library_service.settings import ( engine, get_app, get_logger, + OLLAMA_URL, ) @@ -51,6 +53,13 @@ async def lifespan(_): except Exception as e: logger.error(f"[-] Seeding failed: {e}") + logger.info("[+] Loading ollama models...") + ollama_client = Client(host=OLLAMA_URL) + try: + ollama_client.pull("mxbai-embed-large") + ollama_client.pull("llama3.2") + except ResponseError as e: + logger.error(f"[-] Failed to pull models {e}") asyncio.create_task(cleanup_task()) logger.info("[+] Starting application...") yield # Обработка запросов diff --git a/library_service/models/db/book.py b/library_service/models/db/book.py index 97b4e96..265d98e 100644 --- a/library_service/models/db/book.py +++ b/library_service/models/db/book.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING, List +from pgvector.sqlalchemy import Vector from sqlalchemy import Column, String from sqlmodel import Field, Relationship @@ -25,6 +26,7 @@ class Book(BookBase, table=True): sa_column=Column(String, nullable=False, default="active"), description="Статус", ) + embedding: list[float] | None = Field(sa_column=Column(Vector(1024))) authors: List["Author"] = Relationship( back_populates="books", link_model=AuthorBookLink ) diff --git a/library_service/routers/books.py b/library_service/routers/books.py index d0c8e83..aaf31bb 100644 --- a/library_service/routers/books.py +++ b/library_service/routers/books.py @@ -1,13 +1,21 @@ """Модуль работы с книгами""" +from pydantic import Field +from typing_extensions import Annotated + +from sqlalchemy.orm import selectinload, defer + +from sqlalchemy import text, case, distinct + from datetime import datetime, timezone from typing import List from fastapi import APIRouter, Depends, HTTPException, Path, Query, status +from ollama import Client from sqlmodel import Session, select, col, func from library_service.auth import RequireStaff -from library_service.settings import get_session +from library_service.settings import get_session, OLLAMA_URL from library_service.models.enums import BookStatus from library_service.models.db import ( Author, @@ -32,6 +40,7 @@ from library_service.models.dto.misc import ( router = APIRouter(prefix="/books", tags=["books"]) +ollama_client = Client(host=OLLAMA_URL) def close_active_loan(session: Session, book_id: int) -> None: @@ -47,72 +56,64 @@ def close_active_loan(session: Session, book_id: int) -> None: session.add(active_loan) -@router.get( - "/filter", - response_model=BookFilteredList, - summary="Фильтрация книг", - description="Фильтрация списка книг по названию, авторам и жанрам с пагинацией", -) +from sqlalchemy import select, func, distinct, case, exists +from sqlalchemy.orm import selectinload + + +@router.get("/filter", response_model=BookFilteredList) def filter_books( session: Session = Depends(get_session), q: str | None = Query(None, max_length=50, description="Поиск"), - min_page_count: int | None = Query( - None, ge=0, description="Минимальное количество страниц" - ), - max_page_count: int | None = Query( - None, ge=0, description="Максимальное количество страниц" - ), - author_ids: List[int] | None = Query(None, gt=0, description="Список ID авторов"), - genre_ids: List[int] | None = Query(None, gt=0, description="Список ID жанров"), - page: int = Query(1, gt=0, description="Номер страницы"), - size: int = Query(20, gt=0, le=100, description="Количество элементов на странице"), + min_page_count: int | None = Query(None, ge=0), + max_page_count: int | None = Query(None, ge=0), + author_ids: List[Annotated[int, Field(gt=0)]] | None = Query(None), + genre_ids: List[Annotated[int, Field(gt=0)]] | None = Query(None), + page: int = Query(1, gt=0), + size: int = Query(20, gt=0, le=100), ): - """Возвращает отфильтрованный список книг с пагинацией""" - statement = select(Book).distinct() - - if q: - statement = statement.where( - (col(Book.title).ilike(f"%{q}%")) | (col(Book.description).ilike(f"%{q}%")) - ) + statement = select(Book).options( + selectinload(Book.authors), selectinload(Book.genres), defer(Book.embedding) + ) if min_page_count: statement = statement.where(Book.page_count >= min_page_count) - if max_page_count: statement = statement.where(Book.page_count <= max_page_count) if author_ids: - statement = statement.join(AuthorBookLink).where( - AuthorBookLink.author_id.in_( # ty: ignore[unresolved-attribute, unresolved-reference] - author_ids + statement = statement.where( + exists().where( + AuthorBookLink.book_id == Book.id, + AuthorBookLink.author_id.in_(author_ids), ) ) if genre_ids: - statement = statement.join(GenreBookLink).where( - GenreBookLink.genre_id.in_( # ty: ignore[unresolved-attribute, unresolved-reference] - genre_ids + for genre_id in genre_ids: + statement = statement.where( + exists().where( + GenreBookLink.book_id == Book.id, GenreBookLink.genre_id == genre_id + ) ) - ) - total_statement = select(func.count()).select_from(statement.subquery()) - total = session.exec(total_statement).one() + count_statement = select(func.count()).select_from(statement.subquery()) + total = session.scalar(count_statement) + + if q: + emb = ollama_client.embeddings(model="mxbai-embed-large", prompt=q)["embedding"] + distance_col = Book.embedding.cosine_distance(emb) + statement = statement.where(Book.embedding.is_not(None)) + + keyword_match = case((Book.title.ilike(f"%{q}%"), 0), else_=1) + statement = statement.order_by(keyword_match, distance_col) + else: + statement = statement.order_by(Book.id) offset = (page - 1) * size statement = statement.offset(offset).limit(size) - results = session.exec(statement).all() + results = session.scalars(statement).unique().all() - books_with_data = [] - for db_book in results: - books_with_data.append( - BookWithAuthorsAndGenres( - **db_book.model_dump(), - authors=[AuthorRead(**a.model_dump()) for a in db_book.authors], - genres=[GenreRead(**g.model_dump()) for g in db_book.genres], - ) - ) - - return BookFilteredList(books=books_with_data, total=total) + return BookFilteredList(books=results, total=total) @router.post( @@ -127,11 +128,13 @@ def create_book( session: Session = Depends(get_session), ): """Создает новую книгу в системе""" - db_book = Book(**book.model_dump()) + full_text = book.title + " " + book.description + emb = ollama_client.embeddings(model="mxbai-embed-large", prompt=full_text) + db_book = Book(**book.model_dump(), embedding=emb["embedding"]) session.add(db_book) session.commit() session.refresh(db_book) - return BookRead(**db_book.model_dump()) + return BookRead(**db_book.model_dump(exclude={"embedding"})) @router.get( @@ -144,7 +147,8 @@ def read_books(session: Session = Depends(get_session)): """Возвращает список всех книг""" books = session.exec(select(Book)).all() return BookList( - books=[BookRead(**book.model_dump()) for book in books], total=len(books) + books=[BookRead(**book.model_dump(exclude={"embedding"})) for book in books], + total=len(books), ) @@ -165,19 +169,19 @@ def get_book( status_code=status.HTTP_404_NOT_FOUND, detail="Book not found" ) - authors = session.exec( + authors = session.scalars( select(Author).join(AuthorBookLink).where(AuthorBookLink.book_id == book_id) ).all() author_reads = [AuthorRead(**author.model_dump()) for author in authors] - genres = session.exec( + genres = session.scalars( select(Genre).join(GenreBookLink).where(GenreBookLink.book_id == book_id) ).all() genre_reads = [GenreRead(**genre.model_dump()) for genre in genres] - book_data = book.model_dump() + book_data = book.model_dump(exclude={"embedding"}) book_data["authors"] = author_reads book_data["genres"] = genre_reads @@ -186,7 +190,7 @@ def get_book( @router.put( "/{book_id}", - response_model=Book, + response_model=BookRead, summary="Обновить информацию о книге", description="Обновляет информацию о книге в системе", ) @@ -221,11 +225,19 @@ def update_book( if book_update.description is not None: db_book.description = book_update.description + full_text = ( + (book_update.title or db_book.title) + + " " + + (book_update.description or db_book.description) + ) + emb = ollama_client.embeddings(model="mxbai-embed-large", prompt=full_text) + db_book.embedding = emb["embedding"] + session.add(db_book) session.commit() session.refresh(db_book) - return BookRead(**db_book.model_dump()) + return BookRead(**db_book.model_dump(exclude={"embedding"})) @router.delete( @@ -249,6 +261,7 @@ def delete_book( id=(book.id or 0), title=book.title, description=book.description, + page_count=book.page_count, status=book.status, ) session.delete(book) diff --git a/library_service/settings.py b/library_service/settings.py index 9daf680..93d7376 100644 --- a/library_service/settings.py +++ b/library_service/settings.py @@ -95,7 +95,9 @@ USER = os.getenv("POSTGRES_USER") PASSWORD = os.getenv("POSTGRES_PASSWORD") DATABASE = os.getenv("POSTGRES_DB") -if not all([HOST, PORT, USER, PASSWORD, DATABASE]): +OLLAMA_URL = os.getenv("OLLAMA_URL") + +if not all([HOST, PORT, USER, PASSWORD, DATABASE, OLLAMA_URL]): raise ValueError("Missing required POSTGRES environment variables") POSTGRES_DATABASE_URL = f"postgresql://{USER}:{PASSWORD}@{HOST}:{PORT}/{DATABASE}" diff --git a/library_service/static/page/create_book.js b/library_service/static/page/create_book.js index 736baec..13b87be 100644 --- a/library_service/static/page/create_book.js +++ b/library_service/static/page/create_book.js @@ -242,7 +242,7 @@ $(document).ready(() => { return; } - if (!parseInt(pageCount)) { + if (!pageCount) { Utils.showToast("Введите количество страниц", "error"); return; } @@ -253,7 +253,7 @@ $(document).ready(() => { const bookPayload = { title: title, description: description || null, - page_count: pageCount ? parseInt(pageCount) : null, + page_count: pageCount, }; const createdBook = await Api.post("/api/books/", bookPayload); diff --git a/library_service/static/page/edit_book.js b/library_service/static/page/edit_book.js index 28f578d..29063d5 100644 --- a/library_service/static/page/edit_book.js +++ b/library_service/static/page/edit_book.js @@ -331,7 +331,7 @@ $(document).ready(() => { const title = $titleInput.val().trim(); const description = $descInput.val().trim(); - const pages = $pagesInput.val(); + const pages = parseInt($("#book-page-count").val()) || null; const status = $statusSelect.val(); if (!title) { @@ -343,7 +343,7 @@ $(document).ready(() => { if (title !== originalBook.title) payload.title = title; if (description !== (originalBook.description || "")) payload.description = description || null; - if (pageCount !== originalBook.page_count) payload.page_count = pages; + if (pages !== originalBook.page_count) payload.page_count = pages; if (status !== originalBook.status) payload.status = status; if (Object.keys(payload).length === 0) { diff --git a/library_service/templates/api.html b/library_service/templates/api.html index d7374f7..b7f4fd1 100644 --- a/library_service/templates/api.html +++ b/library_service/templates/api.html @@ -168,8 +168,7 @@ jsPlumb.ready(function () { const instance = jsPlumb.getInstance({ Container: "erDiagram", - Connector: ["Flowchart", { stub: 30, gap: 10, cornerRadius: 5, alwaysRespectStubs: true }], - ConnectionOverlays: [["Arrow", { location: 1, width: 10, length: 10, foldback: 0.8 }]] + Connector: ["Flowchart", { stub: 30, gap: 10, cornerRadius: 5, alwaysRespectStubs: true }] }); const container = document.getElementById("erDiagram"); diff --git a/library_service/templates/edit_genre.html b/library_service/templates/edit_genre.html index b3b6587..6f0c745 100644 --- a/library_service/templates/edit_genre.html +++ b/library_service/templates/edit_genre.html @@ -120,7 +120,7 @@ Отмена diff --git a/migrations/script.py.mako b/migrations/script.py.mako index 6ce3351..1c15a07 100644 --- a/migrations/script.py.mako +++ b/migrations/script.py.mako @@ -9,7 +9,7 @@ from typing import Sequence, Union from alembic import op import sqlalchemy as sa -import sqlmodel +import sqlmodel, pgvector ${imports if imports else ""} # revision identifiers, used by Alembic. diff --git a/migrations/versions/6c616cc9d1f0_book_vector_search.py b/migrations/versions/6c616cc9d1f0_book_vector_search.py new file mode 100644 index 0000000..bbae50f --- /dev/null +++ b/migrations/versions/6c616cc9d1f0_book_vector_search.py @@ -0,0 +1,38 @@ +"""Book vector search + +Revision ID: 6c616cc9d1f0 +Revises: c5dfc16bdc66 +Create Date: 2026-01-27 22:37:48.077761 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel, pgvector + + +# revision identifiers, used by Alembic. +revision: str = "6c616cc9d1f0" +down_revision: Union[str, None] = "c5dfc16bdc66" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute("CREATE EXTENSION IF NOT EXISTS vector") + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "book", + sa.Column( + "embedding", pgvector.sqlalchemy.vector.VECTOR(dim=1024), nullable=True + ), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("book", "embedding") + # ### end Alembic commands ### diff --git a/pyproject.toml b/pyproject.toml index d5150aa..1611714 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "LiB" -version = "0.7.0" +version = "0.8.0" description = "Это простое API для управления авторами, книгами и их жанрами." authors = [{ name = "wowlikon" }] readme = "README.md" @@ -23,6 +23,8 @@ dependencies = [ "pyotp>=2.9.0", "slowapi>=0.1.9", "limits>=5.6.0", + "ollama>=0.6.1", + "pgvector>=0.4.2", ] [dependency-groups] diff --git a/setup-replication.sh b/setup-replication.sh new file mode 100644 index 0000000..e3d7acb --- /dev/null +++ b/setup-replication.sh @@ -0,0 +1,77 @@ +#!/bin/sh +set -e + +echo "=== Настройка репликации ===" +echo "Этот узел: NODE_ID=${NODE_ID}" +echo "Удаленный хост: ${REMOTE_HOST}" + +echo "Ждем локальную базу..." +sleep 10 + +until PGPASSWORD="${POSTGRES_PASSWORD}" psql -h db -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -c '\q' 2>/dev/null; do + echo "Локальная база не готова, ждем..." + sleep 2 +done +echo "Локальная база готова" + +echo "Настройка генераторов ID (NODE_ID=${NODE_ID})..." + +PGPASSWORD="${POSTGRES_PASSWORD}" psql -h db -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" </dev/null; do + sleep 5 + ELAPSED=$((ELAPSED + 5)) + if [ $ELAPSED -ge $TIMEOUT ]; then + echo "Таймаут ожидания удаленного хоста. Репликация НЕ настроена." + echo "Вы можете запустить этот скрипт вручную позже:" + echo "docker compose restart replication-setup" + exit 0 + fi + echo "Удаленный хост недоступен, ждем... (${ELAPSED}s/${TIMEOUT}s)" +done +echo "Удаленный хост доступен" + +EXISTING=$(PGPASSWORD="${POSTGRES_PASSWORD}" psql -h db -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -tAc "SELECT COUNT(*) FROM pg_subscription WHERE subname = 'sub_from_remote';") + +if [ "$EXISTING" -gt 0 ]; then + echo "Подписка уже существует, пропускаем создание" +else + echo "Создаем подписку на удаленный хост..." + + PGPASSWORD="${POSTGRES_PASSWORD}" psql -h db -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" <