Skip to content

Lookup submision IDs by strategic metadata#112

Open
jerbaroo wants to merge 6 commits into
masterfrom
jerbaroo-64-lookup-by-strategic-metadata
Open

Lookup submision IDs by strategic metadata#112
jerbaroo wants to merge 6 commits into
masterfrom
jerbaroo-64-lookup-by-strategic-metadata

Conversation

@jerbaroo

@jerbaroo jerbaroo commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

This PR adds the lookup_submission_ids_by_strategic_metadata method to ProducerClient, which will return the submission IDs of in-progress subsmissions that match ALL of the provided strategic metadata key-value pairs.

    def lookup_submission_ids_by_strategic_metadata(
        self, strategic_metadata: dict[str, int]
    ) -> list[SubmissionId]:
        """Attempts to find in-progress submissions where the strategic metadata
        of that submission includes all of the key-value pairs of the given
        'strategic_metadata'. A matching submission must include all of the
        given key-value pairs, but it may also contain other key-value pairs.

        Raises:
        - `LookupIdsWithEmptyStrategicMetadataError` if the provided
          'strategic_metadata' contained no key-value pairs to look for.

        """

@jerbaroo jerbaroo requested a review from Qqwy June 8, 2026 15:58
@jerbaroo jerbaroo force-pushed the jerbaroo-64-lookup-by-strategic-metadata branch from 3b0ca52 to 6f9e1e3 Compare June 8, 2026 15:58
@jerbaroo jerbaroo requested a review from ReinierMaas June 23, 2026 12:30
@ReinierMaas

Copy link
Copy Markdown
Contributor

Open question shouldn't an empty StrategicMetadataMap return all submissions instead of raising an error on that?
Also this is an easy way to DDoS OpsQueue if the number of returned submissions is large.

@jerbaroo

Copy link
Copy Markdown
Contributor Author

Open question shouldn't an empty StrategicMetadataMap return all submissions instead of raising an error on that?

It depends on the API contract we want to provide. An argument in favour of throwing an error is that if a caller is using an API to "search for submissions matching some strategic metadata" but they haven't provided any strategic metadata, then they possibly have an issue in their code that they should be alerted to. Also, the SQLite query will need a special case to handle this, more code for something we don't have evidence for that it is a usecase we want to support.

@ReinierMaas ReinierMaas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I have a few comment and would like to point out that for the other queries we also assert the query plan. This allows us to spot potentially very bad execution behaviour.

Comment on lines +382 to +384
Raises:
- `LookupIdsWithEmptyStrategicMetadataError` if the provided
'strategic_metadata' contained no key-value pairs to look for.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can this potentially also raise:

- `InternalProducerClientError` if there is a low-level internal error.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes, good spot. The rust producer/client.rs code lifts network and decoding errors into this type. Documented in b557df0

Comment on lines +374 to +376
def lookup_submission_ids_by_strategic_metadata(
self, strategic_metadata: dict[str, int]
) -> list[SubmissionId]:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I checked the other APIs on the producer and they either provide a single element or an iterator so that it can be lazily evaluated and doesn't need to be materialized in memory all at once.

@jerbaroo jerbaroo Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The other ProducerClient APIs which are returning iterators are returning chunk iterators. These chunks are deterministically-addressable. Internally, the iterator is keeping track of a submission prefix, a current index, and a max index, then on each call to next() the iterator is performing a network request (GET <object-storage-path><prefix>/<current-index>-out.bin) to fetch the chunk.

This new API is different (not chunks, not querying object storage) from the existing APIs that return iterators, so would need some new wiring put in place to support streaming. But since we are only dealing with submission IDs, it's less of a memory concern. A pragmatic solution for now might be to just implement the configurable upper bound + add some metrics and keep an eye on it.

Comment thread opsqueue/src/producer/server.rs Outdated
async fn lookup_submission_ids_by_strategic_metadata(
State(state): State<ServerState>,
extract::Json(strategic_metadata): extract::Json<StrategicMetadataMap>,
) -> Result<Json<Vec<SubmissionId>>, ServerError> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Our SubmissionIds are close to u64::max in serialized size, which is reasonable for our random (snowflake) identifiers. This issues the following serialized to JSON response size:

  • 1 ([) + elements * 20 + elements (,|]) characters

For ~1 million this is ~20MB. We can put that as an configurable upper bound to return to the client with an explicit error if it goes over that so that people can update the settings and return the larger response knowingly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Configurable upper bound added in 54bc50b with a roundtrip test in be5f6d2

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It might be a bit over-engineered with a newtype that ensures we don't overflow when adding (+1) when building the SQLite query, but.. types good right ? 😅

