Skip to content

Commit 17da946

Browse files
AchoArnoldCopilot
andauthored
feat(api): add Turso/libSQL backend for heartbeat repositories (NdoleStudio#886)
* docs: add design spec for Turso/libSQL heartbeat backend Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(api): add Turso/libSQL backend for heartbeat repositories Add alternative HeartbeatRepository and HeartbeatMonitorRepository implementations using libSQL (Turso) via database/sql. Switchable via HEARTBEAT_DB_BACKEND=turso env var. Requires TURSO_DATABASE_URL and TURSO_AUTH_TOKEN when enabled. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(api): add context timeout to DeleteAllForUser in libSQL repos Also update design spec to reference correct package (libsql-client-go, not go-libsql). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: add design spec for hedging repository pattern Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(api): add hedging repositories for heartbeat dual-write Add composite repositories that write to GORM (primary) and Turso (secondary) with fail-open semantics. Secondary failures are logged and counted via OTel metric. Activated via HEARTBEAT_DB_BACKEND=hedging. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(api): add hedging heartbeat integration test with Turso Add sqld (libSQL server) to test docker-compose. Integration test stores a heartbeat via the hedging repository and reads it back from both PostgreSQL (primary) and Turso/libSQL (secondary) to verify dual-write. Gated by TEST_DATABASE_URL and TEST_TURSO_DATABASE_URL environment variables. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ci: run hedging integration test in GitHub Actions Wait for sqld health before running tests. Set TEST_DATABASE_URL and TEST_TURSO_DATABASE_URL env vars pointing to docker compose services. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: add black-box heartbeat integration test with hedging Store a heartbeat via POST /v1/heartbeats and read it back via the Index endpoint. The API is configured with HEARTBEAT_DB_BACKEND=hedging so it dual-writes to both PostgreSQL and Turso/sqld. The test only interacts with the HTTP API, no implementation details exposed. - Add sqld dependency to API service in docker-compose - Add HEARTBEAT_DB_BACKEND, TURSO_DATABASE_URL to .env.test - Remove repo-level integration test in favor of black-box test - Keep sqld health wait in CI workflow Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(tests): remove curl-based healthcheck from sqld container The ghcr.io/tursodatabase/libsql-server:latest image is based on debian:bullseye-slim and does not include curl. The health check was always failing, causing the container to be reported as unhealthy and blocking the api service from starting. Instead, use service_started condition since sqld starts nearly instantly and the workflow already has an explicit health polling step that checks sqld readiness from the host before running tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor(api): combine Turso URL and auth token into single DSN env var Replace TURSO_DATABASE_URL and TURSO_AUTH_TOKEN with a single TURSO_DATABASE_DSN that contains the full connection string including the authToken query parameter. This simplifies configuration and aligns with standard DSN conventions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(tests): retry heartbeat store to wait for async phone-API-key association The phone API key gets its phone numbers associated asynchronously via the PhoneUpdated event. In the emulator queue mode used by CI, this event is processed in a background goroutine. The heartbeat test was calling the store endpoint immediately, before the async event had associated the phone number with the API key, resulting in a 401. Add a retry loop (up to 15s) consistent with other integration tests that use polling patterns (waitForFCMPush, waitForWebhookEvents). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(api): handle rows.Err() and uuid.Parse errors in libsql repositories - Add rows.Err() check after iteration loop in Index to catch network errors or timeouts that silently end iteration - Propagate uuid.Parse errors in scanHeartbeat and scanHeartbeatRow instead of discarding them with _ - Propagate uuid.Parse errors in scanHeartbeatMonitorRow for both monitor ID and phone ID fields Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor(api): make scan functions methods on libsql repository structs Convert package-level functions scanHeartbeat, scanHeartbeatRow, and scanHeartbeatMonitorRow into methods on their respective repository structs for consistency with the repository pattern. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Remove uneeded log --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 45bb281 commit 17da946

15 files changed

Lines changed: 1434 additions & 22 deletions

.github/workflows/api.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,21 @@ jobs:
3737
- name: Wait for services to be healthy
3838
working-directory: ./tests
3939
run: |
40+
echo "Waiting for sqld to be healthy..."
41+
for i in $(seq 1 20); do
42+
if curl -sf http://localhost:8090/health >/dev/null 2>&1; then
43+
echo "sqld is healthy!"
44+
break
45+
fi
46+
if [ $i -eq 20 ]; then
47+
echo "sqld failed to become healthy"
48+
docker compose logs sqld
49+
exit 1
50+
fi
51+
echo "sqld attempt $i/20 - waiting 3s..."
52+
sleep 3
53+
done
54+
4055
echo "Waiting for API to be healthy..."
4156
for i in $(seq 1 40); do
4257
if docker compose exec api curl -sf http://localhost:8000/health >/dev/null 2>&1; then

api/go.mod

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ require (
4444
github.com/stretchr/testify v1.11.1
4545
github.com/swaggo/swag v1.16.6
4646
github.com/thedevsaddam/govalidator v1.9.10
47+
github.com/tursodatabase/libsql-client-go v0.0.0-20260514053736-a9a8fadfe885
4748
github.com/uptrace/uptrace-go v1.43.0
4849
github.com/xuri/excelize/v2 v2.10.1
4950
go.opentelemetry.io/otel v1.43.0
@@ -93,11 +94,13 @@ require (
9394
github.com/PuerkitoBio/goquery v1.12.0 // indirect
9495
github.com/andybalholm/brotli v1.2.1 // indirect
9596
github.com/andybalholm/cascadia v1.3.3 // indirect
97+
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
9698
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
9799
github.com/cespare/xxhash/v2 v2.3.0 // indirect
98100
github.com/clipperhouse/displaywidth v0.11.0 // indirect
99101
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
100102
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect
103+
github.com/coder/websocket v1.8.12 // indirect
101104
github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect
102105
github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect
103106
github.com/fatih/color v1.19.0 // indirect
@@ -183,6 +186,7 @@ require (
183186
go.uber.org/zap v1.28.0 // indirect
184187
go.yaml.in/yaml/v3 v3.0.4 // indirect
185188
golang.org/x/crypto v0.50.0 // indirect
189+
golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect
186190
golang.org/x/mod v0.35.0 // indirect
187191
golang.org/x/net v0.53.0 // indirect
188192
golang.org/x/oauth2 v0.36.0 // indirect

api/go.sum

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eT
6666
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
6767
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
6868
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
69+
github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI=
70+
github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g=
6971
github.com/avast/retry-go/v5 v5.0.0 h1:kf1Qc2UsTZ4qq8elDymqfbISvkyMuhgRxuJqX2NHP7k=
7072
github.com/avast/retry-go/v5 v5.0.0/go.mod h1://d+usmKWio1agtZfS1H/ltTqwtIfBnRq9zEwjc3eH8=
7173
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
@@ -88,6 +90,8 @@ github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os
8890
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
8991
github.com/cockroachdb/cockroach-go/v2 v2.4.3 h1:LJO3K3jC5WXvMePRQSJE1NsIGoFGcEx1LW83W6RAlhw=
9092
github.com/cockroachdb/cockroach-go/v2 v2.4.3/go.mod h1:9U179XbCx4qFWtNhc7BiWLPfuyMVQ7qdAhfrwLz1vH0=
93+
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
94+
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
9195
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
9296
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
9397
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
@@ -320,6 +324,8 @@ github.com/thedevsaddam/govalidator v1.9.10 h1:m3dLRbSZ5Hts3VUWYe+vxLMG+FdyQuWOj
320324
github.com/thedevsaddam/govalidator v1.9.10/go.mod h1:Ilx8u7cg5g3LXbSS943cx5kczyNuUn7LH/cK5MYuE90=
321325
github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
322326
github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
327+
github.com/tursodatabase/libsql-client-go v0.0.0-20260514053736-a9a8fadfe885 h1:YssVXwM/9nUAjGNmUWdgvb05JVcsaBrDn5yr+MaJTn0=
328+
github.com/tursodatabase/libsql-client-go v0.0.0-20260514053736-a9a8fadfe885/go.mod h1:08inkKyguB6CGGssc/JzhmQWwBgFQBgjlYFjxjRh7nU=
323329
github.com/uptrace/uptrace-go v1.43.0 h1:5QuCdyFJdWUEXx6Fr6sYfezdgO6n6lnkOvUTLlyQO7U=
324330
github.com/uptrace/uptrace-go v1.43.0/go.mod h1:ehDTIdtBSolg4Z0CCvg1C8yR6VX1YFDqBcg2KmsXWn0=
325331
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
@@ -410,6 +416,8 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v
410416
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
411417
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
412418
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
419+
golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 h1:aAcj0Da7eBAtrTp03QXWvm88pSyOt+UgdZw2BFZ+lEw=
420+
golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8/go.mod h1:CQ1k9gNrJ50XIzaKCRR2hssIjF07kZFEiieALBM/ARQ=
413421
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
414422
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
415423
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=

api/pkg/di/container.go

Lines changed: 92 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package di
33
import (
44
"context"
55
"crypto/tls"
6+
"database/sql"
67
"fmt"
78
"net/http"
89
"os"
@@ -82,6 +83,7 @@ type Container struct {
8283
projectID string
8384
db *gorm.DB
8485
dedicatedDB *gorm.DB
86+
tursoDB *sql.DB
8587
version string
8688
app *fiber.App
8789
eventDispatcher *services.EventDispatcher
@@ -269,19 +271,16 @@ func (container *Container) DedicatedDB() (db *gorm.DB) {
269271
container.logger.Fatal(err)
270272
}
271273

272-
sqlDB, err := db.DB()
273-
if err != nil {
274-
container.logger.Fatal(stacktrace.Propagate(err, "cannot get sql.DB from GORM"))
275-
}
276-
277-
sqlDB.SetMaxOpenConns(1)
278-
sqlDB.SetMaxIdleConns(0)
279-
sqlDB.SetConnMaxLifetime(10 * time.Second)
280-
281274
if err = db.Use(tracing.NewPlugin()); err != nil {
282275
container.logger.Fatal(stacktrace.Propagate(err, "cannot use GORM tracing plugin"))
283276
}
284277

278+
container.dedicatedDB = db
279+
if os.Getenv("DATABASE_MIGRATION_SKIP") != "" {
280+
container.logger.Debug(fmt.Sprintf("skipping migrations for [%T]", db))
281+
return container.dedicatedDB
282+
}
283+
285284
container.logger.Debug(fmt.Sprintf("Running migrations for dedicated [%T]", db))
286285
if err = db.AutoMigrate(&entities.Heartbeat{}); err != nil {
287286
container.logger.Fatal(stacktrace.Propagate(err, fmt.Sprintf("cannot migrate %T", &entities.Heartbeat{})))
@@ -291,10 +290,43 @@ func (container *Container) DedicatedDB() (db *gorm.DB) {
291290
container.logger.Fatal(stacktrace.Propagate(err, fmt.Sprintf("cannot migrate %T", &entities.HeartbeatMonitor{})))
292291
}
293292

294-
container.dedicatedDB = db
295293
return container.dedicatedDB
296294
}
297295

296+
// TursoDB creates a *sql.DB connection to a Turso/libSQL database
297+
func (container *Container) TursoDB() *sql.DB {
298+
if container.tursoDB != nil {
299+
return container.tursoDB
300+
}
301+
302+
container.logger.Debug("creating Turso *sql.DB connection")
303+
304+
db, err := repositories.NewTursoDB(os.Getenv("TURSO_DATABASE_DSN"))
305+
if err != nil {
306+
container.logger.Fatal(err)
307+
}
308+
309+
container.tursoDB = db
310+
return container.tursoDB
311+
}
312+
313+
// HedgingFailureCounter creates an OTel counter for hedging secondary write failures
314+
func (container *Container) HedgingFailureCounter() otelMetric.Int64Counter {
315+
meter := otel.GetMeterProvider().Meter(
316+
container.projectID,
317+
otelMetric.WithInstrumentationVersion(otel.Version()),
318+
)
319+
counter, err := meter.Int64Counter(
320+
"hedging.secondary.write.failures",
321+
otelMetric.WithUnit("1"),
322+
otelMetric.WithDescription("Number of failed secondary writes in hedging repositories"),
323+
)
324+
if err != nil {
325+
container.logger.Fatal(stacktrace.Propagate(err, "cannot create hedging failure counter"))
326+
}
327+
return counter
328+
}
329+
298330
// DBWithoutMigration creates an instance of gorm.DB if it has not been created already
299331
func (container *Container) DBWithoutMigration() (db *gorm.DB) {
300332
if container.db != nil {
@@ -889,12 +921,31 @@ func (container *Container) MessageThreadRepository() (repository repositories.M
889921

890922
// HeartbeatMonitorRepository creates a new instance of repositories.HeartbeatMonitorRepository
891923
func (container *Container) HeartbeatMonitorRepository() (repository repositories.HeartbeatMonitorRepository) {
892-
container.logger.Debug("creating GORM repositories.HeartbeatMonitorRepository")
893-
return repositories.NewGormHeartbeatMonitorRepository(
894-
container.Logger(),
895-
container.Tracer(),
896-
container.DedicatedDB(),
897-
)
924+
switch os.Getenv("HEARTBEAT_DB_BACKEND") {
925+
case "turso":
926+
container.logger.Debug("creating libSQL repositories.HeartbeatMonitorRepository")
927+
return repositories.NewLibsqlHeartbeatMonitorRepository(
928+
container.Logger(),
929+
container.Tracer(),
930+
container.TursoDB(),
931+
)
932+
case "hedging":
933+
container.logger.Debug("creating hedging repositories.HeartbeatMonitorRepository")
934+
return repositories.NewHedgingHeartbeatMonitorRepository(
935+
container.Logger(),
936+
container.Tracer(),
937+
repositories.NewGormHeartbeatMonitorRepository(container.Logger(), container.Tracer(), container.DedicatedDB()),
938+
repositories.NewLibsqlHeartbeatMonitorRepository(container.Logger(), container.Tracer(), container.TursoDB()),
939+
container.HedgingFailureCounter(),
940+
)
941+
default:
942+
container.logger.Debug("creating GORM repositories.HeartbeatMonitorRepository")
943+
return repositories.NewGormHeartbeatMonitorRepository(
944+
container.Logger(),
945+
container.Tracer(),
946+
container.DedicatedDB(),
947+
)
948+
}
898949
}
899950

900951
// HeartbeatService creates a new instance of services.HeartbeatService
@@ -1708,12 +1759,31 @@ func (container *Container) RegisterSwaggerRoutes() {
17081759

17091760
// HeartbeatRepository registers a new instance of repositories.HeartbeatRepository
17101761
func (container *Container) HeartbeatRepository() repositories.HeartbeatRepository {
1711-
container.logger.Debug("creating GORM repositories.HeartbeatRepository")
1712-
return repositories.NewGormHeartbeatRepository(
1713-
container.Logger(),
1714-
container.Tracer(),
1715-
container.DedicatedDB(),
1716-
)
1762+
switch os.Getenv("HEARTBEAT_DB_BACKEND") {
1763+
case "turso":
1764+
container.logger.Debug("creating libSQL repositories.HeartbeatRepository")
1765+
return repositories.NewLibsqlHeartbeatRepository(
1766+
container.Logger(),
1767+
container.Tracer(),
1768+
container.TursoDB(),
1769+
)
1770+
case "hedging":
1771+
container.logger.Debug("creating hedging repositories.HeartbeatRepository")
1772+
return repositories.NewHedgingHeartbeatRepository(
1773+
container.Logger(),
1774+
container.Tracer(),
1775+
repositories.NewGormHeartbeatRepository(container.Logger(), container.Tracer(), container.DedicatedDB()),
1776+
repositories.NewLibsqlHeartbeatRepository(container.Logger(), container.Tracer(), container.TursoDB()),
1777+
container.HedgingFailureCounter(),
1778+
)
1779+
default:
1780+
container.logger.Debug("creating GORM repositories.HeartbeatRepository")
1781+
return repositories.NewGormHeartbeatRepository(
1782+
container.Logger(),
1783+
container.Tracer(),
1784+
container.DedicatedDB(),
1785+
)
1786+
}
17171787
}
17181788

17191789
// UserRepository registers a new instance of repositories.UserRepository
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package repositories
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"github.com/google/uuid"
8+
otelMetric "go.opentelemetry.io/otel/metric"
9+
10+
"github.com/NdoleStudio/httpsms/pkg/entities"
11+
"github.com/NdoleStudio/httpsms/pkg/telemetry"
12+
"github.com/palantir/stacktrace"
13+
)
14+
15+
// hedgingHeartbeatMonitorRepository writes to both primary and secondary repositories.
16+
// Reads only hit primary. Secondary writes are fail-open.
17+
type hedgingHeartbeatMonitorRepository struct {
18+
logger telemetry.Logger
19+
tracer telemetry.Tracer
20+
primary HeartbeatMonitorRepository
21+
secondary HeartbeatMonitorRepository
22+
failureCounter otelMetric.Int64Counter
23+
}
24+
25+
// NewHedgingHeartbeatMonitorRepository creates a hedging HeartbeatMonitorRepository
26+
func NewHedgingHeartbeatMonitorRepository(
27+
logger telemetry.Logger,
28+
tracer telemetry.Tracer,
29+
primary HeartbeatMonitorRepository,
30+
secondary HeartbeatMonitorRepository,
31+
failureCounter otelMetric.Int64Counter,
32+
) HeartbeatMonitorRepository {
33+
return &hedgingHeartbeatMonitorRepository{
34+
logger: logger.WithService(fmt.Sprintf("%T", &hedgingHeartbeatMonitorRepository{})),
35+
tracer: tracer,
36+
primary: primary,
37+
secondary: secondary,
38+
failureCounter: failureCounter,
39+
}
40+
}
41+
42+
func (repository *hedgingHeartbeatMonitorRepository) Store(ctx context.Context, monitor *entities.HeartbeatMonitor) error {
43+
ctx, span := repository.tracer.Start(ctx)
44+
defer span.End()
45+
46+
if err := repository.primary.Store(ctx, monitor); err != nil {
47+
return err
48+
}
49+
50+
if err := repository.secondary.Store(ctx, monitor); err != nil {
51+
repository.logger.Error(stacktrace.Propagate(err, fmt.Sprintf("hedging: secondary write failed for monitor [%s]", monitor.ID)))
52+
repository.failureCounter.Add(ctx, 1)
53+
}
54+
55+
return nil
56+
}
57+
58+
func (repository *hedgingHeartbeatMonitorRepository) Load(ctx context.Context, userID entities.UserID, phoneNumber string) (*entities.HeartbeatMonitor, error) {
59+
return repository.primary.Load(ctx, userID, phoneNumber)
60+
}
61+
62+
func (repository *hedgingHeartbeatMonitorRepository) Exists(ctx context.Context, userID entities.UserID, monitorID uuid.UUID) (bool, error) {
63+
return repository.primary.Exists(ctx, userID, monitorID)
64+
}
65+
66+
func (repository *hedgingHeartbeatMonitorRepository) UpdateQueueID(ctx context.Context, monitorID uuid.UUID, queueID string) error {
67+
ctx, span := repository.tracer.Start(ctx)
68+
defer span.End()
69+
70+
if err := repository.primary.UpdateQueueID(ctx, monitorID, queueID); err != nil {
71+
return err
72+
}
73+
74+
if err := repository.secondary.UpdateQueueID(ctx, monitorID, queueID); err != nil {
75+
repository.logger.Error(stacktrace.Propagate(err, fmt.Sprintf("hedging: secondary UpdateQueueID failed for monitor [%s]", monitorID)))
76+
repository.failureCounter.Add(ctx, 1)
77+
}
78+
79+
return nil
80+
}
81+
82+
func (repository *hedgingHeartbeatMonitorRepository) Delete(ctx context.Context, userID entities.UserID, phoneNumber string) error {
83+
ctx, span := repository.tracer.Start(ctx)
84+
defer span.End()
85+
86+
if err := repository.primary.Delete(ctx, userID, phoneNumber); err != nil {
87+
return err
88+
}
89+
90+
if err := repository.secondary.Delete(ctx, userID, phoneNumber); err != nil {
91+
repository.logger.Error(stacktrace.Propagate(err, fmt.Sprintf("hedging: secondary delete failed for monitor with owner [%s]", phoneNumber)))
92+
repository.failureCounter.Add(ctx, 1)
93+
}
94+
95+
return nil
96+
}
97+
98+
func (repository *hedgingHeartbeatMonitorRepository) UpdatePhoneOnline(ctx context.Context, userID entities.UserID, monitorID uuid.UUID, online bool) error {
99+
ctx, span := repository.tracer.Start(ctx)
100+
defer span.End()
101+
102+
if err := repository.primary.UpdatePhoneOnline(ctx, userID, monitorID, online); err != nil {
103+
return err
104+
}
105+
106+
if err := repository.secondary.UpdatePhoneOnline(ctx, userID, monitorID, online); err != nil {
107+
repository.logger.Error(stacktrace.Propagate(err, fmt.Sprintf("hedging: secondary UpdatePhoneOnline failed for monitor [%s]", monitorID)))
108+
repository.failureCounter.Add(ctx, 1)
109+
}
110+
111+
return nil
112+
}
113+
114+
func (repository *hedgingHeartbeatMonitorRepository) DeleteAllForUser(ctx context.Context, userID entities.UserID) error {
115+
ctx, span := repository.tracer.Start(ctx)
116+
defer span.End()
117+
118+
if err := repository.primary.DeleteAllForUser(ctx, userID); err != nil {
119+
return err
120+
}
121+
122+
if err := repository.secondary.DeleteAllForUser(ctx, userID); err != nil {
123+
repository.logger.Error(stacktrace.Propagate(err, fmt.Sprintf("hedging: secondary delete all failed for user [%s]", userID)))
124+
repository.failureCounter.Add(ctx, 1)
125+
}
126+
127+
return nil
128+
}

0 commit comments

Comments
 (0)