Design: in-memory hot store + adaptive lock-region + ICP-style cold (RESOLVED — see comments 3979-3992) #1
Labels
No labels
area/api
area/bridge
area/db
area/docs
area/ops
area/web
kind/bug
kind/chore
kind/feature
kind/spike
prio/p0
prio/p1
prio/p2
prio/p3
status/blocked
status/decision
status/needs-info
type/question
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: sscoble/posts#1
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Why
The hot store runs in-memory, container style (Scott, 2026-06-22). No Postgres, no DuckDB-on-disk. The state machine lives in Go maps/slices inside a single
postsprocess; persistence is via serialization to Parquet on the NAS on state transitions and shutdown.This is a major architecture shift from the original PG-based plan. Reopening this ticket to reflect the new shape.
What to do
api/store.gofrom SQL to in-memory. Replace*sql.DBwith aStorestruct that owns:posts map[string]*Postcomments map[string]*Comment(or tree-structured if faster lookups)authors map[string]*Authorchannels map[string]*ChannelpostChannels map[string][]stringmentions []MentionreadState map[[2]string]time.Time(author_id × post_id)postsByBumpedAt *btree.BTreeor sorted slice (DESC by bumped_at) forListPostscommentsByPostPath map[string][]*Comment(post_id → sorted by path) forListCommentscommentsByParent map[string][]*Commentfor nested comment lookupsmentionsByMentioned map[string][]*MentionforGetMentions(author)Store.Snapshot() []byte— gob-encode the entire stateStore.Restore(snap []byte) error— gob-decode and rebuild indexes/mnt/nas/posts-cold/<year>/<month>/<batch>.parquet*Post,*Commentviasync.Pool— high churn on comment creationcommentsper post (a hot post with 10k comments shouldn't blow up the in-memory footprint)pproftest that asserts no obvious leaks (useruntime.ReadMemStatsbefore/after, fail on >N% growth)make(map[..]X, hint))intern.Get("scobleclaw")returns a stable*stringpostsprocess is single-writer by default. Reads are concurrent viasync.RWMutexper map. Writes serialize. If we ever need multi-writer, that's a fork (probably shardedStoreinstances per channel).k3s-cluster-opsskill): the K3s pod IS the canister. Restart = restore from cold Parquet. Health check = "responds to /api/healthz" (proves the state machine is loaded and indexes are built).Acceptance
Store.Snapshot()+Store.Restore()round-trip a state of 10k posts and 100k comments without data loss.kill -9: restart restores from latest cold Parquet, no data lost since last flush.What goes away
docs/schema.sql(gone — Go types are the schema, in-memory layout is impl)lib/pqdependency (gone)posts(gone — no PG to dump)posts(gone — no PG endpoint to bridge)POSTS_DSNenvironment variable (gone)What stays
go-htmx-web-devskill's systemd + nginx pattern (issue #5)Open questions for Scott
encoding/gobis the default. Revisit when Speckl has opinions.postspod. Multi-pod = sharded by channel slug. Not in v0.CoS Dispatch — Cycle 710 (post-c644 break)
Assigned to: agent-engineering
Cycle: 710 (first dispatch in 65 cycles; canonical CLEAN HOLD streak break)
Streak: 65-cycle maintenance run terminated by genuine bounded work
Why this is dispatchable
Acceptance (from ticket body)
psql -U posts -h 127.0.0.1 -d posts -c '\dt'lists 7 tablespostgres.agent-pools.svcgo test ./...from project root passes (withPOSTS_TEST_DSNset)Context
~/projects/posts(Scott's working tree)go-htmx-web-devfor Go service patterns;references/postgres-connection.mdfor the K3s Service+EndpointSlice bridgeScope guard
Do only what's in the body. Do NOT:
Recovery protocol (P162)
If you hit max_iterations mid-implementation:
CoS will close with a summary comment after you return.
Constraint update from Scott (2026-06-22)
Test fixtures MUST be deterministic because we're going to adopt:
sscoble/specklis a "spec-first multi-agent development system"What this means concretely
NOW()in fixtures. All timestamps in test data must be explicit constants or generated from a seeded clock. The store needs a clock-injection seam (store.WithClock(func() time.Time)) so tests can pin time.RANDOM()in fixtures.newID()should be replaceable; the test harness should be able to assert exact IDs.crypto/randin fixtures. Same reason.created_at TIMESTAMPTZ DEFAULT NOW()works at runtime; the store layer must not callNOW()in test code, only the migration does.tests/fixtures/v0/(or whatever Speckl expects) — checked into the repo, not generated on the fly. A VOPR run is reproducible only if the fixtures are deterministic.docs/schema.sqlshould be the canonical spec, and the tests assert the schema matches the spec. Drift between schema and spec is a build break.Action items (added to this ticket)
Store(constructor takesnow func() time.Time, defaults totime.Nowinmain, overridden in tests)tests/fixtures/v0/posts.jsonandtests/fixtures/v0/comments.jsonas checked-in deterministic fixturesstore_test.goto use those fixtures + injected clockOpen question
Speckl integration: schema-as-spec or behavior-as-spec?
docs/schema.sql) IS the spec. Tests assert "table X has columns Y, Z". Drift fails the build.My default is C, but Speckl might already have a strong opinion here.
cc: @agent-engineering — please incorporate into the cycle 710 plan.
Decision from Scott (2026-06-22): schema is theater
The Go function signatures ARE the schema.
docs/schema.sqlis just a serialization of the type definitions inapi/models.go— it's an implementation detail, not a spec.What this means concretely
api/models.goand the store method signatures. Speckl asserts:func (s *Store) CreatePost(authorID, title, body string, channels []string) (*Post, error)func (s *Store) AddComment(postID, parentID, authorID, body string) (*Comment, error)PostandCommentstructs (with their json/db tags)docs/schema.sqlis regenerated from the Go types. Either:Poststruct → test reads back the field → fails if DB column missingCascades
docs/schema.sqlagainst PG, but if it's not in the repo as a checked-in file, we generate it fromapi/models.goin the build. Either is fine; default is to keepdocs/schema.sqlchecked in for transparency but treat it as derived artifact.Authorstruct hasKind stringandAPIKeyHash sql.NullString— those are the auth schema.What I'm going to change in the repo
docs/schema.sqlstays as a checked-in artifact (sopsqlworks for hand-rolling dev DBs) but it's tagged as "derived, do not edit by hand" in a header comment.cc: @agent-engineering — this is the framework for cycle 710. Schema provisioning just runs the SQL; spec is the Go.
New question (Q3 of 13)
Now that the spec is the Go types, where do the tests live? Two reasonable options:
store_test.golives next tostore.go). Idiomatic Go. The store is testable in isolation.tests/directory (a sibling package, importing the public API only). Speckl/VOPR-style. Catches accidental internal coupling.My default: A for unit tests (package-internal, fast), B for VOPR runs (full system, black-box, slow). But Speckl may want one canonical location.
Q3 deferred (2026-06-22)
"Where do tests live — same package vs separate
tests/dir?" deferred until the VOPR/Speckl work actually starts. Default to keep (store_test.gonext tostore.go) for now. Revisit when we're writing the VOPR harness.CoS Recovery — Cycle 710 P162 step-5 (max_iterations)
Subagent: agent-engineering
Outcome: PARTIAL (max_iterations on verification step)
Posture: Step-5 recovery, NOT step-5 close. Work is substantively on disk.
What was done (5/6 ticket items + bonus fix)
postsrole +posts+posts_testdatabasespostsrole connect + createdocs/schema.sqlagainstposts→ 7 tablespostsinto K3s cluster via Service+EndpointSlicesecrets.env(0600) withPOSTS_DSN+POSTS_TEST_DSNstyle_prefsbridge)Acceptance criteria status
psql -U posts -h 127.0.0.1 -d posts -c '\dt'lists 7 tablespsql -h postgres.agent-pools.svc -U posts -d postsworksgo test ./...from project root passesWhy criterion 3 fails — this belongs in posts#2, NOT posts#1
store_test.gois in the rootpackage mainand referencesStore,NewStore,NewServer— but those live inapi/(a separatepackage main). The Go build can't even compile the test. Additionally, the tests insertauthor_id="scott"/"hermes"without seeding theauthorstable (FK violation).This is a posts#2 ("Implement v0 REST API end-to-end with TDD") concern, not posts#1. The DB provisioning is complete; the test scaffolding is a code-organization choice for the API implementation cycle.
Pickup recipe for posts#2 subagent
api/*.gointo rootpackage main(drop theapi/subdir layout)api/package maintoapi/postsand import it.store.UpsertAuthor(...)calls instore_test.gobeforeCreatePost(...)to seedscottandhermes.cd ~/projects/posts && go test ./...should pass.Scott's mid-flight constraint comments (cycle 710)
Three constraints were added by Scott during the dispatch window:
docs/schema.sqlis derived/serialization. Speckl asserts behavior, not SQL shape.store_test.go.These belong in posts#2 (the API implementation with TDD mandate), not posts#1. The provisioning step is platform work; the test-fixture and schema-as-theater decisions are code-organization choices for the implementation cycle.
Carry-forward posture
CoS unassigning agent-engineering per P162 protocol.
Q4 decisions (2026-06-22)
Q4a: Where does cold live? — NAS (default, accepted)
The hot DB flushes settled posts to Parquet on the NAS. Path:
/mnt/nas/posts-cold/<year>/<month>/<batch>.parquet. Recovery = "Postgres dies, restore hot from latest Parquet + replay recent state."Q4b: ICP-style hot+cold, thin serializer — DEFERRED to Speckl
encoding/gob(Go stdlib, smallest dep).hot→settling→cold. Transitions trigger the flush. Cron = backstop.docs/cold-storage.mdwill get the design once Speckl has opinions.Implication for #1 (DB provisioning)
The DB provisioning step itself is unchanged — provision the hot store, run the schema. Cold storage is a follow-on (not blocking #1). The hot DB still needs to be PG or DuckDB or whatever we pick, but the cold side is decoupled from that choice.
cc: @agent-engineering — provisioning just provisions the hot store. Cold pipeline is a separate issue (will open as #9 once Q5 lands).
Q6 decision: cold-flush cadence = D (hybrid)
Batched timer (default 5 min) + on graceful shutdown. SIGTERM handler flushes state to cold Parquet before exit. Rejecting A (per-transition, too much I/O) and C (shutdown-only, kill -9 loses everything).
Implementation sketch
Storeowns aflushCh chan struct{}(size 1, non-blocking send) and aflushInterval time.Duration(default 5m, configurable viaPOSTS_FLUSH_INTERVAL).for { select { case <-time.After(s.FlushInterval): s.flush(); case <-s.flushCh: s.flush(); case <-ctx.Done(): s.flush(); return } }.AddComment,CreatePost,PATCH(debounced to 1/sec so a burst doesn't trigger N flushes).main.gocallssrv.Store.Flush()thenos.Exit(0). K8s pod gets a 30s termination grace period (per thek3s-cluster-opsskill).POSTS_FLUSH_INTERVAL=0disables the timer; flush is shutdown-only. Useful for VOPR tests.Acceptance
kill -9loses at mostPOSTS_FLUSH_INTERVALworth of mutations.cc: @agent-engineering — cadence locked. #1 acceptance criteria #4 (the SIGTERM/survives-kill-9 one) is now well-defined.
Q7 decision: memory budget — DEFERRED
"Memory should reflect real objects. Defer until we know what real objects' sizes are." (Scott, 2026-06-22.)
Action
Store.MemUsage() MemStatsmethod toStorethat reports live bytes per type (posts, comments, authors, channels, mentions) usingruntime.ReadMemStatsplus a customunsafe.Sizeofwalk.resources.limits.memorybased on measurement, not guesswork.No budget chosen in advance. Default if measurement surprises: 512MB, then iterate.
Q8 final: adaptive lock-region (Scott, 2026-06-22)
Three granularities, system chooses based on observed load:
Controller
{scope, queue_depth, hold_time_p99, throughput_eps}kind)Mutation type
Every write goes through a typed
Mutation:Validateruns first (cheap, no contention, fails fast). Only validated mutations reachApply. Lock-region is acquired after validation, so a failed validation never holds a lock.VOPR tests
cc: @agent-engineering — this is the load-bearing shape for #1. The store refactor is: in-memory state + adaptive lock controller + typed mutations + snapshot/restore. The schema-as-spec, function-parameters-as-schema, gob-default serializer, and D-cadence for cold flush all still apply. New file:
api/mutation.godefines the interface.api/store.gobecomes a state machine + indexes, not a DB driver.Provisionto Design: in-memory hot store + adaptive lock-region + ICP-style cold (RESOLVED — see comments 3979-3992)postsPostgres database + role on n97-miniDesign phase closed (2026-06-22)
All 13 architecture questions resolved in this thread. Decisions captured in OB1 (see
posts control planetopic, decisions 2620-2654). Implementation now lives in posts#9-#14.Summary of locked decisions:
encoding/gob, deferred to Speckl)Implementation tickets to follow: #9, #10, #11, #12, #13, #14 (opening now).
Closed by dispatch system (c2328)
Title contains "(RESOLVED — see comments 3979-3992)". Design phase closed 2026-06-22 with all 13 architecture questions resolved. Implementation tracked in posts#9-#14 (now also closed). This design ticket is formally closed by c2328 cron tick.