Lookup submision IDs by strategic metadata#112
Conversation
3b0ca52 to
6f9e1e3
Compare
|
Open question shouldn't an empty |
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
left a comment
There was a problem hiding this comment.
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.
| Raises: | ||
| - `LookupIdsWithEmptyStrategicMetadataError` if the provided | ||
| 'strategic_metadata' contained no key-value pairs to look for. |
There was a problem hiding this comment.
Can this potentially also raise:
- `InternalProducerClientError` if there is a low-level internal error.
There was a problem hiding this comment.
Yes, good spot. The rust producer/client.rs code lifts network and decoding errors into this type. Documented in b557df0
| def lookup_submission_ids_by_strategic_metadata( | ||
| self, strategic_metadata: dict[str, int] | ||
| ) -> list[SubmissionId]: |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| async fn lookup_submission_ids_by_strategic_metadata( | ||
| State(state): State<ServerState>, | ||
| extract::Json(strategic_metadata): extract::Json<StrategicMetadataMap>, | ||
| ) -> Result<Json<Vec<SubmissionId>>, ServerError> { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 ? 😅
| Raises: | ||
| - `LookupIdsWithEmptyStrategicMetadataError` if the provided | ||
| 'strategic_metadata' contained no key-value pairs to look for. |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
SGTM. Added support for empty strategic_metadata in 8057405. Will add the streaming / DoS protection in separate commits.
8057405 to
736a7fd
Compare
Document InternalProducerClientError raised
Support empty strategic metadata
Add configurable limit to submissions lookup
736a7fd to
54bc50b
Compare
74a4aa8 to
841d1a3
Compare
Query plan asserted in c6c9f04 |
841d1a3 to
c6c9f04
Compare
assert query plan
c6c9f04 to
6c0256e
Compare
ReinierMaas
left a comment
There was a problem hiding this comment.
I have a few code simplifications and one API generalisation.
There was a problem hiding this comment.
I am not sure these timeouts are long enough on CI.
I had the same experience locally and made it configurable for local use:
|
|
||
| impl From<CError<TooManyMatchingSubmissions>> for PyErr { | ||
| fn from(value: CError<TooManyMatchingSubmissions>) -> Self { | ||
| TooManyMatchingSubmissionsError::new_err(value.0 .0) |
There was a problem hiding this comment.
| TooManyMatchingSubmissionsError::new_err(value.0 .0) | |
| TooManyMatchingSubmissionsError::new_err(value.0.0) |
| class LookupIdsWithEmptyStrategicMetadataError(Exception): | ||
| pass |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
We should also prevent 0 from appearing, i.e. use NonZeroU64 to store the value.
| 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}) |
There was a problem hiding this comment.
We can also test the endpoint keeps functioning until the one that brings it over the edge, this prevents off-by-one errors:
| 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}) |
| 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 | ||
| ? | ||
| "); |
There was a problem hiding this comment.
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"); |
There was a problem hiding this comment.
This is to scope it to the InProgress submissions. We could make this INNER JOIN table configurable based on the requested SubmissionState enum.
| // 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(), | ||
| ) |
There was a problem hiding this comment.
We can simplify this a bit further to make it easier to follow:
| // 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"); |
There was a problem hiding this comment.
In the other query you added AS "id: SubmissionId" don't we need that here?
This PR adds the
lookup_submission_ids_by_strategic_metadatamethod toProducerClient, which will return the submission IDs of in-progress subsmissions that match ALL of the provided strategic metadata key-value pairs.