This is an automated email from the ASF dual-hosted git repository.
vincbeck pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new 1a83f11e7a8 Add `S3VectorsCreateIndexOperator` (#66121)
1a83f11e7a8 is described below
commit 1a83f11e7a8f768756bf6721c5ee3a0aad10e469
Author: John Jackson <[email protected]>
AuthorDate: Mon May 4 06:48:21 2026 -0700
Add `S3VectorsCreateIndexOperator` (#66121)
---
providers/amazon/docs/operators/s3_vectors.rst | 24 ++++--
.../providers/amazon/aws/operators/s3_vectors.py | 83 ++++++++++++++++++++
.../tests/system/amazon/aws/example_s3_vectors.py | 27 ++++++-
.../unit/amazon/aws/operators/test_s3_vectors.py | 89 ++++++++++++++++++++++
4 files changed, 217 insertions(+), 6 deletions(-)
diff --git a/providers/amazon/docs/operators/s3_vectors.rst
b/providers/amazon/docs/operators/s3_vectors.rst
index 88db11ce778..00ef5f88ab6 100644
--- a/providers/amazon/docs/operators/s3_vectors.rst
+++ b/providers/amazon/docs/operators/s3_vectors.rst
@@ -25,7 +25,7 @@ Use the operators below to manage S3 Vectors resources.
.. _howto/operator:S3VectorsCreateVectorBucketOperator:
Create a Vector Bucket
-~~~~~~~~~~~~~~~~~~~~~~
+----------------------
To create an Amazon S3 Vectors vector bucket, use
:class:`~airflow.providers.amazon.aws.operators.s3_vectors.S3VectorsCreateVectorBucketOperator`.
@@ -36,15 +36,24 @@ To create an Amazon S3 Vectors vector bucket, use
:start-after: [START howto_operator_s3vectors_create_vector_bucket]
:end-before: [END howto_operator_s3vectors_create_vector_bucket]
-Reference
-~~~~~~~~~
+.. _howto/operator:S3VectorsCreateIndexOperator:
-* `AWS boto3 Library Documentation for S3 Vectors
<https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/s3vectors.html>`__
+Create an Index
+---------------
+
+To create an index in an Amazon S3 Vectors vector bucket, use
+:class:`~airflow.providers.amazon.aws.operators.s3_vectors.S3VectorsCreateIndexOperator`.
+
+.. exampleinclude:: /../../amazon/tests/system/amazon/aws/example_s3_vectors.py
+ :language: python
+ :dedent: 4
+ :start-after: [START howto_operator_s3vectors_create_index]
+ :end-before: [END howto_operator_s3vectors_create_index]
.. _howto/operator:S3VectorsDeleteVectorBucketOperator:
Delete a Vector Bucket
-~~~~~~~~~~~~~~~~~~~~~~
+----------------------
To delete an Amazon S3 Vectors vector bucket, use
:class:`~airflow.providers.amazon.aws.operators.s3_vectors.S3VectorsDeleteVectorBucketOperator`.
@@ -54,3 +63,8 @@ To delete an Amazon S3 Vectors vector bucket, use
:dedent: 4
:start-after: [START howto_operator_s3vectors_delete_vector_bucket]
:end-before: [END howto_operator_s3vectors_delete_vector_bucket]
+
+Reference
+---------
+
+* `AWS boto3 Library Documentation for S3 Vectors
<https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/s3vectors.html>`__
diff --git
a/providers/amazon/src/airflow/providers/amazon/aws/operators/s3_vectors.py
b/providers/amazon/src/airflow/providers/amazon/aws/operators/s3_vectors.py
index 7708c56a58f..c1990ad65f1 100644
--- a/providers/amazon/src/airflow/providers/amazon/aws/operators/s3_vectors.py
+++ b/providers/amazon/src/airflow/providers/amazon/aws/operators/s3_vectors.py
@@ -126,3 +126,86 @@ class
S3VectorsDeleteVectorBucketOperator(AwsBaseOperator[AwsBaseHook]):
def execute(self, context: Context) -> None:
self.hook.conn.delete_vector_bucket(vectorBucketName=self.vector_bucket_name)
self.log.info("Deleted vector bucket %s", self.vector_bucket_name)
+
+
+class S3VectorsCreateIndexOperator(AwsBaseOperator[AwsBaseHook]):
+ """
+ Create an index in an Amazon S3 Vectors vector bucket.
+
+ An index stores vectors and supports similarity search queries.
+
+ .. seealso::
+ For more information on how to use this operator, take a look at the
guide:
+ :ref:`howto/operator:S3VectorsCreateIndexOperator`
+
+ :param vector_bucket_name: The name of the vector bucket. (templated)
+ :param index_name: The name of the index to create. (templated)
+ :param data_type: The data type for vectors (e.g. ``float32``). (templated)
+ :param dimension: The number of dimensions for each vector.
+ :param distance_metric: The distance metric for similarity search (e.g.
``cosine``, ``euclidean``).
+ :param metadata_configuration: Optional metadata configuration dict.
+ :param if_exists: Behavior when the index already exists.
+ ``"fail"`` raises an error, ``"skip"`` returns the existing index ARN.
+ """
+
+ aws_hook_class = AwsBaseHook
+ template_fields: tuple[str, ...] = (
+ *AwsBaseOperator.template_fields,
+ "vector_bucket_name",
+ "index_name",
+ "data_type",
+ "distance_metric",
+ )
+ template_fields_renderers = {"metadata_configuration": "json"}
+
+ def __init__(
+ self,
+ *,
+ vector_bucket_name: str,
+ index_name: str,
+ data_type: str,
+ dimension: int,
+ distance_metric: str = "cosine",
+ metadata_configuration: dict[str, Any] | None = None,
+ if_exists: Literal["fail", "skip"] = "skip",
+ **kwargs,
+ ) -> None:
+ super().__init__(**kwargs)
+ self.vector_bucket_name = vector_bucket_name
+ self.index_name = index_name
+ self.data_type = data_type
+ self.dimension = dimension
+ self.distance_metric = distance_metric
+ self.metadata_configuration = metadata_configuration
+ self.if_exists = if_exists
+
+ @property
+ def _hook_parameters(self) -> dict[str, Any]:
+ return {**super()._hook_parameters, "client_type": "s3vectors"}
+
+ def execute(self, context: Context) -> str:
+ self.log.info("Creating index %s in vector bucket %s",
self.index_name, self.vector_bucket_name)
+ kwargs: dict[str, Any] = prune_dict(
+ {
+ "vectorBucketName": self.vector_bucket_name,
+ "indexName": self.index_name,
+ "dataType": self.data_type,
+ "dimension": self.dimension,
+ "distanceMetric": self.distance_metric,
+ "metadataConfiguration": self.metadata_configuration,
+ }
+ )
+ try:
+ response = self.hook.conn.create_index(**kwargs)
+ index_arn = response["indexArn"]
+ except ClientError as e:
+ if e.response["Error"]["Code"] == "ConflictException" and
self.if_exists == "skip":
+ self.log.info("Index %s already exists, skipping.",
self.index_name)
+ response = self.hook.conn.get_index(
+ vectorBucketName=self.vector_bucket_name,
indexName=self.index_name
+ )
+ index_arn = response["index"]["indexArn"]
+ else:
+ raise
+ self.log.info("Index %s: %s", self.index_name, index_arn)
+ return index_arn
diff --git a/providers/amazon/tests/system/amazon/aws/example_s3_vectors.py
b/providers/amazon/tests/system/amazon/aws/example_s3_vectors.py
index 454ae72e995..54e5e5c2599 100644
--- a/providers/amazon/tests/system/amazon/aws/example_s3_vectors.py
+++ b/providers/amazon/tests/system/amazon/aws/example_s3_vectors.py
@@ -19,6 +19,7 @@ from __future__ import annotations
from datetime import datetime
from airflow.providers.amazon.aws.operators.s3_vectors import (
+ S3VectorsCreateIndexOperator,
S3VectorsCreateVectorBucketOperator,
S3VectorsDeleteVectorBucketOperator,
)
@@ -28,14 +29,24 @@ from system.amazon.aws.utils import ENV_ID_KEY,
SystemTestContextBuilder
from tests_common.test_utils.version_compat import AIRFLOW_V_3_0_PLUS
if AIRFLOW_V_3_0_PLUS:
- from airflow.sdk import TriggerRule
+ from airflow.sdk import TriggerRule, task
else:
+ from airflow.decorators import task # type: ignore[attr-defined,no-redef]
from airflow.utils.trigger_rule import TriggerRule # type:
ignore[no-redef,attr-defined]
DAG_ID = "example_s3_vectors"
sys_test_context_task = SystemTestContextBuilder().build()
+
+@task(trigger_rule=TriggerRule.ALL_DONE)
+def delete_index(vector_bucket_name: str, index_name: str):
+ """Delete the index."""
+ import boto3
+
+
boto3.client("s3vectors").delete_index(vectorBucketName=vector_bucket_name,
indexName=index_name)
+
+
with DAG(
dag_id=DAG_ID,
schedule=None,
@@ -45,6 +56,7 @@ with DAG(
test_context = sys_test_context_task()
env_id = test_context[ENV_ID_KEY]
bucket_name = f"{env_id}-test-vectors"
+ index_name = f"{env_id}-test-index"
# [START howto_operator_s3vectors_create_vector_bucket]
create_vector_bucket = S3VectorsCreateVectorBucketOperator(
@@ -53,6 +65,17 @@ with DAG(
)
# [END howto_operator_s3vectors_create_vector_bucket]
+ # [START howto_operator_s3vectors_create_index]
+ create_index = S3VectorsCreateIndexOperator(
+ task_id="create_index",
+ vector_bucket_name=bucket_name,
+ index_name=index_name,
+ data_type="float32",
+ dimension=128,
+ distance_metric="cosine",
+ )
+ # [END howto_operator_s3vectors_create_index]
+
# [START howto_operator_s3vectors_delete_vector_bucket]
delete_vector_bucket = S3VectorsDeleteVectorBucketOperator(
task_id="delete_vector_bucket",
@@ -64,6 +87,8 @@ with DAG(
chain(
test_context,
create_vector_bucket,
+ create_index,
+ delete_index(vector_bucket_name=bucket_name, index_name=index_name),
delete_vector_bucket,
)
diff --git
a/providers/amazon/tests/unit/amazon/aws/operators/test_s3_vectors.py
b/providers/amazon/tests/unit/amazon/aws/operators/test_s3_vectors.py
index f155bb11367..109bec8303d 100644
--- a/providers/amazon/tests/unit/amazon/aws/operators/test_s3_vectors.py
+++ b/providers/amazon/tests/unit/amazon/aws/operators/test_s3_vectors.py
@@ -18,9 +18,11 @@ from __future__ import annotations
from unittest.mock import MagicMock
+import pytest
from botocore.exceptions import ClientError
from airflow.providers.amazon.aws.operators.s3_vectors import (
+ S3VectorsCreateIndexOperator,
S3VectorsCreateVectorBucketOperator,
S3VectorsDeleteVectorBucketOperator,
)
@@ -112,3 +114,90 @@ class TestS3VectorsDeleteVectorBucketOperator:
vector_bucket_name=BUCKET_NAME,
)
validate_template_fields(op)
+
+
+INDEX_NAME = "test-index"
+INDEX_ARN =
"arn:aws:s3vectors:us-east-1:123456789012:vector-bucket/test-bucket/index/test-index"
+
+
+class TestS3VectorsCreateIndexOperator:
+ def setup_method(self):
+ self.operator = S3VectorsCreateIndexOperator(
+ task_id="create_index",
+ vector_bucket_name=BUCKET_NAME,
+ index_name=INDEX_NAME,
+ data_type="float32",
+ dimension=128,
+ )
+
+ def test_execute(self):
+ mock_conn = MagicMock()
+ mock_conn.create_index.return_value = {"indexArn": INDEX_ARN}
+ self.operator.hook.conn = mock_conn
+
+ result = self.operator.execute({})
+
+ mock_conn.create_index.assert_called_once_with(
+ vectorBucketName=BUCKET_NAME,
+ indexName=INDEX_NAME,
+ dataType="float32",
+ dimension=128,
+ distanceMetric="cosine",
+ )
+ assert result == INDEX_ARN
+
+ def test_execute_with_metadata_config(self):
+ meta_config = {"nonFilterableMetadataKeys": ["key1"]}
+ op = S3VectorsCreateIndexOperator(
+ task_id="create_index",
+ vector_bucket_name=BUCKET_NAME,
+ index_name=INDEX_NAME,
+ data_type="float32",
+ dimension=128,
+ distance_metric="euclidean",
+ metadata_configuration=meta_config,
+ )
+ mock_conn = MagicMock()
+ mock_conn.create_index.return_value = {"indexArn": INDEX_ARN}
+ op.hook.conn = mock_conn
+
+ result = op.execute({})
+
+ call_kwargs = mock_conn.create_index.call_args[1]
+ assert call_kwargs["distanceMetric"] == "euclidean"
+ assert call_kwargs["metadataConfiguration"] == meta_config
+ assert result == INDEX_ARN
+
+ def test_execute_skip_existing(self):
+ mock_conn = MagicMock()
+ mock_conn.create_index.side_effect = ClientError(
+ {"Error": {"Code": "ConflictException", "Message": "Already
exists"}},
+ "CreateIndex",
+ )
+ mock_conn.get_index.return_value = {"index": {"indexArn": INDEX_ARN}}
+ self.operator.hook.conn = mock_conn
+
+ result = self.operator.execute({})
+ assert result == INDEX_ARN
+
+ def test_execute_fail_on_conflict(self):
+ op = S3VectorsCreateIndexOperator(
+ task_id="create_index",
+ vector_bucket_name=BUCKET_NAME,
+ index_name=INDEX_NAME,
+ data_type="float32",
+ dimension=128,
+ if_exists="fail",
+ )
+ mock_conn = MagicMock()
+ mock_conn.create_index.side_effect = ClientError(
+ {"Error": {"Code": "ConflictException", "Message": "Already
exists"}},
+ "CreateIndex",
+ )
+ op.hook.conn = mock_conn
+
+ with pytest.raises(ClientError):
+ op.execute({})
+
+ def test_template_fields(self):
+ validate_template_fields(self.operator)