mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 20:14:50 +00:00
fix(vault): deduplicate wikilinks before batch INSERT to prevent SQLSTATE 21000 (#1565)
When a document contains multiple [[target]] wikilinks pointing to the same destination, SyncDocLinks produced duplicate (FromDocID, ToDocID, LinkType) entries. The batch INSERT ... ON CONFLICT DO UPDATE in CreateLinks then failed because PostgreSQL rejects a single statement updating the same row twice. Deduplicate by (FromDocID, ToDocID, LinkType) in SyncDocLinks before calling CreateLinks, concatenating contexts with " | " separator so no surrounding text is lost. Fixes #1473 (cherry picked from commit 549c81fd2406875be34cec3d7274125238e7d4a0)
This commit is contained in:
1 parent
cefeae6c32
commit
a0b9d4272c
2 files changed
+137
No files matched your search
@@ -101,6 +101,15 @@ func SyncDocLinks(ctx context.Context, vs store.VaultStore, doc *store.VaultDocu
|
||||
}
|
||||
|
||||
// Resolve all wikilinks, then batch-create links in a single call.
|
||||
// Deduplicate by (FromDocID, ToDocID, LinkType) to prevent PostgreSQL
|
||||
// "ON CONFLICT DO UPDATE command cannot affect row a second time" (SQLSTATE 21000)
|
||||
// when the same target appears multiple times in the document.
|
||||
type linkKey struct {
|
||||
fromDocID string
|
||||
toDocID string
|
||||
linkType string
|
||||
}
|
||||
seen := make(map[linkKey]int) // key → index in links slice
|
||||
var links []store.VaultLink
|
||||
for _, m := range matches {
|
||||
target, err := ResolveWikilinkTarget(ctx, vs, m.Target, tenantID, agentID)
|
||||
@@ -112,6 +121,12 @@ func SyncDocLinks(ctx context.Context, vs store.VaultStore, doc *store.VaultDocu
|
||||
slog.Debug("vault.link_unresolved", "target", m.Target)
|
||||
continue
|
||||
}
|
||||
k := linkKey{fromDocID: doc.ID, toDocID: target.ID, linkType: "wikilink"}
|
||||
if idx, ok := seen[k]; ok {
|
||||
links[idx].Context += " | " + m.Context
|
||||
continue
|
||||
}
|
||||
seen[k] = len(links)
|
||||
links = append(links, store.VaultLink{
|
||||
FromDocID: doc.ID,
|
||||
ToDocID: target.ID,
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package vault
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
type fakeVaultStoreLinks struct {
|
||||
store.VaultStore
|
||||
mu sync.Mutex
|
||||
docsByPath map[string]*store.VaultDocument
|
||||
created []store.VaultLink
|
||||
deletedType bool
|
||||
}
|
||||
|
||||
func (f *fakeVaultStoreLinks) GetDocument(_ context.Context, _, _, path string) (*store.VaultDocument, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if doc, ok := f.docsByPath[path]; ok {
|
||||
return doc, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeVaultStoreLinks) GetDocumentByBasename(_ context.Context, _, _, basename string) (*store.VaultDocument, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for _, doc := range f.docsByPath {
|
||||
if doc.Path == basename || doc.Title == basename {
|
||||
return doc, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeVaultStoreLinks) DeleteDocLinksByType(_ context.Context, _, _, _ string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.deletedType = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeVaultStoreLinks) CreateLinks(_ context.Context, links []store.VaultLink) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.created = append(f.created, links...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestSyncDocLinks_DeduplicatesSameTarget(t *testing.T) {
|
||||
targetDoc := &store.VaultDocument{ID: "target-id-1", Title: "products", Path: "products.md"}
|
||||
sourceDoc := &store.VaultDocument{ID: "source-id-1", Title: "notes", Path: "notes.md"}
|
||||
|
||||
fake := &fakeVaultStoreLinks{
|
||||
docsByPath: map[string]*store.VaultDocument{
|
||||
"products": targetDoc,
|
||||
"products.md": targetDoc,
|
||||
},
|
||||
}
|
||||
|
||||
content := "First mention [[products]] here and later [[products]] again."
|
||||
|
||||
err := SyncDocLinks(context.Background(), fake, sourceDoc, content, "tenant-1", "agent-1")
|
||||
if err != nil {
|
||||
t.Fatalf("SyncDocLinks() error = %v", err)
|
||||
}
|
||||
|
||||
if len(fake.created) != 1 {
|
||||
t.Fatalf("expected 1 deduplicated link, got %d", len(fake.created))
|
||||
}
|
||||
|
||||
link := fake.created[0]
|
||||
if link.FromDocID != sourceDoc.ID {
|
||||
t.Errorf("FromDocID = %q, want %q", link.FromDocID, sourceDoc.ID)
|
||||
}
|
||||
if link.ToDocID != targetDoc.ID {
|
||||
t.Errorf("ToDocID = %q, want %q", link.ToDocID, targetDoc.ID)
|
||||
}
|
||||
if link.LinkType != "wikilink" {
|
||||
t.Errorf("LinkType = %q, want %q", link.LinkType, "wikilink")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncDocLinks_PreservesDistinctTargets(t *testing.T) {
|
||||
targetA := &store.VaultDocument{ID: "target-a", Title: "alpha", Path: "alpha.md"}
|
||||
targetB := &store.VaultDocument{ID: "target-b", Title: "beta", Path: "beta.md"}
|
||||
sourceDoc := &store.VaultDocument{ID: "source-id-2", Title: "notes", Path: "notes.md"}
|
||||
|
||||
fake := &fakeVaultStoreLinks{
|
||||
docsByPath: map[string]*store.VaultDocument{
|
||||
"alpha": targetA,
|
||||
"alpha.md": targetA,
|
||||
"beta": targetB,
|
||||
"beta.md": targetB,
|
||||
},
|
||||
}
|
||||
|
||||
content := "See [[alpha]] and [[beta]] and [[alpha]] again."
|
||||
|
||||
err := SyncDocLinks(context.Background(), fake, sourceDoc, content, "tenant-1", "agent-1")
|
||||
if err != nil {
|
||||
t.Fatalf("SyncDocLinks() error = %v", err)
|
||||
}
|
||||
|
||||
if len(fake.created) != 2 {
|
||||
t.Fatalf("expected 2 links (alpha + beta), got %d", len(fake.created))
|
||||
}
|
||||
|
||||
targetIDs := map[string]bool{}
|
||||
for _, l := range fake.created {
|
||||
targetIDs[l.ToDocID] = true
|
||||
}
|
||||
if !targetIDs["target-a"] {
|
||||
t.Error("missing link to target-a (alpha)")
|
||||
}
|
||||
if !targetIDs["target-b"] {
|
||||
t.Error("missing link to target-b (beta)")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user