Skip to content

Commit 42c34fc

Browse files
authored
Idempotent ingest + self-diagnosing not-found (HAL-323) (#49)
* engine: idempotent ingest + self-diagnosing not-found (HAL-323) Two-part fix for flaky bulk ingestion and the bench's duplicate uploads. Idempotent ingest (root cause of "same doc ingested up to 6x"): - New `idempotency_key` column on documents + partial unique index on (org_id, idempotency_key) — migration 0007. NULL key = no dedup (preserves current behavior for callers that don't opt in). - All three ingest handlers (standalone/local in internal/api, multi-tenant REST in internal/handler, Connect RPC in internal/connecthandler) honor the `Idempotency-Key` header the SDK already sends: a repeat ingest returns the original document instead of creating a duplicate, and the concurrent same-key race is resolved by dropping the orphan source and returning the winner. The SDK retry-on-transient (which re-POSTs after a reset that landed AFTER the server committed) is now a no-op. - db: ErrConflict sentinel mapped from SQLSTATE 23505; GetDocumentByIdempotencyKey. Self-diagnosing not-found (resolves the pathless "object not found" mystery): - Every storage backend's ErrNotFound now carries the resolved key/path (local Get+Delete, s3 Get+Delete, gcs Get+Delete) so a failure can always be attributed to a code path — pathless rows were pre-path-wrap leftovers. - local.Get: the open-after-stat result is wrapped as retryable ErrNotFound (previously a raw error getSourceWithRetry would NOT retry), and a Windows-only bounded retry rides through the Defender scan window that transiently hides a freshly-written file. Docs: OpenAPI documents the Idempotency-Key header; README documents the mandatory Windows Defender exclusion for the storage root. Verified: go build/vet clean; storage + db (live-Postgres) tests pass; and an end-to-end double-POST with one Idempotency-Key returns one document_id while a keyless control gets its own. * engine: gofmt — align idempotency_key struct field * engine: address PR review — scope idempotency replay by org, log orphan cleanup - middleware.Idempotency: compose the cache key as method|path|org|key instead of the bare Idempotency-Key, so two tenants reusing the same opaque key can't receive each other's cached 202 (cross-tenant leak). New tests cover same-org replay, cross-org isolation, and keyless pass-through. (CodeRabbit, Major) - All three ingest handlers now log orphan-source cleanup failures on the idempotency-conflict path instead of discarding the Delete error. (Sourcery + CodeRabbit) - GetDocumentByIdempotencyKey hydrates IdempotencyKey from the DB column. - Strengthen TestLocalGetRetriesTransientNotExist: on Windows it now asserts the FIRST Get succeeds, so the test fails if the retry loop is removed. (Sourcery) - README: "freshly written" typo.
1 parent ebb9c10 commit 42c34fc

16 files changed

Lines changed: 554 additions & 55 deletions

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,15 @@ llm:
164164
165165
> **Anthropic-compatible gateways (GLM/Z.ai):** `base_url` **must include `/v1`** — the client posts to `${base_url}/messages`.
166166

167+
> **Windows + local storage:** Windows Defender real-time protection scans
168+
> each freshly written file and briefly hides it from `os.Stat`/`os.Open`,
169+
> which under heavy concurrent ingestion can surface as transient
170+
> `object not found` errors. The local backend rides through this window with
171+
> a short internal retry, but for large bulk loads **add a Defender exclusion
172+
> for your storage root** (`local.root` / `VLE_STORAGE_LOCAL_ROOT`):
173+
> `Add-MpPreference -ExclusionPath "C:\path\to\data\documents"`. Linux has no
174+
> such scan-hold and needs no exclusion.
175+
167176
### Supported formats
168177

