v1.0.177: History — объединение history+History, 5 подпапок
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
# Запрос на анализ: чанковая загрузка падает с «Сеть»
|
||||
|
||||
**Дата:** 2026-06-17
|
||||
**Версия:** v1.17
|
||||
**СТРОГО: ТОЛЬКО АНАЛИЗ. КОД НЕ ПРАВИТЬ. Ответ — в этот же файл.**
|
||||
|
||||
---
|
||||
|
||||
## Симптом
|
||||
|
||||
Загрузка PDF 153 KB (3 чанка по 50 KB) через `POST /chunk`:
|
||||
- Прогресс быстро доходит до 100%
|
||||
- Затем ~10 секунд висит «Загрузка...»
|
||||
- Итог: `✗ Сеть` (XHR onerror — обрыв соединения, нет HTTP-ответа)
|
||||
|
||||
Пробовали:
|
||||
- v1.15: чанки в памяти Python `_chunk_store = {}` → «Сеть»
|
||||
- v1.17: чанки в PostgreSQL (таблица `chunks`) → ТО ЖЕ САМОЕ
|
||||
|
||||
Значит дело НЕ в worker'ах/shared memory — с БД чанки общие для всех процессов.
|
||||
|
||||
## Архитектура
|
||||
|
||||
- **Платформа:** pythonk8s.services.ngcloud.ru (managed Flask-хостинг, деплой = git push)
|
||||
- **Ingress:** nginx перед приложением, таймаут ~30 сек (известно по предыдущему опыту)
|
||||
- **Приложение:** Flask (app.run), Python 3.12
|
||||
- **БД:** PostgreSQL 17.6, внутренний кластер
|
||||
|
||||
## Клиентский код (upload.html)
|
||||
|
||||
```javascript
|
||||
async function uploadFileChunked(file, cid, onProgress) {
|
||||
var CHUNK_SIZE = 50 * 1024; // 50 KB
|
||||
var totalChunks = Math.ceil(file.size / CHUNK_SIZE);
|
||||
var uploadId = file.name + '_' + Date.now() + '_' + Math.random();
|
||||
|
||||
for (var i = 0; i < totalChunks; i++) {
|
||||
var chunk = file.slice(i * CHUNK_SIZE, Math.min((i+1) * CHUNK_SIZE, file.size));
|
||||
|
||||
var resp = await new Promise((resolve, reject) => {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/chunk');
|
||||
xhr.timeout = 60000;
|
||||
|
||||
xhr.onload = function() {
|
||||
if (xhr.status === 200) {
|
||||
var r = JSON.parse(xhr.responseText);
|
||||
if (r.error) reject(new Error(r.error));
|
||||
else resolve(r);
|
||||
} else reject(new Error('HTTP ' + xhr.status));
|
||||
};
|
||||
xhr.onerror = () => reject(new Error('Сеть'));
|
||||
xhr.ontimeout = () => reject(new Error('Таймаут'));
|
||||
|
||||
var fd = new FormData();
|
||||
fd.append('chunk', chunk);
|
||||
fd.append('upload_id', uploadId);
|
||||
fd.append('filename', file.name);
|
||||
fd.append('chunk_index', i);
|
||||
fd.append('total_chunks', totalChunks);
|
||||
if (cid) fd.append('cid', cid);
|
||||
xhr.send(fd);
|
||||
});
|
||||
// resp = {contract_id: "..."} только на последнем чанке
|
||||
// или {chunk: N, received: N, total: N} на промежуточных
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Файл 153 KB → 3 чанка. Прогресс 33% → 67% → 100% (значит все 3 XHR завершились). Затем 10 сек паузы → «Сеть».
|
||||
|
||||
## Серверный код (app.py)
|
||||
|
||||
```python
|
||||
def _chunk_upload(self):
|
||||
upload_id = request.form.get("upload_id")
|
||||
chunk_index = int(request.form.get("chunk_index", 0))
|
||||
total_chunks = int(request.form.get("total_chunks", 1))
|
||||
chunk_data = request.files.get("chunk").read()
|
||||
|
||||
# Сохранить в БД
|
||||
db.execute(
|
||||
"INSERT INTO chunks (...) VALUES (...) ON CONFLICT DO NOTHING",
|
||||
(upload_id, chunk_index, chunk_data, ...)
|
||||
)
|
||||
|
||||
# Проверить: все чанки получены?
|
||||
received = db.query("SELECT COUNT(*) FROM chunks WHERE upload_id=%s", ...)
|
||||
|
||||
if received == total_chunks:
|
||||
# Собрать из БД
|
||||
rows = db.query("SELECT chunk_data FROM chunks WHERE upload_id=%s ORDER BY chunk_index", ...)
|
||||
file_bytes = b"".join(...)
|
||||
|
||||
# Сохранить как документ
|
||||
_save_file_to_db(filename, file_bytes, mime, contract_id)
|
||||
|
||||
# Очистить чанки
|
||||
db.execute("DELETE FROM chunks WHERE upload_id=%s", ...)
|
||||
|
||||
return jsonify({"contract_id": str(contract_id)})
|
||||
|
||||
return jsonify({"chunk": chunk_index, "received": received, "total": total_chunks})
|
||||
```
|
||||
|
||||
## Вопросы для анализа
|
||||
|
||||
1. **Почему «Сеть» (XHR onerror), а не HTTP-ошибка?** Сервер не возвращает ответ — соединение обрывается на уровне TCP. Это не таймаут (был бы `ontimeout`) и не HTTP-ошибка (был бы `onload`).
|
||||
|
||||
2. **Почему прогресс 100% но потом 10 сек паузы?** Все 3 чанка отправлены и получили ответ? Или третий чанк висит 10 сек и потом обрыв?
|
||||
|
||||
3. **Может ли `ON CONFLICT DO NOTHING` + `SELECT COUNT(*)` дать гонку?** Два worker'а одновременно пишут чанки — COUNT может не увидеть только что вставленный?
|
||||
|
||||
4. **Может ли сборка (`b"".join` + `_save_file_to_db`) быть медленной и вызывать таймаут Ingress?** 153 KB — вроде быстро, но если БД тормозит...
|
||||
|
||||
5. **Может ли `client_max_body_size` на nginx резать чанки?** 50 KB — вроде мало.
|
||||
|
||||
6. **Есть ли ограничение на количество запросов от одного клиента?** Rate limiting?
|
||||
|
||||
7. **Что видят логи сервера?** Есть ли способ посмотреть?
|
||||
|
||||
## Что нужно
|
||||
|
||||
**ТОЛЬКО АНАЛИЗ. КОД НЕ ТРОГАТЬ.**
|
||||
|
||||
1. Определить наиболее вероятную причину обрыва
|
||||
2. Предложить способ диагностики (как узнать точно)
|
||||
3. Предложить решение (без правки кода)
|
||||
@@ -0,0 +1,197 @@
|
||||
# Запрос на анализ: случайные обрывы соединения при HTTP POST
|
||||
|
||||
**Дата:** 2026-06-17
|
||||
**Проект:** Contracts App (Flask + PostgreSQL на managed-хостинге)
|
||||
**Версия:** v1.21
|
||||
|
||||
---
|
||||
|
||||
## Симптом
|
||||
|
||||
Любой HTTP POST на managed-хостинг pythonk8s.services.ngcloud.ru **случайно** обрывается:
|
||||
|
||||
```
|
||||
100KB #1: HTTP:000
|
||||
100KB #2: HTTP:000
|
||||
100KB #3: HTTP:200
|
||||
100KB #4: HTTP:000
|
||||
100KB #5: HTTP:000
|
||||
```
|
||||
|
||||
- HTTP 000 — curl не может установить TCP-соединение (Connection reset? Connection refused?)
|
||||
- Не зависит от: размера (17KB падает, 35KB работает), типа файла, кодировки
|
||||
- FormData и JSON — одинаково
|
||||
- Health check (GET /health, без БД) — всегда 200 (5/5)
|
||||
- Прямой доступ к БД через /test (JSON) — всегда работает
|
||||
|
||||
## Инфраструктура
|
||||
|
||||
```
|
||||
Клиент → ddos-guard → nginx/Ingress → gunicorn → Flask (1 worker?)
|
||||
↓
|
||||
PostgreSQL 17.6
|
||||
```
|
||||
|
||||
- Managed-хостинг: pythonk8s.services.ngcloud.ru
|
||||
- Деплой: git push master → автосборка
|
||||
- БД: внутренний кластер postgresqlk8s-master...svc.cluster.local
|
||||
- Код: Flask (app.run, host=0.0.0.0:5000)
|
||||
|
||||
## Что пробовали
|
||||
|
||||
1. **FormData/multipart** — POST / с файлом → ✗
|
||||
2. **Чанки 50KB** (FormData) → только первый чанк доходит
|
||||
3. **Чанки в PostgreSQL** (таблица chunks) → только первый чанк
|
||||
4. **Пул соединений psycopg2** (ThreadedConnectionPool min=1 max=3) → ✗
|
||||
5. **JSON/base64** (POST /upload, Content-Type: application/json) → ✗
|
||||
6. **Прямой INSERT в БД** через /test endpoint (JSON) → ✓ всегда
|
||||
|
||||
## Код
|
||||
|
||||
### Сервер: простое сохранение файла в БД
|
||||
|
||||
```python
|
||||
# app.py
|
||||
def _upload_json(self):
|
||||
data = request.get_json(silent=True)
|
||||
if not data:
|
||||
return jsonify({"error": "Нет JSON"}), 400
|
||||
|
||||
filename = data.get("filename", "")
|
||||
b64 = data.get("data", "")
|
||||
if not filename or not b64:
|
||||
return jsonify({"error": "Нет filename или data"}), 400
|
||||
|
||||
import base64 as b64mod
|
||||
file_bytes = b64mod.b64decode(b64)
|
||||
mime = mimeutil.guess_mime(filename) or "application/octet-stream"
|
||||
cid = data.get("cid")
|
||||
|
||||
conn = None
|
||||
if cid:
|
||||
contract_id = cid
|
||||
else:
|
||||
conn, err = db.connect()
|
||||
if err:
|
||||
return jsonify({"error": f"БД: {err}"}), 500
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"INSERT INTO contracts (number, client) VALUES (%s, %s) RETURNING id",
|
||||
("б/н " + __import__("datetime").datetime.now().strftime("%Y%m%d-%H%M"), ""),
|
||||
)
|
||||
contract_id = cur.fetchone()[0]
|
||||
conn.commit()
|
||||
|
||||
doc_id = _save_file_to_db(filename, file_bytes, mime, str(contract_id), conn=conn)
|
||||
if conn:
|
||||
cur.close()
|
||||
db.put_conn(conn)
|
||||
return jsonify({"contract_id": str(contract_id), "doc_id": str(doc_id) if doc_id else None})
|
||||
```
|
||||
|
||||
### Пул соединений (db.py)
|
||||
|
||||
```python
|
||||
import os
|
||||
from psycopg2 import pool as _pgpool
|
||||
|
||||
_pool = None
|
||||
|
||||
def _get_pool():
|
||||
global _pool
|
||||
if _pool is None:
|
||||
_pool = _pgpool.ThreadedConnectionPool(
|
||||
minconn=1,
|
||||
maxconn=3,
|
||||
host=os.getenv("DB_HOST"),
|
||||
port=os.getenv("DB_PORT", 5432),
|
||||
dbname=os.getenv("DB_NAME"),
|
||||
user=os.getenv("DB_USER"),
|
||||
password=os.getenv("DB_PASS"),
|
||||
sslmode=os.getenv("DB_SSLMODE", "disable"),
|
||||
)
|
||||
return _pool
|
||||
|
||||
def connect():
|
||||
try:
|
||||
return _get_pool().getconn(), None
|
||||
except Exception as e:
|
||||
return None, str(e)
|
||||
|
||||
def put_conn(conn):
|
||||
try:
|
||||
_get_pool().putconn(conn)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def execute(sql_text, params=None):
|
||||
conn, err = connect()
|
||||
if err:
|
||||
return None, f"connect: {err}"
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(sql_text, params)
|
||||
conn.commit()
|
||||
rowcount = cur.rowcount
|
||||
cur.close()
|
||||
return rowcount, None
|
||||
except Exception as e:
|
||||
try: conn.rollback()
|
||||
except: pass
|
||||
return None, str(e)
|
||||
finally:
|
||||
put_conn(conn)
|
||||
```
|
||||
|
||||
### Клиент (JavaScript)
|
||||
|
||||
```javascript
|
||||
function readFileAsBase64(file, onProgress) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
var reader = new FileReader();
|
||||
reader.onload = function() {
|
||||
var b64 = reader.result.split(',')[1];
|
||||
resolve(b64);
|
||||
};
|
||||
reader.onerror = function() { reject(new Error('Read error')); };
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function uploadFileJSON(file, cid, onProgress) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
readFileAsBase64(file, onProgress).then(function(b64) {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/upload');
|
||||
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||
xhr.timeout = 120000;
|
||||
xhr.onload = function() {
|
||||
if (xhr.status === 200) {
|
||||
try {
|
||||
var resp = JSON.parse(xhr.responseText);
|
||||
if (resp.error) { reject(new Error(resp.error)); return; }
|
||||
resolve(resp);
|
||||
} catch(e) { reject(new Error('Bad JSON')); }
|
||||
} else {
|
||||
reject(new Error('HTTP ' + xhr.status));
|
||||
}
|
||||
};
|
||||
xhr.onerror = function() { reject(new Error('Сеть')); };
|
||||
xhr.ontimeout = function() { reject(new Error('Таймаут')); };
|
||||
xhr.send(JSON.stringify({
|
||||
filename: file.name,
|
||||
data: b64,
|
||||
cid: cid || null
|
||||
}));
|
||||
}).catch(reject);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Вопросы
|
||||
|
||||
1. Почему health check (GET /health, просто return "OK") всегда работает, а POST с любым телом — случайно обрывается?
|
||||
2. Может ли проблема быть в ddos-guard, который считает частые POST за флуд?
|
||||
3. Может ли gunicorn с 1 worker'ом не справляться с последовательными запросами (keepalive, таймауты)?
|
||||
4. Почему curl с локалхоста показывает HTTP 000 (нет TCP-соединения), а не HTTP 502/504?
|
||||
5. Может ли managed PostgreSQL обрывать соединения пула, вызывая каскадный отказ?
|
||||
@@ -0,0 +1,89 @@
|
||||
# План: Порядок файлов вместо Базового radio
|
||||
|
||||
**v1.0.106+**
|
||||
|
||||
## Суть
|
||||
|
||||
Убрать столбец «Базовый» с radio-кнопкой. Вместо этого:
|
||||
- Первый файл в списке = основной договор (исходная спецификация)
|
||||
- Порядок файлов определяет последовательность обработки в сравнении
|
||||
- Перемещение стрелками ↑↓
|
||||
- Первая строка визуально выделена
|
||||
|
||||
## UI (index.cfm)
|
||||
|
||||
### Столбцы
|
||||
`Имя | Изменён | Размер | Парсинг | ↕ | Текст | ✕`
|
||||
|
||||
### Строка
|
||||
```html
|
||||
<tr class="first-row"> <!-- если первая -->
|
||||
<td>имя</td>
|
||||
<td>дата</td>
|
||||
<td>размер</td>
|
||||
<td>статус</td>
|
||||
<td>
|
||||
<button onclick="moveUp(i)" [disabled если первая]>↑</button>
|
||||
<button onclick="moveDown(i)" [disabled если последняя]>↓</button>
|
||||
</td>
|
||||
<td><button onclick="showText(i)">Текст</button></td>
|
||||
<td><button onclick="removeFile(i)">✕</button></td>
|
||||
</tr>
|
||||
```
|
||||
|
||||
### CSS
|
||||
```css
|
||||
.first-row td { background: rgba(37,99,235,.05); font-weight: 600; }
|
||||
.arrow-btn { cursor: pointer; color: var(--muted); background: none; border: none; padding: 2px 4px; font-size: 12px; }
|
||||
.arrow-btn:hover { color: var(--brand-primary); }
|
||||
.arrow-btn:disabled { opacity: .3; cursor: default; }
|
||||
```
|
||||
|
||||
### JS
|
||||
```js
|
||||
function moveUp(i) {
|
||||
if (i <= 0) return;
|
||||
var tmp = fileQueue[i]; fileQueue[i] = fileQueue[i-1]; fileQueue[i-1] = tmp;
|
||||
renderTable();
|
||||
}
|
||||
function moveDown(i) {
|
||||
if (i >= fileQueue.length - 1) return;
|
||||
var tmp = fileQueue[i]; fileQueue[i] = fileQueue[i+1]; fileQueue[i+1] = tmp;
|
||||
renderTable();
|
||||
}
|
||||
```
|
||||
|
||||
## Бэкенд (convert_server.py)
|
||||
|
||||
### Передача порядка
|
||||
EventSource URL: `/process-v2?contract_id=X&order=sid1,sid2,sid3`
|
||||
|
||||
### Обработка
|
||||
В `_handle_process_v2`:
|
||||
- Если есть `order` — обрабатывать supplements в этом порядке
|
||||
- Если нет — `ORDER BY created_at` (как сейчас)
|
||||
|
||||
```python
|
||||
order = params.get("order", [None])[0]
|
||||
if order:
|
||||
ordered_ids = order.split(",")
|
||||
# Переупорядочить supps по ordered_ids
|
||||
supps.sort(key=lambda s: ordered_ids.index(s["id"]) if s["id"] in ordered_ids else 999)
|
||||
```
|
||||
|
||||
### UI — построение order
|
||||
```js
|
||||
var order = fileQueue.map(function(f) { return f.supp_id; }).filter(Boolean).join(',');
|
||||
var es = new EventSource('/process-v2?contract_id=' + contractId + '&order=' + order);
|
||||
```
|
||||
|
||||
## Что убрать
|
||||
|
||||
- `<th>Базовый</th>` из заголовка
|
||||
- Radio-кнопки из строк
|
||||
- `setInitial()` JS-функцию
|
||||
- `action=set_initial` в api.cfm (опционально, не мешает)
|
||||
|
||||
## Приоритет
|
||||
|
||||
Сделать когда вернём все security-фиксы и деплой заработает.
|
||||
@@ -0,0 +1,60 @@
|
||||
# Ответ: HTTP/2 из Python к aillm.ru
|
||||
|
||||
_Дата: 2026-06-14_
|
||||
|
||||
---
|
||||
|
||||
## Проблема
|
||||
`api.aillm.ru` защищен ddos-guard, который требует наличия HTTP/2 в TLS-хендшейке от облачных IP. Библиотека `requests` работает только по HTTP/1.1, что приводит к ложным ошибкам 401/403.
|
||||
|
||||
## Решение
|
||||
Использование библиотеки **`httpx`** с установленной поддержкой HTTP/2 через чистый Python-пакет **`h2`**.
|
||||
|
||||
### 1. Подготовка зависимостей (requirements.txt)
|
||||
Так как облачная платформа не всегда корректно обрабатывает синтаксис `httpx[http2]`, необходимо прописать зависимости **явно отдельными строками**. Это гарантирует установку `h2` — pure Python реализации протокола, которая не требует системных библиотек или `apt`.
|
||||
|
||||
```text
|
||||
# requirements.txt
|
||||
httpx
|
||||
h2
|
||||
```
|
||||
|
||||
### 2. Код клиента (llm_client.py)
|
||||
Для работы по HTTP/2 в `httpx` нужно явно передать параметр `http2=True` при создании клиента.
|
||||
|
||||
```python
|
||||
import os
|
||||
import httpx
|
||||
|
||||
def ask_llm(prompt: str):
|
||||
api_url = "https://api.aillm.ru/v1/chat/completions"
|
||||
api_key = os.getenv("LLM_API_KEY", "sk-ucI5YvOticoOQ9Kuj5K9mQ")
|
||||
|
||||
payload = {
|
||||
"model": "gpt-oss-120b",
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"max_tokens": 8000,
|
||||
"temperature": 0.1
|
||||
}
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
# ВАЖНО: http2=True включает поддержку нужного протокола
|
||||
with httpx.Client(http2=True, timeout=120) as client:
|
||||
resp = client.post(api_url, json=payload, headers=headers)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
```
|
||||
|
||||
## Почему это работает в slim-контейнере
|
||||
1. **`httpx`** — современный клиент, поддерживающий HTTP/2.
|
||||
2. **`h2`** — это замена системным библиотекам `nghttp2`. Она написана на чистом Python, поэтому корректно устанавливается через `pip` даже в максимально «обрезанных» образах (\*-slim, alpine) без компиляторов и `apt`.
|
||||
3. **HTTP/2 Fingerprint** — ddos-guard видит поддержку HTTP/2 в TLS Client Hello, что является для него признаком «легального» клиента (браузера или современного SDK), а не простого скрипта на `requests` или старого бота.
|
||||
|
||||
## Резюме для реализации
|
||||
- Используйте `httpx.Client(http2=True)`.
|
||||
- Убедитесь, что `h2` есть в `requirements.txt` отдельной строкой.
|
||||
- Не используйте `httpx[http2]` в конфигах, если платформа капризная.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Вопрос: HTTP/2 из Python к aillm.ru
|
||||
|
||||
## Контекст
|
||||
|
||||
Flask-приложение в контейнере `python:3.12-slim`, деплоится через облачную платформу.
|
||||
Нужно отправлять промпты к LLM API: `https://api.aillm.ru/v1/chat/completions`
|
||||
(ключ: `sk-ucI5YvOticoOQ9Kuj5K9mQ`, модель: `gpt-oss-120b`).
|
||||
|
||||
## Проблема
|
||||
|
||||
`api.aillm.ru` за ddos-guard, который **требует HTTP/2 от облачных IP**.
|
||||
С локальной машины работает любой HTTP-клиент (requests, curl без --http2),
|
||||
но из облачного контейнера — только HTTP/2.
|
||||
|
||||
Что пробовали, что НЕ сработало:
|
||||
- `requests` — HTTP/1.1, ddos-guard возвращает ложный 401
|
||||
- `httpx[http2]` в requirements.txt — платформа не парсит `[http2]`, h2 не ставится
|
||||
- `curl` через subprocess — curl не установлен в slim-контейнере, apt недоступен
|
||||
- свой HTTP/2 клиент на h2+socket — слишком сложно, заказчик против
|
||||
|
||||
## Вопрос
|
||||
|
||||
**Как в Python из slim-контейнера сделать HTTP/2 POST-запрос к aillm.ru?**
|
||||
|
||||
Требования:
|
||||
1. Никакого apt/curl — только pip
|
||||
2. Просто, без велосипедов на сокетах
|
||||
3. Работает в python:3.12-slim
|
||||
4. Ключ передаётся как `Authorization: Bearer sk-ucI5YvOticoOQ9Kuj5K9mQ`
|
||||
5. Тело: JSON с model, messages, max_tokens, temperature
|
||||
|
||||
Доступные модели на aillm.ru: `gpt-oss-120b`, `qwen3.6-27b-fp8`, `qwen3-6-27b-fp8-opt`, `whisper-large-v3-turbo`.
|
||||
|
||||
## Для справки
|
||||
|
||||
В Node.js это работает одной строкой:
|
||||
```js
|
||||
await fetch('https://api.aillm.ru/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model: 'gpt-oss-120b', messages: [...] })
|
||||
});
|
||||
```
|
||||
|
||||
Нужен такой же простой способ в Python.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Логика хранения файлов — временная (на период тестирования)
|
||||
|
||||
**v1.0.87**
|
||||
|
||||
## Как сейчас
|
||||
|
||||
- Каждый сеанс (перезагрузка страницы) = новый `contract_id`
|
||||
- Файлы сохраняются в БД (documents + supplements) навсегда
|
||||
- При повторной загрузке того же файла в том же контракте — старый удаляется, новый INSERT
|
||||
- Старые контракты (от предыдущих сеансов) остаются в БД, но юзер их не видит
|
||||
- Таблица на экране — только файлы текущего сеанса
|
||||
|
||||
## Что изменится
|
||||
|
||||
- Файлы не должны копиться в БД бесконечно
|
||||
- Будет «файловая» система: юзер видит что уже есть в БД, может выбрать
|
||||
- При совпадении имён — спрашивать разрешение на перезапись
|
||||
- Возможно: хранить файлы только в сессии браузера, БД — только для промптов
|
||||
|
||||
## Не хардкодить
|
||||
|
||||
Текущая логика удаления-перезаписи (upload.cfm) — временная. При рефакторинге файловой системы пересмотреть.
|
||||
@@ -0,0 +1,102 @@
|
||||
# Запрос на анализ: XHR onerror («Сеть») при POST /upload с телом > 100KB
|
||||
|
||||
**Дата:** 2026-06-17
|
||||
**Версия:** v1.40
|
||||
**ТОЛЬКО АНАЛИЗ. КОД НЕ ПРАВИТЬ.**
|
||||
|
||||
---
|
||||
|
||||
## Симптом
|
||||
|
||||
Браузер отправляет XHR POST на `/upload` с JSON-телом ~200KB (base64 файла). Прогресс 100% (данные ушли). Затем `xhr.onerror` → «Сеть». Сервер иногда успевает обработать (логи подтверждают), иногда нет.
|
||||
|
||||
Прямой curl с такими же данными — иногда HTTP 200, иногда ReadTimeout.
|
||||
|
||||
## Что ПРОБОВАЛИ (40 версий)
|
||||
|
||||
| v | Подход | Результат |
|
||||
|---|---|---|
|
||||
| 1.0-1.13 | FormData multipart POST | ✗ Сеть |
|
||||
| 1.14-1.20 | Чанки 50KB, пул БД, keepalive | ✗ |
|
||||
| 1.21-1.25 | JSON/base64, TEXT вместо BYTEA | частично ✓ |
|
||||
| 1.26 | `original_bytes DROP NOT NULL` + `original_b64 TEXT` | ✓ РАБОТАЛО (!) |
|
||||
| 1.27-1.36 | camelot, tabula, libreoffice для PDF | регресс загрузки |
|
||||
| 1.37 | Логи в памяти | ✗ |
|
||||
| 1.38-1.39 | Redis очередь (RPUSH → 200 сразу) | ✗ |
|
||||
| 1.40 | Сырой regex вместо get_json() | ✗ |
|
||||
|
||||
## Текущий код (самое быстрое что смогли)
|
||||
|
||||
```python
|
||||
# app.py — НЕТ JSON-парсинга, НЕТ base64.decode(), НЕТ DB INSERT
|
||||
def _upload_json_impl(self):
|
||||
raw = request.get_data(as_text=True)
|
||||
import re
|
||||
m = re.search(r'"data"\s*:\s*"([^"]*)"', raw)
|
||||
b64 = m.group(1)
|
||||
m = re.search(r'"filename"\s*:\s*"([^"]*)"', raw)
|
||||
filename = m.group(1) if m else ""
|
||||
m = re.search(r'"cid"\s*:\s*"([^"]*)"', raw)
|
||||
cid = m.group(1) if m else None
|
||||
|
||||
# Контракт — синхронно (быстро)
|
||||
if not cid:
|
||||
conn = db.connect()
|
||||
cur = conn.cursor()
|
||||
cur.execute("INSERT INTO contracts ... RETURNING id")
|
||||
cid = cur.fetchone()[0]
|
||||
conn.commit()
|
||||
db.put_conn(conn)
|
||||
|
||||
# Файл — в Redis (мгновенно, без DB)
|
||||
redis_client.push({"filename": filename, "b64": b64, "mime": mime, "cid": str(cid)})
|
||||
return jsonify({"contract_id": str(cid)})
|
||||
```
|
||||
|
||||
```python
|
||||
# db.py — пул с keepalive
|
||||
ThreadedConnectionPool(minconn=2, maxconn=10, keepalives=1, connect_timeout=10)
|
||||
```
|
||||
|
||||
```javascript
|
||||
// upload.html — XHR без ретраев
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/upload');
|
||||
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||
xhr.timeout = 30000;
|
||||
xhr.onerror = function() { reject(new Error('Сеть')); };
|
||||
xhr.send(JSON.stringify({filename: file.name, data: b64, cid: cid || null}));
|
||||
```
|
||||
|
||||
## Инфраструктура
|
||||
|
||||
```
|
||||
Браузер → ddos-guard → nginx/Ingress → gunicorn → Flask
|
||||
↓
|
||||
Redis (RPUSH) → воркер (BRPOP → DB)
|
||||
```
|
||||
|
||||
- Платформа: pythonk8s.services.ngcloud.ru (managed)
|
||||
- Redis: redisk8s....svc.cluster.local
|
||||
- PostgreSQL: postgresqlk8s-master...svc.cluster.local
|
||||
- Пул БД: 2-10 соединений, keepalive
|
||||
|
||||
## Тесты curl
|
||||
|
||||
```
|
||||
100KB #1: HTTP:000
|
||||
100KB #2: HTTP:000
|
||||
100KB #3: HTTP:200
|
||||
100KB #4: HTTP:000
|
||||
100KB #5: HTTP:000
|
||||
|
||||
10KB: всегда HTTP 200
|
||||
```
|
||||
|
||||
## Вопросы
|
||||
|
||||
1. Почему `request.get_data()` для 200KB тела иногда убивает соединение ещё до ответа? Это на уровне gunicorn/Flask/Werkzeug?
|
||||
2. Может ли nginx/Ingress иметь `client_max_body_size` или `proxy_request_buffering` который режет тело?
|
||||
3. Почему v1.26 работал, а v1.40 с тем же `/upload` (без изменений в логике) — нет?
|
||||
4. Может ли проблема быть в HTTP/2 vs HTTP/1.1? curl с `--http2` иногда помогает на этом хостинге.
|
||||
5. Redis внутри кластера — может ли `redis.Redis()` блокироваться на DNS-резолве `.svc.cluster.local`?
|
||||
@@ -0,0 +1,76 @@
|
||||
# Запрос на анализ: XHR onerror («Сеть») при POST /upload с телом > 100KB
|
||||
|
||||
**Дата:** 2026-06-17
|
||||
**Версия:** v1.41
|
||||
**ТОЛЬКО АНАЛИЗ. КОД НЕ ПРАВИТЬ.**
|
||||
|
||||
---
|
||||
|
||||
## Что поменялось в понимании
|
||||
|
||||
После просмотра текущего кода причина выглядит уже не как `request.get_data()` сама по себе.
|
||||
|
||||
Ветка `/upload` сейчас делает так:
|
||||
|
||||
1. читает всё тело через `request.get_data(as_text=True)`
|
||||
2. выдёргивает `filename`, `data`, `cid` regex-ом
|
||||
3. если `cid` пустой, синхронно создаёт контракт в PostgreSQL
|
||||
4. затем синхронно вызывает `redis_client.push(...)`
|
||||
5. только после этого возвращает `jsonify({"contract_id": ...})`
|
||||
|
||||
Самое подозрительное место — именно синхронный Redis push в запросе, а не чтение тела. Если Redis/DNS/сеть внутри кластера тормозит или подвисает, клиент уже видит `xhr.onerror` как «Сеть», хотя тело запроса успело уйти целиком.
|
||||
|
||||
---
|
||||
|
||||
## Текущий код
|
||||
|
||||
```python
|
||||
# app.py
|
||||
raw = request.get_data(as_text=True)
|
||||
...
|
||||
redis_client.push({"filename": filename, "b64": b64, "mime": mime, "cid": str(cid)})
|
||||
return jsonify({"contract_id": str(cid)})
|
||||
```
|
||||
|
||||
```python
|
||||
# redis_client.py
|
||||
_r = redis.Redis(
|
||||
host=os.getenv("REDIS_HOST", "redisk8s....svc.cluster.local"),
|
||||
port=int(os.getenv("REDIS_PORT", "6379")),
|
||||
password=os.getenv("REDIS_PASS", "..."),
|
||||
decode_responses=True,
|
||||
socket_connect_timeout=5,
|
||||
)
|
||||
|
||||
def push(task: dict):
|
||||
_get().rpush(UPLOAD_QUEUE, json.dumps(task, ensure_ascii=False))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Локальная гипотеза
|
||||
|
||||
`request.get_data()` не ломает соединение на 200KB. Запрос уже дошёл.
|
||||
|
||||
Соединение рвётся или уходит в таймаут на этапе синхронного `rpush()` в Redis, либо на первом обращении к Redis-клиенту, если DNS/соединение внутри кластера нестабильны. Это объясняет, почему:
|
||||
|
||||
- маленькие тела проходят чаще;
|
||||
- сервер иногда успевает обработать и записать лог;
|
||||
- `xhr.upload` показывает 100%, а затем приходит `onerror`;
|
||||
- `curl` даёт плавающий `HTTP:000` / `ReadTimeout`.
|
||||
|
||||
---
|
||||
|
||||
## Что надо проверить первым
|
||||
|
||||
Самый дешёвый дискриминирующий тест — замерить время именно вокруг `redis_client.push()` и временно обойти его для `/upload`.
|
||||
|
||||
Если ответ сразу стабилизируется, проблема подтверждается.
|
||||
|
||||
Если нет, следующая точка проверки — внешний таймаут/обрыв на ingress или gunicorn, но по текущему коду Redis выглядит ближе всего к критическому узлу.
|
||||
|
||||
---
|
||||
|
||||
## Вывод
|
||||
|
||||
В v1.40 сместили фокус с парсинга тела на более поздний синхронный шаг. Сам `get_data()` теперь выглядит не как корень проблемы, а как просто первая стадия запроса. Корневой кандидат — блокирующий `redis_client.push()` в обработчике `/upload`.
|
||||
Reference in New Issue
Block a user