Skip to main content

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

Go functions cannot use third-party packages

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:

handler.go
package main

func Handle(event map[string]any) (map[string]any, error)
map[string]any means a JSON array body is refused

The 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

handler.go
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

handler.go
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

handler.go
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:

handler.go
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())))
Read one reply per command you sent

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 shape

It 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

WhatNumber
Warm run, one index~26 ms (measured)
Cold start (Go binary, no interpreter)300-600 ms
Nightly costone 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

TrapWhat you seeWhy
import "github.com/..."Build failsStandard library only; your code joins the shim's module.
POSTing a JSON array400 {"error": "invalid JSON body: ..."} before your code runsThe 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 passwordThe job reports success, nothing is storedThe AUTH reply was never read. Read one reply per command.
Verdict write failure fails the runAlerts on a healthy indexRecord-writing must be best-effort.
panic on a missing env varA stack trace, no fix namedReturn an error that says which binding and how to apply it.
Overlapping ticksTwo runs at onceBound 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