169178
PDF (positioned text + tables via [`pdftable`](https://github.com/hallelx2/pdftable)) · Markdown · HTML · DOCX · Text.

internal/api/server.go

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,28 @@ func (d Deps) handleIngestDocument(w http.ResponseWriter, r *http.Request) {
231231

232232
docID := ingest.NewDocumentID()
233233

234+
// Idempotent ingest: if the caller supplied an Idempotency-Key and a
235+
// document already exists under it (for the standalone org), return that
236+
// document instead of creating a duplicate. A client — or the SDK's own
237+
// transport retry-on-transient — that re-POSTs the same upload after a
238+
// reset that landed AFTER the server committed the row is then a no-op.
239+
// This is the standalone/local-mode half of the HAL-323 duplicate-upload
240+
// fix (the multi-tenant handler in internal/handler mirrors it).
241+
idemKey := r.Header.Get("Idempotency-Key")
242+
if idemKey != "" {
243+
if existing, err := d.DB.GetDocumentByIdempotencyKey(ctx, standaloneOrgID, idemKey); err == nil {
244+
writeJSON(w, http.StatusAccepted, map[string]any{
245+
"document_id": existing.ID,
246+
"status": string(existing.Status),
247+
})
248+
return
249+
} else if !errors.Is(err, db.ErrNotFound) {
250+
d.Logger.Error("ingest: idempotency lookup failed", "err", err)
251+
writeErr(w, http.StatusInternalServerError, "db read failed")
252+
return
253+
}
254+
}
255+
234256
var (
235257
filename string
236258
contentType string
@@ -300,14 +322,29 @@ func (d Deps) handleIngestDocument(w http.ResponseWriter, r *http.Request) {
300322
}
301323

302324
if err := d.DB.NewDocument(ctx, db.Document{
303-
ID: docID,
304-
OrgID: standaloneOrgID,
305-
Title: title,
306-
ContentType: contentType,
307-
SourceRef: key,
308-
Status: db.StatusPending,
309-
ByteSize: size,
325+
ID: docID,
326+
OrgID: standaloneOrgID,
327+
Title: title,
328+
ContentType: contentType,
329+
SourceRef: key,
330+
Status: db.StatusPending,
331+
ByteSize: size,
332+
IdempotencyKey: idemKey,
310333
}); err != nil {
334+
// A concurrent same-key ingest won the race: drop the orphan source we
335+
// just wrote and return the winner instead of a 500.
336+
if idemKey != "" && errors.Is(err, db.ErrConflict) {
337+
if delErr := d.Storage.Delete(ctx, key); delErr != nil {
338+
d.Logger.Warn("ingest: orphan source cleanup failed", "err", delErr, "source_ref", key, "idempotency_key", idemKey)
339+
}
340+
if existing, lookupErr := d.DB.GetDocumentByIdempotencyKey(ctx, standaloneOrgID, idemKey); lookupErr == nil {
341+
writeJSON(w, http.StatusAccepted, map[string]any{
342+
"document_id": existing.ID,
343+
"status": string(existing.Status),
344+
})
345+
return
346+
}
347+
}
311348
d.Logger.Error("ingest: db insert failed", "err", err)
312349
writeErr(w, http.StatusInternalServerError, "db write failed")
313350
return

internal/connecthandler/documents.go

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,23 @@ func (s *DocumentsService) CreateDocument(
8282
return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("content is required"))
8383
}
8484

85+
// Idempotent ingest: a repeat CreateDocument carrying the same
86+
// Idempotency-Key returns the original document rather than a duplicate
87+
// (mirrors the REST path; closes the HAL-323 duplicate-upload bug for SDK
88+
// callers that retry the RPC).
89+
idemKey := req.Header().Get("Idempotency-Key")
90+
if idemKey != "" {
91+
if existing, err := s.db.GetDocumentByIdempotencyKey(ctx, orgID, idemKey); err == nil {
92+
return connect.NewResponse(&v1.CreateDocumentResponse{
93+
DocumentId: string(existing.ID),
94+
Status: string(existing.Status),
95+
}), nil
96+
} else if !errors.Is(err, db.ErrNotFound) {
97+
s.logger.Error("ingest: idempotency lookup failed", "err", err)
98+
return nil, connect.NewError(connect.CodeInternal, errors.New("db read failed"))
99+
}
100+
}
101+
85102
docID := ingest.NewDocumentID()
86103
contentType := msg.ContentType
87104
if contentType == "" {
@@ -105,15 +122,29 @@ func (s *DocumentsService) CreateDocument(
105122
}
106123

107124
if err := s.db.NewDocument(ctx, db.Document{
108-
ID: docID,
109-
OrgID: orgID,
110-
StoreID: storeIDFromConnect(req),
111-
Title: title,
112-
ContentType: contentType,
113-
SourceRef: key,
114-
Status: db.StatusPending,
115-
ByteSize: size,
125+
ID: docID,
126+
OrgID: orgID,
127+
StoreID: storeIDFromConnect(req),
128+
Title: title,
129+
ContentType: contentType,
130+
SourceRef: key,
131+
Status: db.StatusPending,
132+
ByteSize: size,
133+
IdempotencyKey: idemKey,
116134
}); err != nil {
135+
// Concurrent same-key insert won the race: drop the orphan source and
136+
// return the winner instead of erroring.
137+
if idemKey != "" && errors.Is(err, db.ErrConflict) {
138+
if delErr := s.storage.Delete(ctx, key); delErr != nil {
139+
s.logger.Warn("ingest: orphan source cleanup failed", "err", delErr, "source_ref", key, "idempotency_key", idemKey)
140+
}
141+
if existing, lookupErr := s.db.GetDocumentByIdempotencyKey(ctx, orgID, idemKey); lookupErr == nil {
142+
return connect.NewResponse(&v1.CreateDocumentResponse{
143+
DocumentId: string(existing.ID),
144+
Status: string(existing.Status),
145+
}), nil
146+
}
147+
}
117148
s.logger.Error("ingest: db insert failed", "err", err)
118149
return nil, connect.NewError(connect.CodeInternal, errors.New("db write failed"))
119150
}

internal/handler/documents.go

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,25 @@ func (h *DocumentsHandler) HandleIngestDocument(w http.ResponseWriter, r *http.R
158158
ctx := r.Context()
159159
docID := ingest.NewDocumentID()
160160

161+
// Idempotent ingest: if the client supplied an Idempotency-Key and we
162+
// already have a document for this org under that key, return it instead
163+
// of creating a duplicate. This makes a client/transport retry of the
164+
// ingest POST (the HAL-323 duplicate-upload bug) a no-op.
165+
idemKey := r.Header.Get("Idempotency-Key")
166+
if idemKey != "" {
167+
if existing, err := h.db.GetDocumentByIdempotencyKey(ctx, orgID, idemKey); err == nil {
168+
writeJSON(w, http.StatusAccepted, map[string]any{
169+
"document_id": existing.ID,
170+
"status": string(existing.Status),
171+
})
172+
return
173+
} else if !errors.Is(err, db.ErrNotFound) {
174+
h.logger.Error("ingest: idempotency lookup failed", "err", err)
175+
writeErr(w, http.StatusInternalServerError, "db read failed")
176+
return
177+
}
178+
}
179+
161180
var (
162181
filename string
163182
contentType string
@@ -227,15 +246,31 @@ func (h *DocumentsHandler) HandleIngestDocument(w http.ResponseWriter, r *http.R
227246
}
228247

229248
if err := h.db.NewDocument(ctx, db.Document{
230-
ID: docID,
231-
OrgID: orgID,
232-
StoreID: storeID(r),
233-
Title: title,
234-
ContentType: contentType,
235-
SourceRef: key,
236-
Status: db.StatusPending,
237-
ByteSize: size,
249+
ID: docID,
250+
OrgID: orgID,
251+
StoreID: storeID(r),
252+
Title: title,
253+
ContentType: contentType,
254+
SourceRef: key,
255+
Status: db.StatusPending,
256+
ByteSize: size,
257+
IdempotencyKey: idemKey,
238258
}); err != nil {
259+
// A concurrent request with the same Idempotency-Key won the race and
260+
// inserted first. Drop the orphan source we just wrote and return the
261+
// winner's document rather than a 500.
262+
if idemKey != "" && errors.Is(err, db.ErrConflict) {
263+
if delErr := h.storage.Delete(ctx, key); delErr != nil {
264+
h.logger.Warn("ingest: orphan source cleanup failed", "err", delErr, "source_ref", key, "idempotency_key", idemKey)
265+
}
266+
if existing, lookupErr := h.db.GetDocumentByIdempotencyKey(ctx, orgID, idemKey); lookupErr == nil {
267+
writeJSON(w, http.StatusAccepted, map[string]any{
268+
"document_id": existing.ID,
269+
"status": string(existing.Status),
270+
})
271+
return
272+
}
273+
}
239274
h.logger.Error("ingest: db insert failed", "err", err)
240275
writeErr(w, http.StatusInternalServerError, "db write failed")
241276
return

internal/middleware/idempotency.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,13 +122,22 @@ func Idempotency(cfg IdempotencyConfig) func(http.Handler) http.Handler {
122122
return
123123
}
124124

125-
key := r.Header.Get("Idempotency-Key")
126-
if key == "" {
125+
rawKey := r.Header.Get("Idempotency-Key")
126+
if rawKey == "" {
127127
// No key provided — proceed normally.
128128
next.ServeHTTP(w, r)
129129
return
130130
}
131131

132+
// Scope the cache entry by tenant + method + path, NOT the raw
133+
// Idempotency-Key alone. Two orgs legitimately use the same opaque
134+
// key (e.g. "1", a per-corpus id); replaying by the bare key would
135+
// hand org B a 202 cached for org A's document — a cross-tenant
136+
// leak. The org header is absent in standalone/local mode, which is
137+
// single-tenant, so an empty org collapses to one namespace there.
138+
org := r.Header.Get("X-Vectorless-Org")
139+
key := r.Method + "|" + r.URL.Path + "|" + org + "|" + rawKey
140+
132141
// Check cache.
133142
if cr, ok := cache.get(key); ok {
134143
w.Header().Set("X-Idempotency-Replayed", "true")
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package middleware
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"sync/atomic"
7+
"testing"
8+
)
9+
10+
// handler that returns a per-org body and counts invocations, so we can tell a
11+
// cached replay from a real pass-through.
12+
func countingOrgHandler(calls *atomic.Int64) http.Handler {
13+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
14+
calls.Add(1)
15+
w.WriteHeader(http.StatusAccepted)
16+
_, _ = w.Write([]byte("doc-for-" + r.Header.Get("X-Vectorless-Org")))
17+
})
18+
}
19+
20+
func post(mw http.Handler, org, key string) *httptest.ResponseRecorder {
21+
req := httptest.NewRequest(http.MethodPost, "/v1/documents", nil)
22+
if org != "" {
23+
req.Header.Set("X-Vectorless-Org", org)
24+
}
25+
if key != "" {
26+
req.Header.Set("Idempotency-Key", key)
27+
}
28+
rec := httptest.NewRecorder()
29+
mw.ServeHTTP(rec, req)
30+
return rec
31+
}
32+
33+
func body(rec *httptest.ResponseRecorder) string {
34+
return rec.Body.String()
35+
}
36+
37+
func TestIdempotencySameOrgReplays(t *testing.T) {
38+
var calls atomic.Int64
39+
mw := Idempotency(IdempotencyConfig{})(countingOrgHandler(&calls))
40+
41+
first := post(mw, "orgA", "k1")
42+
second := post(mw, "orgA", "k1")
43+
44+
if calls.Load() != 1 {
45+
t.Fatalf("handler invoked %d times, want 1 (second should replay)", calls.Load())
46+
}
47+
if got := second.Header().Get("X-Idempotency-Replayed"); got != "true" {
48+
t.Fatalf("second response missing replay header, got %q", got)
49+
}
50+
if body(first) != body(second) {
51+
t.Fatalf("replay body %q != original %q", body(second), body(first))
52+
}
53+
}
54+
55+
func TestIdempotencyDifferentOrgsDoNotCollide(t *testing.T) {
56+
// The core cross-tenant guarantee: two orgs sending the SAME opaque
57+
// Idempotency-Key must each hit the handler and get THEIR OWN response —
58+
// org B must never be handed org A's cached document.
59+
var calls atomic.Int64
60+
mw := Idempotency(IdempotencyConfig{})(countingOrgHandler(&calls))
61+
62+
a := post(mw, "orgA", "shared-key")
63+
b := post(mw, "orgB", "shared-key")
64+
65+
if calls.Load() != 2 {
66+
t.Fatalf("handler invoked %d times, want 2 (no cross-org collision)", calls.Load())
67+
}
68+
if b.Header().Get("X-Idempotency-Replayed") == "true" {
69+
t.Fatal("org B got a replayed response — cross-tenant leak")
70+
}
71+
if body(a) == body(b) {
72+
t.Fatalf("org B received org A's body %q — cross-tenant leak", body(b))
73+
}
74+
if body(b) != "doc-for-orgB" {
75+
t.Fatalf("org B body = %q, want doc-for-orgB", body(b))
76+
}
77+
}
78+
79+
func TestIdempotencyNoKeyAlwaysPassesThrough(t *testing.T) {
80+
var calls atomic.Int64
81+
mw := Idempotency(IdempotencyConfig{})(countingOrgHandler(&calls))
82+
83+
post(mw, "orgA", "")
84+
post(mw, "orgA", "")
85+
86+
if calls.Load() != 2 {
87+
t.Fatalf("handler invoked %d times, want 2 (no key = no caching)", calls.Load())
88+
}
89+
}

openapi.yaml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,21 @@ paths:
113113
(field: "file") or JSON body with content. Returns immediately
114114
with a document_id in "pending" state. The ingest pipeline runs
115115
asynchronously: parse → build tree → summarize sections.
116+
117+
Pass an `Idempotency-Key` header to make ingestion idempotent: a
118+
repeated request with the same key returns the original document
119+
(and its current status) instead of creating a duplicate. Use this
120+
to make a client/network retry of the upload safe.
121+
parameters:
122+
- name: Idempotency-Key
123+
in: header
124+
required: false
125+
description: |
126+
Opaque client-chosen key, unique per document. Repeating a
127+
request with the same key returns the existing document rather
128+
than ingesting a duplicate.
129+
schema:
130+
type: string
116131
requestBody:
117132
required: true
118133
content:

pkg/db/db.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"time"
1717

1818
"github.com/jackc/pgx/v5"
19+
"github.com/jackc/pgx/v5/pgconn"
1920
"github.com/jackc/pgx/v5/pgxpool"
2021
)
2122

@@ -25,6 +26,10 @@ var migrationsFS embed.FS
2526
// ErrNotFound signals a missing row.
2627
var ErrNotFound = errors.New("db: not found")
2728

29+
// ErrConflict signals a unique-constraint violation (SQLSTATE 23505), e.g.
30+
// a second insert with the same (org_id, idempotency_key).
31+
var ErrConflict = errors.New("db: conflict")
32+
2833
// Pool wraps *pgxpool.Pool with engine-specific helpers.
2934
type Pool struct {
3035
*pgxpool.Pool
@@ -136,5 +141,9 @@ func mapErr(err error) error {
136141
if errors.Is(err, pgx.ErrNoRows) {
137142
return ErrNotFound
138143
}
144+
var pgErr *pgconn.PgError
145+
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
146+
return fmt.Errorf("%w: %s", ErrConflict, pgErr.ConstraintName)
147+
}
139148
return err
140149
}

0 commit comments

Comments
 (0)