fix(core,mlops): honor caller region in feature_store ingest_dataframe and stop telemetry from blocking SDK calls - #6197
Conversation
ingest_dataframe() had no way to say which AWS region the FeatureGroup lives in, so both the DescribeFeatureGroup call and the record writes fell back to whatever region boto3 resolved, with no caller control. Add an optional `region` argument and thread it through the whole ingestion path: - feature_utils.ingest_dataframe -> CoreFeatureGroup.get(region=...) and IngestionManagerPandas(region=...) - IngestionManagerPandas gains a `region` field, forwarded to put_record(region=...) and batch_write_record(region=...) in the single-thread, multi-thread, multi-process, and BatchWriteRecord paths `region` is appended last and defaults to None, so existing calls and their behavior are unchanged.
|
Claude finished @nargokul's task in 2m 15s —— View job Review:
|
Telemetry could add unbounded latency to any decorated SDK call. A Feature Store ingest was reported taking ~47 minutes from a private VPC while the underlying PutRecord completed server-side in 348ms; all of the remaining time was spent in the two telemetry emissions that follow the call. Three defects, all in the emission path: 1. `_requests_helper` passed the timeout positionally. `requests.get` takes `params` as its second positional argument, so the value was appended to the query string and the request had no timeout at all. From a VPC with no route to the telemetry endpoint the GET hung until a network device dropped the flow. Now passed as `timeout=`, verified bounded at ~2s against a black-holed address. The same one-line defect existed in sagemaker-serve's telemetry_logger and is fixed there too. 2. `_get_default_sagemaker_session` hardcoded us-west-2. Module-level functions such as `ingest_dataframe` have no session of their own, so the decorator synthesizes one, which pointed both the STS `get_caller_identity` call and the telemetry GET at a region the caller may have no route to. The region is now resolved by boto3 from the caller's environment, with the default kept only as a last resort since `Session` requires a region. 3. Emission was synchronous. `_send_telemetry_request` now dispatches to a daemon thread and returns immediately, so neither the STS call nor the GET can sit in the caller's critical path. Daemon threads are killed at interpreter exit, so a pending send cannot delay shutdown either. In-flight sends are capped and excess events dropped rather than growing threads without bound, and nothing can escape the thread. The existing body moved to `_send_telemetry_request_sync`. Telemetry request failures now log at debug instead of logging a full traceback at error level; a best-effort metric should not look like an error. Note: events queued at process exit may now be lost. That is the intended trade-off for never blocking the caller.
|
Claude finished @nargokul's task in 1m 12s —— View job Review:
|
Removes the in-flight cap added alongside the async send. Telemetry events are data we cannot silently lose, so every event now gets its own daemon thread rather than being dropped once eight sends are outstanding. Feature Store ingestion is the special case that motivated moving the send off the caller's thread at all: it is decorated at more than one level (ingest_dataframe and IngestionManagerPandas.run), so a single user call emits several events, and sending them serially turned an ingest the service finished in under a second into a multi-minute wait.
|
Claude finished @nargokul's task in 1m 57s —— View job Review:
|
| # Telemetry must never sit in the caller's critical path, so every event is sent | ||
| # from a daemon thread. A slow or unreachable telemetry endpoint (for example from | ||
| # inside a VPC with no route to it) can no longer stall the SDK call the customer | ||
| # actually made, and because the threads are daemons a pending send cannot delay |
There was a problem hiding this comment.
Minor nit on whether you want to include all these comments here!
Issue
sagemaker.mlops.feature_store.feature_utils.ingest_dataframe()had no way for a caller to say which AWS region the FeatureGroup lives in. Both theDescribeFeatureGroupcall and the record writes were made with no region, so the region was whatever boto3 happened to resolve for the process. Callers who work with a FeatureGroup outside their resolved default region had no way to redirect the call.Note that
list_records()in the same module already acceptsregion, so this also makes the module consistent.Description of changes
Adds an optional
regionargument toingest_dataframe()and threads it through the whole ingestion path:feature_utils.ingest_dataframe(..., region=None)CoreFeatureGroup.get(feature_group_name=..., region=region)IngestionManagerPandas(..., region=region)IngestionManagerPandasgains aregionfield, forwarded to the FeatureStore runtime calls in every ingestion path:put_record(region=...)_ingest_single_batch→put_record(region=...)_run_multi_threaded(passed positionally through the process pool args) → both batch paths_ingest_batch_write→batch_write_record(region=...)regionis appended as the last parameter and defaults toNone, so existing positional and keyword calls keep working and behave exactly as before.Also documents the new argument in the feature store
MIGRATION_GUIDE.md.Known limitation (not addressed here)
sagemaker-core'sSageMakerClientis a process-wide singleton keyed only on the class (sagemaker-core/src/sagemaker/core/utils/utils.py), so the first region used in a process wins and laterregion=arguments are ignored:So
regionhere is honored wheningest_dataframeis the first thing in the process to build a client (the common case), and is subject to that cache otherwise. This affects everyregion/sessionargument in sagemaker-core equally, not just this function, so it is called out in the docstring and left for a separate fix in sagemaker-core.Testing
New unit tests (14), all passing:
tests/unit/sagemaker/mlops/feature_store/test_feature_utils.py::TestIngestDataframeRegionCoreFeatureGroup.getandIngestionManagerPandasNonewhen not supplieduse_batch_write_record=Trueregionis appended last and does not shift existing positional argstests/unit/sagemaker/mlops/feature_store/test_ingestion_manager_pandas.py::TestIngestionManagerRegionregionstored on the manager, defaults toNone_ingest_rowforwards region toput_record(and passesNonewhen unset)run()forwards region to everyput_record_ingest_single_batchforwards region to everyput_record_ingest_batch_writeand ause_batch_write_record=Truerun()forward region tobatch_write_record_run_multi_threadedforwards region to each thread's batch_run_multi_processincludes region in the process pool argsFull existing suite for the area still green: 375 passed in
sagemaker-mlops/tests/unit/sagemaker/mlops/feature_store/(thefeature_processorsubdirectory is excluded — it fails collection onModuleNotFoundError: No module named 'pyspark'on master too, unrelated to this change).Second change: telemetry no longer blocks SDK calls
Issue
A customer reported
ingest_dataframetaking over 45 minutes from a private VPC, while the same write from boto3 was fast. The service-side worklog shows the underlyingPutRecordcompleted in 348 ms, about two seconds into a notebook cell that ran for ~47 minutes. All of the remaining time was client-side, spent in the telemetry emissions that run after the wrapped function returns.There are exactly two telemetry-decorated calls in that flow (
ingest_dataframeandIngestionManagerPandas.run), which matches ~23.5 minutes per emission: two identical unbounded waits.Three separate defects combine to produce it.
1. The telemetry GET had no timeout at all.
requests.get(url, params=None, **kwargs)takesparamssecond, so the2was appended to the query string (the prepared URL ends in&2) and the request was left with no timeout. From a VPC with no route to the endpoint the call hangs until some network device drops the flow.2. The fallback session hardcoded
us-west-2.ingest_dataframeis a module-level function with no session, so the decorator synthesizes one via_get_default_sagemaker_session(), which wasboto3.Session(region_name=DEFAULT_AWS_REGION). That pointed both the STSget_caller_identitycall and the telemetry GET at public us-west-2 endpoints, regardless of the caller's actual region (ca-central-1 here). This is also why theregionargument added above does not fix the hang on its own: telemetry ignored the caller's region entirely.3. Emission was synchronous. Both network legs sat directly in the caller's critical path.
The team's successful repro without a VPC is consistent with this rather than ruling the SDK out: without egress restrictions both legs complete in milliseconds, which is exactly how a client-side call to an unreachable endpoint behaves.
Description of changes
sagemaker-core/src/sagemaker/core/telemetry/telemetry_logging.py:_requests_helperpassestimeout=as a keyword, with a comment explaining theparamstrap so it does not regress. The timeout value is now the namedTELEMETRY_REQUEST_TIMEOUTconstant._get_default_sagemaker_sessionlets boto3 resolve the region from the caller's environment (AWS_REGION,AWS_DEFAULT_REGION, or the active profile), falling back toDEFAULT_AWS_REGIONonly when boto3 resolves nothing, sinceSessionrequires a region._send_telemetry_requestnow dispatches to a daemon thread and returns immediately; the existing body moved unchanged to_send_telemetry_request_sync. This covers both slow legs, since the STS call lives inside that body. Daemon threads are killed at interpreter exit, so a pending send cannot delay shutdown either. Nothing can escape the thread. No event is dropped - one thread per event, no in-flight cap.Worth calling out why the send has to move off the caller's thread rather than just having its timeout tightened: Feature Store ingestion is decorated at more than one level (
ingest_dataframeandIngestionManagerPandas.run), so a single user call emits several events. Even a bounded 2s per event adds up on the caller's thread, and unbounded it is what produced the 45+ minute cell in this ticket.Request failures log at debug instead of emitting a full traceback at error level; a best-effort metric should not surface as an error.
sagemaker-serve/src/sagemaker/serve/utils/telemetry_logger.pyhad the identical positional-timeout defect in its own_requests_helper; fixed the same way.Trade-off
Telemetry events still in flight when the process exits may be lost, because the threads are daemons. That is the one case where an event can go missing, and it is intentional: telemetry must not be able to hold up a customer's call or their interpreter shutdown. Events are never dropped by the SDK itself.
Still outstanding (not in this PR)
sagemaker-serve's_send_telemetryremains synchronous. It always uses the caller's real session and region, so it does not have the cross-region problem, and with the timeout fix its GET is now bounded at 2s. Its_get_accountIdSTS call still runs with default botocore timeouts, though. Happy to make that path async too if preferred.Testing
10 new unit tests in
sagemaker-core/tests/unit/telemetry/test_telemetry_logging.py:TestRequestsHelperTimeoutparamsis not set&2and the only kwarg istimeoutTestTelemetryIsNonBlocking_send_telemetry_requestreturns to the caller while the send is still blocked_send_telemetry_request_syncTestDefaultSessionRegionDEFAULT_AWS_REGIONonly when no region resolvesExisting tests that asserted the old behaviour were updated: the six that exercise URL construction now call
_send_telemetry_request_syncdirectly, and therequests.getassertions expecttimeout=as a keyword.Also verified at the unit level against a black-holed address (
10.255.255.1), which hangs rather than refusing:End-to-end reproduction of the ticket
Ran the customer's notebook as a script against a real Feature Group in a dev account: create feature group (offline store only) -> poll until
Created->ingest_dataframe(data_frame=df, max_workers=1, wait=True)with a single row, exactly as in the ticket. The customer's no-egress VPC was imitated by pointing DNS forsm-pysdk-t-*andsts.*at the non-routable10.255.255.1, so those hostnames resolve but never connect.ingest_dataframeTwo things this settles:
The local run measures 12.8 min rather than the reported 45+ because TCP connect-retry behaviour and proxy configuration differ between macOS here and the customer's environment; the failure mode is the same, only the length of the stall varies.
Suites run:
sagemaker-core/tests/unit: 3550 passed, 22 skippedsagemaker-core/tests/unit/telemetry+ both sagemaker-serve telemetry test files: 111 passed, 2 skippedsagemaker-mlops/tests/unit/sagemaker/mlops/feature_store(excludingfeature_processor, which fails collection on missingpysparkon master too): still greenMerge Checklist
By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.