Compare commits

...
3 Commits
Author SHA1 Message Date
Denozordec 78f2ecc246 fix(auth): update Select components to include SelectValue for better placeholder handling
CI / changes (push) Successful in 9s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 57s
CI / go (push) Successful in 1m5s
CI / bird2 (push) Successful in 17s
CI / release (push) Successful in 4m0s
Modified the Select components in operations.tsx, settings.tsx, and tenant-settings.tsx to incorporate SelectValue for improved placeholder functionality. Additionally, updated the daemon PID and timestamps in .codegraph/daemon.pid for consistency.
2026-07-03 14:10:23 +07:00
DenozordecandCursor 8c97445f7e fix(jobs): make deploy coalescing deterministic via inflight counter
CI / changes (push) Successful in 12s
CI / openapi (push) Has been skipped
CI / web (push) Has been skipped
CI / commitlint (push) Has been skipped
CI / go (push) Successful in 1m11s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 4m27s
TestParallelModuleRefresh_CoalescesDeployApply падал на CI под -race
(want exactly one deploy_apply job, got 2). Локально тест проходил
стабильно (100/500 итераций с -cpu), но узкая гонка проявлялась при
замедлении под race-детектором.

Корень: коалесцирование решало «делать ли deploy» через
CountOtherActiveRefresh, который опрашивал статусы job-ов (queued/
running). Статусы меняются асинхронно относительно tenantRefreshMu,
поэтому в редких таймингах оба параллельных refresh могли решить,
что другой уже не активен, и каждый породил свой deploy_apply.

Решение — детерминированный inflight-счётчик refresh-kind job-ов в
Registry (inflightRefresh map[string]int), управляемый под r.mu:
- инкремент в Enqueue при создании нового refresh-kind job-а;
- декремент + проверка «последний ли я» в finishModuleRefreshSuccess
  через новый метод finalizeRefreshCoalesce (под tenantRefreshMu).

Последний refresh (счётчик <= 1) делает render + deploy_apply; все
остальные defer-ят. Решение больше не зависит от опроса статусов и
таймингов ingest.

Чтобы счётчик не утёк на error/cancel путях (где refresh не доходит
до finishModuleRefreshSuccess), обработка module_refresh и
tenant_refresh вынесена в runModuleRefresh / runTenantRefresh с
defer-обёрткой, которая гарантированно освобождает слот, если
finishModuleRefreshSuccess не отработал.

CountOtherActiveRefresh / CountOtherActiveModuleRefresh оставлены
как публичные методы (могут использоваться в мониторинге); из
продакшн-логики коалесцирования убраны.

Проверки: go build, go vet, go test ./internal/... -count=1 — exit 0.
Стресс-тест коалесцирования: 200 итераций с -cpu=4 — стабильно.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 01:31:32 +07:00
DenozordecandCursor 0ea5b3b738 fix(bird2): switch download host from bird.network.cz to bird.nic.cz
CI / changes (push) Successful in 11s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Has been skipped
CI / go (push) Failing after 41s
CI / bird2 (push) Has been skipped
CI / release (push) Has been skipped
Docker-сборка birdc падала с HTTP 403 при скачивании исходников BIRD
2.14 с bird.network.cz. Старый домен bird.network.cz больше не отдаёт
файлы (403 для всех путей и User-Agent), официальный сайт BIRD переехал
на bird.nic.cz.

URL bird.nic.cz/download/bird-2.14.tar.gz проверен: 200 OK, ~1.4 МБ,
совпадает с размером на старом домене. Страница old-releases на новом
домене подтверждает тот же путь /download/bird-2.14.tar.gz.

