Add 5-phase plan to self-host on Coolify (docker-compose) with a MongoDB Atlas backend, an in-process cron scheduler, DynamoDB->Atlas migration, and full AWS teardown. Red-teamed and validated; decommission scope verified against live AWS/Cloudflare accounts. Include free-tier audit and S3-elimination research reports. Ignore wrangler local cache.
7.6 KiB
phase, title, status, priority, dependencies, effort
| phase | title | status | priority | dependencies | effort |
|---|---|---|---|---|---|
| 1 | MongoDB Storage Provider | pending | P1 | M |
Phase 1: MongoDB Storage Provider
Overview
Add mongodb as a 4th KVProvider, modeled exactly on the existing firestore provider (collection-per-module isolation). Wire it into buildProvider and config via KV_PROVIDER=mongodb, MONGO_URL, MONGO_DATABASE. No module code changes — the KVStore interface is the only contract.
Requirements
- Functional: implement
KVStore(Get/GetJSON/Put/PutJSON/Delete/List) +CompareAndSwapStore(CompareAndSwap) backed by MongoDB. - Functional: one Mongo collection per module (mirrors
FirestoreProvider); document_id= key, fieldvalue= raw bytes, fieldupdatedAt= timestamp. - Functional:
List(prefix)= range/regex query on_idwithbegins_withsemantics; empty prefix = whole collection. - Non-functional: behavior parity with firestore/dynamodb — same
ErrNotFound,ErrConflict, key validation (reusevalidateKey/validatePrefix), samecollectionNameRemodule-name guard viainvalidStore. - Non-functional: bounded startup connect timeout (match
dynamodbInitTimeout = 5sstyle), gracefulClose().
Architecture
Reuse the shared helpers already in internal/storage:
validateKey/validatePrefix(infirestore_kv.go) — key constraints. Mongo_idhas no/restriction, but reusing keeps cross-backend parity and is harmless.collectionNameRe(infirestore_provider.go) — module-name alphabet.invalidStore(ininvalid_store.go) — returned for bad module names.
Document shape (parity with firestore value/updatedAt):
{ "_id": "<key>", "value": <BinData>, "updatedAt": <int64 nanos> }
Store value as BSON binary (bson.Binary) so non-UTF-8 round-trips; on read accept both binary and string (firestore does the same dual-type handling). Store updatedAt as int64 unix-nanos (NOT BSON datetime) — matches DynamoDB exactly (dynamodb_kv.go:101), keeps migration byte-faithful, and avoids ms-truncation if a future TTL/sort ever reads it. The migrator (Phase 4) and the provider MUST share one encoding — see Phase 4 (migrator writes through MongoKVStore.Put, not raw UpdateOne).
CompareAndSwap mapping — the expected == nil branch is a LIVE path (first write of every new coin/gold portfolio, coin/portfolio.go:81-83, gold/portfolio.go:62-80), not an edge case. Map it to a plain InsertOne and rely SOLELY on the unique _id index for the conflict:
expected == nil→InsertOne({_id, value, updatedAt});mongo.IsDuplicateKeyError(err)→ErrConflict. Do NOT use a{value:{$exists:false}}upsert filter (it can false-conflict on a value-less doc and muddies the contract).expected != nil→UpdateOne({_id, value: expected}, {$set:{value,updatedAt}});MatchedCount == 0→ErrConflict._idis unique by default, so the absent-insert race is linearizable: exactly oneInsertOnewins, losers get duplicate-key →ErrConflict→ caller retry loop reloads the winner's state. This must be proven by a blocking concurrent-writer test (see Success Criteria), not just asserted.
List(prefix): Find({_id: {$gte: prefix, $lt: prefixSuccessor(prefix)}}, projection={_id:1}) — reuse the existing prefixSuccessor helper from firestore_kv.go. Empty prefix → Find({}). Avoids regex injection and uses the _id index.
Related Code Files
- Create:
internal/storage/mongodb_client.go—NewMongoClient(ctx, uri) (*mongo.Client, error)with connect+ping timeout;NewMongoDatabase. Mirrordynamodb_client.go. - Create:
internal/storage/mongodb_provider.go—MongoProvider{ db *mongo.Database },For(module)returnsinvalidStoreon bad name elseNewMongoKVStore. Mirrorfirestore_provider.go. - Create:
internal/storage/mongodb_kv.go—MongoKVStore, all methods. Mirrorfirestore_kv.go. - Create:
internal/storage/mongodb_kv_test.go+mongodb_provider_test.go— parity tests, gated onMONGODB_TEST_URL(skip when unset), mirroringdynamodb_kv_test.gogating onDYNAMODB_LOCAL_URL. - Modify:
cmd/server/main.go— addmongodbcase tobuildProvider; addMongoURL,MongoDatabasetoconfig+loadConfig(MONGO_URL,MONGO_DATABASE); updatebuildProviderdoc comment + auto-detect note (mongo is explicit-only). - Modify:
go.mod/go.sum— addgo.mongodb.org/mongo-driver/v2. - Modify:
Makefile— addmongo-local(dockermongo:7) +test-mongotarget gated byMONGODB_TEST_URL, mirroringdynamodb-local/test-dynamodb. - Modify:
README.md— addmongodbto the storage backend list + local-run snippet.
Implementation Steps
go get go.mongodb.org/mongo-driver/v2/mongo(and/bson).- Write
mongodb_client.go: connect withoptions.Client().ApplyURI(uri),client.Pingunder a 5s context, return client; helper to get*mongo.DatabasefromMONGO_DATABASE. - Write
mongodb_kv.go: implement methods per Architecture; reusevalidateKey,validatePrefix,prefixSuccessor; constantsmongoValueField="value",mongoUpdatedAtField="updatedAt". - Write
mongodb_provider.go:Forguards withcollectionNameRe, returnsdb.Collection(module)-backed store. - Wire
buildProvider:case "mongodb": requireMONGO_URL+MONGO_DATABASE(error if missing, mirror dynamodb'sDYNAMODB_TABLEcheck); construct client under timeout; closer callsclient.Disconnect. The startuplog.Info("storage backend", …)line MUST log only non-secret fields —"backend","mongodb","database",cfg.MongoDatabase. NEVER logMONGO_URL(it ismongodb+srv://user:pass@…; the firestore/dynamodb cases atmain.go:234-253log a benign identifier, but the mongo equivalent is a credential). If a host is wanted for diagnostics, parse and log only the host, never the userinfo. - Add config fields + env reads. Mongo is explicit-only (not in the auto-detect switch) to avoid surprising Lambda.
- Tests: replicate the firestore/dynamodb test bodies against a real Mongo (
mongo-local), covering Get/Put/Delete/List/prefix/CAS-absent/CAS-match/CAS-conflict/ErrNotFound + cross-module isolation. make vet && make test && MONGODB_TEST_URL=mongodb://localhost:27017 make test-mongo.
Success Criteria
internal/storageexposesMongoProvider/MongoKVStorepassing the same test matrix asDynamoDBKVStore.KV_PROVIDER=mongodbwithMONGO_URL/MONGO_DATABASEboots; missing either errors clearly at startup.- Cross-module isolation verified (collection-per-module).
- CompareAndSwap returns
ErrConflicton stale expected + on absent-with-non-nil-expected; succeeds on nil-expected insert. - (blocking) Concurrent-writer CAS test: N goroutines race a nil-expected insert + a stale-update on the same key against a real Mongo; assert exactly one winner, losers get
ErrConflict. Plus a "doc exists without value field" edge case. - Startup log shows
backend=mongodb database=<db>and does NOT contain the connection string / any credential. make vetandmake testpass; AWS/firestore paths untouched.
Risk Assessment
- CAS semantics drift: Mongo upsert race differs from DynamoDB conditional put. Mitigation: unique
_id+IsDuplicateKeyErrorfor the absent case; assert with a concurrent-writer test. - Value type on read: driver may decode as binary or string. Mitigation: dual-type switch like firestore's.
- TLS to Atlas:
mongodb+srv://URIs need DNS SRV + TLS. Mitigation: driver handles it; document thatMONGO_URLis the full Atlas SRV connection string incl. credentials. - Driver version: v2 API differs from v1 (
mongo.Connectsignature). Mitigation: pin v2, follow current docs.