Skip to content

Commit db694c7

Browse files
authored
PR audit findings (#1155)
* Re-enable AVA worker threads via nodeArguments tsx loader Register the tsx loader through AVA's nodeArguments config instead of NODE_OPTIONS, which lets the test suites run with worker threads enabled. Removes the --no-worker-threads workaround added during the TypeScript migration (#1067) and the duplicated NODE_OPTIONS exports in package.json and bin/system-tests.sh. * Use nvm in pre-commit hook only when it is available Load and use nvm to select the project's Node version when nvm is installed. When it is not (e.g. Node provided by Nix or a system package manager), skip nvm entirely and use whatever node is on PATH rather than failing with 'nvm: command not found'. Version compatibility is left to the developer, with CI enforcing the pinned Node version. * Scope collection searches via _index body filter instead of request path Listing many collection indices in the OpenSearch request path can overflow the server's URL line-length limit. PR #1047 worked around this by falling back to searching all indices once more than 10 collections were requested, which loses scoping and degrades performance. Instead, always restrict to the requested collections via a terms filter on the _index metadata field in the query body, which has no comparable length limit. The request path keeps the default restriction (*, -.*, -collections) so searches never reach into system or collections indices. Fixes #770. * Tighten STAC types: bbox and collection summaries - StacItem.bbox is now BBox only, matching the STAC document model. The request-time string form remains on APIParameters where it belongs; the document type no longer forces consumers to narrow away a string. getBBox's return type is tightened to match (its sns.ts consumer already indexed it as an array). - Collection summaries now accept a Range Object or JSON Schema in addition to value arrays, per the STAC spec, instead of only string[]/number[]. - Fixed a missing space in the StacItem.geometry union. * Add PR #1155 link to CHANGELOG entries for this branch * Fix ingestStatus mis-reporting for empty-message ingest errors Post-ingest SNS notifications derived ingestStatus from the truthiness of the error message string (payload.error ? 'failed' : 'successful'). Since the converted ingest lambda passes result.error?.message, an ingest failure whose Error has an empty message produced a falsy value and was published as 'successful' — silently hiding the failure from SNS subscribers. Derive the status from the presence of an error (error !== undefined) instead. Add integration-style unit tests that drive publishRecordToSns through a mocked SNS client and assert the published MessageAttributes, covering success, error-with-message, and the empty-message failure case. * Restore missing-href scenario in asset-proxy test The TS test conversion (#1095) added href: '' to the 'handles assets without href' test to satisfy the Assets type, which turned it into a with-empty-href test that no longer matched its name. Provide an asset genuinely missing href (via a cast) so it exercises the intended missing-href branch of getProxiedAssets. * Preserve declared temporal sub-intervals during auto-extent calculation The automatic temporal extent feature (#999) replaced a collection's entire extent.temporal.interval array with a single computed interval whenever the overall extent needed backfilling, discarding any declared sub-intervals (interval[1..n]). Only compute the overall extent (interval[0]) from items and preserve declared sub-intervals. Adds a regression test covering a collection with multiple intervals. * Resolve mapped collection indices without re-hashing When COLLECTION_TO_INDEX_MAPPINGS is configured, a mapped collection resolves to a specific index name (e.g. a shared or remote index) that must be used as-is. The scoped-search path was passing every resolved name through collectionUniqueIndexID(), re-hashing the mapped name into a nonexistent index so those collections returned no results. Collections with no mapping entry still fall back to their hashed index name. Extract the resolution into a pure, exported resolveCollectionIndices(collections, mapping) so the mapped/unmapped/mixed cases are unit-testable without the module-level mapping cache, and add tests covering each. * Prefer request-path index scoping, body filter only as fallback Scoping collections via an _index body filter against a broad path (_all) makes OpenSearch enumerate and fan out a can_match request to every shard in the cluster before skipping the non-matching ones; path-based scoping instead prunes to the target shards at the coordinating node before any fan-out. Always using the body filter therefore regressed the shard-fan-out optimization noted in issue #770 for the common case of a few collections. Scope in the request path when the resolved index list fits within the URL line-length budget, and fall back to the _index body filter only when the list would overflow it. Correctness and the large-collection-list fix are preserved; typical queries regain path-based shard pruning. Addresses review feedback on PR #1155.
1 parent f03d772 commit db694c7

13 files changed

Lines changed: 371 additions & 66 deletions

File tree

.husky/pre-commit

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
#!/usr/bin/env sh
22

3-
# Load nvm
4-
export NVM_DIR="$HOME/.nvm"
3+
# Load nvm if available
4+
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
55
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
66

7-
# Use project's Node version
8-
if [ -f .nvmrc ]; then
7+
# Select the project's Node version via nvm when it's available. When nvm is not
8+
# installed (e.g. Node provided by Nix or a system package manager), just use
9+
# whatever node is on PATH — it's up to the developer to run a compatible Node,
10+
# and CI enforces the pinned version regardless.
11+
if [ -f .nvmrc ] && command -v nvm >/dev/null 2>&1; then
912
nvm use
1013
fi
1114

CHANGELOG.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,30 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
88
## [Unreleased]
99

1010
### Fixed
11+
- Scoped searches over collections configured with `COLLECTION_TO_INDEX_MAPPINGS`
12+
now target the mapped index name directly. Previously the mapped name was
13+
re-hashed as if it were a collection id, producing a nonexistent index and
14+
returning no results for those collections. Collections without a mapping entry
15+
continue to resolve to their hashed index name.
16+
([1155](https://github.com/stac-utils/stac-server/pull/1155))
17+
- Automatic temporal extent calculation no longer discards a collection's declared
18+
temporal sub-intervals. Only the overall extent (`interval[0]`) is computed from
19+
items; any finer-grained sub-intervals (`interval[1..n]`) declared on the
20+
collection are now preserved instead of being overwritten with a single computed
21+
interval. ([1155](https://github.com/stac-utils/stac-server/pull/1155))
22+
- Post-ingest SNS notifications now report `ingestStatus` as `failed` for any
23+
ingest error, including errors whose message is empty. Previously the status
24+
was derived from the truthiness of the error message, so an empty-message
25+
error was mis-reported as `successful`. ([1155](https://github.com/stac-utils/stac-server/pull/1155))
26+
- Searches with too many collections no longer fail with an OpenSearch
27+
request-URL length error, and no longer fall back to searching all indices.
28+
Collections are scoped in the request path as before when the index list is
29+
short; only when it would overflow the URL length limit does the search fall
30+
back to an `_index` filter in the query body, preserving index scoping either
31+
way. ([770](https://github.com/stac-utils/stac-server/issues/770), [1155](https://github.com/stac-utils/stac-server/pull/1155))
32+
- Re-enabled AVA worker threads for the test suites by registering the `tsx`
33+
loader via AVA's `nodeArguments` config instead of `NODE_OPTIONS`, removing the
34+
`--no-worker-threads` workaround introduced during the TypeScript migration. ([1155](https://github.com/stac-utils/stac-server/pull/1155))
1135

1236
- The `datetime_frequency` aggregation now honors the `datetime_frequency_interval`
1337
parameter (`day`/`week`/`month`/`quarter`/`year`) instead of always
@@ -50,6 +74,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
5074
### Changed
5175

5276
- Upgrade `got` from 13 to 15 (HTTP client used by the ingest lambda for fetching remote STAC records). No API or behavior changes; remote-record fetches keep lenient Content-Length handling. `got` 15 requires Node.js >= 22, now declared in `engines` and used by all CI workflows.
77+
- Corrected STAC types: `StacItem.bbox` is now `BBox` (matching the STAC document
78+
model, with the request-string form kept only on `APIParameters`), and
79+
collection `summaries` now accept range objects and JSON Schema values in
80+
addition to value arrays, per the STAC spec. ([1155](https://github.com/stac-utils/stac-server/pull/1155))
5381
- Typing the top level lambda layer ([1087](https://github.com/stac-utils/stac-server/pull/1087))
5482
- Typing the api layer in `api.ts`, pushing some minor functions to a new utility files `api-utils.ts` ([1081](https://github.com/stac-utils/stac-server/pull/1081))
5583
- Converting the database layer to typescript as part of migration ([1077](https://github.com/stac-utils/stac-server/pull/1077))

bin/system-tests.sh

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,12 @@ export ENABLE_TRANSACTIONS_EXTENSION=true
1010
export REQUEST_LOGGING_ENABLED=false
1111
# export ENABLE_RESPONSE_COMPRESSION=false
1212

13-
# Force ALL Node processes (including AVA workers) to use tsx
14-
export NODE_OPTIONS="--import=tsx"
15-
1613
echo "Running tests"
1714
set +e
1815

1916
# add --match to restrict by test name
20-
npx ava "tests/system/${TEST_PATTERN}" --serial --verbose --no-worker-threads
17+
# tsx is registered via the ava `nodeArguments` config in package.json
18+
npx ava "tests/system/${TEST_PATTERN}" --serial --verbose
2119
TEST_RESULT="$?"
2220
set -e
2321

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
"test": "npm run test:unit && npm run test:system",
2020
"test:system": "./bin/system-tests.sh",
2121
"test:system:coverage": "c8 npm run test:system",
22-
"test:unit": "NODE_OPTIONS='--import=tsx' ava tests/unit/*.[tj]s --no-worker-threads",
22+
"test:unit": "ava tests/unit/*.[tj]s",
2323
"test:unit:coverage": "c8 npm run test:unit",
2424
"typecheck": "tsc && tsc -p tsconfig.test.json",
2525
"audit-prod": "npx better-npm-audit audit --production",
@@ -37,6 +37,9 @@
3737
"timeout": "1m",
3838
"extensions": [
3939
"ts"
40+
],
41+
"nodeArguments": [
42+
"--import=tsx"
4043
]
4144
},
4245
"publishConfig": {

src/lib/api.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1065,16 +1065,23 @@ const populateTemporalExtentIfMissing = async (
10651065
backend: Backend,
10661066
collection: StacCollection
10671067
): Promise<void> => {
1068-
const [start, end] = collection.extent?.temporal?.interval?.[0] ?? [null, null]
1068+
const intervals = collection.extent?.temporal?.interval
1069+
const [start, end] = intervals?.[0] ?? [null, null]
10691070

1070-
// Nothing to do when both bounds are already declared.
1071+
// Nothing to do when both bounds of the overall extent are already declared.
10711072
if (start != null && end != null) return
10721073

10731074
const temporalExtent = await backend.getTemporalExtentFromItems(collection.id)
10741075
if (temporalExtent && collection.extent?.temporal) {
10751076
const [computedStart, computedEnd] = temporalExtent[0]
1076-
// Preserve any declared bound; only fill the missing one(s) from items.
1077-
collection.extent.temporal.interval = [[start ?? computedStart, end ?? computedEnd]]
1077+
// Only the overall extent (interval[0]) is computed from items; any declared
1078+
// sub-intervals (interval[1..n]) are preserved, as is any bound already set
1079+
// on the overall extent.
1080+
const rest = intervals?.slice(1) ?? []
1081+
collection.extent.temporal.interval = [
1082+
[start ?? computedStart, end ?? computedEnd],
1083+
...rest
1084+
]
10781085
}
10791086
}
10801087

src/lib/database.ts

Lines changed: 94 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,6 @@ export const DEFAULT_FIELDS = [
7878
'collection',
7979
'properties.datetime'
8080
]
81-
const MAX_COLLECTIONS_IN_QUERY_PATH = 10
8281

8382
let collectionToIndexMapping: Record<string, string> | null = null
8483
let unrestrictedIndices: string[] | null = null
@@ -481,6 +480,22 @@ export function collectionUniqueIndexID(collectionId: string): string {
481480
return `${collectionId.toLowerCase()}-${collectionHash(collectionId)}`
482481
}
483482

483+
/**
484+
* Resolve a list of collection ids to the OpenSearch index names to search.
485+
*
486+
* A collection present in the `COLLECTION_TO_INDEX_MAPPINGS` mapping resolves to
487+
* its configured index name and is used as-is (it may be a shared or remote index
488+
* that must not be altered). Any other collection — including every collection
489+
* when the mapping is empty — resolves to the hashed index name derived from its
490+
* id. This matches how mapping values are used in populateUnrestrictedIndices.
491+
*/
492+
export function resolveCollectionIndices(
493+
collections: string[],
494+
mapping: Record<string, string>
495+
): string[] {
496+
return collections.map((c) => mapping[c] ?? collectionUniqueIndexID(c))
497+
}
498+
484499
function buildItemSearchQuery(parameters: QueryParameters): OpenSearchFilterQuery {
485500
const { intersects, collections, ids } = parameters
486501
const filterQueries: OpenSearchFilterQuery[] = []
@@ -523,6 +538,15 @@ function buildItemSearchQuery(parameters: QueryParameters): OpenSearchFilterQuer
523538
}
524539
}
525540

541+
// Normalise a bool `filter`/`must_not` clause, which may be a single query
542+
// object or an array, into an array.
543+
function toFilterArray(
544+
v: OpenSearchFilterQuery | OpenSearchFilterQuery[] | undefined
545+
): OpenSearchFilterQuery[] {
546+
if (!v) return []
547+
return Array.isArray(v) ? v : [v]
548+
}
549+
526550
function buildOpenSearchQuery(parameters: QueryParameters): OpenSearchBody {
527551
const { query, filter, intersects, collections, ids } = parameters
528552

@@ -547,25 +571,17 @@ function buildOpenSearchQuery(parameters: QueryParameters): OpenSearchBody {
547571
itemSearchQuery = buildItemSearchQuery(parameters)
548572
}
549573

550-
// Normalise filter/must_not which can be single item or array
551-
const toArray = (
552-
v: OpenSearchFilterQuery | OpenSearchFilterQuery[] | undefined
553-
): OpenSearchFilterQuery[] => {
554-
if (!v) return []
555-
return Array.isArray(v) ? v : [v]
556-
}
557-
558574
const combinedFilter: OpenSearchFilterQuery[] = [
559-
...toArray(cql2Query.bool?.filter),
560-
...toArray(stacqlQuery.bool?.filter),
561-
...toArray(itemSearchQuery.bool?.filter)
575+
...toFilterArray(cql2Query.bool?.filter),
576+
...toFilterArray(stacqlQuery.bool?.filter),
577+
...toFilterArray(itemSearchQuery.bool?.filter)
562578
]
563579
const combinedShould: OpenSearchFilterQuery[] = [
564580
...(cql2Query.bool?.should || []),
565581
]
566582
const combinedMustNot: OpenSearchFilterQuery[] = [
567-
...toArray(cql2Query.bool?.must_not),
568-
...toArray(stacqlQuery.bool?.must_not),
583+
...toFilterArray(cql2Query.bool?.must_not),
584+
...toFilterArray(stacqlQuery.bool?.must_not),
569585
]
570586

571587
if (!isEmpty(combinedFilter)) {
@@ -586,6 +602,45 @@ function buildOpenSearchQuery(parameters: QueryParameters): OpenSearchBody {
586602
return { query: { bool: osBool } }
587603
}
588604

605+
/**
606+
* Restrict a query to a set of indices by adding a `terms` filter on the
607+
* `_index` metadata field.
608+
*
609+
* This is the fallback for when the index list is too long to list in the request
610+
* path: OpenSearch puts the path-based index list in the request URL, which is
611+
* subject to the server's `http.max_initial_line_length` limit (4KB by default),
612+
* so a search spanning many collections can exceed that and fail. The body has no
613+
* comparable limit, so this keeps the search scoped without the cliff. The search
614+
* must then be sent to a broad path (the default restriction) for the `_index`
615+
* filter to narrow against. See issue #770.
616+
*/
617+
function restrictQueryToIndices(
618+
body: OpenSearchBody, indices: string[]
619+
): OpenSearchBody {
620+
const indexFilter: OpenSearchFilterQuery = { terms: { _index: indices } }
621+
622+
// match_all means there are no other constraints; wrap it so we can filter.
623+
if (body.query.match_all) {
624+
return { ...body, query: { bool: { filter: [indexFilter] } } }
625+
}
626+
627+
const bool = body.query.bool ?? {}
628+
const filter = [...toFilterArray(bool.filter), indexFilter]
629+
630+
return { ...body, query: { ...body.query, bool: { ...bool, filter } } }
631+
}
632+
633+
// The comma-joined index list goes in the request path, which shares OpenSearch's
634+
// `http.max_initial_line_length` budget (4KB by default) with the method, the
635+
// rest of the path, the query string, and URL-encoding expansion. Cap the joined
636+
// index list well below that so the rest of the request line always fits; longer
637+
// lists fall back to an `_index` body filter instead.
638+
const MAX_INDEX_PATH_LENGTH = 2048
639+
640+
function indexPathWithinLimit(indices: string[]): boolean {
641+
return indices.join(',').length <= MAX_INDEX_PATH_LENGTH
642+
}
643+
589644
function buildIdQuery(id: string): OpenSearchBody {
590645
return {
591646
query: {
@@ -869,10 +924,6 @@ async function populateCollectionToIndexMapping() {
869924
}
870925
}
871926

872-
async function indexForCollection(collectionId: string): Promise<string> {
873-
return (collectionToIndexMapping ?? {})[collectionId] || collectionId
874-
}
875-
876927
async function populateUnrestrictedIndices() {
877928
if (!unrestrictedIndices) {
878929
if (process.env['COLLECTION_TO_INDEX_MAPPINGS']) {
@@ -914,32 +965,37 @@ export async function constructSearchParams(
914965
body.search_after = buildSearchAfter(parameters)
915966
}
916967

917-
let indices
968+
// Default index restriction used when no collections are requested:
969+
// `*, -.*, -collections` (all local item indices, excluding system indices and
970+
// the collections index).
971+
if (!unrestrictedIndices) {
972+
await populateUnrestrictedIndices()
973+
}
974+
let index = unrestrictedIndices!
975+
918976
if (Array.isArray(collections) && collections.length) {
919-
if (collections.length > MAX_COLLECTIONS_IN_QUERY_PATH) {
920-
indices = ['_all']
921-
} else if (process.env['COLLECTION_TO_INDEX_MAPPINGS']) {
922-
if (!collectionToIndexMapping) await populateCollectionToIndexMapping()
923-
indices = await Promise.all(collections.map(async (x) => await indexForCollection(x)))
924-
} else {
925-
indices = collections
977+
// Resolve the explicitly-requested collections to their indices.
978+
if (process.env['COLLECTION_TO_INDEX_MAPPINGS'] && !collectionToIndexMapping) {
979+
await populateCollectionToIndexMapping()
926980
}
927-
} else {
928-
if (!unrestrictedIndices) {
929-
await populateUnrestrictedIndices()
981+
const indices = resolveCollectionIndices(collections, collectionToIndexMapping ?? {})
982+
983+
// Prefer scoping in the request path: OpenSearch prunes to those shards at
984+
// the coordinating node before fan-out, which is cheaper than searching all
985+
// indices and filtering per-shard. But the path is part of the request URL,
986+
// so a long index list can overflow the server's line-length limit; in that
987+
// case fall back to an `_index` terms filter in the request body (which has
988+
// no comparable limit), leaving the path at the default restriction so it
989+
// never reaches into system indices.
990+
if (indexPathWithinLimit(indices)) {
991+
index = indices
992+
} else {
993+
body = restrictQueryToIndices(body, indices)
930994
}
931-
indices = unrestrictedIndices!
932995
}
933-
// hash indices
934-
indices = indices.map((index) => {
935-
if (DEFAULT_INDICES.includes(index) || index === '_all') {
936-
return index
937-
}
938-
return collectionUniqueIndexID(index)
939-
})
940996

941997
const searchParams: SearchParameters = {
942-
index: indices,
998+
index,
943999
body,
9441000
size: limit,
9451001
track_total_hits: true

src/lib/sns.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { StacRecord } from './types.js'
66

77
interface SNSPayload {
88
record: StacRecord
9+
// The ingest error message, or `undefined` when ingestion succeeded. An empty
10+
// string is a failure with no message, not a success.
911
error: string | undefined
1012
}
1113
/**
@@ -36,7 +38,9 @@ const attrsFromPayload = function (
3638
},
3739
ingestStatus: {
3840
DataType: 'String',
39-
StringValue: payload.error ? 'failed' : 'successful'
41+
// Presence of an error, not its message text, determines failure: an
42+
// error with an empty message still means the ingest failed.
43+
StringValue: payload.error !== undefined ? 'failed' : 'successful'
4044
},
4145
collection: {
4246
DataType: 'String',

src/lib/stac-utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ export function getStartAndEndDates(
8787
return { startDate, endDate }
8888
}
8989

90-
export function getBBox(record: StacRecord): string | BBox | undefined {
90+
export function getBBox(record: StacRecord): BBox | undefined {
9191
if (isCollection(record)) {
9292
return record.extent?.spatial?.bbox?.length > 0
9393
? record.extent.spatial.bbox[0]

src/lib/types.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ export interface StacItem {
1111
stac_version: string
1212
stac_extensions?: string[]
1313
id: string
14-
geometry: Geometry| null
15-
bbox?: BBox | string // only required if geometry is not null
14+
geometry: Geometry | null
15+
bbox?: BBox // only required if geometry is not null
1616
properties: ItemProperties
1717
links: Link[]
1818
assets: Assets
@@ -37,7 +37,7 @@ export interface StacCollection {
3737
extent: Extent
3838
queryables?: Queryables
3939
aggregations?: Aggregation[]
40-
summaries?: {[key: string]: string[] | number[]}
40+
summaries?: {[key: string]: Summary}
4141
links: Link[]
4242
assets?: Assets
4343
item_assets?: {[key: string]: Asset}
@@ -48,6 +48,25 @@ export interface FeatureCollection {
4848
features: StacItem[]
4949
}
5050

51+
/**
52+
* A Range Object summary. Per the STAC spec a range may be open-ended, so a
53+
* bound may be omitted; at least one of `minimum`/`maximum` is present.
54+
*/
55+
export type SummaryRange =
56+
| { minimum: string | number, maximum?: string | number }
57+
| { maximum: string | number, minimum?: string | number }
58+
59+
/**
60+
* A collection summary. Per the STAC spec a summary may be a set of distinct
61+
* values, a Range Object with `minimum`/`maximum` bounds, or a JSON Schema
62+
* describing the values.
63+
* https://github.com/radiantearth/stac-spec/blob/master/collection-spec/collection-spec.md#summaries
64+
*/
65+
export type Summary =
66+
| Array<string | number | boolean>
67+
| SummaryRange
68+
| { [key: string]: unknown } // JSON Schema
69+
5170
export interface StacCatalog {
5271
stac_version: string
5372
type: string

0 commit comments

Comments
 (0)