Comment on lines +382 to +384
Raises:
- `LookupIdsWithEmptyStrategicMetadataError` if the provided
'strategic_metadata' contained no key-value pairs to look for.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I would change the interface to raise an explicit error if there are too many matching Submissions, we need that for DoS protection anyway, but allow an empty strategic_metadata set to be processed.

@jerbaroo jerbaroo Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

SGTM. Added support for empty strategic_metadata in 8057405. Will add the streaming / DoS protection in separate commits.

@jerbaroo jerbaroo force-pushed the jerbaroo-64-lookup-by-strategic-metadata branch 3 times, most recently from 8057405 to 736a7fd Compare July 7, 2026 15:48
@jerbaroo jerbaroo force-pushed the jerbaroo-64-lookup-by-strategic-metadata branch from 736a7fd to 54bc50b Compare July 7, 2026 15:48
@jerbaroo jerbaroo force-pushed the jerbaroo-64-lookup-by-strategic-metadata branch from 74a4aa8 to 841d1a3 Compare July 9, 2026 14:26
@jerbaroo

jerbaroo commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

I have a few comment and would like to point out that for the other queries we also assert the query plan. This allows us to spot potentially very bad execution behaviour.

Query plan asserted in c6c9f04

@jerbaroo jerbaroo requested a review from ReinierMaas July 9, 2026 14:27
@jerbaroo jerbaroo force-pushed the jerbaroo-64-lookup-by-strategic-metadata branch from 841d1a3 to c6c9f04 Compare July 9, 2026 15:57
@jerbaroo jerbaroo force-pushed the jerbaroo-64-lookup-by-strategic-metadata branch from c6c9f04 to 6c0256e Compare July 9, 2026 16:31

@ReinierMaas ReinierMaas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I have a few code simplifications and one API generalisation.

Comment thread justfile

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I am not sure these timeouts are long enough on CI.

I had the same experience locally and made it configurable for local use:

https://github.com/channable/opsqueue/pull/122/changes#diff-deb9bb56fb122db0b605aa5b63f95a4665c905b18dd670e1fa6c877576a94ff1


impl From<CError<TooManyMatchingSubmissions>> for PyErr {
fn from(value: CError<TooManyMatchingSubmissions>) -> Self {
TooManyMatchingSubmissionsError::new_err(value.0 .0)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
TooManyMatchingSubmissionsError::new_err(value.0 .0)
TooManyMatchingSubmissionsError::new_err(value.0.0)

Comment on lines +47 to +48
class LookupIdsWithEmptyStrategicMetadataError(Exception):
pass

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is this error still produced?

pub type StrategicMetadataMap = FxHashMap<String, MetaStateVal>;

/// Maximum number of submissions a lookup may return.
/// Constructor checks: MaxSubmissions + 1 <= i64::MAX;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We should also prevent 0 from appearing, i.e. use NonZeroU64 to store the value.

Comment on lines +580 to +588
inserted = 0
for _ in range(max_ + 1):
producer_client.insert_submission(
[1], chunk_size=1, strategic_metadata={"k": 1}
)
inserted += 1
with pytest.raises(TooManyMatchingSubmissionsError):
assert inserted == max_ + 1 # Make sure Exception wasn't raised too early.
producer_client.lookup_submission_ids_by_strategic_metadata({"k": 1})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We can also test the endpoint keeps functioning until the one that brings it over the edge, this prevents off-by-one errors:

Suggested change
inserted = 0
for _ in range(max_ + 1):
producer_client.insert_submission(
[1], chunk_size=1, strategic_metadata={"k": 1}
)
inserted += 1
with pytest.raises(TooManyMatchingSubmissionsError):
assert inserted == max_ + 1 # Make sure Exception wasn't raised too early.
producer_client.lookup_submission_ids_by_strategic_metadata({"k": 1})
inserted = []
for _ in range(max_ + 1):
assert producer_client.lookup_submission_ids_by_strategic_metadata({"k": 1}) == inserted
inserted.append(producer_client.insert_submission(
[1], chunk_size=1, strategic_metadata={"k": 1}
))
with pytest.raises(TooManyMatchingSubmissionsError):
assert len(inserted) == max_ + 1 # Make sure Exception wasn't raised too early.
producer_client.lookup_submission_ids_by_strategic_metadata({"k": 1})

Comment on lines +1159 to +1172
insta::assert_snapshot!(formatted_query, @"
SELECT
s0.submission_id
FROM
submissions_metadata as s0
INNER JOIN submissions on submissions.id = s0.submission_id
WHERE
s0.metadata_key = ?
AND s0.metadata_value = ?
ORDER BY
s0.submission_id
LIMIT
?
");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This idea turned out to be so beautiful! It makes it easy to review the generated queries.

" JOIN submissions_metadata AS s{i} ON s{i}.submission_id = s0.submission_id"
));
}
query_builder.push(" INNER JOIN submissions on submissions.id = s0.submission_id");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is to scope it to the InProgress submissions. We could make this INNER JOIN table configurable based on the requested SubmissionState enum.

