Skip to content

Commit 702ddd1

Browse files
schenksjclaude
andcommitted
feat(contrib-delta) P7o: resolve S3A credential chain Scala-side for log replay
Closes the P1 credential-asymmetry gap carried from apache#3932 (commit 461fa4f). Previously the kernel-rs log-replay path's DeltaStorageConfig only honored explicit static keys (`fs.s3a.access.key` / `fs.s3a.secret.key` / `fs.s3a.session.token`) set in core-site.xml. Users running under SimpleAWSCredentialsProvider / TemporaryAWSCredentialsProvider / AssumedRoleCredentialProvider / IAMInstanceCredentialsProvider would see data-file reads authenticate (those go through Comet's existing native `build_credential_provider`) but log replay fail. Resolution happens Scala-side via reflection against `org.apache.hadoop.fs.s3a.S3AUtils.createAWSCredentialProviderList` -- the same Hadoop credential machinery Spark uses everywhere else. The resolved (access_key, secret_key, session_token) tuple is stuffed into the `storageOptions` map under the standard Hadoop keys before the JNI call. Reflective because hadoop-aws is an optional dep; absence falls through to static-only behavior (any user without S3 stays unaffected). Architecture note: an in-crate cherry-pick of 461fa4f wasn't viable here because the JNI lives in `contrib/delta/native/` -- a standalone Cargo crate that deliberately doesn't depend on core (to keep the arrow-57 / arrow-58 split clean). The Scala-side approach has the same correctness properties and avoids the crate boundary entirely. Method handles cached via @volatile Option[Option[Binding]] -- the augment path runs on every Delta scan; resolving the Class + getMethod chain on each call would be a per-scan reflection round-trip just to find the same handles every time. SNAPSHOT resolution: log replay completes in seconds, well within any reasonable credential TTL. Long-running data reads continue to use Comet's refresh-capable native credential provider. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6ba81b3 commit 702ddd1

1 file changed

Lines changed: 142 additions & 1 deletion

File tree

contrib/delta/src/main/scala/org/apache/comet/contrib/delta/CometDeltaNativeScan.scala

Lines changed: 142 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,136 @@ object CometDeltaNativeScan extends CometOperatorSerde[CometScanExec] with Loggi
108108
.get(DeltaConf.NeedsInputFileNameOption)
109109
.contains("true")
110110

111+
/**
112+
* Reflectively resolve Hadoop's AWSCredentialProviderList for an s3/s3a URI and merge
113+
* the resulting (access, secret, optional token) triple into `baseOptions` under the
114+
* standard `fs.s3a.access.key` / `fs.s3a.secret.key` / `fs.s3a.session.token` keys --
115+
* the same keys `NativeConfig.extractObjectStoreOptions` would have picked up if the
116+
* user had set them explicitly in `core-site.xml`.
117+
*
118+
* Reflection is intentional: `hadoop-aws` is an optional dep; on a default Comet
119+
* deployment without S3 support on the classpath, `Class.forName` fails and we return
120+
* the base options unchanged. Non-s3/s3a URIs return base options unchanged too --
121+
* Azure / GCS / OSS resolve their own credential chains in kernel-rs's object_store
122+
* (or via the static keys already in `baseOptions`).
123+
*
124+
* Skip when the user has already set explicit static keys (don't overwrite an explicit
125+
* config with a resolved IAM-instance token).
126+
*
127+
* If reflection succeeds but credential resolution fails (e.g. IMDS unreachable, no
128+
* provider configured), log a warning and return `baseOptions` -- the engine will
129+
* still try anonymous access or surface a clearer error than a silent crash on first
130+
* S3 read.
131+
*/
132+
// Cached reflective binding for the S3A credential chain. Resolved once per JVM.
133+
// The whole augment path is invoked on every Delta scan -- without caching, each scan
134+
// pays a Class.forName + getMethod round-trip just to find the bridge available.
135+
//
136+
// `None` means we tried once and failed (hadoop-aws not on classpath, signature drift,
137+
// etc.) -- subsequent calls short-circuit.
138+
private case class S3ACredentialBinding(
139+
createProviderList: java.lang.reflect.Method,
140+
getCredentials: java.lang.reflect.Method,
141+
getAccessKey: java.lang.reflect.Method,
142+
getSecretKey: java.lang.reflect.Method,
143+
sessionCredsCls: Option[Class[_]],
144+
getSessionToken: Option[java.lang.reflect.Method])
145+
146+
@volatile private var s3aCredentialBindingCache: Option[Option[S3ACredentialBinding]] = None
147+
148+
private def s3aCredentialBinding: Option[S3ACredentialBinding] =
149+
s3aCredentialBindingCache.getOrElse {
150+
val binding = try {
151+
// scalastyle:off classforname
152+
val utilsCls = Class.forName("org.apache.hadoop.fs.s3a.S3AUtils")
153+
// scalastyle:on classforname
154+
val createMethod = utilsCls.getMethod(
155+
"createAWSCredentialProviderList",
156+
classOf[java.net.URI],
157+
classOf[org.apache.hadoop.conf.Configuration])
158+
// Resolve the provider-list + credentials methods off the runtime classes
159+
// returned by createAWSCredentialProviderList. Method.invoke walks subclasses, so
160+
// a one-time lookup on the declared return / argument types is enough.
161+
val providerListCls = createMethod.getReturnType
162+
val getCredentialsMethod = providerListCls.getMethod("getCredentials")
163+
val credentialsCls = getCredentialsMethod.getReturnType
164+
val getAccessKeyMethod = credentialsCls.getMethod("getAWSAccessKeyId")
165+
val getSecretKeyMethod = credentialsCls.getMethod("getAWSSecretKey")
166+
val (sessionCredsCls, getSessionTokenMethod) = try {
167+
// scalastyle:off classforname
168+
val cls = Class.forName("com.amazonaws.auth.AWSSessionCredentials")
169+
// scalastyle:on classforname
170+
(Some(cls), Some(cls.getMethod("getSessionToken")))
171+
} catch { case _: ClassNotFoundException => (None, None) }
172+
Some(
173+
S3ACredentialBinding(
174+
createMethod,
175+
getCredentialsMethod,
176+
getAccessKeyMethod,
177+
getSecretKeyMethod,
178+
sessionCredsCls,
179+
getSessionTokenMethod))
180+
} catch {
181+
// hadoop-aws not on classpath, or signature drift -- mark as unavailable for the
182+
// rest of the JVM's lifetime.
183+
case _: ClassNotFoundException => None
184+
case _: NoSuchMethodException => None
185+
case scala.util.control.NonFatal(e) =>
186+
logWarning(
187+
s"S3A credential-chain reflection lookup failed; falling back to static-only " +
188+
s"keys in Delta log replay: ${e.getMessage}",
189+
e)
190+
None
191+
}
192+
s3aCredentialBindingCache = Some(binding)
193+
binding
194+
}
195+
196+
private[delta] def augmentWithResolvedAwsCredentials(
197+
baseOptions: Map[String, String],
198+
tableRootUri: java.net.URI,
199+
hadoopConf: org.apache.hadoop.conf.Configuration): Map[String, String] = {
200+
val scheme = Option(tableRootUri.getScheme).map(_.toLowerCase).getOrElse("")
201+
if (scheme != "s3" && scheme != "s3a") return baseOptions
202+
if (baseOptions.contains("fs.s3a.access.key") &&
203+
baseOptions.contains("fs.s3a.secret.key")) {
204+
return baseOptions
205+
}
206+
s3aCredentialBinding match {
207+
case None => baseOptions // hadoop-aws not available; nothing to resolve
208+
case Some(binding) =>
209+
try {
210+
val providerList = binding.createProviderList.invoke(null, tableRootUri, hadoopConf)
211+
val credentials = binding.getCredentials.invoke(providerList)
212+
val accessKey = binding.getAccessKey.invoke(credentials)
213+
val secretKey = binding.getSecretKey.invoke(credentials)
214+
val sessionToken: Option[String] = (binding.sessionCredsCls, binding.getSessionToken) match {
215+
case (Some(cls), Some(m)) if cls.isInstance(credentials) =>
216+
Option(m.invoke(credentials)).map(_.toString)
217+
case _ => None
218+
}
219+
val resolved = scala.collection.mutable.Map[String, String]() ++= baseOptions
220+
Option(accessKey).map(_.toString).filter(_.nonEmpty).foreach { ak =>
221+
resolved("fs.s3a.access.key") = ak
222+
}
223+
Option(secretKey).map(_.toString).filter(_.nonEmpty).foreach { sk =>
224+
resolved("fs.s3a.secret.key") = sk
225+
}
226+
sessionToken.filter(_.nonEmpty).foreach { st =>
227+
resolved("fs.s3a.session.token") = st
228+
}
229+
resolved.toMap
230+
} catch {
231+
case scala.util.control.NonFatal(e) =>
232+
logWarning(
233+
s"Delta log-replay credential resolution failed for $tableRootUri: " +
234+
s"${e.getMessage}; falling back to static-only keys in storage options",
235+
e)
236+
baseOptions
237+
}
238+
}
239+
}
240+
111241
override def enabledConfig: Option[ConfigEntry[Boolean]] = Some(
112242
DeltaConf.COMET_DELTA_NATIVE_ENABLED)
113243

@@ -155,8 +285,19 @@ object CometDeltaNativeScan extends CometOperatorSerde[CometScanExec] with Loggi
155285
val hadoopConf =
156286
relation.sparkSession.sessionState.newHadoopConfWithOptions(relation.options)
157287
val tableRootUri = java.net.URI.create(tableRoot)
288+
val baseOptions: Map[String, String] =
289+
NativeConfig.extractObjectStoreOptions(hadoopConf, tableRootUri)
290+
// For s3/s3a tables, resolve Hadoop's credential provider chain here so log replay
291+
// authenticates under SimpleAWSCredentialsProvider / TemporaryAWSCredentialsProvider /
292+
// AssumedRoleCredentialProvider / IAMInstanceCredentialsProvider just like the data
293+
// path does. The contrib's native engine (delta-kernel-rs's DefaultEngine backed by
294+
// object_store_kernel) doesn't run core's `build_credential_provider`, so we feed it
295+
// resolved static keys instead. SNAPSHOT resolution: log replay completes in seconds,
296+
// well within any reasonable credential TTL.
158297
val storageOptions: java.util.Map[String, String] =
159-
NativeConfig.extractObjectStoreOptions(hadoopConf, tableRootUri).asJava
298+
CometDeltaNativeScan
299+
.augmentWithResolvedAwsCredentials(baseOptions, tableRootUri, hadoopConf)
300+
.asJava
160301

161302
// Honor Delta's time-travel options (versionAsOf / timestampAsOf) via the Delta-
162303
// resolved snapshot version sitting on the FileIndex. Delta's analysis phase pins

0 commit comments

Comments
 (0)