Skip to content

Harden PostgreSQL concurrency, integrity, and error handling - #1023

Open
Jet Chiang (supreme-gg-gg) wants to merge 9 commits into
agent-substrate:mainfrom
supreme-gg-gg:fix/improve-postgres
Open

Harden PostgreSQL concurrency, integrity, and error handling#1023
Jet Chiang (supreme-gg-gg) wants to merge 9 commits into
agent-substrate:mainfrom
supreme-gg-gg:fix/improve-postgres

Conversation

@supreme-gg-gg

Copy link
Copy Markdown
Collaborator

This PR makes improvements to the Postgres store by addressing review comments on #940 and optimizing Postgres updates as suggested in #988.

  • Replaces Postgres row-locking updates for actors, templates, and snapshot tags with bounded optimistic concurrency control using UID/version CAS checks.
  • Strengthens relational integrity with missing FKs and indexes, and replace snapshot-tag creation pre-check with FK
  • Removes unnecessary UID uniqueness constraints
  • Standardize pagination and validation across stores, map malformed tokens and invalid sizes to appropriate gRPC errors
  • Improves actor resume race handling when workers disappear or concurrent assignments exhaust their retry budget
  • Improves Postgres reliability with startup retries, expired-lease cleanup, worker-watch reconnection after malformed msg
  • Expand Postgres, Redis, Contract, and Control API tests to cover new constraints, error semantics, concurrency behaviour

@supreme-gg-gg
Jet Chiang (supreme-gg-gg) marked this pull request as ready for review August 17, 2026 22:43
Signed-off-by: Jet Chiang <pokyuen.jetchiang-ext@solo.io>
…in list ops, and replace snapshot tag creation precheck with fk

Signed-off-by: Jet Chiang <pokyuen.jetchiang-ext@solo.io>
Signed-off-by: Jet Chiang <pokyuen.jetchiang-ext@solo.io>
…tor RPC

Signed-off-by: Jet Chiang <pokyuen.jetchiang-ext@solo.io>
Signed-off-by: Jet Chiang <pokyuen.jetchiang-ext@solo.io>
Signed-off-by: Jet Chiang <pokyuen.jetchiang-ext@solo.io>
Signed-off-by: Jet Chiang <pokyuen.jetchiang-ext@solo.io>
Signed-off-by: Jet Chiang <pokyuen.jetchiang-ext@solo.io>
…o fixes potential data loss on corrupt worker notifications

Signed-off-by: Jet Chiang <pokyuen.jetchiang-ext@solo.io>

@thockin Tim Hockin (thockin) left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I skimmed - this is not my expertise area, but a few thoughts

REFERENCES atespaces(name) ON DELETE RESTRICT,
name text NOT NULL,
uid text NOT NULL UNIQUE,
uid text NOT NULL,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we include UID in every resource table? Not everything is updateable now, but that tends to not last, and we want consistent precondition semantics for every resource. This way we do not need to back-populate them when we inevitably need them later.

I see atespaces and actor_snapshots do not have UID

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

actor_snapshots should be immutable, so it should not need UID for update? I can add version and UID to atespaces in case it will become updatable

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Immutability tends to erode, unless there's a clear reason not to we should probably just be consistent?


// updateMaxAttempts bounds how many times a read-modify-write is retried after
// its optimistic uid/version check loses to a concurrent writer.
const updateMaxAttempts = 5

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we retry at all if there are preconditions specified on input? If I asked for version==6 and I find DB has version 7, no retries will ever fix it. If we support non-precondition updates, this could matter.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current update interface does support non-precondition updates, so the retry loop intends to support unpinned mutations which wish to reapply the change to the latest stored value, there is also a test in atepg_test.go that exhibits this behaviour. You're right about version-pinned updates, they will return with ErrVersionConflict after a failed CAS.

I just double checked that all ateapi callers are pinned using WithPrecondition, so we might want to remove the retry loop and make the update interface always pinned. However, this seems to me to be partially reversing #763 which replaced expectedVersion with a transactional mutate closure.

Julian Gutierrez Oschmann (@juli4n) is the goal of #763 to support retryable, unpinned read-modify-writes?

@jpbetz Joe Betz (jpbetz) Aug 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we end up keeping the retry loop, recommend adding a small amount of jittered backoff. Without it this is a relatively hot loop that expires quickly. More bang for the buck if you spread the requests over a bit more time.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Julian's proposal for update is:

  • Whole-object updates, require preconditions now
  • Maybe whole-object updates without preconditions later
  • Probably patch-updates without preconditions later

I'm fine to keep the loop now as long as we only loop when there are no preconditions in the user input. Note that (I think?) a patch is still:

user calls patch with no preconditions
loop
  read old value
  call mutate()
  submit to storage with preconditions
  if success; break loop

?

// its optimistic uid/version check loses to a concurrent writer.
const updateMaxAttempts = 5

func validateMetadataProjection(resource string, metadata *ateapipb.ResourceMetadata, uid string, version int64) error {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is Projection a term I don't know or did you mean Precondition?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here "projection" refers to the version/uid columns "projected" from the stored proto, which is different from precondition which refers to caller provided expectations. I agree it's a bit unfamiliar, I'll change it to something like validateProtoMetadataMatchesColumns, which is more literal.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! A comment would suffice.

@jpbetz Joe Betz (jpbetz) left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for getting this all addressed, this all looks like it's moving in the right direction to me. A few minor comments and I saw Tim Hockin (@thockin) had some comments, after that this looks ready to me.

func (p *Persistence) AcquireLock(ctx context.Context, key string) (*store.Lock, error) {
ttl := p.lockTTL
token := uuid.NewString()
if err := p.cleanupExpiredLeases(ctx); err != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recommend opening an issue to move this to a dedicated cleanup task for the long term. It's not super urgent, but it's worth tracking and circling back on.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opened #1040 to track

Comment on lines +490 to +496
parent := resources.ActorTemplateRefFromObjectRef(atv.GetActorTemplate())
exists, err := s.ActorTemplateExists(ctx, parent)
if err != nil {
return nil, fmt.Errorf("while checking actor template parent %s: %w", parent, err)
}
if !exists {
return nil, store.ErrFailedPrecondition

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding a comment that is best-effort but orphans here are possible.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants