Replace ateredis with atepg - #940
Conversation
268e244 to
10d1061
Compare
Eitan Yarmush (EItanya)
left a comment
There was a problem hiding this comment.
Very large PR, but so far this is my main comment
…ocs to remove redis reference 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>
Signed-off-by: Jet Chiang <pokyuen.jetchiang-ext@solo.io>
Signed-off-by: Jet Chiang <pokyuen.jetchiang-ext@solo.io>
…'re only using postgres 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>
53bc70b to
82f9821
Compare
| * Storage and visualization for benchmark results | ||
| * Integrate debugging into load tests | ||
| * State Store Scale: Horizontal sharding support (via Redis Hash Tags) to enable management of 1M+ concurrent actors. | ||
| * State Store Scale: PostgreSQL scaling and partitioning support to enable management of 1M+ concurrent actors. |
There was a problem hiding this comment.
The startup DDL is CREATE TABLE IF NOT EXISTS and is not going to be sufficient long term.
Reliable detection of the DDL state to ensure compatibility with the binary will be needed early, and a upgrade mechanism will need to be introduced to allow for future schema updates. Fix now or track with an issue?
There was a problem hiding this comment.
+1 for the upgrade mechanism, ideally it should keep the schema compatible during a pod rolling window, where old and new binaries are hitting the same database at the same time, so migrations follow an expand and contract pattern.
| // requested, opens the cluster client, and pings with retries. | ||
| func connectRedis(ctx context.Context) (*redis.ClusterClient, error) { | ||
| tlsConfig, err := buildRedisTLSConfig(ctx) | ||
| persistence, err := atepg.Connect(ctx, *postgresConnectionString) |
There was a problem hiding this comment.
Is the switch to requiring the DB be available on the first request on startup intentional here? The redis path was more tolerant of blips.
There was a problem hiding this comment.
Not an intentional design, will add retry like redis
| } | ||
|
|
||
| default_postgres_connection_string() { | ||
| echo "postgresql://postgres@postgres.ate-system.svc:5432/atepg?sslmode=verify-full&sslrootcert=/run/servicedns.podcert.ate.dev/trust-bundle.pem&sslcert=/run/podidentity.podcert.ate.dev/credential-bundle.pem&sslkey=/run/podidentity.podcert.ate.dev/credential-bundle.pem" |
There was a problem hiding this comment.
This connects as the postgres superuser with no password, and the server side is trust auth, so ateapi runs all its DDL and DML with full privileges. Recommend sorting out the app roles now (or very soon) so that you build on least-privilege instead of having to figure it out later.
| Short: "DANGEROUS: Flush all data from Redis", | ||
| var debugClearStoreCmd = &cobra.Command{ | ||
| Use: "debug-clear-store", | ||
| Short: "DANGEROUS: Clear all control-plane state", |
There was a problem hiding this comment.
I can see the value having this had for ease-of-testing for redis.
But I checked and there is are no server side role restrictions. And this is now deleting persistent state.
Is there an appropriately prioritized tracking issue to figure out how to remove or replace this before v1.0?
| // Backends that enforce the actor->atespace foreign key (atepg) reject | ||
| // CreateActor for a nonexistent atespace, so every actor test needs a real | ||
| // parent atespace even though ateredis doesn't check. | ||
| // The PostgreSQL store enforces the actor->atespace foreign key. |
There was a problem hiding this comment.
Is there a reason why creating a actor_template_versions without a parent is allowed? Is it to support out-of-order creation? The difference between how the referential integrity of this reference and object->namespace (which is FK projected) is what made me wonder what the rationale is here.
| // | ||
| // TODO: add metrics — at minimum a gauge for worker count, a counter for | ||
| // resync events, and a counter for failed PUBLISH operations (in ateredis). | ||
| // resync events, and a counter for failed worker-watch notifications. |
There was a problem hiding this comment.
I'm concerned that the proto payload can go over the 8000 byte NOTIFY limit with not well defined handling of the resulting behavior.
This appears to be a real problem because a larg-ish label set would make every worker write for that pool fail (scheduling included).
There was a problem hiding this comment.
I was aware of this size limit, but we will also be moving away from the pg_notify mechanism #934 which will bypass this issue
| These resources represent the high-frequency, ephemeral state of individual | ||
| actors and workers. They are stored in a high-performance, low-latency state | ||
| store (currently ValKey/Redis) to support real-time operations. | ||
| store (PostgreSQL) to support real-time operations. |
There was a problem hiding this comment.
One thing to keep an eye on now that Postgres backs the hot path. ResumeActor takes the actor lease before the already-running check, so every routed cold check does a lease upsert plus delete on a tiny table.
The leases have potential to cause a lot of churn. The leases can also become permanently orphaned. Since this is persistent state, cleanup is needed (open issue or address now?)
There was a problem hiding this comment.
Thanks for the careful work on the schema design.
At a high level, the main concerns I have with the PostgreSQL implementation are below. Please consider either addressing in this PR or opening issues to ensure this all gets resolved:
- Superuser for everything. Let's move to a least-privilege app role before this gets real exposure.
- No schema evolution or mismatch detection. The schema is applied as CREATE TABLE IF NOT EXISTS on every startup. Later, if the schema were to be updated, there is nothing to detect drift for a fresh install vs. upgraded clusters.
- Pessimistic locking. The read-modify-write paths hold SELECT ... FOR UPDATE row locks while app code runs between statements means that rows are locked for however long it takes for the server to run the code between statements. I'm concerned the scalability implications of having the lock duration paired with the go code execution.
- Found worrying number of concurrency issues around pre-condition checks that can race with other DB updates. I pointed out quite a few of them and did a AI run to search for them as well, but they are difficult to find. Recommend adding significantly more tests that interleave operations that can interact.
- Not enough unexpected input test cases. PageSize cases I found are a good example. Recommend going through all user inputs on all operations and checking for bad inputs.
- LISTEN/NOTIFY robustness. Allowing undecodable events to be silently dropped instead of closing the channel, so consumers can't tell they have a gap, is unsafe. Recommend hardening the channel code for bad event shapes. Better to fail with a clear error than to silently drop data on a channel.
- Object lifecycles. Orphaned leases is a good example of where transitioning to a persistent store surfaces issues that are not safe to ignore. In a persistent store like PostgreSQL, the orphaned data needs to get cleaned up.
| } | ||
|
|
||
| actor, err := persistence.CreateActor(ctx, &ateapipb.Actor{ | ||
| actor := storetest.MustCreateActor(t, ctx, persistence, &ateapipb.Actor{ |
There was a problem hiding this comment.
An AI scan found these cases and they both look legitimate:
Case 1: the worker vanishes mid-claim, client is told the actor doesn't exist.
- Router asks to resume actor X. Scheduler picks worker W from the in-memory cache.
- W's pod dies. The syncer notices and deletes W's row from the store.
- The resume tries to claim W. The CAS UpdateWorker finds no row and returns ErrNotFound. That error is about the worker.
- The retry loop only retries version conflicts, so it gives up and passes the error up as is (workflow_resume.go#L323).
- The ResumeActor handler sees ErrNotFound and assumes it means the actor (actor.go#L410-L418). Client gets NotFound 'Actor X not found'.
- The router treats NotFound as final, so the request dies with a 404 for an actor that exists. The right outcome was to retry with another worker.
Case 2: heavy contention becomes a permanent 500.
- Resume tries to claim a worker. Another resume gets there first, the CAS loses with ErrVersionConflict.
- The loop retries with a fresh worker, 5 attempts total.
- Pool is busy, all 5 lose the same way.
- The backoff helper then returns its own generic 'timed out waiting for the condition' error, not the last conflict.
- Nothing recognizes that error, so it surfaces as Internal, which the router won't retry.
| t.Fatalf("Get source ActorTemplate: %v", err) | ||
| } | ||
| snapshot, err := tc.persistence.CreateActorSnapshot(context.Background(), &ateapipb.ActorSnapshot{ | ||
| snapshot := storetest.MustCreateActorSnapshot(t, context.Background(), tc.persistence, &ateapipb.ActorSnapshot{ |
There was a problem hiding this comment.
This handler only checks ErrAlreadyExists (actor.go#L103-L109), so the ErrFailedPrecondition error falls through and the client gets Internal instead of 'Atespace not found'.
Recommend a test that deletes the atespace concurrently with the create.
|
|
||
| atespace, name := tag.GetMetadata().GetAtespace(), tag.GetMetadata().GetName() | ||
| snapshot, err := persistence.CreateActorSnapshot(context.Background(), &ateapipb.ActorSnapshot{ | ||
| snapshot := storetest.MustCreateActorSnapshot(t, context.Background(), persistence, &ateapipb.ActorSnapshot{ |
There was a problem hiding this comment.
Same problem as mentioned for CreateActor. If you create a tag for a snapshot that doesn't exist and you get ErrNotFound from the pre-check. But if the snapshot is deleted after the pre-check and before the insert, the FK violation comes back as ErrFailedPrecondition instead.
Generally we need to not rely on pre-checks to guard an operation if it can race with other operations.
| } | ||
|
|
||
| // TestListWorkers tests that workers mirrored to Redis are listed. | ||
| // TestListWorkers tests that workers mirrored to the store are listed. |
There was a problem hiding this comment.
Please add tests for invalid page tokens. These should not result in internal errors.
| // Backend-specific behavior (e.g. ateredis's multi-shard pagination, atepg's | ||
| // foreign-key races and transactional notifications) is NOT covered here; see | ||
| // each backend's own test file for that. | ||
| // PostgreSQL-specific behavior such as foreign-key races and transactional |
There was a problem hiding this comment.
Looks like it's possible to panic the server by setting PageSize to 0 or negative. Please fix and add tests.
|
|
||
| for _, name := range []string{"snapshot-1", "snapshot-2"} { | ||
| if _, err := persistence.CreateActorSnapshot(ctx, &ateapipb.ActorSnapshot{ | ||
| storetest.MustCreateActorSnapshot(t, ctx, persistence, &ateapipb.ActorSnapshot{ |
There was a problem hiding this comment.
I decided to also do a quick scan for unindexed queries.
Postgres automatically indexes the referenced side of a foreign key but not the referencing side, and there's no index on actor_snapshot_tags (snapshot_atespace, snapshot_name) so the queries against are full scans.
There was a problem hiding this comment.
Good catch for this, thanks! I missed the usage of this index in the FK trigger when deleting snapshots
First off, thanks so much for the detailed review! I'll run through these one at a time so we can try and see if we're on the same page.
|
| rpc ListActors(ListActorsRequest) returns (ListActorsResponse) {} | ||
|
|
||
| // Create a new Atespace. Substrate-native, stored in Redis. | ||
| // Create a new Atespace. Substrate-native, stored in PostgreSQL. |
There was a problem hiding this comment.
Nit: We shouldn't talk about PostgreSQL here as it's not really relevant information for the API client.
|
Julian Gutierrez Oschmann (@juli4n) Benjamin Elder (@BenTheElder) For scale, a couple things to consider:
|
|
Joe Betz (@jpbetz), thank you for the detailed review! I addressed the review comments in a separate PR #1023, to keep those changes separate from the larger changes in this PR, which do not modify the PostgreSQL implementation itself. I also switched the PostgreSQL store to optimistic concurrency, removed the UNIQUE constraints on server-generated UIDs, and added the missing indexes on referencing columns, as you've correctly pointed out.
I don’t believe there are currently any cascading deletes; the foreign keys use ON DELETE RESTRICT. Dropping the foreign keys would be a significant semantic tradeoff. I’m not aware of an alternative that preserves the current race-free referential-integrity guarantees without introducing additional locking or transactional coordination. As you noted in several comments, application-level existence checks alone cannot provide the same guarantees because they can race with concurrent deletion. |
|
Thanks Jet Chiang (@supreme-gg-gg)! I'll follow along on the subsequent PR. |
Summary
A follow up to #640 where we introduced PostgreSQL as an alternative storage backend, selected conditionally in ateapi.
debug-clear-storein CLIBenchmarking
Extensive benchmarking have been performed to evaluate Redis vs Postgres, and results can be found in these two documents: