feat(v0.1.58): ImageExists cache hit, timing analysis, in-cluster registry plan

This commit is contained in:
Naeel
2026-03-23 06:22:25 +03:00
parent 7023e0e6fc
commit 9edd43edc5
20 changed files with 596 additions and 515 deletions
+73 -11
View File
@@ -1,5 +1,6 @@
// Изменено: 2026-03-18 (добавлены created_at, last_built_at в functionResponse и fnToResponse)
// Изменено: 2026-03-21 (fix: DeleteFunction возвращает 404 вместо 204 при отсутствующем объекте)
// Изменено: 2026-03-22 (fix: CreateFunction 409 при пересоздании функции — не учитывался DeletionTimestamp)
// functions.go — CRUD handlers для Function CRD.
// Принимает JSON, создаёт/обновляет/удаляет k8s ресурсы Function.
// Namespace берётся из URL: /v1/namespaces/{namespace}/functions/{name}
@@ -9,6 +10,7 @@ package handler
import (
"encoding/json"
"net/http"
"time"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -128,22 +130,59 @@ func (h *Handler) CreateFunction(w http.ResponseWriter, r *http.Request) {
S3Key: req.S3Key,
},
}
// Пытаемся создать Function CRD в k8s (запись в etcd через controller-runtime).
if err := h.K8s.Create(r.Context(), fn); err != nil {
// IsAlreadyExists = etcd вернул 409 CONFLICT.
// Возникает в двух основных сценариях:
//
// [A] terraform apply -replace (= delete + create за один apply):
// 1. terraform DELETE /functions/{name} → API вызывает h.K8s.Delete(fn)
// 2. k8s ставит DeletionTimestamp и ждёт снятия finalizer sless.kube5s.ru/finalizer
// function_controller.go убивает kaniko Job и снимает finalizer асинхронно
// 3. terraform сразу POST /functions/{name} → IsAlreadyExists ← БАГ до этого фикса
// → НОВЫЙ КОД: обнаруживает DeletionTimestamp → ждёт исчезновения → создаёт
//
// [B] Split-brain кеша controller-runtime:
// Объект удалён из etcd, но informer-кеш ещё не обновился.
// Create падает с IsAlreadyExists из кеша, Get возвращает NotFound → пересоздаём.
//
// [C] Функция в фазе Failed:
// Предыдущий build провалился. Новый apply пытается создать снова.
// Удаляем Failed объект и пересоздаём.
if errors.IsAlreadyExists(err) {
// IsAlreadyExists может прийти из кеша controller-runtime (split-brain):
// объект удалён из etcd, но кеш informer ещё южив. Делаем uncached Get:
// если реально NotFound — кеш устарел, пересоздаём.
// если существует и фаза Failed — тоже пересоздаём (build провалился, терраформ не добавил в state).
// если существует и фаза Ready/Building — возвращаем 409 (функция реально есть).
// Получаем актуальное состояние объекта из etcd (не из кеша informer).
existing := &slessv1alpha1.Function{}
getErr := h.K8s.Get(r.Context(), client.ObjectKey{Name: req.Name, Namespace: ns}, existing)
shouldRecreate := errors.IsNotFound(getErr) ||
(getErr == nil && existing.Status.Phase == slessv1alpha1.FunctionPhaseFailed)
if shouldRecreate {
if getErr == nil {
_ = h.K8s.Delete(r.Context(), existing)
// --- Сценарий A: объект ожидает удаления ---
// DeletionTimestamp ≠ zero = k8s принял DELETE, finalizer ещё не снят.
// function_controller.go снимает finalizer после cleanup Job — обычно 1-5 сек.
if getErr == nil && !existing.DeletionTimestamp.IsZero() {
deleted := false
// Polling каждую секунду, максимум 30 раз (= 30 секунд).
// 30 сек — запас на медленный кластер; в норме 1-3 итерации.
for i := 0; i < 30; i++ {
time.Sleep(1 * time.Second)
checkErr := h.K8s.Get(r.Context(), client.ObjectKey{Name: req.Name, Namespace: ns}, existing)
// NotFound = finalizer снят, объект исчез из etcd — можно создавать.
if errors.IsNotFound(checkErr) {
deleted = true
break
}
// Любая другая ошибка (timeout, сбой API) — продолжаем ждать.
}
// Сбрасываем ResourceVersion — при split-brain etcd считает объект новым
// Таймаут: объект не исчез за 30 секунд.
// Клиент (terraform) получит 409 и должен сделать retry позже.
if !deleted {
writeJSON(w, http.StatusConflict, errResp("function is being deleted, try again later"))
return
}
// Объект исчез — сбрасываем ResourceVersion и создаём как новый.
fn.ResourceVersion = ""
if createErr := h.K8s.Create(r.Context(), fn); createErr != nil {
writeJSON(w, http.StatusInternalServerError, errResp(createErr.Error()))
@@ -152,6 +191,29 @@ func (h *Handler) CreateFunction(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, fnToResponse(fn))
return
}
// --- Сценарии B и C: split-brain или Failed ---
// NotFound при Get = кеш врёт (B); Failed фаза = сломанный build (C).
shouldRecreate := errors.IsNotFound(getErr) ||
(getErr == nil && existing.Status.Phase == slessv1alpha1.FunctionPhaseFailed)
if shouldRecreate {
// Если объект реально есть (Failed) — сначала удаляем.
// Ошибку Delete игнорируем: Create покажет ошибку сам если что-то пошло не так.
if getErr == nil {
_ = h.K8s.Delete(r.Context(), existing)
}
// Сбрасываем ResourceVersion — при split-brain etcd считает объект новым.
fn.ResourceVersion = ""
if createErr := h.K8s.Create(r.Context(), fn); createErr != nil {
writeJSON(w, http.StatusInternalServerError, errResp(createErr.Error()))
return
}
writeJSON(w, http.StatusCreated, fnToResponse(fn))
return
}
// Объект живой (Ready/Building), DeletionTimestamp=zero.
// Легитимный конфликт — клиент пытается создать дубликат.
writeJSON(w, http.StatusConflict, errResp("function already exists"))
return
}