Skip to content

Commit a752fa2

Browse files
committed
perf(contacts): bound thread contact lookups
Fetch and cache only phone numbers present in the current thread page. Keep cache entries consistent across mutations and include the staged contact form UX updates. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ffe6b904-8f76-4e89-a786-d07a0cc9096d
1 parent 9dc3985 commit a752fa2

13 files changed

Lines changed: 499 additions & 449 deletions

api/pkg/di/container.go

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -84,18 +84,20 @@ import (
8484

8585
// Container is used to resolve services at runtime
8686
type Container struct {
87-
projectID string
88-
db *gorm.DB
89-
dedicatedDB *gorm.DB
90-
mongoDB *mongoDriver.Database
91-
version string
92-
app *fiber.App
93-
eventDispatcher *services.EventDispatcher
94-
logger telemetry.Logger
95-
attachmentRepository repositories.AttachmentRepository
96-
userRistrettoCache *ristretto.Cache[string, entities.AuthContext]
97-
phoneRistrettoCache *ristretto.Cache[string, *entities.Phone]
98-
inMemoryCache cache.Cache
87+
projectID string
88+
db *gorm.DB
89+
dedicatedDB *gorm.DB
90+
mongoDB *mongoDriver.Database
91+
version string
92+
app *fiber.App
93+
eventDispatcher *services.EventDispatcher
94+
logger telemetry.Logger
95+
attachmentRepository repositories.AttachmentRepository
96+
contactService *services.ContactService
97+
userRistrettoCache *ristretto.Cache[string, entities.AuthContext]
98+
phoneRistrettoCache *ristretto.Cache[string, *entities.Phone]
99+
contactRistrettoCache *ristretto.Cache[string, services.ContactCacheEntry]
100+
inMemoryCache cache.Cache
99101
}
100102

101103
// NewLiteContainer creates a Container without any routes or listeners
@@ -1152,13 +1154,17 @@ func (container *Container) MessageThreadService() (service *services.MessageThr
11521154

11531155
// ContactService creates a new instance of services.ContactService
11541156
func (container *Container) ContactService() (service *services.ContactService) {
1157+
if container.contactService != nil {
1158+
return container.contactService
1159+
}
11551160
container.logger.Debug(fmt.Sprintf("creating %T", service))
1156-
return services.NewContactService(
1161+
container.contactService = services.NewContactService(
11571162
container.Logger(),
11581163
container.Tracer(),
11591164
container.ContactRepository(),
1160-
container.InMemoryCache(),
1165+
container.ContactRistrettoCache(),
11611166
)
1167+
return container.contactService
11621168
}
11631169

11641170
// EmailNotificationService creates a new instance of services.EmailNotificationService
@@ -1841,6 +1847,24 @@ func (container *Container) PhoneRistrettoCache() *ristretto.Cache[string, *enti
18411847
return container.phoneRistrettoCache
18421848
}
18431849

1850+
// ContactRistrettoCache creates an in-memory cache keyed by user and phone number.
1851+
func (container *Container) ContactRistrettoCache() *ristretto.Cache[string, services.ContactCacheEntry] {
1852+
if container.contactRistrettoCache != nil {
1853+
return container.contactRistrettoCache
1854+
}
1855+
container.logger.Debug(fmt.Sprintf("creating %T", container.contactRistrettoCache))
1856+
ristrettoCache, err := ristretto.NewCache[string, services.ContactCacheEntry](&ristretto.Config[string, services.ContactCacheEntry]{
1857+
MaxCost: 5000,
1858+
NumCounters: 5000 * 10,
1859+
BufferItems: 64,
1860+
})
1861+
if err != nil {
1862+
container.logger.Fatal(stacktrace.Propagatef(err, "cannot create contact ristretto cache"))
1863+
}
1864+
container.contactRistrettoCache = ristrettoCache
1865+
return container.contactRistrettoCache
1866+
}
1867+
18441868
// UserRistrettoCache creates an in-memory *ristretto.Cache[string, entities.AuthContext]
18451869
func (container *Container) UserRistrettoCache() *ristretto.Cache[string, entities.AuthContext] {
18461870
if container.userRistrettoCache != nil {

api/pkg/handlers/contact_handler.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -254,8 +254,9 @@ func (h *ContactHandler) Update(c fiber.Ctx) error {
254254
return h.responseInternalServerError(c)
255255
}
256256

257+
previousPhoneNumbers := append([]string{}, contact.PhoneNumbers...)
257258
sanitized.ApplyTo(contact)
258-
if err = h.service.Update(ctx, contact); err != nil {
259+
if err = h.service.Update(ctx, contact, previousPhoneNumbers); err != nil {
259260
ctxLogger.Error(stacktrace.Propagatef(err, "cannot update contact [%s] for user [%s]", contactID, userID))
260261
return h.responseInternalServerError(c)
261262
}

api/pkg/handlers/contact_handler_test.go

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,17 @@ import (
1414
"testing"
1515
"time"
1616

17-
"github.com/NdoleStudio/httpsms/pkg/cache"
1817
"github.com/NdoleStudio/httpsms/pkg/entities"
1918
"github.com/NdoleStudio/httpsms/pkg/middlewares"
2019
"github.com/NdoleStudio/httpsms/pkg/repositories"
2120
"github.com/NdoleStudio/httpsms/pkg/services"
2221
"github.com/NdoleStudio/httpsms/pkg/telemetry"
2322
"github.com/NdoleStudio/httpsms/pkg/validators"
2423
"github.com/NdoleStudio/stacktrace"
24+
"github.com/dgraph-io/ristretto/v2"
2525
"github.com/gofiber/fiber/v3"
2626
"github.com/google/uuid"
2727
"github.com/lib/pq"
28-
ttlCache "github.com/patrickmn/go-cache"
2928
"github.com/stretchr/testify/assert"
3029
"github.com/stretchr/testify/require"
3130
"gorm.io/gorm"
@@ -122,7 +121,7 @@ func (r *contactHandlerFakeRepo) Count(_ context.Context, _ entities.UserID, par
122121
return r.countResult, nil
123122
}
124123

125-
func (r *contactHandlerFakeRepo) FetchAll(context.Context, entities.UserID) (*[]entities.Contact, error) {
124+
func (r *contactHandlerFakeRepo) FetchByPhoneNumbers(context.Context, entities.UserID, []string) (*[]entities.Contact, error) {
126125
out := []entities.Contact{}
127126
return &out, nil
128127
}
@@ -170,8 +169,13 @@ func newContactHandlerTestAppWithEntitlements(
170169
) *fiber.App {
171170
logger := &messageThreadHandlerNoopLogger{}
172171
tracer := telemetry.NewOtelLogger("test", logger)
173-
appCache := cache.NewMemoryCache(tracer, ttlCache.New(time.Minute, time.Minute))
174-
service := services.NewContactService(logger, tracer, repo, appCache)
172+
contactCache, err := ristretto.NewCache[string, services.ContactCacheEntry](&ristretto.Config[string, services.ContactCacheEntry]{
173+
MaxCost: 100, NumCounters: 1_000, BufferItems: 64,
174+
})
175+
if err != nil {
176+
panic(err)
177+
}
178+
service := services.NewContactService(logger, tracer, repo, contactCache)
175179
entitlementService := services.NewEntitlementService(
176180
logger,
177181
tracer,
@@ -640,8 +644,12 @@ func TestContactHandler_Index_InvalidLimit_ReturnsUnprocessableEntity(t *testing
640644
func TestContactService_WiresIntoMessageThreadService(t *testing.T) {
641645
logger := &messageThreadHandlerNoopLogger{}
642646
tracer := telemetry.NewOtelLogger("test", logger)
643-
appCache := cache.NewMemoryCache(tracer, ttlCache.New(time.Minute, time.Minute))
644-
contactService := services.NewContactService(logger, tracer, &contactHandlerFakeRepo{}, appCache)
647+
contactCache, err := ristretto.NewCache[string, services.ContactCacheEntry](&ristretto.Config[string, services.ContactCacheEntry]{
648+
MaxCost: 100, NumCounters: 1_000, BufferItems: 64,
649+
})
650+
require.NoError(t, err)
651+
t.Cleanup(contactCache.Close)
652+
contactService := services.NewContactService(logger, tracer, &contactHandlerFakeRepo{}, contactCache)
645653

646654
// If this compiles and runs, the ContactService satisfies the
647655
// contactMapProvider interface expected by NewMessageThreadService.

api/pkg/handlers/message_thread_handler_contacts_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ type messageThreadHandlerContactProviderStub struct {
3636
calls int
3737
}
3838

39-
func (stub *messageThreadHandlerContactProviderStub) GetContactMap(context.Context, entities.UserID) (map[string]*entities.Contact, error) {
39+
func (stub *messageThreadHandlerContactProviderStub) GetContactMap(context.Context, entities.UserID, []string) (map[string]*entities.Contact, error) {
4040
stub.calls++
4141
return stub.contacts, nil
4242
}

api/pkg/repositories/contact_repository.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,9 @@ type ContactRepository interface {
2525
// name/emails/phone_numbers filter as Index, ignoring pagination.
2626
Count(ctx context.Context, userID entities.UserID, params IndexParams) (int64, error)
2727

28-
// FetchAll returns every contact for a user ordered by updated_at ascending.
29-
FetchAll(ctx context.Context, userID entities.UserID) (*[]entities.Contact, error)
28+
// FetchByPhoneNumbers returns contacts containing at least one requested
29+
// phone number, ordered by updated_at ascending.
30+
FetchByPhoneNumbers(ctx context.Context, userID entities.UserID, phoneNumbers []string) (*[]entities.Contact, error)
3031

3132
// Delete a contact by ID for a user.
3233
Delete(ctx context.Context, userID entities.UserID, contactID uuid.UUID) error

api/pkg/repositories/gorm_contact_repository.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"github.com/NdoleStudio/httpsms/pkg/telemetry"
1111
"github.com/NdoleStudio/stacktrace"
1212
"github.com/google/uuid"
13+
"github.com/lib/pq"
1314
"gorm.io/gorm"
1415
)
1516

@@ -143,16 +144,17 @@ func (repository *gormContactRepository) Count(ctx context.Context, userID entit
143144
return count, nil
144145
}
145146

146-
func (repository *gormContactRepository) FetchAll(ctx context.Context, userID entities.UserID) (*[]entities.Contact, error) {
147+
func (repository *gormContactRepository) FetchByPhoneNumbers(ctx context.Context, userID entities.UserID, phoneNumbers []string) (*[]entities.Contact, error) {
147148
ctx, span := repository.tracer.Start(ctx)
148149
defer span.End()
149150

150151
contacts := new([]entities.Contact)
151152
if err := repository.db.WithContext(ctx).
152153
Where("user_id = ?", userID).
154+
Where("phone_numbers && ?", pq.Array(phoneNumbers)).
153155
Order("updated_at ASC").
154156
Find(contacts).Error; err != nil {
155-
return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot fetch all contacts for user [%s]", userID))
157+
return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot fetch contacts for user [%s] by phone numbers [%v]", userID, phoneNumbers))
156158
}
157159

158160
return contacts, nil

api/pkg/repositories/gorm_contact_repository_test.go

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -321,16 +321,21 @@ func TestGormContactRepository_Index_OrdersByRequestedFieldAndDirection(t *testi
321321
}
322322
}
323323

324-
func TestGormContactRepository_FetchAll_ScopesByUserAndOrdersUpdatedAtAsc(t *testing.T) {
324+
func TestGormContactRepository_FetchByPhoneNumbers_ScopesByUserAndRequestedNumbers(t *testing.T) {
325325
repository, recorder := newContactTestRepo(t)
326326

327-
_, err := repository.FetchAll(context.Background(), entities.UserID("user-1"))
327+
_, err := repository.FetchByPhoneNumbers(
328+
context.Background(),
329+
entities.UserID("user-1"),
330+
[]string{"+18005550199", "+18005550100"},
331+
)
328332

329333
require.NoError(t, err)
330334
statement := lastContactStatement(t, recorder)
331-
assert.Equal(t, `SELECT * FROM "contacts" WHERE user_id = $1 ORDER BY updated_at ASC`, statement.query)
332-
require.Len(t, statement.args, 1)
335+
assert.Equal(t, `SELECT * FROM "contacts" WHERE user_id = $1 AND phone_numbers && $2 ORDER BY updated_at ASC`, statement.query)
336+
require.Len(t, statement.args, 2)
333337
assert.Equal(t, entities.UserID("user-1"), statement.args[0])
338+
assert.Equal(t, &pq.StringArray{"+18005550199", "+18005550100"}, statement.args[1])
334339
}
335340

336341
func TestGormContactRepository_Delete_ScopesByUserAndContactID(t *testing.T) {

0 commit comments

Comments
 (0)