Изменены все 5 вхождений старого домена в репозитории:
- deploy/docker/bird/bird-from-source.sh — критичный curl в сборке;
- .cursor/rules/networking-bird.mdc — BIRD2 docs и DOC-SYNC-05;
- .cursor/rules/context7-stack.mdc — приоритет источников;
- .cursor/rules/engineering.mdc — таблица Documentation Sync.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 23:30:56 +07:00
10 changed files with 129 additions and 42 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
{
"pid": 52448,
"pid": 48400,
"version": "0.9.9",
"socketPath": "\\\\.\\pipe\\codegraph-97b92efdcc5351da",
"startedAt": 1782969267595
"startedAt": 1783060842523
}
+1 -1
View File
@@ -82,7 +82,7 @@ UI-правила репозитория: `.cursor/rules/web-shadcn.mdc` (MCP +
1. **Контракт HTTP** — `docs/openapi.yaml` (не Context7).
2. **Context7** — синтаксис и API библиотек из таблицы.
3. **Локальные docs** — `docs/`, `web/README.md`, `AGENTS.md`.
4. **Официальный сайт** — BIRD: https://bird.network.cz/?get_doc (если Context7 не покрыл кейс).
4. **Официальный сайт** — BIRD: https://bird.nic.cz/?get_doc (если Context7 не покрыл кейс).
## Примеры запросов
+1 -1
View File
@@ -207,7 +207,7 @@ alwaysApply: true
| OpenAPI / problem+json | `docs/openapi.yaml`, RFC 9457 |
| Svelte / Kit | https://svelte.dev/docs , https://kit.svelte.dev/docs |
| shadcn-svelte | https://shadcn-svelte.com/docs |
| BIRD 2 | https://bird.network.cz/?get_doc |
| BIRD 2 | https://bird.nic.cz/?get_doc |
| Prometheus Go | https://pkg.go.dev/github.com/prometheus/client_golang |
**DOC-SYNC-01** | MUST | Новый API библиотеки — сверка версии в `go.mod`/`package.json` с официальной документацией.
+2 -2
View File
@@ -23,7 +23,7 @@ alwaysApply: false
| Параметры BIRD tenant | Global settings: `bird_router_id`, `bird_local_asn`, … (`docs/manual.md`) |
| BGP peers | `BGPPeer` + `ParsePeerNeighbor` |
**BIRD2 docs:** https://bird.network.cz/?get_doc
**BIRD2 docs:** https://bird.nic.cz/?get_doc
---
@@ -199,7 +199,7 @@ alwaysApply: false
## Documentation Sync
**DOC-SYNC-05** | MUST | BIRD — https://bird.network.cz/?get_doc
**DOC-SYNC-05** | MUST | BIRD — https://bird.nic.cz/?get_doc
**DOC-SYNC-08** | MUST | BGP policy — RFC 4271, 4760, 7454 + BIRD docs + `birdfmt`
**DOC-SYNC-09** | MUST | CIDR — https://pkg.go.dev/net/netip ; примеры — RFC 5737, 3849
+7 -2
View File
@@ -12,6 +12,7 @@ import {
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@evobgp/ui/components/select'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import {
@@ -378,7 +379,9 @@ function DiffTab({ revisions }: { revisions: import('@/types/api').RevisionRow[]
<div className="flex w-full max-w-xs flex-col gap-1">
<span className="text-xs text-muted-foreground">Ревизия A</span>
<Select value={a} onValueChange={(v) => v && setA(v)}>
<SelectTrigger>{a ? a.slice(0, 12) + '…' : 'Выберите'}</SelectTrigger>
<SelectTrigger>
<SelectValue placeholder="Выберите" />
</SelectTrigger>
<SelectContent>
{revisions.map((r) => (
<SelectItem key={r.id} value={r.id}>
@@ -391,7 +394,9 @@ function DiffTab({ revisions }: { revisions: import('@/types/api').RevisionRow[]
<div className="flex w-full max-w-xs flex-col gap-1">
<span className="text-xs text-muted-foreground">Ревизия B</span>
<Select value={b} onValueChange={(v) => v && setB(v)}>
<SelectTrigger>{b ? b.slice(0, 12) + '…' : 'Выберите'}</SelectTrigger>
<SelectTrigger>
<SelectValue placeholder="Выберите" />
</SelectTrigger>
<SelectContent>
{revisions.map((r) => (
<SelectItem key={r.id} value={r.id}>
+10 -2
View File
@@ -4,7 +4,13 @@ import { useQuery } from '@tanstack/react-query'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { Select, SelectContent, SelectItem, SelectTrigger } from '@evobgp/ui/components/select'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@evobgp/ui/components/select'
import { PageHeader } from '@/components/page-header'
import { LoadingButton } from '@/components/loading-button'
@@ -85,7 +91,9 @@ function SettingsComponent() {
<CardContent className="flex flex-col gap-2">
<Label htmlFor="theme-select">Тема</Label>
<Select value={theme ?? 'system'} onValueChange={(v) => v && setTheme(v)}>
<SelectTrigger id="theme-select" className="w-full max-w-xs" />
<SelectTrigger id="theme-select" className="w-full max-w-xs">
<SelectValue placeholder="Выберите тему" />
</SelectTrigger>
<SelectContent>
<SelectItem value="light">Светлая</SelectItem>
<SelectItem value="dark">Тёмная</SelectItem>
@@ -13,6 +13,7 @@ import {
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@evobgp/ui/components/select'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import {
@@ -251,7 +252,7 @@ function TenantSettingsComponent() {
}
>
<SelectTrigger>
{runtimeLogsForm.runtime_logs_auto_enabled === 'true' ? 'Вкл' : 'Выкл'}
<SelectValue placeholder="Выберите" />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">Вкл</SelectItem>
@@ -308,11 +309,11 @@ function TenantSettingsComponent() {
}
>
<SelectTrigger>
{runtimeLogsForm.runtime_logs_auto_mode || 'Выберите'}
<SelectValue placeholder="Выберите" />
</SelectTrigger>
<SelectContent>
<SelectItem value="delete">Delete</SelectItem>
<SelectItem value="truncate">Truncate</SelectItem>
<SelectItem value="truncate">truncate обнулить</SelectItem>
<SelectItem value="delete">delete удалить файл</SelectItem>
</SelectContent>
</Select>
<p className="font-mono text-xs text-muted-foreground">
+1 -1
View File
@@ -18,7 +18,7 @@ apt-get install -y --no-install-recommends \
cd /tmp
rm -rf "bird-${BIRD_VERSION}"
curl -fsSL "https://bird.network.cz/download/bird-${BIRD_VERSION}.tar.gz" | tar xz
curl -fsSL "https://bird.nic.cz/download/bird-${BIRD_VERSION}.tar.gz" | tar xz
cd "bird-${BIRD_VERSION}"
./configure --prefix=/usr/local --enable-client
make -j"$(nproc)"
+40 -4
View File
@@ -184,6 +184,10 @@ type Registry struct {
onTerminal func(j *Job)
onEnqueued func(j *Job)
onRunning func(j *Job)
// inflightRefresh counts refresh-kind jobs (module_refresh, tenant_refresh) per tenant that
// have been enqueued but not yet finalized in finishModuleRefreshSuccess. Used for deterministic
// deploy coalescing under tenantRefreshMu (instead of polling job statuses).
inflightRefresh map[string]int
}
type idempoKey struct {
@@ -194,10 +198,11 @@ type idempoKey struct {
func NewRegistry(workerStart func(j *Job)) *Registry {
maxWorkers := registryMaxConcurrentJobs()
return &Registry{
byID: make(map[string]*Job),
byIdempo: make(map[idempoKey]*Job),
workerStart: workerStart,
workerSem: make(chan struct{}, maxWorkers),
byID: make(map[string]*Job),
byIdempo: make(map[idempoKey]*Job),
workerStart: workerStart,
workerSem: make(chan struct{}, maxWorkers),
inflightRefresh: make(map[string]int),
}
}
@@ -340,6 +345,9 @@ func (r *Registry) Enqueue(tenantID, kind string, idempotencyKey *string, module
r.byIdempo[idempoKey{tenant: tenantID, key: *idempotencyKey}] = j
}
r.byID[j.ID] = j
if isRefreshKind(kind) {
r.inflightRefresh[tenantID]++
}
r.pruneTerminalIfOver(maxJobs)
enqueuedHook := r.onEnqueued
workerStart := r.workerStart
@@ -474,6 +482,34 @@ func (r *Registry) CountOtherActiveRefresh(tenantID, excludeJobID string) int {
return n
}
// isRefreshKind reports whether a job kind participates in deploy coalescing.
func isRefreshKind(kind string) bool {
return kind == KindModuleRefresh || kind == KindTenantRefresh
}
// finalizeRefreshCoalesce is called from finishModuleRefreshSuccess under tenantRefreshMu.
// It atomically decrements the per-tenant inflight refresh counter and reports whether the
// caller is the last outstanding refresh for the tenant (and therefore should render+deploy).
//
// Unlike CountOtherActiveRefresh (which polls job statuses and races under -race), this counter
// is incremented in Enqueue under r.mu and decremented here, so the "last one" decision is
// deterministic regardless of how fast each refresh's ingest completes.
func (r *Registry) finalizeRefreshCoalesce(tenantID string) bool {
if r == nil {
return true
}
r.mu.Lock()
defer r.mu.Unlock()
n := r.inflightRefresh[tenantID]
if n <= 1 {
// Last (or already-balanced to zero) — clear the slot and let the caller deploy.
delete(r.inflightRefresh, tenantID)
return true
}
r.inflightRefresh[tenantID] = n - 1
return false
}
func parseCursor(s string, off *int) error {
_, err := fmt.Sscanf(s, "%d", off)
return err
+60 -23
View File
@@ -119,26 +119,7 @@ func (w *Worker) Process(j *Job) {
switch j.Kind {
case KindModuleRefresh:
mid, _ := j.Meta["module_id"].(string)
if strings.TrimSpace(mid) == "" {
j.Fail("missing module_id in job meta")
return
}
ctx, cancel := j.workContext()
defer cancel()
if ctx.Err() != nil {
j.MarkCancelled()
return
}
if err := pipeline.RefreshModuleIngest(ctx, w.Store, w.httpClient(), j.TenantID, mid); err != nil {
if ctx.Err() != nil {
j.MarkCancelled()
return
}
j.Fail(err.Error())
return
}
w.finishModuleRefreshSuccess(j, mid)
w.runModuleRefresh(j)
case KindTenantRefresh:
w.runTenantRefresh(j)
case KindPeerReconcile:
@@ -295,7 +276,55 @@ func (w *Worker) tenantRefreshMu(tenantID string) *sync.Mutex {
// finishModuleRefreshSuccess marks the refresh job and, for the last active refresh in tenant,
// creates one aggregate revision and enqueues a single deploy_apply.
// runModuleRefresh handles a single module_refresh job and guarantees the per-tenant inflight
// slot is released exactly once — even on failure/cancellation before finishModuleRefreshSuccess.
func (w *Worker) runModuleRefresh(j *Job) {
coalesceFinalized := false
defer func() {
if !coalesceFinalized && w != nil && w.Registry != nil {
// Refresh failed/was cancelled before reaching finishModuleRefreshSuccess.
// Decrement the counter under the tenant mutex so the "last one" logic stays sound.
mu := w.tenantRefreshMu(j.TenantID)
mu.Lock()
w.Registry.finalizeRefreshCoalesce(j.TenantID)
mu.Unlock()
}
}()
mid, _ := j.Meta["module_id"].(string)
if strings.TrimSpace(mid) == "" {
j.Fail("missing module_id in job meta")
return
}
ctx, cancel := j.workContext()
defer cancel()
if ctx.Err() != nil {
j.MarkCancelled()
return
}
if err := pipeline.RefreshModuleIngest(ctx, w.Store, w.httpClient(), j.TenantID, mid); err != nil {
if ctx.Err() != nil {
j.MarkCancelled()
return
}
j.Fail(err.Error())
return
}
w.finishModuleRefreshSuccess(j, mid)
coalesceFinalized = true
}
func (w *Worker) runTenantRefresh(j *Job) {
coalesceFinalized := false
defer func() {
if !coalesceFinalized && w != nil && w.Registry != nil {
mu := w.tenantRefreshMu(j.TenantID)
mu.Lock()
w.Registry.finalizeRefreshCoalesce(j.TenantID)
mu.Unlock()
}
}()
moduleIDs := moduleIDsFromJobMeta(j.Meta)
if len(moduleIDs) == 0 {
j.Fail("missing module_ids in job meta")
@@ -318,6 +347,7 @@ func (w *Worker) runTenantRefresh(j *Job) {
}
j.mergeMeta(map[string]any{"module_ids": moduleIDs, "modules_refreshed": len(moduleIDs)})
w.finishModuleRefreshSuccess(j, trigger)
coalesceFinalized = true
}
func moduleIDsFromJobMeta(meta map[string]any) []string {
@@ -346,6 +376,9 @@ func moduleIDsFromJobMeta(meta map[string]any) []string {
func (w *Worker) finishModuleRefreshSuccess(j *Job, triggerModuleID string) {
if w == nil || w.Store == nil {
if w != nil && w.Registry != nil {
w.Registry.finalizeRefreshCoalesce(j.TenantID)
}
j.Succeed()
return
}
@@ -354,11 +387,15 @@ func (w *Worker) finishModuleRefreshSuccess(j *Job, triggerModuleID string) {
defer mu.Unlock()
ctx, cancel := j.workContext()
defer cancel()
deferDeploy := false
// Determine whether this is the last outstanding refresh for the tenant. The counter is
// incremented in Enqueue (under r.mu) and decremented here, so the "last one" decision is
// deterministic regardless of ingest timing — unlike the previous status-polling approach
// (CountOtherActiveRefresh) which could race under -race.
isLastRefresh := true
if w.Registry != nil {
deferDeploy = w.Registry.CountOtherActiveRefresh(j.TenantID, j.ID) > 0
isLastRefresh = w.Registry.finalizeRefreshCoalesce(j.TenantID)
}
if deferDeploy {
if !isLastRefresh {
j.mergeMeta(map[string]any{
"deploy_apply_deferred": true,
"deploy_apply_defer_reason": "parallel_module_refresh",