9.9 KiB
Code Review — Self-host (Coolify + MongoDB) Implementation
Reviewer: code-reviewer | Date: 2026-06-28 | Branch: feature/selfhosted
Plan: plans/260627-1849-selfhost-coolify-mongodb/
Scope
- New:
internal/storage/mongodb_{client,kv,provider}.go(+tests),internal/cron/scheduler.go(+test),cmd/migrate-dynamo-to-mongo/(+test),compose.yml,.env.example, 2 docs. - Modified:
cmd/server/main.go,internal/server/router.go,internal/telegram/client.go,internal/modules/lolschedule/cron.go,internal/modules/module.go,internal/modules/stock/{stock,handlers}.go,Dockerfile,Makefile,go.mod/go.sum,aws/telegram-commands.json. - Deleted:
internal/telegram/webhook.go(+test),internal/modules/stock/income_events.go(+test). - Build/vet:
go vet ./...clean (re-run). Author reports fullgo test ./...+ integration suites pass.
Overall Assessment
Solid, disciplined change set. The implementation matches the plan's validated decisions point-for-point, and the 15 red-team findings are all materially addressed in code (verified below). No Critical or High defects found. The CAS implementation, MONGO_URL secret handling, cron lifecycle, and deletion hygiene are correct. Findings are Low/informational.
Verification of Acceptance Criteria
- mongodb auto-select + boot — PASS.
buildProvider(main.go:227-235) selectsmongodbwhenMONGO_URL!=""andKV_PROVIDERempty; explicitKV_PROVIDERoverrides; oldAWS_LAMBDA_FUNCTION_NAMEauto-detect gone. Cron runs unconditionally (main.go:146). No/webhookroute, noWebhookSecret(router.go). - CAS correctness — PASS. nil-expected →
InsertOne+IsDuplicateKeyError→ErrConflict(linearizable single-winner via unique_id); non-nil →UpdateOnefiltered on{_id, value:Binary(expected)},MatchedCount==0→ErrConflict(mongodb_kv.go:133-164). value stored asbson.Binarygeneric subtype;updatedAtint64 unix-nanos. Matches DynamoDB encoding intent. - MONGO_URL never logged — PASS (Finding-3). Only
databaselogged (main.go:266); SECURITY comments at mongodb_client.go:28-29 and config field 334. No interpolation ofcfg.MongoURLinto any log/error string found. - Cron scheduler — PASS (Finding-4). 60s local
cronTimeoutconst (scheduler.go:23, not imported from server), per-firecontext.WithTimeout,recover()with stack,cron.WithLocation(time.UTC), bad schedule returns error →log.Fatalat startup (main.go:147). Panic-recovery deferred before dispatch — correct ordering. - lolschedule idempotent per UTC date — PASS (Finding-6).
claimDailyPushCAS ondaily_push:last_datebefore fan-out; loser logs + returns nil. New testTestRunDailyPush_IdempotentPerDateproves one-message-per-subscriber across two same-date fires. CAS-less stores fall back to plain Put (test-only path) with documented loss of atomicity. - Migrator — PASS (Findings 9/15). Read-only
Scanonly (scanTable), writes throughprovider.For(pk).Put(byte-identical value +validateKeyper sk + upsert-by-_id= idempotent),--verifytallies per-pk via Scan vsCountDocuments, exits non-zero on mismatch. e2e test asserts byte-identical round-trip through the app read path. - No regression to other backends / modules / /cron — PASS. memory/firestore/dynamodb switch arms unchanged; DispatchScheduled +
/cronHTTP path intact (router + cron_dispatcher untouched in contract);CronHandlerdoc updated only. - No new lint/vet/build errors; intentional contract changes — PASS.
server.ConfigdroppedWebhookSecret;storagegainedMongoProvider.go.modcorrectly promotesrobfig/cron/v3andmongo-driver/v2to direct requires;go vet ./...clean.
Critical Issues
None.
High Priority
None.
Medium Priority
None blocking. (Finding dispositions all confirmed in code.)
Low Priority / Informational
-
L1 — Stale env name in a user-facing string.
internal/deploynotify/deploy_notify.go:97returns"no BOT_OWNER_ID configured"as a skip reason, but the env key was renamed toOWNER_ID(Session 5). This is a log/diagnostic string only (not flow control), but it now points an operator at a key that no longer exists. Pre-existing line, surfaced by the rename. Suggest:"no OWNER_ID configured". Not in review scope's modified files but directly caused by this change. -
L2 —
claimDailyPushruns the expensive fetch before claiming on every fire.runDailyPush(cron.go:182-213) callslistSubscribers+GetEventsCachedbeforeclaimDailyPush. A losing double-fire still does the subscriber read + match fetch before no-op'ing. This is intentional and documented (cron.go:202-204: "Placed after the fetch so a transient fetch failure does not consume the day's claim") — a deliberate correctness-over-efficiency trade so a fetch failure doesn't burn the day's claim. Correct call; noting only so it isn't mistaken for an oversight. The wasted fetch is cache-backed and rare (only on genuine double-fire). -
L3 —
decodeValueacceptsstring/[]bytein addition tobson.Binary. mongodb_kv.go:42-57 handles three value types. Onlybson.Binaryis ever written by this code, so thestring/[]bytearms are dead for app-written data. Documented as mirroring Firestore's dual-type handling for forward-compat. Harmless; flagging as minor YAGNI surface, not a defect (it does aid migrator/raw-write robustness).
Targeted concerns from the request
-
DeleteWebhook-on-startup safety vs manual-cutover framing. SAFE and consistent with the plan. The plan (Session 2, line 118) explicitly makes cutover =
deleteWebhook→ start polling container; addingDeleteWebhookto startup is the in-code realization of that step, not a divergence.DropPendingUpdates:falsepreserves the buffered queue (lossless). It is idempotent and harmless when no webhook is set. The go-telegram polling loop (get_updates.go) retries with exponential backoff on errors, so even a transient 409 (webhook deletion racing the firstgetUpdates) self-heals. The pre-cutover Lambda keeps its own webhook code untilsam delete, so rollback in the window still works. No issue. -
Goroutine/shutdown lifecycle of
cron.Run+b.Start. Correct. Bothb.Start(rootCtx)and the in-process scheduler bind torootCtx(SIGINT/SIGTERM). On signal:b.Startreturns when ctx cancelled;stopCron()(deferred) callsc.Stop()and blocks on the returned context until in-flight jobs finish;srv.Shutdowngets a fresh 15s ctx. Order of defers (stop→closeProvider→stopCron) runs LIFO: stopCron waits for jobs, THEN closeProvider disconnects Mongo — correct (jobs that touch Mongo finish before disconnect). One nuance:srv.Shutdownruns in the main flow before the deferredstopCron/closeProvider, so an in-flight cron job during shutdown still has its Mongo client until stopCron completes. Fine. -
CAS race edge cases. The nil-expected
InsertOnerace is the right primitive (Finding-1): unique_idgives exactly one winner, losers get duplicate-key→ErrConflict. Author reports the blocking concurrent-CAS linearizability test passes against real MongoDB 7. The non-nil path's filter-on-value is a true CAS (no read-then-write gap). No TOCTOU window. TheclaimDailyPushGet-then-CAS in lolschedule has a benign read-ahead (theGetis an optimization; the CAS is the authority), so a concurrent claim between Get and CAS still resolves to a single winner viaErrConflict. Correct. -
Dangling references after deletions. None. Grep for
IncomeEventClient,handleIncomeEvents,stock_income_events,WebhookHandler,WebhookSecret,NewIncomeEventClientFromEnvfinds zero references in non-test and test Go code.aws/telegram-commands.jsonhad thestock_income_eventscommand removed (diff -4 lines). Only the L1 log-string mention of the old env name remains. -
Divergence from plan's validated decisions. None found.
updatedAtis int64 nanos everywhere; CAS absent-case isInsertOne(no$exists:falseupsert); migrator writes throughPut; health is plain-textmiti99bot ok\n(health.go:20) matching the compose note;/cron404s whenCRON_SHARED_SECRETunset;WithAllowedUpdates{message,callback_query}matches prior webhook allowed_updates; gitSHA injected via DockerfileARG GIT_SHA.
Positive Observations (risk-relevant)
- The
b.Startpolling loop's built-in error backoff means theDeleteWebhook-before-Startordering is robust even under a race — no custom retry needed. - Migrator writing through
Put(not raw driver) is the correct choice for encoding parity and is verified by the e2e byte-identity assertion. - Provider
For()re-validates module name againstcollectionNameRe(defense-in-depth identical to firestore/dynamodb), andinvalidStorefails closed.
Metrics
- Type/contract changes: intentional only (
server.Config -WebhookSecret,storage +MongoProvider). - Lint/vet:
go vet ./...clean. - Test coverage: new tests for scheduler (fire/stop/bad-schedule/panic-recovery), mongo CAS parity + concurrent linearizability, migrator e2e + decode/sort units, lolschedule idempotency. Adequate for the change.
Recommended Actions
- (Low) Update
internal/deploynotify/deploy_notify.go:97skip-reason stringBOT_OWNER_ID→OWNER_IDto match the renamed env key. - (Optional) Consider whether the
string/[]bytearms inMongoKVStore.decodeValueearn their keep, or trim tobson.Binaryonly with a clear error for other types (YAGNI). Non-blocking.
Unresolved Questions
None. Plan decisions are internally consistent and the implementation tracks them.
Status: DONE
Summary: Self-host implementation is production-ready; all 8 acceptance criteria and all 15 red-team findings verified satisfied in code. No Critical/High issues. One Low cosmetic fix (stale BOT_OWNER_ID string in deploynotify skip reason).
Concerns: Only L1 (stale env-name string in a diagnostic log line); cosmetic, non-blocking.