Repository navigation
Replies: 1 comment
Revision 1: the open questions answered, WireMock, the LGTM stack, and code-first pagesThe open questions:
Review changes four more things:
Each section below replaces or adds to the part of the same name in the post. Background, The data, The run, Exit statuses, SummaryThe bullets on tests, manifests and pages become:
The applicationThe codeThis section is added.
Charging, and what each answer means
WireMock in place of the payments APIThis section replaces The stand-in payments API. WireMock serves the payments API from the mappings in
AlertsThis is added: In the kind overlay, Mimir's ruler loads The testsThe table becomes:
The type Target struct {
Postgres string
Schema string
WireMock *url.URL
Client *http.Client
Key string
Run func(ctx context.Context, databaseURL string, env map[string]string) (int, error)
Timeout time.Duration
}
A case's account IDs start with its database's name. So the cases share one WireMock, run in parallel, and each reads only its own requests. The cases become:
The deploymentThe kind overlay becomes:
The pagesThis section is replaced. They are under
Changes to other filesThis is added:
Design decisions"Open question 1." and "Open question 2." are dropped from their bullets. The bullet "The stand-in is the walkthrough's" is replaced by the first of these, and the others are added:
TestingThis is added: The prototype ran the stand-in that this revision drops. Its results stand. The unit tests now serve a handler of their own in its place. Open questionsThis section is replaced. None. The three above are answered. Out of scopeThese are added:
ReferencesThese are added:
|
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Important
Revision 1 answers the open questions and changes four things. It is a billing job, run daily, against an invented payments API, as recommended. WireMock stands in for the API, so
paymentsapiis dropped, and there is no compose file: the kind overlay is how to run the job with WireMock. The overlay's OpenTelemetry Collector forwards to the LGTM stack (Loki, Grafana, Tempo and Mimir), and Mimir's ruler loads the alerts. The code has no comments except its license headers and package comments, and the pages are code-first steps, in few words.Note
Part of the humus re-evaluation: wave 6 of Proposal: humus on the bedrock process model. It keeps the points that revision 13 sets for every walkthrough, so it doesn't ask them again: no API of its own, standing alone, a stated production-ready bar, three levels of tests sharing one set of cases, failure injection in the tests, an image and manifests, CI jobs that aren't required, alerts as rules, pages under the Walkthroughs section, and a home in the
examplemodule.It builds on proposals that are all done: jobs, outbound HTTP clients, failure injection and humus's own metrics, with humus.Main, the HTTP server foundation and wave 1. It depends on no other walkthrough.
Summary
A walkthrough is a production-ready application, kept in this repository, with pages that walk a reader through how it was built and why. This proposal sets out the job's: a billing job that charges subscriptions once a month.
BAGGAGErehearses the payments API or PostgreSQL failing, mid-run.synctest; the whole App against PostgreSQL in a container; and the deployed image, run as Kubernetes Jobs in a kind cluster in CI. The second and third run the same black-box cases.httpserver, runs beside the job in the tests and in kind.Background
jobleaves to you: retries inside a run, and idempotency across Kubernetes' retries. Those are the hard parts of a real job, and no page shows them done.1brc-job,example/job/1brc-walkthrough). It ran once under podman-compose, beside an LGTM stack. It read its MinIO keys from an embedded YAML file, and it had no tests, no manifests, and no story for a retry, a resume or a schedule. The reset deleted it.backoffLimit, from the start, in a new process. A job that charges money has to be safe under all of that.httpclientretries aPOSTthat carries anIdempotency-Key, and makes one attempt of aPOSTwithout one.TrustScenario: a job trusts itsBAGGAGE.Target. For a service, a case sends requests. For a job, a case seeds the job's state, runs the job, and reads what it did. So this walkthrough'sTargetruns the job.Proposed API
A walkthrough adds no API to humus. What it adds is an application, its manifests, its tests and its pages.
The application
It lives in the
examplemodule atexample/billing, beside the shoppingcart. It needs nothing beyond the core module and pgx, which the module already has.app.Options()returns whatmainpasses tohumus.Main, the telemetryBuilderandhumus.WithFaults(Faults), so the tests run the App thatmainruns, as Sharing main's options describes.The data
Two tables, in
postgres/schema.sql:subscriptions: an account, its plan's amount in cents and currency, and whether it is active. The end user's other services keep it. The job only reads it.invoices: one per account and month,UNIQUE (account_id, period). Each has an ID, a UUID, which is also its idempotency key; an amount and currency; and a state:pendinguntil it is charged;charged, with the payments API's charge ID;declined, with the API's decline code;failed, with the answer that made it fail, for a person to look at.charged,declinedandfailedare final: nothing changes them.The job applies no migrations. The schema is applied before the job runs: by the container tests, by the kind overlay, and in production by the end user's migration tool, as the API walkthrough shows for the shoppingcart's.
The run
billing.Runis the run thatjob.NewServiceis given. It does three things, in order:ON CONFLICT (account_id, period) DO NOTHING. Running it again issues nothing.pending, whatever its period, oldest first, and charges each through the payments API, with up toBILLING_CONCURRENCYcharges in flight.UPDATE … WHERE id = $1 AND state = 'pending', so a final state is never overwritten.The run's period is
BILLING_PERIOD, or, without it, the month before the run started, in UTC. Every run charges whatever is pending, from any period. So a run that left invoices pending, for any reason, is caught up by the next one, even across a month's end.The run stops early and returns an error in three cases, without handing out more invoices. It waits for the charges already in flight first.
The run ends by returning:
nil, when no invoice is left pending;context.Cause(ctx), when a signal stops it. The charges in flight end with the run's context, and their invoices stay pending.Charging, and what each answer means
The payments API is the walkthrough's own invention, kept small:
POST /v1/charges, withAuthorization: Bearer <key>andIdempotency-Key: <invoice ID>, and the invoice's account, amount and currency as JSON.GET /v1/charges?account=<id>, the account's charges, which the test cases read.The
paymentspackage sends the charge, with its body in a*bytes.Reader, sohttpclientcan replay it. It reads the answer:chargeddeclinedClassifyfailed: the job's bug or bad data, which a retry meets againClassifyThe
Classifyis the default with one addition, the 409, as Writing aClassifyshows for a ledger. Retrying aPOSTis safe only because it carries the key, and the pages say so where the request is built.The key makes every unknown outcome safe. The answer to a charge can be lost after the API has made it: through a timeout, a reset, a SIGTERM, or a record that fails to save. The invoice then stays pending. The next attempt, in the same call or in the next run, sends the same key, and the API answers with the charge it already made.
Exit statuses, and what Kubernetes does with each
backoffLimitactiveDeadlineSecondsactiveDeadlineSecondsthe Job fails, and the next day's run catches upInitconfig.Struct. A bad one exits 78 and names its variable.s.Host.FS. Both are mounted from Secrets.openPostgres), with its close registered withs.OnStop.httpclient.NewClient.job.NewService(s, run.Run), and nothing else: a Job's pod has no probes.Every variable it reads:
DATABASE_URL_FILEPAYMENTS_URLhttpsPAYMENTS_API_KEY_FILEPAYMENTS_CA_FILEPAYMENTS_TIMEOUT,PAYMENTS_ATTEMPT_TIMEOUT,PAYMENTS_MAX_ATTEMPTShttpclient.Settings' fieldshttpclient.DefaultSettings()'BILLING_PERIODYYYY-MM. A month that hasn't ended is a bad settingBILLING_CONCURRENCYFAULT_MAX_DELAYfault.Settings.MaxDelayOTEL_SERVICE_NAME,OTEL_*_EXPORTER,OTEL_EXPORTER_OTLP_ENDPOINTBuilder's, as the Telemetry page'sbilling, andtelemetry.DefaultSettings()'HUMUS_SHUTDOWN_GRACE_PERIODis humus's own, and keeps its 20s.Failure injection
Faultsbuilds the App'sfault.Injectorwithjsonscenario, under the memberacme.fault, the Failure injection page's key, which every walkthrough uses. The job has three injection points:http:<host>:<port>ofPAYMENTS_URL, in the payments client, which every humus client has;postgresql:issueandpostgresql:settle, of the job's own, before each statement, named as Points of your own suggests. An abort fails the statement with a*fault.InjectedError.A job trusts its
BAGGAGE, so there is nothing to set:BAGGAGE, so its runs carry no scenario.BAGGAGEset. Anyone who can do that can already change every setting, so the trust boundary is the RBAC onjobsin the job's namespace.The payments client keeps
ForwardScenariofalse, so a scenario never reaches the payments API.Telemetry
job's span is the root of each run, or a child of the trace inTRACEPARENT. A backfill started by a CI step, or a workflow engine, sets it. Under it: anissuespan, achargespan for each invoice with its ID, andhttpclient's span for each attempt.otelhttpdoesn't record, and the database URL is never logged.billing.invoices.issued, a Counter;billing.invoices.settled, a Counter, bybilling.invoice.state:charged,declinedorfailed;billing.invoices.pending, a Gauge recorded once, as the run ends.humus.circuit.state,humus.circuit.transitions,humus.circuit.callsandhumus.retry.decisions.A job exports its metrics at its exit, when the telemetry shuts down. Each run is a new process, whose counters start from zero, so a series holds a run's own totals, often in a single sample. The alerts read those values with
max_over_time, rather thanincrease, which needs two samples of one process.Alerts
deploy/alerts.yamlis a Prometheus rule file:BillingNotSucceededkube_cronjob_status_last_successful_time, from kube-state-metricsBillingInvoicesPendingbilling_invoices_pendingBillingInvoicesFailedbilling_invoices_settled_total{billing_invoice_state="failed"}BillingPaymentsBreakerOpenedhumus_circuit_transitions_total{humus_circuit_state="open"}CI checks the file with promtool, as every walkthrough's. This one also unit-tests its alerts with
promtool test rules, against series shaped as a job exports them. That is whereincreasewould silently never fire.The stand-in payments API
paymentsapiis anhttpserverService that implements the two endpoints, kept in memory:declined-.It is the walkthrough's own: the payments API is invented, so this is no fake of another project's server. It runs as a second App on the unit tests' humustest Host, on the OS Host in the container tests, and as a Deployment in the kind overlay. It never runs in production.
Production-ready, as a checklist
The walkthrough's pages close with this list, and the application meets each item:
config.Structand the packages' checks, and a pod failure policy that fails the Job on 78s.Host.FS. The key is a header, whichotelhttpdoesn't recordON CONFLICT DO NOTHING.concurrencyPolicy: Forbidkeeps scheduled runs apart,podReplacementPolicy: Failedkeeps a retry from overlapping a pod that is still stopping, and the key makes any overlap harmlessDisruptionTarget, so an eviction isn't counted againstbackoffLimitactiveDeadlineSecondsper run,startingDeadlineSecondsper schedule,httpclient'sTimeoutper call, and the breaker per outageautomountServiceAccountToken: false), non-root, a read-only root filesystem, no capabilitiesdeploy/alerts.yamlThe job has no probes and no PodDisruptionBudget. Nothing routes to a Job's pod, and a budget would only hold up a node's drain, which the pod failure policy already absorbs.
The tests
go test, in the Build job, insidesynctestInvoicesof the test's own, and the stand-in on the same humustest Host. Each answer in the table above. A retriedPOST. SIGTERM mid-charge, and the next run replaying its key. The breaker opening and the run stopping.BILLING_CONCURRENCY.TRACEPARENT. Each bad setting exiting 78postgresagainst PostgreSQLtestcontainerstagtestcontainerstag, in the "Billing walkthrough (testcontainers)" job, which isn't requiredInitandapp.Options(), onhumus.OSHost()with humustest's process, against PostgreSQL in a container, and the stand-in on the OS Hostfunctionaltag, in the "Billing walkthrough (kind)" job, which isn't required, and against an end user's stagingfunctionaltag, kind onlyBILLING_PERIOD, fails after one pod, with the reasonPodFailurePolicycheck rules, andtest rulesThe shared cases are in
cases. It imports neither the application nor humustest, so the compiler keeps them black-box. Each takes aTarget:Runwrites the database URL into a file, and runs the App withhumustest.Startonhumus.OSHost(), withapp.Options().Runputs the database URL in a Secret of its own. It then creates a Job from the CronJob's template, with that Secret mounted in place of the database's,envadded, andbackoffLimit: 0, so a case waits for no retries. It returns the container's exit code. The overlay suspends the CronJob, so only the cases' Jobs run.Every case creates a database of its own, so the cases run in parallel, and against a shared staging server. They never touch another database. The cases:
charged, and one charge for each at the API. The inactive account has none.declined-account: exit 0 anddeclined. Run again, it isn't charged again.BILLING_PERIODof a month that hasn't ended, and one that isn't a month: exit 78 and nothing issued.BAGGAGE:http:*exits 1, with every invoice pending and nothing sent. A run without the scenario then exits 0, and charges each invoice once.http:*by 2s still exits 0, with everything charged.postgresql:settleexits 1. The API has made the first charge, and the invoice is still pending. A run without the scenario exits 0, and the API still holds one charge for each invoice: the key was replayed.postgresql:issueexits 1, having charged nothing. A run without the scenario bills the month.They mutate, so they never run against production.
The deployment
deploy/is a kustomization:alerts.yaml.The pages give the CronJob's every field, the security contexts, the environment and the Secrets' volumes. Kubernetes allows only
podReplacementPolicy: Failedwith a pod failure policy. It is written out anyway, because it is what keeps a retry from overlapping a pod that is still stopping.The kind overlay:
The kind job generates the database's password, the API key, and the stand-in's CA and certificate before it deploys, so no secret is in the repository. The Dockerfile builds static binaries onto a distroless, non-root base, with one target for the job and one for the stand-in.
The pages
They are under
docs/content/walkthroughs/billing/, in the order the job was built:Classifyand what each answer means, and the breaker that stops a run.Init, the settings, secrets as files,Faults, the telemetryBuilder, andOptions.max_over_time.Target.Changes to other files
.github/workflows/build.yaml:testcontainersandfunctionaltags.jobleaves to you".Design decisions
concurrencyPolicy: ForbidandpodReplacementPolicy: Failedkeep runs apart. When a CronJob creates two Jobs anyway, the key makes the overlap harmless: both runs send the same keys, and the API charges each once. A database lock would add a failure mode and protect nothing more.OpenForand costs nothing while it waits.s.Host.FS. They aren't in the environment, which a process hands to its children and a crash report may dump. Each run is a new process, so a rotated secret, or a renewed CA inPAYMENTS_CA_FILE, is read by the next run, with nothing to re-read during one.backoffLimit: 0keeps a failing case quick. The policy itself is checked once, separately, from the template as it is.postgresql:settlecauses it on demand, with no change to the stand-in.Testing
The walkthrough is its own test: see The tests.
What was prototyped. A prototype of the run passed under
go test -race -count=3, against main at 1bb3bb5. It used an in-memory store, with the stand-in as a second App on humustest's Host, insidesynctest, and the run built withjob.NewService,httpclientandhumus.WithFaultswithjsonscenario. It showed that:POSTwith anIdempotency-Keyis retried through two 503s, and charged once. Without the key, it makes one attempt, and the run exits 1 with the invoice pending;serve: terminated signal received. The next run sends the same key, and the API makes no second charge;BAGGAGEscenario abortinghttp:payments:8443sends nothing. The first two invoices' calls spend their attempts, the breaker opens, and the third is rejected. The run stops with all eight invoices pending, anderrors.Is(cause, circuit.ErrRejected)holds through the exit status's cause;AttemptTimeout, ends the call atTimeout, with nothing sent, and exits 1;postgresql:settle, throughs.Faults.Evalwith the run's context, leaves the first charge made but not recorded. The next run replays its key: four requests, three charges.Neither PostgreSQL nor kind was prototyped.
Migration from today
Nothing migrates. v0.15.0's 1BRC walkthrough isn't brought back.
Open questions
Which job.
Recommendation: (a).
How often it runs.
Recommendation: (a).
The payments API.
Recommendation: (a).
Out of scope
parallelismor an Indexed Job.References
BAGGAGEAll reactions