Advanced: scheduled reconciliation job (Go)
Write-time checks catch what goes wrong during a write. They cannot catch what went wrong when nothing was written at all:
- a document deleted at the source that is still in your index
- an ingest that failed silently and was never retried
- chunks overwritten by a name collision
Those show up as an index that is quietly wrong, and nothing will tell you. The only way to notice is to look on a schedule.
Source: examples/functions/reconcile-go.
What you need
- A project,
platformctl login, and a VectorDB index worth checking. - About 20 minutes.
The constraint that shapes everything: standard library only
Your handler.go is compiled into the shim's own module. There is no
go.mod of your own, no vendoring that helps, and no go get. Everything in
net/http, encoding/json, log, net, time and the rest of the standard
library is available; nothing else is.
If you need a runtime that accepts markdown, CSV or binary bodies, use
Python — its shim hands a non-JSON body to
the handler as event["data"] rather than refusing it.
This is not a limitation to work around. It is the first thing to design for, and it is why the job below speaks the Redis wire protocol by hand instead of importing a client.
The signature is exact:
package main
func Handle(event map[string]any) (map[string]any, error)
map[string]any means a JSON array body is refusedThe shim unmarshals the request body into a map. A top-level JSON array —
[1,2,3] — cannot unmarshal into one, so it is rejected before your code runs.
Python's shim is more permissive here; Go is not. Wrap the array in an object.
Errors that name the fix
func env(name string) (string, error) {
v := strings.TrimSpace(os.Getenv(name))
if v == "" {
return "", fmt.Errorf("%s is not set; bind it with "+
"`platformctl secrets bindings set <fn> %s --secret <name>` and apply", name, name)
}
return v, nil
}
A missing binding is an operator mistake with a known fix. A panic produces a stack trace, and a stack trace does not name the fix. This costs three lines and saves the reader a search.
Structured logging, because logs are read by field
logLine("info", "reconciliation complete", map[string]any{
"index": index, "actual": actual, "expected": expected,
"drift": drift, "status": status,
"duration_ms": time.Since(started).Milliseconds(),
})
Every line carries job: "reconcile", which is how these lines are found among
everything else the project logs. The keys are stable; only msg is for a
person.
One deliberate detail: if encoding the record fails, the job logs plainly and carries on. The work matters more than the record of it, and a logging failure must never end a run.
Reading VectorDB
for _, n := range []int64{body.Index.Vectors, body.Index.Points, body.Vectors, body.Points} {
if n > 0 {
return n, nil
}
}
The count has lived under more than one shape. Taking whichever field is populated means a response-shape change degrades to zero rather than to a crash — which for a monitoring job is the right failure: a zero shows up as drift and gets investigated, an exception just stops the job.
Errors carry the status and the body:
return 0, fmt.Errorf("index %q: HTTP %d %s", index, resp.StatusCode, raw.String())
"Index read failed" does not distinguish a missing index from a rejected credential, and those have different fixes.
Writing MemoryStore with no client library
Thirty lines of RESP, because there is nothing to import:
cmd := func(args ...string) {
fmt.Fprintf(&buf, "*%d\r\n", len(args))
for _, a := range args {
fmt.Fprintf(&buf, "$%d\r\n%s\r\n", len(a), a)
}
}
if password != "" {
cmd("AUTH", password)
}
cmd("SET", key, value, "EX", strconv.Itoa(int(ttl.Seconds())))
Skipping the replies leaves a failed AUTH invisible: the SET is refused,
the connection closes cleanly, and the job reports success. Reading the replies
is what turns a wrong password into an error instead of into silence.
And the write itself is best-effort:
if err := redisSet(...); err != nil {
logLine("warn", "verdict not recorded", map[string]any{"error": err.Error()})
}
Failing to write the record must not fail the run that produced it — otherwise a MemoryStore blip turns a healthy index into an alert.
The credential
key, err := env("PIPELINE_KEY")
A workload's own CAI_PROJECT_KEY reaches inference-api but is denied on
project APIs by design. Reading a bound secret is its one power, and the
service-account key that comes back is what talks to VectorDB.
Deploy and schedule it
printf '%s' "$SA_KEY" | platformctl secrets put pipeline-key
platformctl functions deploy ./examples/functions/reconcile-go --name reconcile
platformctl secrets bindings set reconcile PIPELINE_KEY --secret pipeline-key
platformctl secrets bindings apply reconcile
platformctl serverless triggers create reconcile-nightly \
--type schedule \
--target reconcile \
--cron '0 3 * * *' \
--payload '{}'
The job ignores the payload's contents on purpose. A scheduled job that behaves differently depending on what the tick carried is a job nobody can reason about at 3am — which is exactly when they run.
Run it once by hand
platformctl invoke reconcile '{}'
platformctl invoke sends the AGENT body shapeIt posts {"message": "..."}, which is what an agent expects. This job ignores
its payload, so that is harmless here — but a function that READS its body will
see your JSON as a string under message, not as the object you typed.
To exercise a function's real request shape, call its HTTP endpoint with the body you actually want:
curl -sX POST "$FUNCTION_URL" -H 'Content-Type: application/json' -d '{"key":"value"}'
{"level":"info","msg":"reconciliation complete","job":"reconcile",
"index":"docs-rag-index","actual":18,"expected":18,"drift":0,
"status":"ok","duration_ms":47}
The numbers to compare against
| What | Number |
|---|---|
| Warm run, one index | ~26 ms (measured) |
| Cold start (Go binary, no interpreter) | 300-600 ms |
| Nightly cost | one invocation per day |
Go's cold start is the fastest of the four runtimes — a compiled binary with no interpreter to boot — which makes it a good fit for scheduled work that runs from cold every single time. A Python job doing the same thing typically starts in 1-3 seconds.
Traps, at the point you hit them
| Trap | What you see | Why |
|---|---|---|
import "github.com/..." | Build fails | Standard library only; your code joins the shim's module. |
| POSTing a JSON array | 400 {"error": "invalid JSON body: ..."} before your code runs | The body decodes straight into map[string]any. Wrap it in an object. On a bucket trigger this is worse: the poller delivers once, so the object is gone and nothing reaches your logs because your code never ran. |
| Wrong MemoryStore password | The job reports success, nothing is stored | The AUTH reply was never read. Read one reply per command. |
| Verdict write failure fails the run | Alerts on a healthy index | Record-writing must be best-effort. |
panic on a missing env var | A stack trace, no fix named | Return an error that says which binding and how to apply it. |
| Overlapping ticks | Two runs at once | Bound your HTTP and dial timeouts, or a hung run holds the instance into the next tick. |
Teardown
platformctl serverless triggers delete reconcile-nightly
platformctl delete reconcile
What it costs to leave running. The function scales to zero, so the standing cost is one short invocation per night — effectively nothing. The trigger itself is cheap. If you pointed it at a MemoryStore instance, that instance runs continuously and does not scale to zero; it is the only meaningful cost here.
Where next
- Document ingestion pipeline (Python) — what fills the index this job checks.
- Go functions — the full runtime contract.
- Triggers — schedules, topics and buckets.