Files
wehub-resource-sync f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

1142 lines
44 KiB
Go

package postgres
import (
"context"
"database/sql"
"errors"
"fmt"
"log"
"math"
"slices"
"strconv"
"strings"
)
// VectorGenerationInfo identifies the local embedding generation being pushed.
// Machines whose embedding config produces the same Fingerprint share one PG
// generation (and one chunk table); Model and Dimension are recorded for
// diagnostics and to size the halfvec column.
type VectorGenerationInfo struct {
Fingerprint string
Model string
Dimension int
}
// VectorPushChunk is one embedded slice of a document. ChunkIndex is stable
// within a doc_key so re-pushes overwrite the same (doc_key, chunk_index) row.
type VectorPushChunk struct {
ChunkIndex int
Embedding []float32
}
// VectorPushDoc mirrors one local vectors.db document row plus its embeddings.
// DocKey is globally unique and shared across generations, which is why
// vector_documents is a single backend-agnostic table upserted by doc_key
// rather than a per-generation table.
type VectorPushDoc struct {
DocKey string
SessionID string
SourceUUID string
Ordinal int
OrdinalEnd int
Subordinate bool
OffsetsJSON string
Content string
ContentHash string
Chunks []VectorPushChunk
}
// VectorPushSource supplies the locally built vectors.db active generation to
// the PG push phase. Generation reports the active generation (ok=false when
// none is built, an error wrapping ErrVectorSourceNotReady when one exists but
// must not be exported right now). SessionDocHashes returns per-session
// aggregate hashes used for delta detection; SessionDocs returns the full docs
// for one session only when that session needs a (re)push, together with the
// aggregate hash of exactly the returned doc set computed in the same read
// snapshot ("" when no docs are embedded). The push compares that hash against
// the delta-scan hash and defers the session on any divergence, so a local
// index rewritten between the scan and the export can never overwrite valid PG
// vectors with a partial view.
type VectorPushSource interface {
Generation(ctx context.Context) (VectorGenerationInfo, bool, error)
SessionDocHashes(ctx context.Context) (map[string]string, error)
SessionDocs(
ctx context.Context, sessionID string,
) ([]VectorPushDoc, string, error)
}
// ErrVectorSourceNotReady marks a Generation error meaning the local vector
// index exists but is not safe to export right now — an embeddings build is
// rewriting it (or one was interrupted), so its session coverage is partial. A
// push that ran anyway would read that partial view as truth and evict or
// overwrite valid PG vectors. pushVectors turns this into a clean phase skip;
// the next push after the build completes sends everything that changed.
var ErrVectorSourceNotReady = errors.New(
"local vector index is not fully embedded (build in progress or interrupted)")
// VectorPushResult summarizes the vector push phase. Skipped is set (with a
// human reason) when the phase cannot run: no source, no active generation, or
// no pgvector extension. The counters describe what changed on PG.
//
// DocsDeleted counts vector_documents rows removed: on eviction of a whole
// session and when a doc vanished from a re-pushed session (its shared row is
// removed only when no other generation still embeds it). Conflicts counts
// sessions this pusher left untouched because the PG owner marker names a
// different machine — on the push path (a locally changed session PG says
// another machine owns) and on the evict path (an owned-elsewhere session
// absent from local, kept rather than evicted). The user-facing print of
// Conflicts lands with the CLI wiring.
type VectorPushResult struct {
Skipped bool
SkippedReason string
SessionsPushed int
SessionsUnchanged int
// SessionsDeferred counts changed sessions whose vectors were withheld
// because their session-phase push failed this run; the delta state is
// untouched, so the next successful push sends them.
SessionsDeferred int
DocsPushed int
ChunksPushed int
DocsDeleted int
SessionsEvicted int
Conflicts int
}
// vectorChunkInsertBatch caps rows per multi-row INSERT so parameter counts
// stay well under PG's 65535 bound (each chunk row binds 3 parameters).
const vectorChunkInsertBatch = 200
// vectorProgressStride bounds how many unchanged sessions the delta scan
// examines between progress reports; pushed sessions always report.
const vectorProgressStride = 2000
// vectorGeneration is the resolved PG generation this push targets: its id and
// the schema-qualified halfvec type. The type must be qualified because the
// connection's search_path is the target schema only, while pgvector's types
// live in whichever schema first installed the extension.
type vectorGeneration struct {
id int64
halfvecType string
}
// vectorPushScope carries the push-wide constants threaded through every
// per-session push and eviction: the target generation, its local fingerprint
// (for the pre-eviction re-check against the source), the id list of all
// generations whose chunk table exists (for the shared-doc cross-generation
// guard, already filtered so a missing table cannot abort a tx), and this
// pusher's owner identity. Bundling them keeps per-session helpers under the
// positional-parameter limit.
type vectorPushScope struct {
gen vectorGeneration
fingerprint string
genIDs []int64
owner vectorOwnerIdentity
}
// vectorPushStateRow is the PG-side delta state for one owned session in one
// generation, joined with the session's current owner marker and machine.
type vectorPushStateRow struct {
docAggHash string
ownerMarker string
machine string
sessionExists bool
}
// vectorOwnerIdentity is the set of PG session identities this pusher treats
// as its own, mirroring the session phase's sameSessionOwner: a non-empty
// owner_marker decides ownership outright, while an empty marker (a legacy or
// unclaimed row) falls back to the sessions.machine column checked against
// this pusher's machine name and its recorded marker aliases. One divergence
// from the session phase is deliberate: sameSessionOwner compares against
// each pushed session's own machine field, which lets a legacy row be adopted
// through a locally mirrored remote session; the vector phase compares only
// against this pusher's identities, so a legacy row that names another
// machine is a conflict here even when a mirrored copy exists locally —
// deferring to that machine's own vector push rather than overwriting it.
type vectorOwnerIdentity struct {
markerID string
machine string
legacyMachines []string
}
func (id vectorOwnerIdentity) owns(ownerMarker, machine string) bool {
if ownerMarker != "" {
return ownerMarker == id.markerID
}
if machine == "" || machine == "local" || machine == id.machine {
return true
}
return slices.Contains(id.legacyMachines, machine)
}
// vectorOwnerIdentity resolves this push's owner identity: its marker id plus
// the machine name and marker-machine aliases used to adjudicate legacy PG
// rows whose owner_marker is empty.
func (s *Sync) vectorOwnerIdentity(
ctx context.Context,
) (vectorOwnerIdentity, error) {
markerID, err := s.pushMarkerID()
if err != nil {
return vectorOwnerIdentity{}, err
}
markerMachine, aliases, _, err := s.pgPushMarkerMachineState(ctx, markerID)
if err != nil {
return vectorOwnerIdentity{}, err
}
return vectorOwnerIdentity{
markerID: markerID,
machine: s.machine,
legacyMachines: pushMarkerLegacyMachines(markerMachine, aliases),
}, nil
}
// pushVectors replicates the local active generation's embeddings into PG,
// pushing only sessions whose aggregate hash changed and evicting state for
// sessions this pusher no longer has. It is a no-op (Skipped) when there is no
// source, no active generation, or no pgvector extension. Sessions are already
// committed by the caller when this runs; a failure here surfaces as a push
// error without undoing them. full bypasses the unchanged-hash skip, matching
// the session phase's --full semantics: a full push re-sends every session's
// vectors, repairing PG rows whose vector_push_state wrongly reports them
// current. failedSessions names sessions whose session-phase push failed this
// run: their vectors are deferred (counted in SessionsDeferred) so pgvector
// data never runs ahead of the corresponding sessions/messages rows.
// onProgress, when non-nil, receives Phase "vectors" reports so interactive
// pushes are not silent through a long first vector push.
func (s *Sync) pushVectors(
ctx context.Context, full bool, failedSessions map[string]struct{},
onProgress func(PushProgress),
) (VectorPushResult, error) {
var res VectorPushResult
if s.vectorSource == nil {
res.Skipped, res.SkippedReason = true, "no vector source configured"
return res, nil
}
gen, hasGen, err := s.vectorSource.Generation(ctx)
if errors.Is(err, ErrVectorSourceNotReady) {
res.Skipped, res.SkippedReason = true, err.Error()
log.Printf("vector push: skipped: %v", err)
return res, nil
}
if err != nil {
return res, fmt.Errorf("resolving local vector generation: %w", err)
}
if !hasGen {
res.Skipped, res.SkippedReason = true, "no active local generation"
return res, nil
}
unavailable, err := ensureVectorBaseSchemaPG(ctx, s.pg)
if err != nil {
if s.skipVectorsOnPrivilegeError(err, &res) {
return res, nil
}
return res, err
}
if unavailable != "" {
res.Skipped, res.SkippedReason = true, unavailable
return res, nil
}
resolved, err := s.resolveVectorGeneration(ctx, gen)
if err != nil {
if s.skipVectorsOnPrivilegeError(err, &res) {
return res, nil
}
return res, err
}
owner, err := s.vectorOwnerIdentity(ctx)
if err != nil {
return res, err
}
local, err := s.vectorSource.SessionDocHashes(ctx)
if err != nil {
return res, fmt.Errorf("reading local vector doc hashes: %w", err)
}
pgState, err := s.readVectorPushState(ctx, resolved.id)
if err != nil {
return res, err
}
log.Printf("vector push: comparing %d local session(s) against %d PG state row(s) for generation %d",
len(local), len(pgState), resolved.id)
if err := s.applyVectorDeltas(
ctx, resolved, gen.Fingerprint, owner, full, local, pgState,
failedSessions, onProgress, &res,
); err != nil {
return res, err
}
log.Printf("vector push: %d session(s) pushed, %d unchanged, %d deferred, %d evicted, %d chunks",
res.SessionsPushed, res.SessionsUnchanged, res.SessionsDeferred,
res.SessionsEvicted, res.ChunksPushed)
return res, nil
}
// skipVectorsOnPrivilegeError reports whether err is an
// insufficient-privilege error (SQLSTATE 42501) from the vector SETUP phase
// — base tables, generation registration, chunk table — and marks res as
// skipped when it is: a restricted role that cannot create vector tables in
// an already-provisioned schema degrades exactly like a database without
// pgvector, instead of failing a push whose session phase succeeded.
// Privilege errors AFTER setup (mid-push writes) never reach this and still
// fail the push loudly.
func (s *Sync) skipVectorsOnPrivilegeError(err error, res *VectorPushResult) bool {
if !isInsufficientPrivilege(err) {
return false
}
res.Skipped = true
res.SkippedReason = "insufficient PostgreSQL privileges for vector tables"
log.Printf("vector push: skipped: %v", err)
return true
}
// applyVectorDeltas pushes changed/new sessions and evicts sessions absent from
// local, accumulating counters into res. Sessions owned by another machine are
// left untouched on both paths and counted in res.Conflicts (ownership
// conflicts mirror the session push). When a project filter is active, sessions
// that fail the filter are excluded from both paths entirely: they are neither
// pushed, counted unchanged, evicted, nor counted as conflicts, mirroring the
// session push's project scope so a filtered `pg push` never touches vector
// state for out-of-scope sessions. Local candidates are scoped by their live
// local project and PG-only eviction candidates by their PG project (see
// vectorSessionsOutOfScope). Changed sessions in failedSessions are deferred
// rather than pushed: their session-phase push failed, so sending newer
// vectors would leave pgvector data ahead of the sessions/messages rows.
// Their delta state is untouched and the next successful push sends them.
// Failed sessions are excluded from eviction the same way: the local hash map
// only lists sessions with embedded docs, so a failed session whose docs all
// vanished locally would otherwise be evicted — vector state running ahead of
// the sessions/messages rows its session-phase push failed to write. full
// re-sends
// unchanged sessions too, while still honoring out-of-scope exclusion,
// ownership conflicts, and failed-session deferral. onProgress, when non-nil,
// receives a Phase "vectors" report after every pushed session and every
// vectorProgressStride examined ones, so long delta scans stay visible
// without a callback per unchanged session.
func (s *Sync) applyVectorDeltas(
ctx context.Context, gen vectorGeneration, fingerprint string,
owner vectorOwnerIdentity, full bool,
local map[string]string, pgState map[string]vectorPushStateRow,
failedSessions map[string]struct{}, onProgress func(PushProgress),
res *VectorPushResult,
) error {
allGenIDs, err := s.allVectorGenerationIDs(ctx)
if err != nil {
return err
}
genIDs, err := s.existingChunkGenerations(ctx, allGenIDs)
if err != nil {
return err
}
scope := vectorPushScope{
gen: gen, fingerprint: fingerprint, genIDs: genIDs, owner: owner,
}
outOfScope, err := s.vectorSessionsOutOfScope(ctx, local, pgState)
if err != nil {
return err
}
examined := 0
report := func() {
if onProgress == nil {
return
}
onProgress(PushProgress{
Phase: "vectors",
VectorSessionsDone: examined,
VectorSessionsTotal: len(local),
VectorChunksPushed: res.ChunksPushed,
})
}
for sessionID, agg := range local {
examined++
if _, skip := outOfScope[sessionID]; skip {
continue
}
prev, hasState := pgState[sessionID]
if hasState && !owner.owns(prev.ownerMarker, prev.machine) {
res.Conflicts++
continue
}
if !full && hasState && prev.docAggHash == agg {
res.SessionsUnchanged++
continue
}
if _, failed := failedSessions[sessionID]; failed {
res.SessionsDeferred++
continue
}
outcome, err := s.pushVectorSession(ctx, scope, sessionID, agg)
if err != nil {
return err
}
if outcome.conflict {
res.Conflicts++
}
if outcome.deferred {
res.SessionsDeferred++
// One diverged session can be a transient local change; a source
// that is no longer ready (or swapped generations) means a build
// is rewriting the whole index — every remaining session would
// diverge too, so stop the phase (evictions included) and let the
// next push, run against the completed build, send everything.
if !s.vectorSourceStillCurrent(ctx, scope.fingerprint) {
log.Printf(
"vector push: stopping after %d pushed session(s): local generation changed or became unready mid-push",
res.SessionsPushed)
return nil
}
}
if outcome.pushed {
res.SessionsPushed++
res.DocsPushed += outcome.docs
res.ChunksPushed += outcome.chunks
res.DocsDeleted += outcome.deleted
report()
} else if examined%vectorProgressStride == 0 {
report()
}
}
report()
var evict []string
for sessionID, st := range pgState {
if _, inLocal := local[sessionID]; inLocal {
continue
}
if _, skip := outOfScope[sessionID]; skip {
continue
}
if _, failed := failedSessions[sessionID]; failed {
res.SessionsDeferred++
continue
}
switch {
case !st.sessionExists || owner.owns(st.ownerMarker, st.machine):
evict = append(evict, sessionID)
default:
res.Conflicts++
}
}
if len(evict) > 0 && !s.vectorSourceStillCurrent(ctx, scope.fingerprint) {
log.Printf(
"vector push: skipping eviction of %d session(s): local generation changed or became unready mid-push",
len(evict))
return nil
}
return s.evictVectorSessions(ctx, scope, evict, res)
}
// vectorSourceStillCurrent re-verifies that the local source still reports the
// same fully embedded generation the delta scan read its hashes from. It gates
// two decisions computed from that earlier read: the eviction list (an
// embeddings build that starts mid-push collapses the local coverage the list
// was computed from — evicting on that view would remove valid PG vectors
// wholesale — and a generation swap invalidates it outright) and whether to
// keep pushing after a session's export hash diverged from its delta-scan hash
// (an unready source means every remaining session would diverge too).
// Deferring is always safe: both the eviction list and per-session deltas are
// re-derived from scratch on the next push. (A full rebuild that both starts
// and completes between the hash read and this check is not detectable from
// the source's state, but the per-session export-hash comparison in
// pushVectorSession covers the replacement path regardless, and eviction
// candidates lost to that window are re-pushed by the next push.)
func (s *Sync) vectorSourceStillCurrent(
ctx context.Context, fingerprint string,
) bool {
cur, ok, err := s.vectorSource.Generation(ctx)
return err == nil && ok && cur.Fingerprint == fingerprint
}
// vectorSessionsOutOfScope computes the set of candidate sessions a filtered
// push must not touch, partitioned by which project value is authoritative.
// Any candidate with a live local sessions row — present in the vectors.db
// hash map or not, since a live session can have zero embedded docs — is
// scoped by its LIVE local project: a session that moved out of the filter
// locally (alpha->beta) keeps its old PG project because the filtered session
// push skipped it, so scoping those by PG would let a stale value re-push or
// evict their vectors. Only candidates genuinely absent from the local
// sessions table keep the PG-project scope; theirs is then the only project
// there is, and it is correct for eviction. Returns nil (empty set) when no
// filter is active, so the common unfiltered path issues no query.
func (s *Sync) vectorSessionsOutOfScope(
ctx context.Context,
local map[string]string, pgState map[string]vectorPushStateRow,
) (map[string]struct{}, error) {
if !s.isFiltered() {
return nil, nil
}
localIDs := make([]string, 0, len(local))
for id := range local {
localIDs = append(localIDs, id)
}
out, err := s.localOutOfScopeVectorSessions(ctx, localIDs)
if err != nil {
return nil, err
}
if out == nil {
out = make(map[string]struct{})
}
var pgOnly []string
for id := range pgState {
if _, inLocal := local[id]; !inLocal {
pgOnly = append(pgOnly, id)
}
}
absent, err := s.scopeEvictionCandidatesByLocalProject(ctx, pgOnly, out)
if err != nil {
return nil, err
}
pgOut, err := s.outOfScopeVectorSessions(ctx, absent)
if err != nil {
return nil, err
}
for id := range pgOut {
out[id] = struct{}{}
}
return out, nil
}
// scopeEvictionCandidatesByLocalProject scopes eviction candidates that still
// have a live local sessions row by that row's project — adding filter
// failures to out — and returns the candidates with no local row at all, whose
// scoping must fall back to the PG project. Candidates reach here because they
// are absent from the vectors.db hash map, which only proves they have no
// embedded docs, not that the session left the local archive. Caller
// guarantees a filter is active.
func (s *Sync) scopeEvictionCandidatesByLocalProject(
ctx context.Context, candidateIDs []string, out map[string]struct{},
) ([]string, error) {
if len(candidateIDs) == 0 {
return nil, nil
}
projects, err := s.local.SessionProjectsByIDs(ctx, candidateIDs)
if err != nil {
return nil, fmt.Errorf("reading local session projects: %w", err)
}
var absent []string
for _, id := range candidateIDs {
project, ok := projects[id]
if !ok {
absent = append(absent, id)
continue
}
if projectFailsFilter(project, s.projects, s.excludeProjects) {
out[id] = struct{}{}
}
}
return absent, nil
}
// localOutOfScopeVectorSessions returns the subset of localIDs whose LIVE local
// sessions.project fails this push's filter, read from the local DB rather than
// PG so a session that changed projects locally is scoped by its current value.
// A session absent from the local sessions table is left in scope: the
// per-session in-tx ownership probe skips it when no PG row exists, matching the
// unfiltered path rather than silently swallowing it here. Caller guarantees a
// filter is active.
func (s *Sync) localOutOfScopeVectorSessions(
ctx context.Context, localIDs []string,
) (map[string]struct{}, error) {
if len(localIDs) == 0 {
return nil, nil
}
projects, err := s.local.SessionProjectsByIDs(ctx, localIDs)
if err != nil {
return nil, fmt.Errorf("reading local session projects: %w", err)
}
out := make(map[string]struct{})
for _, id := range localIDs {
project, ok := projects[id]
if !ok {
continue
}
if projectFailsFilter(project, s.projects, s.excludeProjects) {
out[id] = struct{}{}
}
}
return out, nil
}
// projectFailsFilter reports whether project is excluded by the push's
// include/exclude filter, mirroring the session push's SQL predicate: an
// include filter excludes any project not in the allowed set; an exclude filter
// excludes any project in the excluded set. Caller guarantees exactly one of
// projects/excludeProjects is set (ValidateProjectFilters rejects both).
func projectFailsFilter(project string, projects, excludeProjects []string) bool {
if len(projects) > 0 {
return !slices.Contains(projects, project)
}
return slices.Contains(excludeProjects, project)
}
// outOfScopeVectorSessions returns the subset of PG-only eviction candidateIDs
// whose PG sessions.project fails this push's project filter. These are state
// rows whose session no longer exists locally, so there is no live local
// project to scope them by; the PG project is authoritative for the eviction
// decision. Sessions with no PG row are never returned: the evict path already
// treats a vanished session as evictable. Returns nil (empty set) when no
// filter is active, so the common unfiltered path issues no query.
func (s *Sync) outOfScopeVectorSessions(
ctx context.Context, candidateIDs []string,
) (map[string]struct{}, error) {
if !s.isFiltered() || len(candidateIDs) == 0 {
return nil, nil
}
query, args := vectorOutOfScopeQuery(
candidateIDs, s.projects, s.excludeProjects,
)
rows, err := s.pg.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("reading out-of-scope vector sessions: %w", err)
}
defer rows.Close()
out := make(map[string]struct{})
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("scanning out-of-scope vector session: %w", err)
}
out[id] = struct{}{}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating out-of-scope vector sessions: %w", err)
}
return out, nil
}
// vectorOutOfScopeQuery builds the SQL that selects candidate session IDs whose
// PG project fails the filter. It mirrors the session push's include/exclude
// semantics: an include filter selects sessions whose project is not in the
// allowed set; an exclude filter selects sessions whose project is in the
// excluded set. Caller guarantees exactly one of projects/excludeProjects is
// set (ValidateProjectFilters rejects both).
func vectorOutOfScopeQuery(
ids, projects, excludeProjects []string,
) (string, []any) {
if len(projects) > 0 {
return `SELECT id FROM sessions
WHERE id = ANY($1) AND NOT (project = ANY($2))`,
[]any{ids, projects}
}
return `SELECT id FROM sessions
WHERE id = ANY($1) AND project = ANY($2)`,
[]any{ids, excludeProjects}
}
// resolveVectorGeneration registers the generation, creates its chunk table,
// and records this machine's push against it.
func (s *Sync) resolveVectorGeneration(
ctx context.Context, gen VectorGenerationInfo,
) (vectorGeneration, error) {
genID, err := ensureVectorGeneration(
ctx, s.pg, gen.Fingerprint, gen.Model, gen.Dimension,
)
if err != nil {
return vectorGeneration{}, err
}
if err := ensureVectorChunkTable(ctx, s.pg, genID, gen.Dimension); err != nil {
return vectorGeneration{}, err
}
extSchema, err := vectorExtensionSchema(ctx, s.pg)
if err != nil {
return vectorGeneration{}, err
}
if _, err := s.pg.ExecContext(ctx, `
INSERT INTO vector_generation_machines (generation_id, machine, last_push_at)
VALUES ($1, $2, now())
ON CONFLICT (generation_id, machine) DO UPDATE SET last_push_at = now()`,
genID, s.machine); err != nil {
return vectorGeneration{}, fmt.Errorf("recording vector push machine: %w", err)
}
return vectorGeneration{id: genID, halfvecType: extSchema + ".halfvec"}, nil
}
// readVectorPushState loads the delta state for genID, joined with each
// session's current owner marker and machine (the machine adjudicates legacy
// rows whose owner_marker is empty; see vectorOwnerIdentity). A LEFT JOIN miss
// (sessionExists false) marks a state row whose session row is gone; such rows
// are always stale.
func (s *Sync) readVectorPushState(
ctx context.Context, genID int64,
) (map[string]vectorPushStateRow, error) {
rows, err := s.pg.QueryContext(ctx, `
SELECT ps.session_id, ps.doc_agg_hash, s.owner_marker, s.machine,
(s.id IS NOT NULL)
FROM vector_push_state ps
LEFT JOIN sessions s ON s.id = ps.session_id
WHERE ps.generation_id = $1`, genID)
if err != nil {
return nil, fmt.Errorf("reading vector push state: %w", err)
}
defer rows.Close()
state := make(map[string]vectorPushStateRow)
for rows.Next() {
var sessionID, aggHash string
var ownerMarker, machine sql.NullString
var exists bool
if err := rows.Scan(
&sessionID, &aggHash, &ownerMarker, &machine, &exists,
); err != nil {
return nil, fmt.Errorf("scanning vector push state: %w", err)
}
state[sessionID] = vectorPushStateRow{
docAggHash: aggHash,
ownerMarker: ownerMarker.String,
machine: machine.String,
sessionExists: exists,
}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating vector push state: %w", err)
}
return state, nil
}
// vectorSessionOutcome reports what one pushVectorSession call did. pushed is
// false when the session was skipped: conflict marks a skip because the
// sessions row is owned by another machine (counted in res.Conflicts);
// deferred marks a skip because the session's export hash no longer matched
// its delta-scan hash — the local index changed mid-push, so nothing was
// written and the next push re-derives the delta; a skip with neither flag
// means the sessions row is gone (the session phase did not push it).
// docs/chunks/deleted are zero on any skip.
type vectorSessionOutcome struct {
pushed bool
conflict bool
deferred bool
docs int
chunks int
deleted int
}
// pushVectorSession replaces one session's docs, chunks, and push state for the
// scoped generation in a single transaction. It re-verifies ownership inside
// the transaction: a missing sessions row means the session phase did not push
// it (skip), and a different owner marker means another machine owns it
// (conflict skip, mirroring the session push's conflict).
//
// doc_key is source_uuid-based and stable across ordinal-shifting rewrites, but
// vector_documents has a UNIQUE (session_id, ordinal): upserting by doc_key
// alone collides with a not-yet-updated sibling when ordinals shift. So the tx
// first parks every existing session row at a unique negative ordinal, then
// upserts the current docs onto their final ordinals (freed by the park), then
// deletes rows still parked — docs that vanished locally — together with every
// generation's chunks for them (see deleteParkedVectorDocs). This mirrors the
// local mirror's park-to-sentinel slot replacement (internal/vector/mirror.go).
func (s *Sync) pushVectorSession(
ctx context.Context, scope vectorPushScope, sessionID, aggHash string,
) (vectorSessionOutcome, error) {
tx, err := s.pg.BeginTx(ctx, nil)
if err != nil {
return vectorSessionOutcome{}, fmt.Errorf("begin vector push tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
// FOR UPDATE locks the sessions row for the life of this tx so a concurrent
// pusher cannot take ownership between this probe and the vector writes
// below (under READ COMMITTED an unlocked probe could read a stale owner).
// This is deadlock-safe: the session push phase has already committed before
// pushVectors runs (see pushVectors), so no session-push tx holds this row
// concurrently, and each vector push tx locks exactly one session row (by
// primary key) before its own vector-table writes — a single, consistent
// lock so two vector pushes cannot form a cycle.
var ownerMarker, machine sql.NullString
err = tx.QueryRowContext(ctx,
`SELECT owner_marker, machine FROM sessions WHERE id = $1 FOR UPDATE`,
sessionID,
).Scan(&ownerMarker, &machine)
if errors.Is(err, sql.ErrNoRows) {
return vectorSessionOutcome{}, nil
}
if err != nil {
return vectorSessionOutcome{}, fmt.Errorf(
"checking vector session ownership %s: %w", sessionID, err)
}
if !scope.owner.owns(ownerMarker.String, machine.String) {
return vectorSessionOutcome{conflict: true}, nil
}
// Export the full docs (content + decoded chunk blobs) only after the
// session passes the existence/ownership probe. A local-only session with no
// PG row, or one owned elsewhere, skips forever; fetching docs first would
// re-export them on every push for sessions that never land.
docs, exportHash, err := s.vectorSource.SessionDocs(ctx, sessionID)
if err != nil {
return vectorSessionOutcome{}, fmt.Errorf(
"reading local docs for session %s: %w", sessionID, err)
}
// The export hash covers exactly the docs read above, in the same local
// snapshot. A mismatch with the delta-scan hash means the local index
// changed between the scan and this export — typically an embeddings
// rebuild clearing and refilling the generation in place — and the docs
// may be a partial view. Writing them would replace valid PG vectors and
// record aggHash as current, which a same-fingerprint rebuild (same doc
// content, same hash) would never repair. Defer instead: nothing is
// written, and the next push re-derives the delta from the settled index.
if exportHash != aggHash {
return vectorSessionOutcome{deferred: true}, nil
}
if err := parkSessionVectorDocs(ctx, tx, sessionID); err != nil {
return vectorSessionOutcome{}, err
}
if err := upsertVectorDocs(ctx, tx, docs); err != nil {
return vectorSessionOutcome{}, err
}
chunks, err := replaceVectorChunks(ctx, tx, scope.gen, sessionID, docs)
if err != nil {
return vectorSessionOutcome{}, err
}
deleted, err := deleteParkedVectorDocs(ctx, tx, sessionID, scope.genIDs)
if err != nil {
return vectorSessionOutcome{}, err
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO vector_push_state (generation_id, session_id, doc_agg_hash)
VALUES ($1, $2, $3)
ON CONFLICT (generation_id, session_id)
DO UPDATE SET doc_agg_hash = EXCLUDED.doc_agg_hash`,
scope.gen.id, sessionID, aggHash); err != nil {
return vectorSessionOutcome{}, fmt.Errorf(
"upserting vector push state %s: %w", sessionID, err)
}
if err := tx.Commit(); err != nil {
return vectorSessionOutcome{}, fmt.Errorf("commit vector push tx: %w", err)
}
return vectorSessionOutcome{
pushed: true, docs: len(docs), chunks: chunks, deleted: deleted,
}, nil
}
// parkSessionVectorDocs moves every non-negative-ordinal vector_documents row
// for sessionID to a unique negative ordinal below the session's existing
// parked floor, freeing the (session_id, ordinal) unique index so a subsequent
// doc_key upsert of shifted/renumbered docs cannot collide with a sibling not
// yet updated. Rows already parked (negative) are left as-is; seeding below
// MIN(ordinal) keeps every parked ordinal distinct across overlapping pushes,
// mirroring the local mirror's parkingFloor (internal/vector/mirror.go). The
// transform is injective, so the single statement never violates the index at
// any intermediate row.
func parkSessionVectorDocs(ctx context.Context, tx *sql.Tx, sessionID string) error {
if _, err := tx.ExecContext(ctx, `
WITH floor AS (
SELECT COALESCE(MIN(ordinal), 0) AS f
FROM vector_documents WHERE session_id = $1 AND ordinal < 0
), parked AS (
SELECT doc_key, row_number() OVER (ORDER BY ordinal) AS rn
FROM vector_documents WHERE session_id = $1 AND ordinal >= 0
)
UPDATE vector_documents d
SET ordinal = (SELECT f FROM floor) - parked.rn
FROM parked
WHERE d.doc_key = parked.doc_key`, sessionID); err != nil {
return fmt.Errorf("parking vector docs for session %s: %w", sessionID, err)
}
return nil
}
// upsertVectorDocs upserts each doc by its globally unique doc_key onto its
// final ordinal. The caller parks the session's prior rows to negative ordinals
// first, so every final (session_id, ordinal) slot is free and an ordinal shift
// cannot collide with a sibling row that has not been updated yet.
func upsertVectorDocs(ctx context.Context, tx *sql.Tx, docs []VectorPushDoc) error {
for _, doc := range docs {
offsets := doc.OffsetsJSON
if offsets == "" {
offsets = "[]"
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO vector_documents (
doc_key, session_id, source_uuid, ordinal, ordinal_end,
subordinate, offsets, content, content_hash)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (doc_key) DO UPDATE SET
session_id = EXCLUDED.session_id,
source_uuid = EXCLUDED.source_uuid,
ordinal = EXCLUDED.ordinal,
ordinal_end = EXCLUDED.ordinal_end,
subordinate = EXCLUDED.subordinate,
offsets = EXCLUDED.offsets,
content = EXCLUDED.content,
content_hash = EXCLUDED.content_hash`,
doc.DocKey, doc.SessionID, sanitizePG(doc.SourceUUID),
doc.Ordinal, doc.OrdinalEnd, doc.Subordinate,
sanitizePG(offsets), sanitizePG(doc.Content),
doc.ContentHash); err != nil {
return fmt.Errorf("upserting vector doc %s: %w", doc.DocKey, err)
}
}
return nil
}
// replaceVectorChunks deletes all of the session's chunk rows for genID and
// re-inserts the current chunk set. Sessions are small, so wholesale replace is
// simpler and correct versus per-doc surgical deletes. It returns the number of
// chunk rows inserted.
func replaceVectorChunks(
ctx context.Context, tx *sql.Tx, gen vectorGeneration,
sessionID string, docs []VectorPushDoc,
) (int, error) {
table := vectorChunkTable(gen.id)
if _, err := tx.ExecContext(ctx, fmt.Sprintf(`
DELETE FROM %s WHERE doc_key IN (
SELECT doc_key FROM vector_documents WHERE session_id = $1)`, table),
sessionID); err != nil {
return 0, fmt.Errorf("clearing chunks for session %s: %w", sessionID, err)
}
type chunkRow struct {
docKey string
chunkIndex int
embedding string
}
var rows []chunkRow
for _, doc := range docs {
for _, chunk := range doc.Chunks {
literal, err := halfvecLiteral(chunk.Embedding)
if err != nil {
return 0, fmt.Errorf(
"doc %s chunk %d: %w", doc.DocKey, chunk.ChunkIndex, err)
}
rows = append(rows, chunkRow{
docKey: doc.DocKey,
chunkIndex: chunk.ChunkIndex,
embedding: literal,
})
}
}
for start := 0; start < len(rows); start += vectorChunkInsertBatch {
end := min(start+vectorChunkInsertBatch, len(rows))
batch := rows[start:end]
var values strings.Builder
args := make([]any, 0, len(batch)*3)
for i, r := range batch {
if i > 0 {
values.WriteByte(',')
}
base := i * 3
fmt.Fprintf(&values, "($%d,$%d,$%d::%s)",
base+1, base+2, base+3, gen.halfvecType)
args = append(args, r.docKey, r.chunkIndex, r.embedding)
}
stmt := fmt.Sprintf(
`INSERT INTO %s (doc_key, chunk_index, embedding) VALUES %s`,
table, values.String())
if _, err := tx.ExecContext(ctx, stmt, args...); err != nil {
return 0, fmt.Errorf("inserting chunks for session %s: %w", sessionID, err)
}
}
return len(rows), nil
}
// evictVectorSessions removes each session's chunks and push state for the
// scoped generation and prunes its now-orphaned shared doc rows, one
// transaction per session. Ownership was adjudicated from the delta scan's
// state read, which can be minutes stale by the time evictions run, so each
// transaction re-probes the sessions row FOR UPDATE (the same lock discipline
// as pushVectorSession): a session another pusher claimed since the scan is
// skipped as a conflict rather than having that owner's fresh chunks deleted.
// A still-missing sessions row keeps the eviction — the state row is stale by
// definition — and a row recreated after this tx commits is re-pushed by its
// new owner's next vector push.
func (s *Sync) evictVectorSessions(
ctx context.Context, scope vectorPushScope, sessionIDs []string,
res *VectorPushResult,
) error {
if len(sessionIDs) == 0 {
return nil
}
table := vectorChunkTable(scope.gen.id)
for _, sessionID := range sessionIDs {
tx, err := s.pg.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin vector evict tx: %w", err)
}
var ownerMarker, machine sql.NullString
err = tx.QueryRowContext(ctx,
`SELECT owner_marker, machine FROM sessions WHERE id = $1 FOR UPDATE`,
sessionID,
).Scan(&ownerMarker, &machine)
switch {
case errors.Is(err, sql.ErrNoRows):
// No sessions row: the push state is stale, evict below.
case err != nil:
_ = tx.Rollback()
return fmt.Errorf(
"checking vector evict ownership %s: %w", sessionID, err)
case !scope.owner.owns(ownerMarker.String, machine.String):
_ = tx.Rollback()
res.Conflicts++
continue
}
if _, err := tx.ExecContext(ctx, fmt.Sprintf(`
DELETE FROM %s WHERE doc_key IN (
SELECT doc_key FROM vector_documents WHERE session_id = $1)`, table),
sessionID); err != nil {
_ = tx.Rollback()
return fmt.Errorf("evicting chunks for session %s: %w", sessionID, err)
}
if _, err := tx.ExecContext(ctx, `
DELETE FROM vector_push_state
WHERE generation_id = $1 AND session_id = $2`,
scope.gen.id, sessionID); err != nil {
_ = tx.Rollback()
return fmt.Errorf("evicting push state for session %s: %w", sessionID, err)
}
deleted, err := deleteOrphanVectorDocs(ctx, tx, sessionID, scope.genIDs)
if err != nil {
_ = tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit vector evict tx: %w", err)
}
res.SessionsEvicted++
res.DocsDeleted += deleted
}
return nil
}
// existingChunkGenerations returns the subset of genIDs whose per-generation
// chunk table currently exists, checked with to_regclass on the schema-
// qualified table name. Filtering up front means the shared-doc cross-
// generation guard never references a missing chunk table (a generation row can
// exist before its chunk table, or after a partial reset), which would
// otherwise abort the deleting transaction.
func (s *Sync) existingChunkGenerations(
ctx context.Context, genIDs []int64,
) ([]int64, error) {
quotedSchema, err := quoteIdentifier(s.schema)
if err != nil {
return nil, fmt.Errorf("quoting schema for chunk-table probe: %w", err)
}
var existing []int64
for _, id := range genIDs {
var present bool
if err := s.pg.QueryRowContext(ctx, `SELECT to_regclass($1) IS NOT NULL`,
quotedSchema+"."+vectorChunkTable(id)).Scan(&present); err != nil {
return nil, fmt.Errorf(
"probing chunk table for generation %d: %w", id, err)
}
if present {
existing = append(existing, id)
}
}
return existing, nil
}
// allVectorGenerationIDs lists every registered generation so eviction can
// check each generation's chunk table before removing a shared doc row.
func (s *Sync) allVectorGenerationIDs(ctx context.Context) ([]int64, error) {
rows, err := s.pg.QueryContext(ctx, `SELECT id FROM vector_generations`)
if err != nil {
return nil, fmt.Errorf("listing vector generations: %w", err)
}
defer rows.Close()
var ids []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("scanning vector generation id: %w", err)
}
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating vector generations: %w", err)
}
return ids, nil
}
// deleteParkedVectorDocs removes the session's still-parked doc rows — docs
// that vanished from the local mirror — together with every generation's
// chunks for them. The local mirror is shared across generations and kit's
// removal deletes a vanished doc's vectors from every generation
// (sqlitevec's all-generations delete), so PG mirrors that. Preserving
// another generation's chunks instead would leave them referencing a row
// hidden behind the read path's ordinal >= 0 tombstone guard: dead KNN slots
// that can never hydrate, silently shrinking that generation's results.
// genIDs must be pre-filtered to existing chunk tables (see
// existingChunkGenerations) so a missing table cannot abort the tx. Returns
// the number of doc rows deleted.
func deleteParkedVectorDocs(
ctx context.Context, tx *sql.Tx, sessionID string, genIDs []int64,
) (int, error) {
for _, id := range genIDs {
if _, err := tx.ExecContext(ctx, fmt.Sprintf(`
DELETE FROM %s WHERE doc_key IN (
SELECT doc_key FROM vector_documents
WHERE session_id = $1 AND ordinal < 0)`, vectorChunkTable(id)),
sessionID); err != nil {
return 0, fmt.Errorf(
"clearing parked chunks for session %s: %w", sessionID, err)
}
}
result, err := tx.ExecContext(ctx,
`DELETE FROM vector_documents WHERE session_id = $1 AND ordinal < 0`,
sessionID)
if err != nil {
return 0, fmt.Errorf("pruning parked docs for session %s: %w", sessionID, err)
}
affected, err := result.RowsAffected()
if err != nil {
return 0, fmt.Errorf("counting pruned docs for session %s: %w", sessionID, err)
}
return int(affected), nil
}
// deleteOrphanVectorDocs deletes the session's doc rows that no listed
// generation's chunk table still references, for whole-session eviction (the
// session left this pusher's embedded set — possibly just a project-filter
// change, with the docs still present locally). vector_documents is shared
// across generations by doc_key, so a doc another generation still embeds
// survives here — those rows keep their non-negative ordinals and stay
// hydratable, unlike parked rows (see deleteParkedVectorDocs). genIDs must be
// pre-filtered to existing chunk tables (see existingChunkGenerations) so a
// missing table cannot abort the tx. Returns the number of doc rows deleted.
func deleteOrphanVectorDocs(
ctx context.Context, tx *sql.Tx, sessionID string, genIDs []int64,
) (int, error) {
var conds strings.Builder
for _, id := range genIDs {
fmt.Fprintf(&conds,
" AND NOT EXISTS (SELECT 1 FROM %s c WHERE c.doc_key = d.doc_key)",
vectorChunkTable(id))
}
stmt := fmt.Sprintf(
`DELETE FROM vector_documents d WHERE d.session_id = $1%s`,
conds.String())
result, err := tx.ExecContext(ctx, stmt, sessionID)
if err != nil {
return 0, fmt.Errorf("pruning orphan docs for session %s: %w", sessionID, err)
}
affected, err := result.RowsAffected()
if err != nil {
return 0, fmt.Errorf("counting pruned docs for session %s: %w", sessionID, err)
}
return int(affected), nil
}
// halfvecLiteral renders v in pgvector's text input format ("[1,2,3]"), bound
// as a parameter and cast with ::halfvec. It errors on any non-finite element
// (NaN, +Inf, -Inf): pgvector rejects those on input, so a single pathological
// vector would otherwise abort the whole multi-row INSERT with an opaque driver
// error. Catching it here lets the caller attribute the failure to a doc/chunk.
func halfvecLiteral(v []float32) (string, error) {
var b strings.Builder
b.WriteByte('[')
for i, f := range v {
if i > 0 {
b.WriteByte(',')
}
f64 := float64(f)
if math.IsNaN(f64) || math.IsInf(f64, 0) {
return "", fmt.Errorf("non-finite embedding value at index %d", i)
}
b.WriteString(strconv.FormatFloat(f64, 'g', -1, 32))
}
b.WriteByte(']')
return b.String(), nil
}