Skip to content

feat(aws-sagemaker): close real-cloud parity gaps in #1450 - #1548

Merged
thzgajendra merged 9 commits into
stackshy:developmentfrom
thzgajendra:feat/sagemaker-parity
Oct 13, 2026
Merged

thzgajendra merged 9 commits into
stackshy:developmentfrom
thzgajendra:feat/sagemaker-parity

Conversation

@thzgajendra

@thzgajendra thzgajendra commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

The problem (in plain words)

The SageMaker emulator diverged from the real service in 20 places (#1450): whole operation families were missing, Describe/List/endpoint/cluster responses were thin, jobs only ever showed a terminal state, two CloudWatch metrics were invented, and no EventBridge events were published. Anyone running the AWS SDK, CLI or Terraform against the emulator hit errors or perpetual diffs.

How this fixes it

The PR adds the missing families and fills in the response shapes so they match what the real service returns, replaces the invented metrics with the real ones, and publishes the documented state-change events. Details follow.

  • The SageMaker emulator diverged from the real service in 20 places listed in AWS SageMaker: open real-cloud parity gaps #1450: missing operation families (Model Monitor, TrialComponents, ML Lineage, Projects, Workforce/Workteam, Studio custom images, Search, model-package policies), thin Describe/List/endpoint/cluster shapes, jobs that only ever showed a terminal state, fabricated JobsCreated/ResourcesCreated metrics and no EventBridge events.
  • This PR closes all 20 items so SDK, CLI and Terraform users see what the real service returns: the new families, real endpoint ProductionVariants and Creating/Updating states, full List filters/sort/paging, distinct job stage times, real invoke and job-utilization metrics, and state-change events on the default bus.
  • Operations go from 121 to 207 (driver methods); the new code is the same three layers as before (driver, in-memory provider, awsJson1_1 server).

Changes

flowchart LR
  C[AWS CLI / SDK / Terraform] --> S[server/aws/sagemaker]
  S --> P[providers/aws/sagemaker Mock]
  P --> M[(memstore stores)]
  P --> CW[CloudWatch metrics]
  P --> EB[EventBridge default bus]
Loading
  • New families (driver + provider + server + Layer-1 wrappers): Model Monitor (4 job-definition kinds, schedules, lazily generated executions, alerts), TrialComponents, ML Lineage (artifact/action/context, associations, QueryLineage), Projects, Workforce/Workteam, Studio Images/ImageVersions/AppImageConfigs, Search/GetSearchSuggestions, BatchDescribeModelPackage and model-package-group policy, UpdateNotebookInstance (+ lifecycle config), UpdateCluster.
  • Shapes: endpoints report real ProductionVariants (+ Creating/Updating under --async-settle); clusters report InstanceGroup counts/status; job Describe echoes the create request and returns distinct SecondaryStatusTransitions; List operations take the real filters, sort and MaxResults/NextToken (default page 10, max 100), with the documented defaults (SortOrder Descending for ListEndpoints, ListModels, ListEndpointConfigs and ListTransformJobs; SortBy Name for ListCodeRepositories and ListNotebookInstances), and a filter on a field the operation lacks is an error, never an empty result; InvokeEndpoint honors Content-Type/Accept/TargetVariant.
  • Metrics: removed the fabricated JobsCreated/ResourcesCreated; added Invocations, InvocationsPerInstance, ModelLatency/OverheadLatency (Microseconds) with EndpointName+VariantName, 4XX/5XX/ModelError counters, per-job host utilization and pipeline metrics.
  • Events: aws.sagemaker state-change events for training/processing/transform/tuning jobs, endpoints, model packages and pipeline executions.
  • Cross-cutting: snapshot rows for every store (+ reflection test), resource discovery for the new standing types, chaos hooks on UpdateEndpoint/CreateHyperParameterTuningJob/StartPipelineExecution, ARN-shape validation, tag cleanup on delete.
  • Docs: regenerated coverage docs, rewrote the SageMaker section of docs/services.md, added docs/coverage/nongoals/sagemaker.md.

Behavior changes to existing behavior (deliberate, real-service aligned): a duplicate create is a ValidationException for CreateModel, CreateEndpointConfig, CreateEndpoint and the other operations whose SDK error list has no ResourceInUse, and stays ResourceInUse for jobs, feature groups, Studio user profiles, spaces and apps, clusters and the other operations that model it; Describe/Delete of a missing target is a ValidationException for operations whose SDK output does not model ResourceNotFound; InvokeEndpoint on a missing endpoint is a 400 ValidationError; Stop on a terminal job is a no-op 200; List order is deterministic with a default page of 10; deleting a model package group cascades to its versions; error messages no longer carry the internal NotFound:/InvalidArgument: prefix; CreateEndpointConfig rejects a variant whose model does not exist, more than 10 variants and negative counts or weights; driver.ProductionVariant.InitialVariantWeight is now a *float64 (nil means omitted; the provider applies the default of 1.0), so Go library callers that wrote InitialVariantWeight: 1 must pass a pointer; InvokeEndpointAsync needs an endpoint configured with an async output path and returns the result under it; every create enforces the 50-tag limit; a notebook instance must be Stopped (or Failed) before it is deleted; a domain with user profiles, spaces or apps cannot be deleted; deleting a pipeline deletes its executions; StopPipelineExecution on a finished run is a no-op; DeleteWorkforce with workteams is a ValidationException (the SDK op models no typed error); job InstanceCount is capped at 500; UpdateEndpoint and the List* driver signatures changed (Go API); two goldens in server/aws/testdata/authz_authoff_golden.json were updated for the message/status changes.

Deliberate deviations / non-goals (also in docs/coverage/nongoals/sagemaker.md): nothing runs (no containers); InvokeEndpointWithResponseStream and BatchGetRecord are not served; ListMonitoringAlerts is empty under serve (AWS creates alerts from Model Dashboard violations; the seeding hook is a Go test seam); no Step Status Change, Model, Feature Group or HyperPod events, and under --async-settle the first state of a change is published at the call and the settled state once, on the first read after it settles (published after the endpoint lock is released, so an event target can read the endpoint back); the endpoint event status literal follows the AWS doc example (IN_SERVICE), which AWS does not document further; per-job utilization is skipped under --async-settle; some caps (500 projects, 25 workteams) and message texts are the emulator's choice. I walked the 15-point service checklist in the repo standards against this diff.

Provider Coverage

  • AWS

Azure, GCP and OCI are not applicable: SageMaker is an AWS-only service.

Checklist

  • All tests pass (go test ./...)
  • Linter passes (golangci-lint run --timeout=9m ./...)
  • Every provider the change applies to implements the same behavior
  • Integration tests added to cloudemu_test.go - SDK round-trip tests added in server/aws/sagemaker, event tests in providers/aws/lifecycle_events_test.go
  • Unit tests added to provider test files
  • Regenerated docs (go generate ./... for coverage; go run ./internal/compatgen for the compat matrix) and committed the result

Test Plan

  • Before review I ran an independent pre-review of this PR against the maintainers' recurring review findings (concurrency and aliasing, wire fidelity against the vendored SDK, validation and structure, tests and security). It found and this PR fixes: orphaned children on delete, a frozen async status, SDK list filters that were ignored, ModelPackageName/AsyncInferenceConfig/InferenceId wire gaps, an unbounded InstanceCount allocation, a reflected-content risk in the invoke echo, an OIDC secret kept in snapshots, unbounded control-plane bodies, and missing SDK round-trip, wrapper and integration tests.
  • Unit and SDK round-trip tests for every new operation and error path (typed exception + HTTP status), go test ./... green, -race on the touched packages.
  • Real E2E against the built binary (docker image from the worktree Dockerfile, AWS CLI v2, boto3, OpenTofu with the AWS provider): 250-job paging and filters, async-settle endpoint lifecycle with waiters and an EventBridge->SQS rule, 50-way same-name creates and create-vs-delete races on a -race binary (0 orphans, no data race), --persist restart, a snapshot written by the development binary loading in this branch, --enforce-auth allow/deny, and a Terraform apply / no-diff plan / in-place update / destroy of 10 SageMaker resources. The E2E found and fixed: baseline-snapshot endpoints losing their variants, Terraform's not-found detection (notebook, project, workforce, image) and a missing DirectInternetAccess.
  • Coverage before -> after: providers/aws/sagemaker 50.3% -> 89.0%, server/aws/sagemaker 40.3% -> 74.9%; services/sagemaker 13.7% -> 98.8%.
  • CodeQL go-security-extended: 11 findings, all in files this PR does not touch (contrib/dockerengine, contrib/realengine, internal/vtl, server/serverkit); none in the SageMaker code.

Related Issues

Closes #1450

@NitinKumar004 NitinKumar004 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.

Thanks for this. It's a big piece of work and most of it holds up well. I checked out 2ad8082, built it, and ran it against the AWS CLI and the real Terraform AWS provider (6.66.0), with and without --async-settle and across a --persist restart. All 20 items from #1450 are addressed, and the Terraform lifecycle for model, endpoint config, endpoint and notebook instance is clean. I'm requesting changes for one regression in the error mapping and a few Medium gaps listed below.

Issue coverage (#1450)

Item Status Evidence
UpdateNotebookInstance missing Done Works through the CLI. Terraform instance_type update goes stop, update, start, then plan is 0. Updating while InService gets the real Status (InService) not in ([Stopped, Failed]) message
DescribeEndpoint/ListEndpoints ProductionVariants shape Done Current/Desired weight and count, DeployedImages and VariantStatus are returned. PendingDeploymentSummary appears only while Updating
Model Monitor family Done Job definitions and schedules created. ListMonitoringAlerts can only be populated through a Go-only seam, see Medium 6
TrialComponent family Done create/describe via CLI
ML Lineage Done CreateArtifact, AddAssociation, QueryLineage
Projects CRUD Done CreateInProgress, then CreateCompleted under async settle
DescribeTrainingJob dropped fields Done SecondaryStatusTransitions have distinct stage times, and the request is echoed
List filters / sort / pagination Partial Paging and filters work, but the default SortOrder is wrong for 4 ops, and LastModifiedTime filters silently return nothing on 2 ops. See Medium 2 and 3
Create/UpdateEndpoint go straight to InService Done Under --async-settle, Creating, then Updating, then InService can all be observed. Terraform waiters finish in 1-3s
VariantName dim, 4XX/5XX, OverheadLatency, job utilization Done (with Low gaps) AWS/SageMaker has Invocations, InvocationsPerInstance, ModelLatency (Microseconds) and OverheadLatency, keyed by EndpointName+VariantName. See Low 1 and 2
EventBridge state-change events Partial Events reach SQS through a rule on aws.sagemaker. Under --async-settle only CREATING/UPDATING/DELETING are sent and IN_SERVICE never is (documented). See Low 3
Workteam/Workforce Done workteam/private-crowd/<name> ARN
Studio custom images Done image-version/<name>/1
Search / GetSearchSuggestions Done Search returns training jobs. GetSearchSuggestions with hint Train returns no suggestions (Low)
BatchDescribeModelPackage, Get/PutModelPackageGroupPolicy Done Present and routed. CreateModelPackage still rejects a group ARN, see Medium 5
DescribeCluster InstanceGroups counts Done ThreadsPerCore, counts and LifeCycleConfig are serialized
InvokeEndpoint echoes Accept as Content-Type Done Accept: */* with text/csv returns text/csv
Jobs complete synchronously Done InProgress/Starting, then later stages under async settle
Fabricated JobsCreated / ResourcesCreated metrics Done Removed

None of these exist on the merge base: UpdateNotebookInstance, CurrentInstanceCount, the new family ops, SecondaryStatusTransitions, OverheadLatency, Invocation4XXErrors and the State Change detail types all return zero hits there. The new tests don't compile on the base because they call new driver methods.

Findings

High

1. Duplicate creates of jobs, feature groups, Studio children and clusters now return ValidationException instead of ResourceInUse. (server/aws/sagemaker/errors.go:36, pinned by server/aws/sagemaker/dispatch_test.go:75)

On the base, AlreadyExists mapped to ResourceInUse. This PR maps every AlreadyExists to ValidationException. That's right for CreateModel, CreateEndpointConfig and CreateEndpoint ("Cannot create already existing ..."). For these ops, AWS returns ResourceInUse and the SDK models it:

  • CreateTrainingJob, CreateProcessingJob, CreateTransformJob, CreateHyperParameterTuningJob, CreateAutoMLJobV2, CreateLabelingJob, CreateCompilationJob
  • CreateFeatureGroup
  • CreateUserProfile, CreateSpace, CreateApp
  • CreateCluster

The provider still returns plain AlreadyExists for all of them (jobs.go, feature_store.go, studio.go, cluster.go).

Repro: aws sagemaker create-training-job --training-job-name dupjob ... twice. The second call returns ValidationException: training job "dupjob" already exists.

Impact: idempotent job-submission code that does errors.As(err, &types.ResourceInUse{}) (or catches ResourceInUse in boto3) stops matching and treats a retry as a hard failure. That code worked before this PR.

Fix: return driver.InUse(...) from those creates, as you already do for Image, AppImageConfig, monitoring and Workteam. Keep the generic AlreadyExists to ValidationException mapping only for ops whose SDK error list has no ResourceInUse. Then fix the test row.

Medium

1. A deleted endpoint comes back InService after a --persist restart when --async-settle is on. (providers/aws/sagemaker/endpoint.go:549)

deleteEndpoint only records a Deleting transition in epTrans. The endpoint is purged lazily on the next read. Snapshot dumps the store as-is, and epTrans isn't persisted.

Repro:

  1. Start serve with --async-settle --persist.
  2. Run create-endpoint pe, wait for InService, then delete-endpoint pe.
  3. Stop the server and start it again.
  4. describe-endpoint pe returns InService and list-endpoints shows it.

Until that next read, arnExists (tags.go:203, m.endpoints.Has) also still accepts AddTags on the deleted endpoint.

Fix: drop endpoints with a Deleting transition when snapshotting (or purge any elapsed Deleting endpoint first), and route the tag liveness check through liveEndpoint.

2. Default SortOrder is ascending where AWS defaults to Descending.

AWS documents SortOrder default Descending for ListEndpoints, ListModels, ListEndpointConfigs and ListTransformJobs (ListEndpoints, ListModels, ListEndpointConfigs, ListTransformJobs). None of their specs sets defaultOrder:

  • providers/aws/sagemaker/endpoint.go:313
  • inference.go:61
  • inference.go:135
  • jobs.go:379

Repro: create ord-a, ord-b, ord-c, then run list-endpoint-configs. It returns them oldest first.

Impact: with the default page size of 10, list-endpoints --max-results 1 and any "latest first" code get the oldest item. ListTrainingJobs and ListProcessingJobs default to Ascending correctly.

3. LastModifiedTime filters silently return nothing, and a valid SortBy is rejected, on two list ops.

  • ListNotebookInstanceLifecycleConfigs (notebook.go:389) and ListCodeRepositories (notebook.go:467) have no modified accessor.
  • So --sort-by LastModifiedTime returns ValidationException ... not supported by this operation.
  • And --last-modified-time-after 2000-01-01 returns an empty list, because inRange returns false when the accessor is nil (list.go:277).
  • Both ops document LastModifiedTime as a SortBy value and as a filter (ListNotebookInstanceLifecycleConfigs, ListCodeRepositories).
  • ListCodeRepositories and ListNotebookInstances (notebook.go:135) also default SortBy to Name in AWS, but default to CreationTime here.

An unsupported filter field should never quietly empty the result. Add the modified accessors and the Name defaults.

4. InvokeEndpointAsync ignores the caller's InferenceId. (server/aws/sagemaker/runtime.go:108-112, :122, providers/aws/sagemaker/runtime.go:158)

The handler never reads X-Amzn-SageMaker-Inference-Id, so --inference-id myid-1 comes back as "InferenceId": "0000008e". AWS only generates one "if none is specified" (InvokeEndpointAsync). Clients that correlate async results by their own id lose the link. The handler also reads the content type from Content-Type rather than X-Amzn-SageMaker-Content-Type.

5. CreateModelPackage rejects a group ARN. (providers/aws/sagemaker/registry.go:126)

ModelPackageGroupName is "The name or Amazon Resource Name (ARN) of the model package group" (CreateModelPackage). Describe/List/Delete and the policy ops normalise through groupNameOf, but create checks the raw value. Passing arn:aws:sagemaker:us-east-1:000000000000:model-package-group/grp1 returns model package group "arn:..." not found.

Fix: cfg.GroupName = m.groupNameOf(cfg.GroupName) on entry.

6. Production code exposes test-only seams that serve can't reach.

These have no non-test caller:

  • SetInvocationFailure (providers/aws/sagemaker/runtime.go:165)
  • PutMonitoringAlert (monitoring.go:656)
  • SetMonitoringExecutionStatus (monitoring.go:684)

PutMonitoringAlert is the only writer of monAlerts, so through serve or Docker ListMonitoringAlerts is always empty. invFailures is also never cleared when an endpoint is deleted, so a failure seeded on one endpoint applies to a re-created endpoint with the same name.

Either expose these through the seed/admin surface, so they're real features for every way of running cloudemu, or move them into an export_test.go.

7. The InitialVariantWeight default lives only in the wire layer. (server/aws/sagemaker/inference.go:290)

weightOrDefault turns an omitted weight into 1 in the server only. driver.ProductionVariant.InitialVariantWeight is a plain float64, so a Go library user who omits it stores 0. DescribeEndpoint then shows CurrentWeight: 0, and variant picking falls back, so the library and serve disagree.

Make the field *float64 (or otherwise carry "unset") and apply the default of 1.0 in the provider.

Low

  1. InvocationsPerInstance is always 1 (providers/aws/sagemaker/metrics.go:69). AWS sends 1/numberOfInstances per request (monitoring-cloudwatch), so a variant with 2 instances reports double.

  2. Other metric gaps.

    • Invocation4XXErrors/5XXErrors are only emitted on failure. AWS sends 0 otherwise, so the Average (error rate) is always 1 here.
    • A bad TargetVariant emits a 4XX datapoint under a VariantName that doesn't exist (nope).
    • Pipeline metrics use unit None instead of Count.
  3. No terminal event under --async-settle (providers/aws/sagemaker/events.go:42). A rule on EndpointStatus: IN_SERVICE (or a training job's Completed) never fires under async settle, which is the main reason people wire these events. It's documented, but the observe path that flips the status could also emit the transition once.

  4. Monitoring executions freeze after 1000 fires (monitoring.go:528). fires keeps the oldest 1000, so an hourly schedule stops producing new executions after about 41 days, or after a large FakeClock advance. Keep the newest N instead.

  5. UpdateEndpointWeightsAndCapacities rejects DesiredInstanceCount=0 (endpoint.go:501). The type allows a minimum of 0 (DesiredWeightAndCapacity).

  6. The reflected-content guard is a short denylist (server/aws/sagemaker/runtime.go:100). application/*+xml and text/xsl still render as active content. Neuter any +xml suffix, or add Content-Security-Policy: sandbox to invocation responses.

  7. Message wording differs from AWS.

    • AWS quotes the ARN for not-found: Could not find endpoint "arn:aws:sagemaker:...:endpoint/nope".
    • Duplicate model and endpoint creates say Cannot create already existing model "arn:...".
    • DescribeNotebookInstance on a missing name returns the bare message RecordNotFound.

    Terraform's substring checks still pass.

  8. ARNs keep the caller's casing. AWS lowercases the name segment, e.g. the EventBridge doc example "EndpointName": "MyEndpoint" with .../endpoint/myendpoint. This predates the PR, but every new family copies it. Lineage artifact ARNs use a 17-character id where AWS uses 32 hex characters.

  9. Small fidelity gaps.

    • DeployedImages.ResolvedImage should be digest-pinned (image@sha256:...), not a copy of the tag.
    • Training-job events send "0" for unset start and end times instead of omitting them.
    • Notebook instances skip Pending even under --async-settle.
    • DeleteWorkteam on a missing team returns {"Success": false}. Please confirm AWS doesn't raise ValidationException here.
  10. Concurrency nits (no data race, but logical races).

    • DeleteDomain checks for children and then deletes without a lock, while the child creates check the domain unlocked, which can leave orphans.
    • Event payloads use the .Tags copy taken at create, so they're stale after AddTags/DeleteTags.
  11. Structure. The endpoint driver API is still in services/sagemaker/driver/inference.go, while provider and server now have endpoint.go. All new families go into the generic services/sagemaker/wrappers_rest.go rather than per-feature files.

  12. Docs and limits.

    • docs/services.md says "Real invocation, job-utilization and pipeline metrics", while the non-goals say the values are nominal. Drop "Real".
    • The 1 MiB control-plane body cap rejects a maximum-size PipelineDefinition once it is JSON-escaped.
  13. Coverage. server/aws/sagemaker is at 73.2% and providers/aws/sagemaker at 87.8%, both under the 90% target. services/sagemaker is at 98.5%.

AWS docs divergences (summary)

Terraform and CLI results

Terraform (hashicorp/aws 6.66.0, default sagemaker endpoint override) with aws_sagemaker_model, aws_sagemaker_endpoint_configuration, aws_sagemaker_endpoint and aws_sagemaker_notebook_instance:

  1. apply: created in 0s (endpoint 3s under --async-settle), no waiter hang.
  2. plan -detailed-exitcode: 0.
  3. Update notebook instance_type, tags on every resource, and the endpoint config weight. This replaces the config and sends UpdateEndpoint. Applied cleanly in both modes.
  4. plan: 0.
  5. After a --persist restart, plan is still 0.
  6. destroy: clean.
  7. Describe on all four afterwards returns the not-found ValidationException.

AWS CLI:

  • Not-found and duplicate cases for endpoint, config, model, notebook and training job.
  • Notebook update and delete while InService (correctly refused).
  • Update while Updating (refused).
  • InvokeEndpoint with Content-Type, Accept and TargetVariant, plus missing endpoint (400 ValidationError "Endpoint nope of account ... not found.").
  • InvokeEndpointAsync.
  • CloudWatch list-metrics and get-metric-statistics.
  • EventBridge rule to SQS.
  • New families: image, image version, trial component, artifact, project, workforce, workteam, search, data-quality job definition.

What I verified

  • Clean build of 2ad8082 with go build ./....
  • go vet on the touched packages.
  • gofmt clean on the changed files.
  • go mod tidy leaves no diff.
  • go generate ./... leaves no diff.
  • golangci-lint reports 0 new issues against the merge base on the touched packages.
  • go test -race passes on providers/aws/sagemaker, server/aws/sagemaker, services/sagemaker, features/chaos, services/resourcediscovery and persist, plus the SageMaker, wiring, authz and golden tests in providers/aws, server/aws and the root package.
  • A concurrent probe ran 8 goroutines × 200 ops of create, describe, update, delete and list endpoint, notebook stop/update/start, invoke, and clock advance under --async-settle, three times with -race. No races.
  • The branch merges cleanly into the current development branch.
  • CI is all green.

Verdict

Request changes. The ResourceInUse regression (High) and Medium 1 to 7 should be fixed before merge. The Low items can go in a follow-up.

wire.WriteJSONError(w, http.StatusBadRequest, "ResourceNotFound", cerrors.Message(err))
case cerrors.IsAlreadyExists(err):
wire.WriteJSONError(w, http.StatusBadRequest, "ResourceInUse", err.Error())
wire.WriteJSONError(w, http.StatusBadRequest, driver.ExValidation, cerrors.Message(err))

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.

This maps every AlreadyExists to ValidationException. That's right for CreateModel/CreateEndpointConfig/CreateEndpoint, but AWS (and the SDK error lists) return ResourceInUse for duplicate CreateTrainingJob/ProcessingJob/TransformJob/HyperParameterTuningJob/AutoMLJobV2/LabelingJob/CompilationJob, CreateFeatureGroup, CreateUserProfile/Space/App and CreateCluster. The base mapped these to ResourceInUse, so this is a regression: create-training-job twice now returns ValidationException: training job "dupjob" already exists, and errors.As(err, &types.ResourceInUse{}) stops matching. Could those creates return driver.InUse(...) (as Image/Workteam already do), with this generic mapping kept only for ops whose SDK error list has no ResourceInUse?

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.

Confirmed against the SDK error lists: CreateTrainingJob/ProcessingJob/TransformJob/HyperParameterTuningJob/AutoMLJobV2/LabelingJob/CompilationJob, CreateFeatureGroup, CreateUserProfile/Space/App and CreateCluster all model ResourceInUse, and the base mapped every AlreadyExists to it. Those twelve creates now return driver.InUse(...); the generic AlreadyExists -> ValidationException mapping stays for the operations whose list has no ResourceInUse (CreateModel, CreateEndpointConfig, CreateEndpoint, CreateNotebookInstance and so on). Pinned per operation in the provider test, and an SDK round trip checks that a duplicate CreateTrainingJob decodes as *types.ResourceInUse. Fixed in 64c3a74.

Comment thread server/aws/sagemaker/dispatch_test.go Outdated
typ string
}{
{"not found", cerrors.New(cerrors.NotFound, "x"), 400, "ResourceNotFound"},
{"already exists is validation", cerrors.New(cerrors.AlreadyExists, "x"), 400, "ValidationException"},

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.

This row pins the generic AlreadyExists -> ValidationException rule for every op, which is wrong for jobs, feature groups, Studio children and clusters (they model ResourceInUse). Could you replace it with per-op cases once the creates return the right typed error?

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 row no longer claims to cover every op: it is renamed to the plain already-exists case (an operation with no ResourceInUse), and the per-op cases live in TestDuplicateCreates_ExceptionPerOp (provider) and TestSDK_DuplicateCreate_ResourceInUse (SDK). Fixed in 64c3a74.

return ep, nil
}

m.beginEndpointTransition(name, driver.EndpointDeleting, now, settle.DefaultEndpointDeleteSettle, nil, "")

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.

Under --async-settle the endpoint stays in the store with a Deleting transition in epTrans, which isn't persisted. If nobody reads it before shutdown, a --persist restart brings it back as InService (repro: create pe, wait, delete-endpoint pe, restart, describe-endpoint pe -> InService, list-endpoints shows it). tags.go:203 also lets AddTags hit it until the lazy purge. Could Snapshot skip or purge endpoints in Deleting, and the tag liveness check go through liveEndpoint?

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.

Reproduced and fixed. Snapshot now leaves out endpoints that have a Deleting transition, together with their tags, without touching the live mock (it is still Deleting and is purged on the next read). The tag liveness check goes through endpointStatus, so an endpoint whose Deleting window has elapsed is not taggable even before the lazy purge. Checked with the CLI against --async-settle --persist: create pe, wait, delete-endpoint pe, restart; describe-endpoint and add-tags both say not found, list-endpoints omits it, and the rest of the state survived. Fixed in 64c3a74.

}
m.epMu.Unlock()

return listItems(observed, listSpec[driver.Endpoint]{

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.

ListEndpoints defaults SortOrder to Descending in AWS (API_ListEndpoints), but this spec sets no defaultOrder, so results come back oldest first and --max-results 1 returns the oldest endpoint. ListModels (inference.go:61), ListEndpointConfigs (inference.go:135) and ListTransformJobs (jobs.go:379) have the same problem.

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.

Confirmed (ListEndpoints / ListModels / ListEndpointConfigs / ListTransformJobs document SortOrder default Descending). All four specs now set defaultOrder: Descending; training and processing jobs keep their ascending default. list-endpoint-configs and --max-results 1 return the newest first. Fixed in 64c3a74.

out := make([]driver.NotebookLifecycleConfig, 0, len(all))
//nolint:gocritic,lll // large value params mirror the driver interface; SDK-shaped literal
func (m *Mock) ListNotebookInstanceLifecycleConfigs(_ context.Context, opts driver.ListOptions) (driver.ListPage[driver.NotebookLifecycleConfig], error) {
return listFrom(m.notebookLCs, listSpec[driver.NotebookLifecycleConfig]{

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.

There's no modified accessor here, so --sort-by LastModifiedTime (a documented value) returns a ValidationException, and --last-modified-time-after 2000-01-01 silently returns an empty list (inRange returns false for a nil accessor, list.go:277). ListCodeRepositories (line 467) has the same gap. AWS also defaults SortBy to Name for ListCodeRepositories and ListNotebookInstances.

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.

Added the modified accessors for lifecycle configs and code repositories (code repositories gained LastModifiedTime, equal to the creation time because there is no UpdateCodeRepository, and it is returned by Describe and the summaries), and the Name default sort for ListCodeRepositories and ListNotebookInstances. Fixed in 64c3a74.

Comment thread providers/aws/sagemaker/metrics.go Outdated

m.putMetrics(ctx, []mondriver.MetricDatum{
m.datum(nsEndpoint, "Invocations", 1, unitNone, dims, now),
m.datum(nsEndpoint, "InvocationsPerInstance", 1, unitNone, dims, now),

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.

AWS reports InvocationsPerInstance as 1/numberOfInstances per request (monitoring-cloudwatch), so a 2-instance variant is double-counted here. Small one, but it's the metric target-tracking scaling uses.

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.

Per request it is now 1/instances (the variant's serving count); a variant scaled to zero publishes no per-instance value. A 2-instance variant reports 0.5 for one call (checked through CloudWatch), and the test that pinned 1 is corrected. Fixed in 64c3a74.

// states are not emitted, because the mock has no scheduler to fire them.
func (m *Mock) emitStates(ctx context.Context, detailType string, resources, states []string, detail func(state string) any) {
if m.opts.SettleDuration(settle.DefaultJobStageSettle) > 0 || m.opts.SettleDuration(settle.DefaultEndpointSettle) > 0 {
states = states[:1]

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.

Under --async-settle only the first state is published, so a rule on EndpointStatus IN_SERVICE or a training job's Completed never fires, and that's the main thing people use these events for. Since the observe path already flips the status, could it emit the transition once when it settles?

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.

Done the way you suggested: the observe path publishes the settled state once. Endpoints publish IN_SERVICE the first time they are read after their Creating or Updating window elapsed, and the four job families publish their terminal status (Completed, Stopped, Failed) the first time they are read settled, each guarded so repeated reads never duplicate. Checked with an EventBridge rule on aws.sagemaker to SQS under --async-settle: CREATING at the call, IN_SERVICE once after it settles. Documented in the non-goals page: the mock has no scheduler, so it is the read that notices the settle, and state restored from a snapshot can publish its settled state again on its first read. Fixed in 64c3a74.

}

now := m.opts.Clock.Now()
fires := sched.fires(parseTime(s.ActiveFrom), now, driver.MaxMonitoringExecutionsPerSchedule)

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.

fires collects the oldest fire times and stops at 1000, so an hourly schedule stops producing new executions after about 41 days (or after a big FakeClock advance), and LastMonitoringExecutionSummary freezes. Keeping the newest N would avoid that.

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.

fires now keeps the newest N (it walks back from the latest possible fire and returns them oldest first), so an hourly schedule keeps producing current executions after a long run or a big FakeClock advance. Fixed in 64c3a74.

Comment thread providers/aws/sagemaker/endpoint.go Outdated

if w.DesiredInstanceCount != nil {
if *w.DesiredInstanceCount < 1 {
return errors.Newf(errors.InvalidArgument, "desiredInstanceCount for variant %q must be at least 1", w.VariantName)

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.

DesiredWeightAndCapacity allows DesiredInstanceCount down to 0 (scale to zero), but this rejects anything below 1.

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.

DesiredInstanceCount of 0 is accepted (scale to zero) and only a negative count is rejected. Fixed in 64c3a74.

Comment thread server/aws/sagemaker/runtime.go Outdated
media := strings.ToLower(strings.TrimSpace(strings.SplitN(ct, ";", 2)[0])) //nolint:mnd // protocol constant

switch media {
case "text/html", "application/xhtml+xml", "image/svg+xml", "text/xml", "application/xml":

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.

The denylist misses application/*+xml (e.g. application/rss+xml) and text/xsl, which browsers still render as active XML. Matching any +xml suffix, or adding Content-Security-Policy: sandbox on invocation responses, closes it.

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.

Both: any +xml media type and the XSL types (text/xsl, application/xslt+xml) are served as application/octet-stream, and invocation responses carry Content-Security-Policy: sandbox. The SDK test covers rss, atom, mathml, xsl and a mixed-case type with a charset. Fixed in 64c3a74.

Return ResourceInUse for duplicate creates of jobs, feature groups, Studio
user profiles, spaces, apps and clusters, as the SDK models it, and keep the
validation-style duplicate for operations without it. Do not persist
endpoints that are being deleted, and check tag liveness through the endpoint
observer. Default list order to Descending for endpoints, models, endpoint
configs and transform jobs, add LastModifiedTime accessors and the Name
default sort for the notebook family, and reject a filter on a field an
operation lacks. Honor the caller's InferenceId and content-type header on
InvokeEndpointAsync, accept a group ARN in CreateModelPackage, and apply the
default variant weight in the provider. Move test-only seams to
export_test.go. Emit InvocationsPerInstance as 1/instances, publish the
settled state once under async settle, keep the newest monitoring fires,
allow DesiredInstanceCount 0, and neuter any +xml type with a sandbox CSP
header.
@thzgajendra

Copy link
Copy Markdown
Collaborator Author

Thanks for the thorough review, and for running the Terraform and CLI checks yourself. Round 1 is pushed in 64c3a74 (and a merge of the latest development): the ResourceInUse regression is fixed per operation (twelve creates return ResourceInUse; the SDK-modeled ValidationException duplicates stay); Mediums 1 to 7 are fixed (persist restart after an async delete, default sort orders, LastModifiedTime accessors and an error for unsupported filters, InferenceId and content-type headers, group ARN in CreateModelPackage, the test seams moved to export_test.go, the default variant weight in the provider); and the Lows with inline comments are done (InvocationsPerInstance per instance, the settled state published once under --async-settle, the newest-1000 fires, scale to zero, any +xml type plus a sandbox CSP header). The other Lows are listed as follow-ups on the non-goals page (message wording, ARN casing, UNUSED-style fidelity gaps, the DeleteDomain race, structure).

Coverage after the fixes: providers/aws/sagemaker 89.0%, server/aws/sagemaker 74.9% (the gap is thin per-operation wire shims; I did not extend it in this PR), services/sagemaker 98.8%. Verified with the AWS CLI against a -race build (the High and Medium repros, --async-settle --persist restart, events to SQS), an OpenTofu apply / no-diff plan / destroy of model, endpoint configuration, endpoint and notebook instance against an --async-settle server, and boto3 against a go installed binary with default flags. The PR description is updated to match.

@NitinKumar004 NitinKumar004 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.

Thanks for the quick turnaround. I re-checked 64c3a74 against the AWS CLI and the Terraform AWS provider (6.66.0), with and without --async-settle, and across a --persist restart. The ResourceInUse regression and all seven Mediums are fixed, and most of the Lows are too. One new problem came in with the fix for settled-state events, though: the IN_SERVICE event is now published while the endpoint lock is held, so a Lambda target that reads the endpoint back deadlocks every endpoint operation. That's the only thing I'd block on.

Status of the earlier findings

Earlier item Status Evidence
High: AlreadyExists mapped to ValidationException Fixed Duplicate create-training-job and create-feature-group now return ResourceInUse. Duplicate create-model still returns ValidationException. I compared the duplicate error of every Create op against the SDK error lists: every op whose list has ResourceInUse returns it, and none of the others do
M1: deleted endpoint back InService after a --persist restart Fixed With --async-settle --persist: create pe and keep, wait for InService, delete pe, restart. describe-endpoint pe returns not found, list-endpoints shows only keep, and pe's tags are gone. AddTags now goes through the observed status
M2: default SortOrder for 4 list ops Fixed list-endpoint-configs, list-models and list-transform-jobs return newest first. --max-results 1 returns the newest. --sort-order Ascending still works. ListTrainingJobs is unchanged
M3: LastModifiedTime on lifecycle configs and code repos, Name defaults Fixed --sort-by LastModifiedTime and --last-modified-time-after 2000-01-01 both work on these two ops. list-code-repositories and list-notebook-instances sort by name by default. An unsupported filter is now an error, not an empty list. I sent every documented filter and SortBy value to every list op the server serves, and only ListUserProfiles and ListSpaces still reject one (new Low 2)
M4: InvokeEndpointAsync ignores InferenceId Fixed --inference-id myid-1 comes back as myid-1 with output s3://b/async/myid-1.out. Without one an id is generated, and 65 characters gets the length ValidationError. The content type now comes from X-Amzn-SageMaker-Content-Type
M5: CreateModelPackage rejects a group ARN Fixed Passing the group ARN creates model-package/grp1/1. An ARN for a missing group gives not found
M6: test-only seams in production Mostly fixed The three seams are in export_test.go, and invFailures is cleared when an endpoint is purged. The state they feed is still production code, see Low 3
M7: default weight only in the wire layer Fixed driver.ProductionVariant.InitialVariantWeight is now *float64, and the provider stores 1.0 when it's omitted. Describe shows 1 through serve, and the provider test covers the library path. See Low 4 about the API change
L1: InvocationsPerInstance always 1 Fixed On a variant with 2 instances, each request sends 0.5
L2: 4XX/5XX zero datapoints, nope variant, pipeline unit Not fixed (documented) Listed as a non-goal now. A bad TargetVariant still creates Invocation4XXErrors under VariantName=nope, and pipeline metrics still use None
L3: no terminal event under --async-settle Fixed, but see High 1 Through SQS I see training InProgress then Completed, and endpoint CREATING then IN_SERVICE then UPDATING. Each event is sent once
L4: monitoring executions freeze after 1000 fires Fixed fires walks back from to and keeps the newest N. There's a test for it
L5: DesiredInstanceCount=0 rejected Fixed update-endpoint-weights-and-capacities ... DesiredInstanceCount=0 is accepted
L6: reflected-content denylist Fixed application/rss+xml and text/xsl come back as application/octet-stream, with Content-Security-Policy: sandbox and nosniff
L7: message wording Not fixed (documented as a follow-up)
L8: ARN casing, lineage id length Not fixed (documented as a follow-up)
L9: ResolvedImage digest, "0" event times, notebook Pending, DeleteWorkteam Partly documented ResolvedImage and notebook Pending are on the follow-up list. The "0" start/end times in training events and DeleteWorkteam returning Success: false on a missing team aren't mentioned
L10: DeleteDomain race, stale tags in events Not fixed The stale tags are documented. The DeleteDomain check-then-delete is unchanged
L11: structure Not fixed Fine as a follow-up
L12: "Real" in services.md, 1 MiB body cap Fixed / documented
L13: coverage Partly providers/aws/sagemaker is at 91.9% in my run and services/sagemaker at 98.5%. server/aws/sagemaker is still 73.3%

Findings

High

1. The settled IN_SERVICE event is published while epMu is held, so a target that reads the endpoint deadlocks it. (providers/aws/sagemaker/endpoint.go:167 and :202-210)

observeEndpoint now calls publishEndpointSettled, and its comment says "The caller holds epMu". DescribeEndpoint, ListEndpoints, UpdateEndpoint and the AddTags liveness check all call it with that lock held. emitEndpointEvents delivers to EventBridge, and EventBridge invokes a Lambda target synchronously. If that target calls DescribeEndpoint (or anything else that takes epMu), it blocks on the non-reentrant mutex. The original Describe never returns, and every endpoint operation after it hangs.

Repro (Go library, aws.New(config.WithClock(clk), config.WithAsyncSettle())):

  1. Create model, config and endpoint ep.
  2. Add a rule on {"source":["aws.sagemaker"],"detail-type":["SageMaker Endpoint State Change"],"detail":{"EndpointStatus":["IN_SERVICE"]}} with a Lambda target. Its registered handler calls p.SageMaker.DescribeEndpoint(ctx, "ep").
  3. Advance the clock past the settle window and call DescribeEndpoint.

The handler fires once, and DescribeEndpoint never returns: I gave up after 5s. "When the endpoint is in service, look it up and do X" is the main reason people wire this event. Before this commit the endpoint events were only sent from CreateEndpoint, UpdateEndpoint and DeleteEndpoint after the lock was released (endpoint.go:37, :381, :474, :555). The ECS and Lambda event paths have the same rule and pin it in providers/aws/lifecycle_events_reentry_test.go.

Fix: have the observer only record that a settled event is due, for example by returning it from liveEndpoint or adding it to a slice under the lock. Publish it after epMu.Unlock(), with the caller's ctx instead of context.Background(). A test along the lines of lifecycle_events_reentry_test.go would pin it. The job paths (publishSettled in jobs.go) observe without a lock, so they're fine.

Medium

None.

Low

  1. Lows not fixed and not documented. Training-job events still send "0" for an unset start or end time, and DeleteWorkteam on a missing team returns {"Success": false} (workforce.go:548). Either fix them or add them to the follow-up list next to the others.
  2. ListUserProfiles and ListSpaces reject --sort-by LastModifiedTime (providers/aws/sagemaker/studio.go:141, :212). These ops got list sorting in this PR. LastModifiedTime is one of the two documented SortBy values (ListUserProfiles, ListSpaces), but both return ValidationException ... not supported by this operation. driver.UserProfile and driver.Space have no LastModifiedTime, so Describe doesn't return one either. This is the same issue as the old Medium 3, on two more ops.
  3. The model-failure state is still production code that only tests can reach. The invFailures map, invMu, invocationFailure, clearInvocationFailure (sagemaker.go:128, runtime.go:175-186) and the 5XX/ModelError branch of InvokeEndpoint can only be set through export_test.go. Through serve, that branch never runs. If it's meant as a test-only behavior, it's cleaner to inject it as a hook. Otherwise expose it through the seed/admin surface.
  4. InitialVariantWeight changing from float64 to *float64 breaks callers of the public driver package. Any Go library caller that writes InitialVariantWeight: 1 stops compiling. That's the right shape, but it needs a line in the release notes.
  5. The new driver comment is wrong about AWS. services/sagemaker/driver/notebook.go:136 says "there is no UpdateCodeRepository". AWS has UpdateCodeRepository, and the emulator returns UnknownOperationException for it, so a Terraform aws_sagemaker_code_repository update fails. The missing op predates this PR, but the comment should say the op isn't implemented yet, not that it doesn't exist.
  6. settledEmitted only grows (events.go:43-51). A key is added for every settled job state and never removed, so a long-running server holds one entry per job forever. This is minor, but it could live on the job record (like settledPublished on the endpoint transition) instead of in a side map.

AWS docs divergences (new or still open)

Terraform and CLI results

Terraform (hashicorp/aws 6.66.0) with aws_sagemaker_model, aws_sagemaker_endpoint_configuration, aws_sagemaker_endpoint and aws_sagemaker_notebook_instance, against a default server and against an --async-settle server:

  1. apply: 4 added (10s default, 8s async), no waiter hang.
  2. plan -detailed-exitcode: 0.
  3. Update notebook instance_type, every tag, and the variant weight. The weight change replaces the config and sends UpdateEndpoint. Result: 1 added, 3 changed, 1 destroyed. The endpoint ends up InService on tfc-2 with CurrentWeight 2, and the notebook is InService on ml.t3.large.
  4. plan: 0.
  5. destroy: 4 destroyed.
  6. Describe on all four afterwards returns the not-found ValidationException.

On the --async-settle --persist server I also ran apply, then a restart, then plan (0), then destroy (clean).

AWS CLI (--async-settle --persist):

  • Duplicate training job, feature group and model.
  • The endpoint delete and restart sequence above, plus tags.
  • Default sort order on configs, models, endpoints and transform jobs, --max-results 1, explicit Ascending.
  • LastModifiedTime sort and filter on lifecycle configs and code repos, and the Name defaults.
  • A probe that sends every documented filter and SortBy value to every served list op.
  • invoke-endpoint-async with, without, and with an oversized --inference-id.
  • invoke-endpoint with application/rss+xml and text/xsl, and the response headers.
  • CreateModelPackage with a group ARN and with a missing-group ARN.
  • get-metric-statistics for InvocationsPerInstance, and list-metrics.
  • An EventBridge rule to SQS for training job and endpoint events.
  • update-endpoint-weights-and-capacities to 0.

What I verified

  • Clean build of 64c3a74.
  • go vet on the touched packages.
  • gofmt clean.
  • go mod tidy leaves no diff.
  • go run ./internal/coveragegen leaves no diff.
  • golangci-lint reports 0 new issues against 2f3f2eb and against the merge base, on the sagemaker packages and providers/aws.
  • go test -race passes on providers/aws/sagemaker, server/aws/sagemaker and services/sagemaker, plus the SageMaker, lifecycle, re-entry, wiring and discovery tests in providers/aws, persist, the root SageMaker tests, and the authorization and golden tests in server/aws.
  • A concurrent probe ran 8 goroutines × 150 rounds of create, describe, list and delete endpoint, Snapshot, ListTrainingJobs, and clock advance under async settle, twice with -race. No races.
  • CI is all green.

Verdict

Request changes for High 1 only. It's a small fix: publish the settled event after releasing epMu. Everything from the first round is fixed or documented, and the Lows can go in a follow-up.

Comment thread providers/aws/sagemaker/endpoint.go Outdated

tr, ok := m.epTrans[ep.EndpointName]
if ok && !tr.active(now) {
m.publishEndpointSettled(tr, out)

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.

This publishes the settled IN_SERVICE event from inside observeEndpoint, which runs with epMu held (DescribeEndpoint, ListEndpoints, UpdateEndpoint, the AddTags liveness check). EventBridge invokes a Lambda target synchronously, so a handler that calls DescribeEndpoint blocks on the non-reentrant epMu, and every endpoint op hangs from then on. Repro in the Go library with WithAsyncSettle: a rule on EndpointStatus IN_SERVICE to a Lambda whose handler calls p.SageMaker.DescribeEndpoint(ctx, "ep"), advance the clock, call DescribeEndpoint: the handler fires and the call never returns. Could the observer just record that the event is due, and the callers publish it after epMu.Unlock() with their ctx (as CreateEndpoint/UpdateEndpoint/DeleteEndpoint already do)? A test like lifecycle_events_reentry_test.go would pin it.

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.

Reproduced with a Lambda target on IN_SERVICE whose handler calls DescribeEndpoint, and fixed. The observer no longer publishes: observeEndpoint only queues the settled event (recordEndpointSettled), and every endpoint entry point that holds epMu now releases it through unlockEndpoints(ctx), which publishes the queued events after the unlock. Pinned by TestSageMakerEndpointSettledEventHandlerReentersWithoutDeadlock in lifecycle_events_reentry_test.go: for each of DescribeEndpoint, ListEndpoints, UpdateEndpoint, InvokeEndpoint and AddTags, a fresh settled endpoint is read first by that path while the handler reads the endpoint back; it was red (4 paths hit the 10s deadlock timeout) before the change and is green now. Job events observe without a lock, as you noted, so they are unchanged. Fixed in 71dff3e.

Comment thread providers/aws/sagemaker/endpoint.go Outdated

tr.settledPublished = true

m.emitEndpointEvents(context.Background(), settled, driver.EndpointInService)

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.

context.Background() drops the caller's ctx. Once this moves outside the lock, passing the request ctx through matches the other emit paths and what the re-entry tests expect from handlers.

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.

Done with the move above: the events are published with the caller's ctx wherever the entry point has one (Create, Describe, List, Update, UpdateEndpointWeightsAndCapacities, Delete, InvokeEndpoint and InvokeEndpointAsync). Two paths have no ctx to pass today and use context.Background(): the tag liveness check and the Search endpoint list; the non-goals note is unchanged because they only publish an event that was due anyway. Fixed in 71dff3e.


return out, nil
//nolint:dupl,gocritic // per-resource shim mirrors the SDK shape; large value params mirror the driver interface
func (m *Mock) ListUserProfiles(_ context.Context, domainID string, opts driver.ListOptions) (driver.ListPage[driver.UserProfile], 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.

ListUserProfiles (and ListSpaces at line 212) document SortBy CreationTime | LastModifiedTime, but there's no modified accessor, so --sort-by LastModifiedTime returns ValidationException 'not supported by this operation'. Same issue as the lifecycle config / code repository lists, but UserProfile and Space don't carry a LastModifiedTime yet.

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.

driver.UserProfile and driver.Space now carry LastModifiedTime (equal to the creation time until an update operation exists), both lists have the accessor, and Describe and the summaries return it. --sort-by LastModifiedTime works on both (checked with the CLI) and old snapshots fall back to the creation time. Fixed in 71dff3e.

Comment thread services/sagemaker/driver/notebook.go Outdated
@@ -73,24 +133,28 @@ type CodeRepository struct {
Branch string
SecretARN string
CreationTime string
// LastModifiedTime equals CreationTime: there is no UpdateCodeRepository.

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.

AWS does have UpdateCodeRepository (the emulator just doesn't implement it yet, so a Terraform aws_sagemaker_code_repository update gets UnknownOperationException). Could the comment say it isn't implemented yet rather than that it doesn't exist?

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.

Reworded: "UpdateCodeRepository is not implemented yet". The same note is on the non-goals page, together with UpdateUserProfile and UpdateSpace, and it says a Terraform aws_sagemaker_code_repository update gets UnknownOperationException. Fixed in 71dff3e.

InitialVariantWeight float64
// InitialVariantWeight is nil when the caller omits it; the provider applies
// SageMaker's default of 1.0, so the Go library and the server agree.
InitialVariantWeight *float64

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.

Right shape, but float64 to *float64 on a public driver type breaks Go library callers that set InitialVariantWeight: 1. Worth a line in the release notes.

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.

Agreed. The non-goals page and the PR description now carry a "Library API change" line for InitialVariantWeight going from float64 to *float64 (the repo has no changelog file, so that is where release notes are drawn from). Fixed in 71dff3e.

return false
}

m.settledEmitted[key] = struct{}{}

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.

Keys are added here for every settled job state and never removed, so this grows for the life of the server. A flag on the job record (like settledPublished on the endpoint transition) would avoid the side map.

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.

Kept the side map for now and documented it: it is one small entry per settled job (a job has at most a few settled states) for the life of the process, and moving the flag onto the job record would add a bookkeeping field to the public driver job types. Happy to do that as a follow-up if you prefer; it is on the follow-up list on the non-goals page.

…to feat/sagemaker-parity

# Conflicts:
#	internal/settle/settle.go
Publishing IN_SERVICE from inside the endpoint observer ran an event target
(a Lambda that reads the endpoint back, for example) while epMu was held, so
it deadlocked every endpoint operation. The observer now only queues the
event, and each endpoint entry point publishes it with the caller's context
after releasing the lock.

Also accept LastModifiedTime as a sort and filter on user profiles and
spaces (the types now carry the field), omit unset training start and end
times from state-change events instead of sending "0", correct the
UpdateCodeRepository driver comment, and document the remaining follow-ups
and the InitialVariantWeight API change.
@thzgajendra

Copy link
Copy Markdown
Collaborator Author

Thanks for catching the deadlock; that one was mine. Round 2 is in 71dff3e:

  • The settled IN_SERVICE event is queued under the endpoint lock and published after it is released, with the caller's ctx. A re-entry test over every read path pins it (it reproduced the hang before the change).
  • ListUserProfiles and ListSpaces accept --sort-by LastModifiedTime (the types now carry the field), unset training start and end times are omitted from the event instead of "0", and the UpdateCodeRepository comment is corrected.
  • Documented as follow-ups on the non-goals page: the model-failure injection state that only Go tests can reach, DeleteWorkteam on a missing team (the AWS behaviour is unconfirmed), the DeleteDomain check-then-delete race, the settled-event map, the unimplemented Update* operations, and the *float64 API change for Go library callers.

Verified with the full go test ./..., -race on the SageMaker packages, lint against the merge base, the AWS CLI against a -race build (the new sorts, events to SQS under --async-settle with a settled IN_SERVICE once, a delete-then-restart under --persist), and an OpenTofu apply / no-diff plan / destroy against an --async-settle server. The PR description is updated.

@NitinKumar004 NitinKumar004 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.

Thanks, this round fixes the deadlock. I re-checked 71dff3e with the Go library, the AWS CLI and the Terraform AWS provider (6.66.0), with and without --async-settle, and across a --persist restart. The settled IN_SERVICE event no longer runs under the endpoint lock, the new test fails on the previous head and passes now, and nothing from the earlier rounds has regressed. I found two small things, both Low. I'm happy for this to go in.

Status of the round-2 findings

Round-2 item Status Evidence
High: settled IN_SERVICE published under epMu Fixed The event is queued under the lock and published by unlockEndpoints after epMu.Unlock(), with the caller's ctx. The new re-entry test hangs on d415690 (all five read paths time out) and passes on 71dff3e. I also ran Lambda handlers that call UpdateEndpoint, DeleteEndpoint, UpdateEndpointWeightsAndCapacities and ListEndpoints from the IN_SERVICE event. None hang, each handler fires once, and the endpoint stays usable afterwards. With 16 concurrent Describe/List/Invoke/AddTags callers after settle, IN_SERVICE is delivered exactly once in 50 out of 50 runs under -race. See Low 1 for ordering
L1: "0" training event times, DeleteWorkteam Fixed / documented An InProgress training event now has no TrainingStartTime or TrainingEndTime, and the Completed event has both. DeleteWorkteam on a missing team is on the follow-up list
L2: ListUserProfiles and ListSpaces reject LastModifiedTime Fixed --sort-by LastModifiedTime (both orders) and LastModifiedTimeAfter/Before work on both ops. Describe and List now return LastModifiedTime
L3: model-failure state reachable only from tests Documented The non-goals page now says the 424 / Invocation5XXErrors branch runs only from Go tests. The code is unchanged, which is fine for a follow-up
L4: InitialVariantWeight to *float64 Documented It's on the coverage page. It still needs a line in the release notes when this ships
L5: wrong UpdateCodeRepository comment Fixed The comment now says the op isn't implemented yet. The non-goals page lists UpdateCodeRepository, UpdateUserProfile and UpdateSpace as missing
L6: settledEmitted only grows Documented Listed as a follow-up
Coverage Not changed providers/aws/sagemaker 91.9%, services/sagemaker 98.5%, server/aws/sagemaker still 73.3%

Findings

High

None.

Medium

None.

Low

1. A settled IN_SERVICE event can arrive after the UPDATING or DELETING event that follows it. (providers/aws/sagemaker/endpoint.go:220-231)

unlockEndpoints releases epMu, then takes the queue, then publishes. If a read notices the settle and an UpdateEndpoint or DeleteEndpoint runs right after it, the mutation can lock, finish and publish UPDATING or DELETING before the reader gets to publish IN_SERVICE. I raced one DescribeEndpoint against one UpdateEndpoint right after settle, 400 times each. 16 runs delivered [UPDATING IN_SERVICE], and racing against DeleteEndpoint, 30 runs delivered [DELETING IN_SERVICE]. IN_SERVICE was still delivered exactly once every time. A related effect: whichever caller unlocks next flushes the whole queue, so an event noticed by one request can be published on another request's ctx.

EventBridge doesn't promise ordering, so this is minor. But a target that keys off the latest event would see an endpoint marked in service after it was deleted. Before this commit the ordering was strict, because the event went out under the lock.

Fix, if you want it: let one flusher drain the queue at a time. Push the call-time CREATING, UPDATING and DELETING events through the same queue under epMu. In unlockEndpoints, a caller that finds a flush already running just returns, and the running flusher loops until the queue is empty. A re-entrant handler then can't deadlock, since its unlock sees the flush in progress and returns. Otherwise, a sentence on the non-goals page that endpoint events can arrive out of order under concurrent calls would cover it.

2. modifiedOrCreated is defined twice (providers/aws/sagemaker/list.go:262 and server/aws/sagemaker/notebook.go:367). The provider already applies the fallback for list sorting. If it also filled LastModifiedTime from CreationTime when it returns a code repository, user profile or space, the wire handler could emit the field as stored and the second copy could go.

AWS docs divergences

None new. UpdateCodeRepository, UpdateUserProfile and UpdateSpace (UpdateCodeRepository, UpdateUserProfile, UpdateSpace) are still unimplemented and are now listed as follow-ups.

Terraform and CLI results

Terraform (hashicorp/aws 6.66.0) with aws_sagemaker_model, aws_sagemaker_endpoint_configuration, aws_sagemaker_endpoint and aws_sagemaker_notebook_instance:

  • Default server: apply added 4 in 6s, plan 0. An update to the notebook instance_type, every tag and the variant weight gave 1 added, 3 changed, 1 destroyed. The endpoint is InService on the new config with CurrentWeight 2, and the notebook is InService on ml.t3.large. Plan 0 afterwards, destroy removed 4, and Describe on the model, endpoint and notebook returns not found.
  • --async-settle --persist server: the same sequence (apply 14s, no waiter hang), with the same results.
  • --async-settle --persist with a restart between apply and plan: plan 0 after the restart, the endpoint is InService, and destroy is clean.

AWS CLI (--async-settle --persist):

  • EventBridge rule to SQS, then 12 parallel describe-endpoint / list-endpoints calls after settle, then update and delete. The events were CREATING, IN_SERVICE, UPDATING, IN_SERVICE, DELETING, with one IN_SERVICE per transition.
  • Earlier repros, all still fixed:
    • Duplicate training job returns ResourceInUse, and duplicate model returns ValidationException.
    • list-models is newest first.
    • invoke-endpoint-async --inference-id myid-1 returns s3://b/async/myid-1.out.
    • CreateModelPackage with a group ARN creates model-package/grp1/1.
    • An endpoint deleted under --async-settle stays deleted across a restart.
  • New in this round:
    • ListUserProfiles and ListSpaces with --sort-by LastModifiedTime.
    • DescribeUserProfile and DescribeSpace LastModifiedTime.
    • Training-job event times.

What I verified

  • Clean build of 71dff3e.
  • go vet on the touched packages.
  • gofmt clean.
  • go mod tidy leaves no diff.
  • go generate ./... leaves no diff.
  • golangci-lint reports 0 new issues against d415690 and against the merge base, on the SageMaker packages and providers/aws.
  • go test -race passes on providers/aws/sagemaker, server/aws/sagemaker, services/sagemaker, providers/aws and persist. The SageMaker, authorization and golden tests in server/aws pass, and so do the root SageMaker tests.
  • The re-entry and concurrency probes above ran three times under -race. No races and no hangs.
  • No AI attribution in the new commit or the PR body.
  • CI is all green, and the branch merges cleanly.

Verdict

Approve. The deadlock is fixed and pinned by a test, every earlier High and Medium is still fixed, and the two Lows can go in a follow-up.

m.epDue = nil
m.epDueMu.Unlock()

for _, ep := range due {

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.

Once epMu is released, a mutation can get in before this loop runs. Racing one DescribeEndpoint against one UpdateEndpoint (or DeleteEndpoint) right after settle, about 4-8% of 400 runs delivered [UPDATING IN_SERVICE] or [DELETING IN_SERVICE]. IN_SERVICE was still sent exactly once every time. The queue is also drained by whichever caller unlocks next, so an event can go out on another request's ctx. EventBridge doesn't promise order, so this is minor. If you want strict order back: route CREATING/UPDATING/DELETING through the same queue under epMu, and have a single flusher drain it (a caller that finds a flush running just returns, which also keeps re-entrant handlers safe). Otherwise a sentence on the non-goals page would cover it.


// modifiedOrCreated is a resource's LastModifiedTime, falling back to its
// creation time for state restored from a snapshot that predates the field.
func modifiedOrCreated(modified, created string) string {

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.

This duplicates modifiedOrCreated in providers/aws/sagemaker/list.go. If the provider filled LastModifiedTime from CreationTime when it returns a restored code repository, user profile or space, the handler could emit the stored value and this copy could go.

@thzgajendra
thzgajendra merged commit 39b938f into stackshy:development Oct 13, 2026
23 checks passed
@thzgajendra
thzgajendra deleted the feat/sagemaker-parity branch October 13, 2026 17:26
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.

AWS SageMaker: open real-cloud parity gaps

2 participants