Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
* Python Sync: Add response buffer support to get() to improve performance by reducing copies ([#5493](https://github.com/valkey-io/valkey-glide/pull/5493))

#### Fixes
* CORE, Python, Java, Node: Add missing NOCONTENT and DIALECT options for FT.SEARCH ([#5550](https://github.com/valkey-io/valkey-glide/pull/5550))
* Java: Fix thread safety issue in NativeClusterScanCursor causing potential JVM crash ([#5527](https://github.com/valkey-io/valkey-glide/issues/5527))
* Java: optimize `convertMapToKeyValueStringArray` and `convertMapToKeyValueGlideStringArray` to fix performance bottleneck and ArrayStoreException ([#5602](https://github.com/valkey-io/valkey-glide/issues/5602))
* CORE: Fix empty hostname in CLUSTER SLOTS metadata causing AllConnectionsUnavailable ([#5367](https://github.com/valkey-io/valkey-glide/issues/5367)). AWS ElastiCache (plaintext, cluster mode) returns `hostname: ""` in node metadata, which was used as the connection address instead of falling back to the IP.
Expand Down
48 changes: 37 additions & 11 deletions glide-core/src/client/value_conversion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -953,7 +953,7 @@ pub(crate) fn convert_to_expected_type(
},
ExpectedReturnType::FTSearchReturnType => match value {
/*
Example of the response
Normal response (with field content):
1) (integer) 2
2) "json:2"
3) 1) "__VEC_score"
Expand All @@ -975,20 +975,46 @@ pub(crate) fn convert_to_expected_type(
1# "__VEC_score" => "91"
2# "$" => "{\"vec\":[1,2,3,4,5,6]}"
Response may contain only 1 element, no conversion in that case.
NOCONTENT response (key names only):
1) (integer) 2
2) "json:2"
3) "json:0"
Converting to:
1) (integer) 2
2) 1# "json:2" => (empty map)
2# "json:0" => (empty map)
Response may contain only 1 element (COUNT option), no conversion in that case.
*/
Value::Array(ref array) if array.len() == 1 => Ok(value),
Value::Array(mut array) => {
Ok(Value::Array(vec![
array.remove(0),
convert_to_expected_type(Value::Array(array), Some(ExpectedReturnType::Map {
key_type: &Some(ExpectedReturnType::BulkString),
value_type: &Some(ExpectedReturnType::Map {
let count = array.remove(0);
if array.is_empty() {
// Empty result set — return count with an empty map.
Ok(Value::Array(vec![count, Value::Map(vec![])]))
} else if array.iter().all(|v| matches!(v, Value::BulkString(_) | Value::SimpleString(_))) {
// NOCONTENT response: every element after count is a key name (BulkString or SimpleString).
// Normal responses alternate key (BulkString) and fields (Array), so at
// least one Array element would be present. If all elements are strings,
// this is a NOCONTENT response — build key -> empty map pairs.
let pairs: Vec<(Value, Value)> = array
.into_iter()
.map(|key| (key, Value::Map(vec![])))
.collect();
Ok(Value::Array(vec![count, Value::Map(pairs)]))
} else {
Ok(Value::Array(vec![
count,
convert_to_expected_type(Value::Array(array), Some(ExpectedReturnType::Map {
key_type: &Some(ExpectedReturnType::BulkString),
value_type: &Some(ExpectedReturnType::BulkString),
}),
}))?
]))
value_type: &Some(ExpectedReturnType::Map {
key_type: &Some(ExpectedReturnType::BulkString),
value_type: &Some(ExpectedReturnType::BulkString),
}),
}))?
]))
}
},
_ => Err((
ErrorKind::TypeError,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,9 @@ public static CompletableFuture<String> create(
* @return A two element array, where first element is count of documents in result set, and the
* second element, which has format <code>
* {@literal Map<GlideString, Map<GlideString, GlideString>>}</code> - a mapping between
* document names and map of their attributes.<br>
* document names and map of their attributes. When {@link
* FTSearchOptions.FTSearchOptionsBuilder#nocontent()} is set, the attribute maps will be
* empty.<br>
* If {@link FTSearchOptions.FTSearchOptionsBuilder#count()} or {@link
* FTSearchOptions.FTSearchOptionsBuilder#limit(int, int)} with values <code>0, 0</code> is
* set, the command returns array with only one element - the count of the documents.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
@Builder
public class FTSearchOptions {

@Builder.Default private final boolean nocontent = false;

@Builder.Default private final Map<GlideString, GlideString> identifiers = new HashMap<>();

/** Query timeout in milliseconds. */
Expand All @@ -31,13 +33,19 @@ public class FTSearchOptions {
*/
@Builder.Default private final Map<GlideString, GlideString> params = new HashMap<>();

/** Query dialect version. Only dialect 2 is currently supported. */
private final Integer dialect;

// TODO maxstale?
// dialect is no-op

/** Convert to module API. */
public GlideString[] toArgs() {
ArrayList<GlideString> args = new ArrayList<GlideString>();
if (nocontent) {
args.add(gs("NOCONTENT"));
}
if (!identifiers.isEmpty()) {
int returnIndex = args.size();
args.add(gs("RETURN"));
int tokenCount = 0;
for (Map.Entry<GlideString, GlideString> pair : identifiers.entrySet()) {
Expand All @@ -49,7 +57,7 @@ public GlideString[] toArgs() {
args.add(pair.getValue());
}
}
args.add(1, gs(Integer.toString(tokenCount)));
args.add(returnIndex + 1, gs(Integer.toString(tokenCount)));
}
if (timeout != null) {
args.add(gs("TIMEOUT"));
Expand All @@ -72,6 +80,10 @@ public GlideString[] toArgs() {
if (count) {
args.add(gs("COUNT"));
}
if (dialect != null) {
args.add(gs("DIALECT"));
args.add(gs(dialect.toString()));
}
return args.toArray(new GlideString[0]);
}

Expand All @@ -82,6 +94,10 @@ void limit(Pair<Integer, Integer> limit) {}

void count(boolean count) {}

void nocontent(boolean nocontent) {}

void dialect(Integer dialect) {}

void identifiers(Map<GlideString, GlideString> identifiers) {}

public FTSearchOptionsBuilder() {
Expand Down Expand Up @@ -137,5 +153,22 @@ public FTSearchOptionsBuilder count() {
this.count$set = true;
return this;
}

/** Once set, the query will return only document IDs without any field content. */
public FTSearchOptionsBuilder nocontent() {
this.nocontent$value = true;
this.nocontent$set = true;
return this;
}

/**
* Set the query dialect version.
*
* @param dialect The dialect version (currently, only dialect 2 is supported in valkey-search).
*/
public FTSearchOptionsBuilder dialect(int dialect) {
this.dialect = dialect;
return this;
}
}
}
105 changes: 105 additions & 0 deletions java/integTest/src/test/java/glide/modules/VectorSearchTests.java
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you check the formatting for this file? The spacing seems weird.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Talked with Kiro about it, but apparently it follows the Google Java Style convention. Running spotless checks also pass. I do agree formatting might be a bit weird but I think a lot of the existing Java files follow this convention so fixing it might be a project-wide task 😵‍💫

Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,111 @@ public void ft_search() {
assertTrue(exception.getMessage().contains("Index not found"));
}

@SneakyThrows
@Test
public void ft_search_nocontent() {
Copy link
Collaborator

@Aryex Aryex Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So from what I gathered, this test is doing:

  1. Insert 2 vectors into test db
  2. Search for them with NOCONTENT set.
  3. assert that the return value is correct.

The additional vector syntax make this test feels complicated for me. Since the focus is to check nocontent was applied, could we just use a simple set and get test with nocontent and assert the response?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should keep this test as is. The issue with NOCONTENT was that it had to change the way the response was converted in core. IMO, probably better to leave it like this to verify the parsing and everything isn't broken.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's simplify this test and keep it to the point. People will most likely copy test code around so a complicated test means more chance for errors.

Also make sure to update the other languages as well.

String prefix = "{" + UUID.randomUUID() + "}:";
String index = prefix + "index";

assertEquals(
OK,
FT.create(
client,
index,
new FieldInfo[] {
new FieldInfo("vec", "VEC", VectorFieldFlat.builder(DistanceMetric.L2, 2).build())
},
FTCreateOptions.builder()
.dataType(DataType.HASH)
.prefixes(new String[] {prefix})
.build())
.get());

String key1 = prefix + "0";
String key2 = prefix + "1";

assertEquals(
1L,
client
.hset(
gs(key1),
createMap(
gs("vec"),
gs(
new byte[] {
(byte) 0, (byte) 0, (byte) 0, (byte) 0, (byte) 0, (byte) 0, (byte) 0,
(byte) 0
})))
.get());
assertEquals(
1L,
client
.hset(
gs(key2),
createMap(
gs("vec"),
gs(
new byte[] {
(byte) 0,
(byte) 0,
(byte) 0,
(byte) 0,
(byte) 0,
(byte) 0,
(byte) 0x80,
(byte) 0xBF
})))
.get());
Thread.sleep(DATA_PROCESSING_TIMEOUT);

FTSearchOptions options =
FTSearchOptions.builder()
.nocontent()
.params(
createMap(
gs("query_vec"),
gs(
new byte[] {
(byte) 0, (byte) 0, (byte) 0, (byte) 0, (byte) 0, (byte) 0, (byte) 0,
(byte) 0
})))
.build();
String query = "*=>[KNN 2 @VEC $query_vec]";
Object[] ftsearch = FT.search(client, index, query, options).get();

assertArrayEquals(
new Object[] {2L, createMap(gs(key1), createMap(), gs(key2), createMap())}, ftsearch);
}

@SneakyThrows
@Test
public void ft_search_dialect() {
String prefix = "{" + UUID.randomUUID() + "}:";
String index = prefix + "index";
String key = prefix + "doc";

assertEquals(
OK,
FT.create(
client,
index,
new FieldInfo[] {new FieldInfo("title", new TextField())},
FTCreateOptions.builder()
.dataType(DataType.HASH)
.prefixes(new String[] {prefix})
.build())
.get());

assertEquals(1L, client.hset(gs(key), createMap(gs("title"), gs("hello world"))).get());
Thread.sleep(DATA_PROCESSING_TIMEOUT);

// dialect 2 is accepted; assert the document is returned
Object[] result =
FT.search(client, index, "hello", FTSearchOptions.builder().dialect(2).build()).get();
assertEquals(2, result.length);
assertEquals(1L, result[0]);
}

@SneakyThrows
@Test
public void ft_drop_and_ft_list() {
Expand Down
11 changes: 11 additions & 0 deletions node/src/server-modules/GlideFt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,7 @@ export class GlideFt {
* @returns A two-element array, where the first element is the number of documents in the result set, and the
* second element has the format: `GlideRecord<GlideRecord<GlideString>>`:
* a mapping between document names and a map of their attributes.
* When `nocontent` is set, the attribute maps will be empty.
*
* If `count` or `limit` with values `{offset: 0, count: 0}` is
* set, the command returns array with only one element: the number of documents.
Expand Down Expand Up @@ -824,6 +825,11 @@ function _addFtSearchOptions(options?: FtSearchOptions): GlideString[] {

const args: GlideString[] = [];

// NOCONTENT
if (options.nocontent) {
args.push("NOCONTENT");
}

// RETURN
if (options.returnFields) {
const returnFields: GlideString[] = [];
Expand Down Expand Up @@ -867,6 +873,11 @@ function _addFtSearchOptions(options?: FtSearchOptions): GlideString[] {
args.push("COUNT");
}

// DIALECT
if (options.dialect !== undefined) {
args.push("DIALECT", options.dialect.toString());
}

return args;
}

Expand Down
7 changes: 7 additions & 0 deletions node/src/server-modules/GlideFtOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,13 @@ export type FtSearchOptions = {
* the parameter name.
*/
params?: GlideRecord<GlideString>;

/** If true, returns only document IDs without field content.
* The document entries in the result will have empty value arrays. */
nocontent?: boolean;

/** Query dialect version. Only dialect 2 is currently supported in valkey-search. */
dialect?: number;
} & (
| {
/**
Expand Down
Loading
Loading