Comment on lines +563 to +594
// If 'max_submissions' results is exceeded return an explicit error.
fn error_if_too_many(
max_submissions: MaxSubmissions,
ids: Vec<SubmissionId>,
) -> Result<Vec<SubmissionId>, E<DatabaseError, TooManyMatchingSubmissions>> {
if ids.len() as u64 > u64::from(max_submissions) {
Err(E::R(TooManyMatchingSubmissions(u64::from(max_submissions))))
} else {
Ok(ids)
}
}

// The main query to match on strategic_metadata will fail at run-time
// if strategic_metadata is empty, so we handle the empty case here.
if strategic_metadata.is_empty() {
let ids = query_scalar!(
r#"SELECT id AS "id: SubmissionId" FROM submissions ORDER by id LIMIT ?"#,
limit
)
.fetch_all(conn.get_inner())
.await?;
return error_if_too_many(max_submissions, ids);
}
let mut query_builder: QueryBuilder<Sqlite> =
lookup_ids_by_strategic_metadata_query(&strategic_metadata, limit);
let rows = query_builder.build().fetch_all(conn.get_inner()).await?;
error_if_too_many(
max_submissions,
rows.into_iter()
.map(|row| row.get("submission_id"))
.collect(),
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We can simplify this a bit further to make it easier to follow:

Suggested change
// If 'max_submissions' results is exceeded return an explicit error.
fn error_if_too_many(
max_submissions: MaxSubmissions,
ids: Vec<SubmissionId>,
) -> Result<Vec<SubmissionId>, E<DatabaseError, TooManyMatchingSubmissions>> {
if ids.len() as u64 > u64::from(max_submissions) {
Err(E::R(TooManyMatchingSubmissions(u64::from(max_submissions))))
} else {
Ok(ids)
}
}
// The main query to match on strategic_metadata will fail at run-time
// if strategic_metadata is empty, so we handle the empty case here.
if strategic_metadata.is_empty() {
let ids = query_scalar!(
r#"SELECT id AS "id: SubmissionId" FROM submissions ORDER by id LIMIT ?"#,
limit
)
.fetch_all(conn.get_inner())
.await?;
return error_if_too_many(max_submissions, ids);
}
let mut query_builder: QueryBuilder<Sqlite> =
lookup_ids_by_strategic_metadata_query(&strategic_metadata, limit);
let rows = query_builder.build().fetch_all(conn.get_inner()).await?;
error_if_too_many(
max_submissions,
rows.into_iter()
.map(|row| row.get("submission_id"))
.collect(),
)
let ids = if strategic_metadata.is_empty() {
// The main query to match on strategic_metadata will fail at run-time
// if strategic_metadata is empty, so we handle the empty case here.
query_scalar!(
r#"SELECT id AS "id: SubmissionId" FROM submissions ORDER BY id LIMIT ?"#,
limit
)
.fetch_all(conn.get_inner())
.await?
} else {
let mut query_builder: QueryBuilder<Sqlite> =
lookup_ids_by_strategic_metadata_query(&strategic_metadata, limit);
query_builder.build_query_scalar().fetch_all(conn.get_inner()).await?
};
if ids.len() as u64 > u64::from(max_submissions) {
// If 'max_submissions' results is exceeded return an explicit error.
Err(E::R(TooManyMatchingSubmissions(u64::from(max_submissions))))
} else {
Ok(ids)
}

Note that if we add a SubmissionState selector we need to switch out the FROM submissions with the correct submission(_*)? tables.

limit: i64,
) -> QueryBuilder<'_, Sqlite> {
let mut query_builder: QueryBuilder<Sqlite> =
QueryBuilder::new("SELECT s0.submission_id FROM submissions_metadata as s0");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

In the other query you added AS "id: SubmissionId" don't we need that here?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support checking whether a submission currently exists in the queue for a particular piece of strategic metadata

2 participants