Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
862 changes: 862 additions & 0 deletions docs/superpowers/plans/2026-05-13-search-index-env-vars.md

Large diffs are not rendered by default.

29 changes: 29 additions & 0 deletions pkg/searchindex/version.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Package searchindex provides helpers shared by services that interact
// with Elasticsearch indices managed by search-sync-worker.
package searchindex

import (
"regexp"
"strconv"
)

var versionSuffix = regexp.MustCompile(`-v(\d+)$`)

// StripVersion splits "<base>-v<N>" into its base and integer version.
// Returns ok=false for unversioned names (e.g. user-room indices) so
// callers can treat the value as a literal index name.
func StripVersion(name string) (base string, version int, ok bool) {
m := versionSuffix.FindStringSubmatchIndex(name)
if m == nil {
return name, 0, false
}
v, _ := strconv.Atoi(name[m[2]:m[3]])
return name[:m[0]], v, true
}

// StripVersionBase returns just the base from StripVersion, discarding
// the version number and ok flag.
func StripVersionBase(name string) string {
base, _, _ := StripVersion(name)
return base
}
83 changes: 83 additions & 0 deletions pkg/searchindex/version_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package searchindex

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestStripVersion(t *testing.T) {
tests := []struct {
name string
input string
wantBase string
wantVersion int
wantOK bool
}{
{
name: "single digit version",
input: "messages-site-a-v1",
wantBase: "messages-site-a",
wantVersion: 1,
wantOK: true,
},
{
name: "multi digit version",
input: "spotlight-site-b-v42",
wantBase: "spotlight-site-b",
wantVersion: 42,
wantOK: true,
},
{
name: "no suffix returns input unchanged",
input: "user-room-mv-site-a",
wantBase: "user-room-mv-site-a",
wantVersion: 0,
wantOK: false,
},
{
name: "uppercase V is not stripped",
input: "messages-site-a-V1",
wantBase: "messages-site-a-V1",
wantVersion: 0,
wantOK: false,
},
{
name: "non-numeric tail is not stripped",
input: "messages-site-a-v1a",
wantBase: "messages-site-a-v1a",
wantVersion: 0,
wantOK: false,
},
{
name: "version in middle is not stripped",
input: "messages-v1-site-a",
wantBase: "messages-v1-site-a",
wantVersion: 0,
wantOK: false,
},
{
name: "empty input",
input: "",
wantBase: "",
wantVersion: 0,
wantOK: false,
},
{
name: "only version suffix",
input: "-v1",
wantBase: "",
wantVersion: 1,
wantOK: true,
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
base, version, ok := StripVersion(tc.input)
assert.Equal(t, tc.wantBase, base)
assert.Equal(t, tc.wantVersion, version)
assert.Equal(t, tc.wantOK, ok)
})
}
}
4 changes: 2 additions & 2 deletions search-service/deploy/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ services:
- SEARCH_REQUEST_TIMEOUT=10s
- SEARCH_METRICS_ADDR=:9090
# Local dev pins to site-suffixed indices; prod uses aliases owned by ops/IaC.
- SEARCH_USER_ROOM_INDEX=user-room-site-local
- SEARCH_SPOTLIGHT_INDEX=spotlight-site-local-v1-chat
- SEARCH_USER_ROOM_INDEX=user-room-mv-site-local
- SEARCH_SPOTLIGHT_INDEX=spotlight-site-local-v1
ports:
# Expose /metrics so Prometheus / curl can scrape from the host
# during local dev. The listener is bound on 0.0.0.0:9090 inside
Expand Down
4 changes: 2 additions & 2 deletions search-service/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ type handlerConfig struct {
RecentWindow time.Duration
RequestTimeout time.Duration
UserRoomIndex string
SpotlightIndex string
SpotlightReadPattern string
}

