Repository navigation
feat(config): expose created_at/updated_at on service config rows - #1562
Conversation
Service config rows already carry created_at and updated_at columns, but
no read selected them, so the API could not say when a Purchasing service
config last changed (Stage 1 of the issue, backend half).
- ServiceConfig gains read-only CreatedAt/UpdatedAt (*time.Time, omitted
from JSON when nil).
- GetServiceConfig, the FOR UPDATE read in UpdateServiceConfigAtomic and
ListServiceConfigs select and scan both columns through one shared
scanServiceConfig.
- The service config upsert RETURNs both stamps and sets them on the saved
row, so UpdateServiceConfigAtomic reports the version it wrote rather
than the pre-write stamp its merge copied, and a caller's value never
survives a save.
- PUT /api/config/service/{provider}/{service} drops stamps from the body;
purchase plan services JSONB is written without them on create and
update.
- openapi.yaml documents both fields as readOnly.
Refs LeanerCloud#8
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Warning Review limit reachedYou've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Next included review available in 59 minutes. View limit details
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
govulncheck in the Docker image build now fails on these two advisories, both fixed in x/net v0.62.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE Head moved from Blocking:
Non-blocking:
Attacked:
Body-stale:
Reviewer: rG2. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths. |
|
Local verification at |
|
Merged by IAmJSD's agent at IAmJSD's request (IAmJSD is acting owner while the maintainers are away), under the bar IAmJSD set: 1 approving independent review(s) on |
Summary
This is the backend half of Stage 1 for service configs.
service_configshas hadcreated_atandupdated_atcolumns since migration 000001, but no read selected them. That left the API with no way to say when a Purchasing service config last changed. Override rows already show their stamp (#1314). This PR does the same for service config rows on the backend side.Refs #8
Root cause
config.ServiceConfighad no timestamp fields. All threeservice_configsreads (GetServiceConfig, theFOR UPDATEread insideUpdateServiceConfigAtomic, andListServiceConfigs) selected only the settings columns.Fix
internal/config/types.go:ServiceConfiggets read-onlyCreatedAt/UpdatedAt(*time.Time,json:"created_at,omitempty"/json:"updated_at,omitempty"). A nil value is omitted from JSON.internal/config/store_postgres.go:serviceConfigColumnsnow includescreated_at, updated_at. All three reads scan through one newscanServiceConfig, soListServiceConfigsno longer keeps its own copy of the column list and scan. The existing comment already mentioned ascanServiceConfigthat didn't exist.saveServiceConfigWithnow ends withRETURNING created_at, updated_atand sets both on the saved struct. SoUpdateServiceConfigAtomicreturns the stamps of the row version it actually wrote, not the pre-writeupdated_atits merge copied from the locked read. A caller-supplied stamp never survives a save. No column is written from the struct; the stamps come only from the column defaults and the existingupdate_service_configs_updated_attrigger.internal/api/handler_config.go:PUT /api/config/service/{provider}/{service}clears both stamps right after it decodes the body. A client that sends back the GET response cannot get them into the merged row. This matters on a first save, wheremergeServiceConfigreturns the body as is.internal/config/store_postgres_plan_writes.go: newmarshalPlanServicesdrops the stamps from each entry before the plan'sservicesJSONB is written. It is used by both the create and update paths. A plan's services map holds a copy of settings, not aservice_configsrow, so a stamp there could only be a value the caller supplied.internal/api/openapi.yaml:ServiceConfig.created_at/updated_atare documented asreadOnlydate-time fields, present on stored rows and left out of plan services entries.Other write paths checked, no change needed:
PUT /api/configdecodes intoGlobalConfig, which has no services field.handler_override_import.go) buildsServiceConfigfrom typed columns only, to run the combo check.PlanRequest.buildServiceConfigbuilds plan services from scalar request fields.No migration is needed.
Concurrency
The read side adds no locking. Writes keep the existing advisory lock plus row lock (#1488); the only change is that the upsert now reports what it wrote.
TestUpdateServiceConfigAtomic_ConcurrentSavesReportTheirOwnStampsruns 8 concurrent atomic updates of one row on real Postgres. It checks that:created_atnever moves;updated_atis at least as new as the seed's;updated_atthat its last writer was given.Verification
All evidence below is local: unit tests against pgxmock and the mock store, plus integration tests against real Postgres (testcontainers). Go 1.26.9 with
AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null AWS_EC2_METADATA_DISABLED=true.New and updated tests:
TestPGXMock_GetServiceConfig_SuccessandTestPGXMock_ListServiceConfigs_Success(stamps scanned per row);TestPGXMock_UpdateServiceConfigAtomic_LockedReadMergeWriteandTestPGXMock_UpdateServiceConfigAtomic_NoRowPassesNil(the returned row carries the RETURNING stamps, not the read's or the caller's);TestMarshalPlanServices_DropsStampsWithoutMutatingInput,TestPGXMock_CreatePurchasePlan_StoresServicesWithoutStamps,TestPGXMock_UpdatePurchasePlan_StoresServicesWithoutStamps;TestGetConfig_ServiceRowsCarryStamps(RFC 3339 on the wire, omitted when nil);TestUpdateServiceConfig_IgnoresClientStamps.Fail-before / pass-after:
origin/main, the new tests do not compile (cfg.CreatedAt undefined), because the fields do not exist on main.TestPGXMock_GetServiceConfig_Success,TestPGXMock_ListServiceConfigs_Successand integrationTestServiceConfigStamps_ReadsAndSavesAgreeFAIL.UpdateServiceConfigAtomicpgxmock tests,TestServiceConfigStamps_ReadsAndSavesAgreeand the concurrent integration test FAIL.TestPurchasePlanServices_StampsNeverStoredFAIL.TestUpdateServiceConfig_IgnoresClientStampsFAILS.Not run here: existing integration tests other than the three above, including
handler_config_service_race_integration_test.goandstore_postgres_service_config_atomic_integration_test.go. They exercise the upsert that changed fromExectoQueryRow ... RETURNING; they are left to the combined-tree run.Combined-tree verification. This branch was tested together with the other queued branches on a local, never-pushed tree:
queue/platform@400d256b= origin/main7b86e386plus these heads, merged in this order:Linux, Go 1.26.9,
AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null AWS_EC2_METADATA_DISABLED=true; Docker Postgres via testcontainers. The full suite ran onf7c48020, which differs from this tree only by test-only commits on fix/issue-30, fix/issue-713 and fix/issue-263 that replace placeholder account-number literals with named constants:go build ./...,go vet ./...,go vet -tags integration ./...and the fullgo test -race -short -count=1 ./....--build-tags integration.gocyclo -over 10is silent on the changed non-test files.-tags integration -count=1):internal/analytics,internal/api,internal/auth,internal/config,internal/database/postgres/migrations,internal/purchase,internal/schedulerandinternal/serverall pass. Migrations 000149, 000150 and 000151 apply in order on this tree.approval-details,settings-accounts,api) pass (221 tests).accountkey); fixed with named constants. Re-run on this tree: git-secrets, using the repo'sscripts/setup-git-secrets.shpatterns in a fresh repo with an isolated HOME, found nothing in the changed files, and a planted fake key was flagged.scripts/test-git-secrets-allowlist.sh: 183 PASS, 0 FAIL..tftest.hclfiles were not run locally (CI runs them). Nothing was applied to any cloud.Out of scope / notes for reviewers
propagateChangedDefaultsruns itsUPDATE service_configswith noWHEREclause. Every row'supdated_atmoves whenever a global default changes, including rows whose values theCASEleaves alone (for example Azure/GCP rows when only the AWS payment default changes).updated_at = NOW()on a save that changes nothing.WHERE ... IS DISTINCT FROMguard on both. It is deliberately not done here because it changes which rows the propagation locks.NOW()is the transaction start time. When two serialized writers queue on the advisory lock, the later one to commit can get the earlierupdated_at, by the length of the lock wait (milliseconds). The trigger overrides any explicit value, so switching toclock_timestamp()would need a migration. The concurrent test checks that each writer gets its own stamp. It does not check that stamps increase in commit order.TestHandler_HandleRequest_GetConfig(handler_test.go), run on its own, makes STS/IMDS calls throughresolveSourceIdentityand trips the no-network guard (internal/api tests call real AWS Cost Explorer and probe IMDS #855). The new GET test avoids this withhandler.sourceIdentityOnce.Do(func() {}).Frontend follow-up for the UI owner
Last updated: <updated_at>in UTC, matching the override rows from feat(frontend): show when each account service override was updated #1314. The data isGET /api/config,services[i].updated_at, an RFC 3339 string, omitted when unknown, so render nothing in that case.created_atis also available if wanted.GET /api/config(orGET /api/config/service/{provider}/{service}) to get the new stamp; the PUT response is still{"status":"updated"}.settings.test.tsto assert that the stamp is rendered when the API returns it.Maintainers: please mirror the issue's triage labels onto this PR:
enhancement,effort/l,triaged,priority/p3,severity/low,urgency/eventually,impact/few,type/feat.🤖 Generated with Claude Code