CurtHagenlocher commented on code in PR #4983: URL: https://github.com/apache/arrow-adbc/pull/4983#discussion_r4426038025
########## csharp/src/Apache.Arrow.Adbc/C/CAdbcDriverExporter.Streams.cs: ########## @@ -0,0 +1,353 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using Apache.Arrow.C; +using Apache.Arrow.Ipc; + +namespace Apache.Arrow.Adbc.C +{ + public partial class CAdbcDriverExporter + { +#if NET5_0_OR_GREATER + private static unsafe delegate* unmanaged<CArrowArrayStream*, CArrowSchema*, int> StreamGetSchemaPtr => &GetStreamSchema; + private static unsafe delegate* unmanaged<CArrowArrayStream*, CArrowArray*, int> StreamGetNextPtr => &GetStreamNext; + private static unsafe delegate* unmanaged<CArrowArrayStream*, byte*> StreamGetLastErrorPtr => &GetStreamLastError; + private static unsafe delegate* unmanaged<CArrowArrayStream*, void> StreamReleasePtr => &ReleaseStream; + private static unsafe delegate* unmanaged<CArrowArrayStream*, AdbcStatusCode*, CAdbcError*> ErrorFromArrayStreamPtr => &GetErrorFromArrayStream; +#else + private static unsafe IntPtr StreamGetSchemaPtr = NativeDelegate<ArrayStreamGetSchema>.AsNativePointer(GetStreamSchema); + private static unsafe IntPtr StreamGetNextPtr = NativeDelegate<ArrayStreamGetNext>.AsNativePointer(GetStreamNext); + private static unsafe IntPtr StreamGetLastErrorPtr = NativeDelegate<ArrayStreamGetLastError>.AsNativePointer(GetStreamLastError); + private static unsafe IntPtr StreamReleasePtr = NativeDelegate<ArrayStreamRelease>.AsNativePointer(ReleaseStream); Review Comment: Fixed in 2e2cae80e. On .NET Framework, exported stream callbacks are now created with Apache.Arrow's own callback delegate types, so an in-process Apache.Arrow importer (and `CArrowArrayStream.Free`) can call them. Covered by `ArrowImporterReadsExportedStream`, which fails on net472 without the fix. ########## csharp/src/Apache.Arrow.Adbc/C/CAdbcDriverExporter.Streams.cs: ########## @@ -0,0 +1,353 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using Apache.Arrow.C; +using Apache.Arrow.Ipc; + +namespace Apache.Arrow.Adbc.C +{ + public partial class CAdbcDriverExporter + { +#if NET5_0_OR_GREATER + private static unsafe delegate* unmanaged<CArrowArrayStream*, CArrowSchema*, int> StreamGetSchemaPtr => &GetStreamSchema; + private static unsafe delegate* unmanaged<CArrowArrayStream*, CArrowArray*, int> StreamGetNextPtr => &GetStreamNext; + private static unsafe delegate* unmanaged<CArrowArrayStream*, byte*> StreamGetLastErrorPtr => &GetStreamLastError; + private static unsafe delegate* unmanaged<CArrowArrayStream*, void> StreamReleasePtr => &ReleaseStream; + private static unsafe delegate* unmanaged<CArrowArrayStream*, AdbcStatusCode*, CAdbcError*> ErrorFromArrayStreamPtr => &GetErrorFromArrayStream; +#else + private static unsafe IntPtr StreamGetSchemaPtr = NativeDelegate<ArrayStreamGetSchema>.AsNativePointer(GetStreamSchema); + private static unsafe IntPtr StreamGetNextPtr = NativeDelegate<ArrayStreamGetNext>.AsNativePointer(GetStreamNext); + private static unsafe IntPtr StreamGetLastErrorPtr = NativeDelegate<ArrayStreamGetLastError>.AsNativePointer(GetStreamLastError); + private static unsafe IntPtr StreamReleasePtr = NativeDelegate<ArrayStreamRelease>.AsNativePointer(ReleaseStream); + private static unsafe IntPtr ErrorFromArrayStreamPtr = NativeDelegate<ErrorFromArrayStream>.AsNativePointer(GetErrorFromArrayStream); +#endif + + /// <summary> + /// Exports a stream produced by the managed driver. The exported stream takes + /// ownership on success; if exporting fails, nobody else will dispose it. + /// </summary> + /// <remarks> + /// When reading the stream fails, the exported stream keeps the exception so that + /// <c>AdbcErrorFromArrayStream</c> can report its status, SQLSTATE, vendor code and + /// details, and returns an errno that corresponds to the status. + /// </remarks> + private unsafe static void ExportStream(IArrowArrayStream? stream, CArrowArrayStream* cStream) + { + try + { + if (stream == null) + { + throw new ArgumentNullException(nameof(stream)); + } + if (cStream == null) + { + throw new ArgumentNullException(nameof(cStream)); + } + + NativeArrayStream* nativeStream = NativeArrayStream.From(cStream); + nativeStream->private_data = (void*)GCHandle.ToIntPtr(GCHandle.Alloc(new ExportedStream(stream))); + nativeStream->get_schema = StreamGetSchemaPtr; + nativeStream->get_next = StreamGetNextPtr; + nativeStream->get_last_error = StreamGetLastErrorPtr; + nativeStream->release = StreamReleasePtr; + } + catch + { + stream?.Dispose(); + throw; + } + } + + private unsafe static ExportedStream? GetExportedStream(CArrowArrayStream* cStream) + { + NativeArrayStream* nativeStream = NativeArrayStream.From(cStream); + if (cStream == null || + (IntPtr)nativeStream->release != (IntPtr)StreamReleasePtr || + nativeStream->private_data == null) + { + return null; + } + return GCHandle.FromIntPtr((IntPtr)nativeStream->private_data).Target as ExportedStream; + } + +#if NET5_0_OR_GREATER + [UnmanagedCallersOnly] +#endif + private unsafe static int GetStreamSchema(CArrowArrayStream* cStream, CArrowSchema* cSchema) + { + ExportedStream? stream = null; + try + { + stream = GetExportedStream(cStream) ?? throw new ArgumentException("not a stream exported by this driver", nameof(cStream)); + stream.ClearError(); + CArrowSchemaExporter.ExportSchema(stream.Stream.Schema, cSchema); + return 0; + } + catch (Exception e) + { + return stream?.SetError(e) ?? Errno.EINVAL; + } + } + +#if NET5_0_OR_GREATER + [UnmanagedCallersOnly] +#endif + private unsafe static int GetStreamNext(CArrowArrayStream* cStream, CArrowArray* cArray) + { + ExportedStream? stream = null; + try + { + NativeArray.From(cArray)->release = default; + stream = GetExportedStream(cStream) ?? throw new ArgumentException("not a stream exported by this driver", nameof(cStream)); + stream.ClearError(); + + ValueTask<RecordBatch?> pending = stream.Stream.ReadNextRecordBatchAsync(); + RecordBatch? batch = pending.IsCompletedSuccessfully ? pending.Result : pending.AsTask().GetAwaiter().GetResult(); Review Comment: Fixed in 2e2cae80e. This risk predates the PR (Arrow's `CArrowArrayStreamExporter` also blocks on `ReadNextRecordBatchAsync().Result`), but when `get_next` is called on a thread with a synchronization context or non-default scheduler, the read is now started on a thread-pool thread. Native callers have no context and keep the direct path. Covered by `ExportedStreamDoesNotBlockSynchronizationContext`. ########## csharp/src/Apache.Arrow.Adbc/C/CAdbcDriverImporter.Streams.cs: ########## @@ -0,0 +1,224 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using Apache.Arrow.Adbc.Extensions; +using Apache.Arrow.C; +using Apache.Arrow.Ipc; + +namespace Apache.Arrow.Adbc.C +{ + public static partial class CAdbcDriverImporter + { + /// <summary> + /// A result stream imported from a driver. When the stream fails, it asks the driver + /// for the ADBC error behind the failure (see <c>AdbcErrorFromArrayStream</c>) and + /// throws it as an <see cref="AdbcException"/>. + /// </summary> + private sealed unsafe class ImportedArrayStream : IArrowArrayStream + { + private readonly ImportedAdbcDriver? _driver; + private readonly Schema _schema; + private CArrowArrayStream* _cStream; + + /// <summary> + /// Imports <paramref name="cStream"/>, which must have been allocated with + /// <see cref="CArrowArrayStream.Create"/>. On success, the imported stream owns + /// it; on failure, the caller still does. + /// </summary> + public ImportedArrayStream(ImportedAdbcDriver? driver, CArrowArrayStream* cStream) + { + if (NativeArrayStream.From(cStream)->release == default) + { + throw new ArgumentException("Tried to import an array stream that has already been released.", nameof(cStream)); + } + + _driver = driver; + + CArrowSchema* cSchema = CArrowSchema.Create(); + try + { + int errno = GetSchema(cStream, cSchema); + if (errno != 0) + { + throw StreamError(cStream, errno); + } + _schema = CArrowSchemaImporter.ImportSchema(cSchema); + } + finally + { + CArrowSchema.Free(cSchema); + } + + _cStream = cStream; + } + + ~ImportedArrayStream() + { + Dispose(); + } + + public Schema Schema => _schema; + + public ValueTask<RecordBatch?> ReadNextRecordBatchAsync(CancellationToken cancellationToken = default) + { + if (_cStream == null) + { + throw new ObjectDisposedException(nameof(ImportedArrayStream)); + } + + if (cancellationToken.IsCancellationRequested) + { + return new ValueTask<RecordBatch?>(Task.FromCanceled<RecordBatch?>(cancellationToken)); + } + + try + { + return new ValueTask<RecordBatch?>(ReadNext()); + } + catch (Exception e) + { + return new ValueTask<RecordBatch?>(Task.FromException<RecordBatch?>(e)); + } + } + + private RecordBatch? ReadNext() + { + CArrowArray* cArray = CArrowArray.Create(); + try + { + int errno = GetNext(_cStream, cArray); + if (errno != 0) + { + throw StreamError(_cStream, errno); + } + + // A released array marks the end of the stream + if (NativeArray.From(cArray)->release == IntPtr.Zero) + { + return null; + } + + return CArrowArrayImporter.ImportRecordBatch(cArray, _schema); + } + finally + { + CArrowArray.Free(cArray); + } + } + + public void Dispose() + { + CArrowArrayStream* cStream = _cStream; + if (cStream != null) + { + _cStream = null; + FreeStream(cStream); + } + GC.SuppressFinalize(this); + } + + /// <summary> + /// Releases and frees a stream allocated with <see cref="CArrowArrayStream.Create"/>. + /// </summary> + /// <remarks> + /// Unlike <see cref="CArrowArrayStream.Free"/>, this can release a stream that + /// <see cref="CAdbcDriverExporter"/> exported in the same process on .NET Framework, + /// where a callback created from a managed delegate can only be called through a + /// delegate of the same type. + /// </remarks> + public static void FreeStream(CArrowArrayStream* cStream) + { + NativeArrayStream* nativeStream = NativeArrayStream.From(cStream); + if (nativeStream->release != default) + { +#if NET5_0_OR_GREATER + nativeStream->release(cStream); +#else + Marshal.GetDelegateForFunctionPointer<ArrayStreamRelease>(nativeStream->release)(cStream); +#endif + nativeStream->release = default; + } + CArrowArrayStream.Free(cStream); + } + + /// <summary> + /// Builds the exception for a stream call that returned <paramref name="errno"/>: + /// the driver's ADBC error if it reports one, and otherwise the stream's last error. + /// </summary> + private Exception StreamError(CArrowArrayStream* cStream, int errno) + { + if (_driver != null && _driver.DriverVersion >= AdbcVersion.Version_1_1_0) + { + ref CAdbcDriver nativeDriver = ref _driver.DriverUnsafe; + if (nativeDriver.ErrorFromArrayStream != default) + { + AdbcStatusCode status = AdbcStatusCode.UnknownError; +#if NET5_0_OR_GREATER + CAdbcError* error = nativeDriver.ErrorFromArrayStream(cStream, &status); +#else + CAdbcError* error = Marshal.GetDelegateForFunctionPointer<ErrorFromArrayStream>(nativeDriver.ErrorFromArrayStream)(cStream, &status); +#endif + // The stream owns the error, so it must not be released here + if (error != null) + { + IReadOnlyList<KeyValuePair<string, byte[]>>? details = ReadErrorDetails(_driver, error); + return new ImportedAdbcException( + ref *error, + status == AdbcStatusCode.Success ? AdbcStatusCode.UnknownError : status, + details); + } + } + } + + NativeArrayStream* nativeStream = NativeArrayStream.From(cStream); +#if NET5_0_OR_GREATER + byte* message = nativeStream->get_last_error(cStream); +#else + byte* message = Marshal.GetDelegateForFunctionPointer<ArrayStreamGetLastError>(nativeStream->get_last_error)(cStream); +#endif + return new Exception(message == null + ? $"Array stream operation failed with no message. Error code: {errno}" + : MarshalExtensions.PtrToStringUTF8(message)); + } + + private static int GetSchema(CArrowArrayStream* cStream, CArrowSchema* cSchema) + { + NativeArrayStream* nativeStream = NativeArrayStream.From(cStream); +#if NET5_0_OR_GREATER + return nativeStream->get_schema(cStream, cSchema); +#else + return Marshal.GetDelegateForFunctionPointer<ArrayStreamGetSchema>(nativeStream->get_schema)(cStream, cSchema); Review Comment: Fixed in 2e2cae80e. The importer now calls `get_schema`, `get_next`, `get_last_error` and `release` through Apache.Arrow's delegate types on .NET Framework, so it works for streams exported in-process by either Apache.Arrow or `CAdbcDriverExporter`, including releasing a stream the driver didn't take in `BindStream`. Covered by `RejectedBindStreamIsReleased`. ########## csharp/src/Apache.Arrow.Adbc/C/CAdbcDriverExporter.Streams.cs: ########## @@ -0,0 +1,353 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using Apache.Arrow.C; +using Apache.Arrow.Ipc; + +namespace Apache.Arrow.Adbc.C +{ + public partial class CAdbcDriverExporter + { +#if NET5_0_OR_GREATER + private static unsafe delegate* unmanaged<CArrowArrayStream*, CArrowSchema*, int> StreamGetSchemaPtr => &GetStreamSchema; + private static unsafe delegate* unmanaged<CArrowArrayStream*, CArrowArray*, int> StreamGetNextPtr => &GetStreamNext; + private static unsafe delegate* unmanaged<CArrowArrayStream*, byte*> StreamGetLastErrorPtr => &GetStreamLastError; + private static unsafe delegate* unmanaged<CArrowArrayStream*, void> StreamReleasePtr => &ReleaseStream; + private static unsafe delegate* unmanaged<CArrowArrayStream*, AdbcStatusCode*, CAdbcError*> ErrorFromArrayStreamPtr => &GetErrorFromArrayStream; +#else + private static unsafe IntPtr StreamGetSchemaPtr = NativeDelegate<ArrayStreamGetSchema>.AsNativePointer(GetStreamSchema); + private static unsafe IntPtr StreamGetNextPtr = NativeDelegate<ArrayStreamGetNext>.AsNativePointer(GetStreamNext); + private static unsafe IntPtr StreamGetLastErrorPtr = NativeDelegate<ArrayStreamGetLastError>.AsNativePointer(GetStreamLastError); + private static unsafe IntPtr StreamReleasePtr = NativeDelegate<ArrayStreamRelease>.AsNativePointer(ReleaseStream); + private static unsafe IntPtr ErrorFromArrayStreamPtr = NativeDelegate<ErrorFromArrayStream>.AsNativePointer(GetErrorFromArrayStream); +#endif + + /// <summary> + /// Exports a stream produced by the managed driver. The exported stream takes + /// ownership on success; if exporting fails, nobody else will dispose it. + /// </summary> + /// <remarks> + /// When reading the stream fails, the exported stream keeps the exception so that + /// <c>AdbcErrorFromArrayStream</c> can report its status, SQLSTATE, vendor code and + /// details, and returns an errno that corresponds to the status. + /// </remarks> + private unsafe static void ExportStream(IArrowArrayStream? stream, CArrowArrayStream* cStream) + { + try + { + if (stream == null) + { + throw new ArgumentNullException(nameof(stream)); + } + if (cStream == null) + { + throw new ArgumentNullException(nameof(cStream)); + } + + NativeArrayStream* nativeStream = NativeArrayStream.From(cStream); + nativeStream->private_data = (void*)GCHandle.ToIntPtr(GCHandle.Alloc(new ExportedStream(stream))); + nativeStream->get_schema = StreamGetSchemaPtr; + nativeStream->get_next = StreamGetNextPtr; + nativeStream->get_last_error = StreamGetLastErrorPtr; + nativeStream->release = StreamReleasePtr; + } + catch + { + stream?.Dispose(); + throw; + } + } + + private unsafe static ExportedStream? GetExportedStream(CArrowArrayStream* cStream) + { + NativeArrayStream* nativeStream = NativeArrayStream.From(cStream); + if (cStream == null || + (IntPtr)nativeStream->release != (IntPtr)StreamReleasePtr || + nativeStream->private_data == null) + { + return null; + } + return GCHandle.FromIntPtr((IntPtr)nativeStream->private_data).Target as ExportedStream; + } + +#if NET5_0_OR_GREATER + [UnmanagedCallersOnly] +#endif + private unsafe static int GetStreamSchema(CArrowArrayStream* cStream, CArrowSchema* cSchema) + { + ExportedStream? stream = null; + try + { + stream = GetExportedStream(cStream) ?? throw new ArgumentException("not a stream exported by this driver", nameof(cStream)); + stream.ClearError(); + CArrowSchemaExporter.ExportSchema(stream.Stream.Schema, cSchema); + return 0; + } + catch (Exception e) + { + return stream?.SetError(e) ?? Errno.EINVAL; + } + } + +#if NET5_0_OR_GREATER + [UnmanagedCallersOnly] +#endif + private unsafe static int GetStreamNext(CArrowArrayStream* cStream, CArrowArray* cArray) + { + ExportedStream? stream = null; + try + { + NativeArray.From(cArray)->release = default; + stream = GetExportedStream(cStream) ?? throw new ArgumentException("not a stream exported by this driver", nameof(cStream)); + stream.ClearError(); + + ValueTask<RecordBatch?> pending = stream.Stream.ReadNextRecordBatchAsync(); + RecordBatch? batch = pending.IsCompletedSuccessfully ? pending.Result : pending.AsTask().GetAwaiter().GetResult(); + if (batch != null) + { + CArrowArrayExporter.ExportRecordBatch(batch, cArray); + } + return 0; + } + catch (Exception e) + { + return stream?.SetError(e) ?? Errno.EINVAL; + } + } + +#if NET5_0_OR_GREATER + [UnmanagedCallersOnly] +#endif + private unsafe static byte* GetStreamLastError(CArrowArrayStream* cStream) + { + try + { + return GetExportedStream(cStream)?.LastErrorMessage; + } + catch (Exception) + { + return null; + } + } + +#if NET5_0_OR_GREATER + [UnmanagedCallersOnly] +#endif + private unsafe static void ReleaseStream(CArrowArrayStream* cStream) + { + NativeArrayStream* nativeStream = NativeArrayStream.From(cStream); + try + { + if (nativeStream->private_data != null) + { + GCHandle gch = GCHandle.FromIntPtr((IntPtr)nativeStream->private_data); + try + { + ((ExportedStream)gch.Target!).Dispose(); + } + finally + { + gch.Free(); + nativeStream->private_data = null; + } + } + } + catch (Exception) + { + // An exception would end the process, and the consumer has no way to learn of it. + } + nativeStream->release = default; + } + +#if NET5_0_OR_GREATER + [UnmanagedCallersOnly] +#endif + private unsafe static CAdbcError* GetErrorFromArrayStream(CArrowArrayStream* cStream, AdbcStatusCode* status) + { + try + { + return GetExportedStream(cStream)?.GetError(status); + } + catch (Exception) + { + return null; + } + } + + /// <summary> + /// The state behind an exported <see cref="CArrowArrayStream"/>: the managed stream + /// and the error from the last call that failed, if any. + /// </summary> + private sealed unsafe class ExportedStream : IDisposable + { + private IArrowArrayStream? _stream; + private CAdbcError* _error; + private AdbcStatusCode _status; + + public ExportedStream(IArrowArrayStream stream) + { + _stream = stream; + } + + public IArrowArrayStream Stream => _stream ?? throw new ObjectDisposedException(nameof(ExportedStream)); + + public byte* LastErrorMessage => _error != null && _error->release != default ? _error->message : null; + + public CAdbcError* GetError(AdbcStatusCode* status) + { + if (_error == null || _error->release == default) + { + return null; + } + if (status != null) + { + *status = _status; + } + return _error; + } + + /// <summary> + /// Records <paramref name="exception"/> as the stream's error and returns the + /// errno that corresponds to its status. + /// </summary> + public int SetError(Exception exception) + { + ClearError(); + _status = CAdbcDriverExporter.SetError(null, exception); + if (_status == AdbcStatusCode.Success) + { + // An errno of 0 would read as the end of the stream + _status = AdbcStatusCode.UnknownError; + } + + try + { + if (_error == null) + { + _error = (CAdbcError*)Marshal.AllocHGlobal(sizeof(CAdbcError)); + *_error = default; + } + + // AdbcErrorFromArrayStream is part of ADBC 1.1.0, so the consumer + // understands the 1.1.0 error layout. + _error->vendor_code = ErrorVendorCodePrivateData; + CAdbcDriverExporter.SetError(_error, exception); Review Comment: This is a limitation of the ADBC C ABI rather than of this change: when an error carries details, `vendor_code` must be `ADBC_ERROR_VENDOR_CODE_PRIVATE_DATA` so the consumer knows `private_data` is valid, which leaves no field for the driver's own vendor code. `FillError` already handles every exported error this way, so stream errors are now consistent with errors returned directly from ADBC functions. Leaving as is. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