type handler struct {
Expand Down Expand Up @@ -137,7 +137,7 @@ func (h *handler) searchRooms(c *natsrouter.Context, req model.SearchRoomsReques
}

observeESDone := observeES()
raw, err := h.store.Search(ctx, []string{h.cfg.SpotlightIndex}, body)
raw, err := h.store.Search(ctx, []string{h.cfg.SpotlightReadPattern}, body)
observeESDone()
if err != nil {
slog.Error("room search backend failed", "account", account, "error", err)
Expand Down
4 changes: 2 additions & 2 deletions search-service/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import (
"github.com/hmchangw/chat/pkg/natsrouter"
)

const testSpotlightIndex = "spotlight"
const testSpotlightIndex = "spotlight-*"

type fakeStore struct {
searchCalls []searchCall
Expand Down Expand Up @@ -86,7 +86,7 @@ func newTestHandler(store SearchStore, cache RestrictedRoomCache) *handler {
MaxDocCounts: 100,
RestrictedRoomsCacheTTL: 5 * time.Minute,
RecentWindow: 365 * 24 * time.Hour,
SpotlightIndex: testSpotlightIndex,
SpotlightReadPattern: testSpotlightIndex,
})
}

Expand Down
15 changes: 7 additions & 8 deletions search-service/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ import (
"github.com/hmchangw/chat/pkg/valkeyutil"
)

const testUserRoomIndex = "user-room"

// --- Fixture -----------------------------------------------------------------

// ccsFixture is the full stack for cross-cluster integration tests: two ES
Expand Down Expand Up @@ -113,10 +115,7 @@ func setupCCSFixture(t *testing.T) *ccsFixture {
t.Cleanup(func() { clientNC.Close() })
t.Logf("CCS fixture: NATS at %s", natsURL)

// Thread the same index name through both the store and handlerConfig
// so the test exercises the full SEARCH_USER_ROOM_INDEX wiring path
// (store.GetUserRoomDoc → ES index; query builder → terms-lookup index).
userRoomIndex := UserRoomIndex
userRoomIndex := testUserRoomIndex
store := newESStore(localEngine, userRoomIndex)
cache := newValkeyCache(valkeyClient)
handler := newHandler(store, cache, handlerConfig{
Expand All @@ -125,7 +124,7 @@ func setupCCSFixture(t *testing.T) *ccsFixture {
RestrictedRoomsCacheTTL: 5 * time.Minute,
RecentWindow: 365 * 24 * time.Hour,
UserRoomIndex: userRoomIndex,
SpotlightIndex: "spotlight-test",
SpotlightReadPattern: "spotlight-test-*",
})

router := natsrouter.New(serverNC, "search-service-test")
Expand Down Expand Up @@ -273,7 +272,7 @@ func messageTestTemplate() json.RawMessage {
}

func userRoomTestTemplate() json.RawMessage {
return buildTestTemplate(UserRoomIndex, map[string]any{
return buildTestTemplate(testUserRoomIndex, map[string]any{
"userAccount": map[string]any{"type": "keyword"},
"rooms": map[string]any{
"type": "text",
Expand Down Expand Up @@ -406,7 +405,7 @@ func TestSearchService_SearchMessages_CCS_CrossCluster_Unrestricted(t *testing.T
monthIdx := "messages-" + createdAt.Format("2006-01")

// user-room doc: unrestricted memberships in both rooms.
seedDoc(t, f.localURL, UserRoomIndex, account, map[string]any{
seedDoc(t, f.localURL, testUserRoomIndex, account, map[string]any{
"userAccount": account,
"rooms": []string{localRoomID, remoteRoomID},
"restrictedRooms": map[string]int64{},
Expand Down Expand Up @@ -515,7 +514,7 @@ func TestSearchService_SearchMessages_CCS_CrossCluster_Restricted(t *testing.T)

// user-room doc: local room unrestricted, remote room restricted with hss.
t.Logf("seed: upserting user-room doc for %s (restricted %s since %s)", account, remoteRoomID, hss.Format(time.RFC3339))
seedDoc(t, f.localURL, UserRoomIndex, account, map[string]any{
seedDoc(t, f.localURL, testUserRoomIndex, account, map[string]any{
"userAccount": account,
"rooms": []string{localRoomID},
"restrictedRooms": map[string]int64{
Expand Down
11 changes: 10 additions & 1 deletion search-service/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
Expand All @@ -15,6 +16,7 @@ import (
"github.com/hmchangw/chat/pkg/natsutil"
"github.com/hmchangw/chat/pkg/otelutil"
"github.com/hmchangw/chat/pkg/searchengine"
"github.com/hmchangw/chat/pkg/searchindex"
"github.com/hmchangw/chat/pkg/shutdown"
"github.com/hmchangw/chat/pkg/valkeyutil"
)
Expand Down Expand Up @@ -76,6 +78,13 @@ func main() {
os.Exit(1)
}

spotlightBase, _, ok := searchindex.StripVersion(cfg.Search.SpotlightIndex)
if !ok {
slog.Error("invalid config", "name", "SEARCH_SPOTLIGHT_INDEX", "value", cfg.Search.SpotlightIndex, "reason", "must end with -v<N>, e.g. spotlight-site-a-v1")
os.Exit(1)
}
spotlightReadPattern := fmt.Sprintf("%s-*", spotlightBase)

ctx := context.Background()

tracerShutdown, err := otelutil.InitTracer(ctx, "search-service")
Expand Down Expand Up @@ -117,7 +126,7 @@ func main() {
RecentWindow: cfg.Search.RecentWindow,
RequestTimeout: cfg.Search.RequestTimeout,
UserRoomIndex: cfg.Search.UserRoomIndex,
SpotlightIndex: cfg.Search.SpotlightIndex,
SpotlightReadPattern: spotlightReadPattern,
})

router := natsrouter.New(nc, "search-service")
Expand Down
9 changes: 5 additions & 4 deletions search-service/query_messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ var MessageIndexPattern = []string{"messages-*", "*:messages-*"}
// terms-lookup so a caller can't reach rooms they don't belong to by
// passing arbitrary roomIds.
func buildMessageQuery(req model.SearchMessagesRequest, account string, restricted map[string]int64, recentWindow time.Duration, userRoomIndex string) (json.RawMessage, error) {
userRoomIndex = resolveUserRoomIndex(userRoomIndex)
clauses := roomAccessClauses(req.RoomIDs, account, restricted, userRoomIndex)

body := map[string]any{
Expand Down Expand Up @@ -134,9 +133,11 @@ func scopedAccessClauses(roomIDs []string, account string, restricted map[string
}

// termsLookupClause resolves the user's allowed rooms via ES terms-lookup
// instead of shipping the rooms[] array on every query. Passing an empty
// userRoomIndex would produce an invalid index name, so callers must
// resolve it (e.g. via resolveUserRoomIndex) before calling.
// instead of shipping the rooms[] array on every query. The caller must
// pass a concrete, non-empty index name (enforced upstream by the
// SEARCH_USER_ROOM_INDEX env var being marked ,required in main.go). ES
// terms_lookup rejects wildcard patterns, which is why this index is
// intentionally unversioned across the codebase.
func termsLookupClause(account, userRoomIndex string) map[string]any {
return map[string]any{
"terms": map[string]any{
Expand Down
14 changes: 7 additions & 7 deletions search-service/query_messages_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ func shouldClauses(t *testing.T, q map[string]any) []any {

func TestBuildMessageQuery_GlobalUnrestricted(t *testing.T) {
req := model.SearchMessagesRequest{SearchText: "hello", Size: 25, Offset: 0}
raw, err := buildMessageQuery(req, "alice", nil, 365*24*time.Hour, "")
raw, err := buildMessageQuery(req, "alice", nil, 365*24*time.Hour, "user-room")
require.NoError(t, err)

q := parseQuery(t, raw)
Expand All @@ -58,7 +58,7 @@ func TestBuildMessageQuery_GlobalWithRestricted(t *testing.T) {
"room-b": 1_700_000_000_000,
"room-a": 1_600_000_000_000,
}
raw, err := buildMessageQuery(req, "alice", restricted, 24*time.Hour, "")
raw, err := buildMessageQuery(req, "alice", restricted, 24*time.Hour, "user-room")
require.NoError(t, err)

q := parseQuery(t, raw)
Expand Down Expand Up @@ -117,7 +117,7 @@ func TestBuildMessageQuery_ScopedInlineTerms(t *testing.T) {
SearchText: "hi",
RoomIDs: []string{"r1", "r2", "r3"},
}
raw, err := buildMessageQuery(req, "alice", nil, time.Hour, "")
raw, err := buildMessageQuery(req, "alice", nil, time.Hour, "user-room")
require.NoError(t, err)

shoulds := shouldClauses(t, parseQuery(t, raw))
Expand All @@ -133,7 +133,7 @@ func TestBuildMessageQuery_ScopedMixed(t *testing.T) {
RoomIDs: []string{"r1", "restricted-r2", "r3"},
}
restricted := map[string]int64{"restricted-r2": 1_600_000_000_000}
raw, err := buildMessageQuery(req, "alice", restricted, time.Hour, "")
raw, err := buildMessageQuery(req, "alice", restricted, time.Hour, "user-room")
require.NoError(t, err)

shoulds := shouldClauses(t, parseQuery(t, raw))
Expand Down Expand Up @@ -170,7 +170,7 @@ func TestBuildMessageQuery_ScopedAllRestricted(t *testing.T) {
RoomIDs: []string{"ra"},
}
restricted := map[string]int64{"ra": 1_700_000_000_000}
raw, err := buildMessageQuery(req, "alice", restricted, time.Hour, "")
raw, err := buildMessageQuery(req, "alice", restricted, time.Hour, "user-room")
require.NoError(t, err)

shoulds := shouldClauses(t, parseQuery(t, raw))
Expand All @@ -181,7 +181,7 @@ func TestBuildMessageQuery_ScopedAllRestricted(t *testing.T) {

func TestBuildMessageQuery_RecentWindow(t *testing.T) {
req := model.SearchMessagesRequest{SearchText: "hi"}
raw, err := buildMessageQuery(req, "alice", nil, 48*time.Hour, "")
raw, err := buildMessageQuery(req, "alice", nil, 48*time.Hour, "user-room")
require.NoError(t, err)

filters := filterClauses(t, parseQuery(t, raw))
Expand All @@ -192,7 +192,7 @@ func TestBuildMessageQuery_RecentWindow(t *testing.T) {

func TestBuildMessageQuery_RecentWindowDefault(t *testing.T) {
req := model.SearchMessagesRequest{SearchText: "hi"}
raw, err := buildMessageQuery(req, "alice", nil, 0, "")
raw, err := buildMessageQuery(req, "alice", nil, 0, "user-room")
require.NoError(t, err)

filters := filterClauses(t, parseQuery(t, raw))
Expand Down
18 changes: 1 addition & 17 deletions search-service/store_es.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,6 @@ import (
"fmt"
)

// UserRoomIndex is the default index holding per-user access-control docs.
// The sync-worker uses a site-qualified name internally, but the search
// service reaches it via a stable alias — one name across cluster
// topologies. Overridable via SEARCH_USER_ROOM_INDEX.
const UserRoomIndex = "user-room"

// esEngine is the narrow slice of pkg/searchengine.SearchEngine the
// store uses — declared at the consumer so unit tests can stub without
// satisfying the full SearchEngine contract.
Expand All @@ -26,17 +20,7 @@ type esStore struct {
}

func newESStore(engine esEngine, userRoomIndex string) *esStore {
return &esStore{engine: engine, userRoomIndex: resolveUserRoomIndex(userRoomIndex)}
}

// resolveUserRoomIndex falls back to UserRoomIndex when empty. Kept as a
// single normalization point so both newESStore and termsLookupClause
// consult the same default without repeating the `if == ""` branch.
func resolveUserRoomIndex(name string) string {
if name == "" {
return UserRoomIndex
}
return name
return &esStore{engine: engine, userRoomIndex: userRoomIndex}
}

func (s *esStore) Search(ctx context.Context, indices []string, body json.RawMessage) (json.RawMessage, error) {
Expand Down
9 changes: 0 additions & 9 deletions search-service/store_es_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,12 +94,3 @@ func TestESStore_GetUserRoomDoc_MalformedBody(t *testing.T) {
_, _, err := s.GetUserRoomDoc(context.Background(), "alice")
assert.Error(t, err)
}

func TestESStore_UsesDefaultIndexWhenEmpty(t *testing.T) {
eng := &stubEngine{docFound: false}
s := newESStore(eng, "")

_, _, err := s.GetUserRoomDoc(context.Background(), "alice")
require.NoError(t, err)
assert.Equal(t, UserRoomIndex, eng.docIndex)
}
2 changes: 2 additions & 0 deletions search-sync-worker/deploy/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ services:
- SEARCH_URL=http://elasticsearch:9200
- SEARCH_BACKEND=elasticsearch
- MSG_INDEX_PREFIX=messages-site-local-v1
- SPOTLIGHT_INDEX=spotlight-site-local-v1
- USER_ROOM_INDEX=user-room-mv-site-local
- BOOTSTRAP_STREAMS=true
volumes:
- ../../docker-local/backend.creds:/etc/nats/backend.creds:ro
Expand Down
Loading
Loading