|
14 | 14 | LiteralMap, |
15 | 15 | Primitive, |
16 | 16 | Scalar, |
| 17 | + StructuredDataset, |
| 18 | + StructuredDatasetMetadata, |
17 | 19 | ) |
18 | 20 | from flyteidl2.core.types_pb2 import ( |
19 | 21 | BlobType, |
@@ -1593,3 +1595,139 @@ async def test_convert_inputs_no_kickoff_key_is_noop(): |
1593 | 1595 | out = await convert.convert_inputs_to_native(Inputs(proto_inputs=proto), interface) |
1594 | 1596 |
|
1595 | 1597 | assert out == {"x": 7} |
| 1598 | + |
| 1599 | + |
| 1600 | +# --------------------------------------------------------------------------- |
| 1601 | +# Content-addressed inputs hash for root actions |
| 1602 | +# |
| 1603 | +# The backend derives `OffloadedInputData.inputs_hash` from the marshaled inputs, which folds |
| 1604 | +# in the offloaded blob URI and ignores `Literal.hash`. Sub-actions don't go through that path |
| 1605 | +# — the controller hashes via `generate_inputs_repr_for_literal`, which substitutes the content |
| 1606 | +# hash — so content-based caching worked for sub-actions but degraded to URI-based caching at |
| 1607 | +# the run entrypoint. `generate_content_inputs_hash` closes that gap. |
| 1608 | +# --------------------------------------------------------------------------- |
| 1609 | + |
| 1610 | +_CONTENT_HASH = "sha256-of-geoparquet-bytes" |
| 1611 | + |
| 1612 | + |
| 1613 | +def _sd_literal(uri: str, hash_val: str | None = None) -> Literal: |
| 1614 | + return Literal( |
| 1615 | + scalar=Scalar( |
| 1616 | + structured_dataset=StructuredDataset( |
| 1617 | + uri=uri, |
| 1618 | + metadata=StructuredDatasetMetadata(structured_dataset_type=StructuredDatasetType(format="parquet")), |
| 1619 | + ) |
| 1620 | + ), |
| 1621 | + hash=hash_val, |
| 1622 | + ) |
| 1623 | + |
| 1624 | + |
| 1625 | +def _int_literal(v: int) -> Literal: |
| 1626 | + return Literal(scalar=Scalar(primitive=Primitive(integer=v))) |
| 1627 | + |
| 1628 | + |
| 1629 | +def _named_inputs(**kwargs: Literal) -> _task_common_pb2.Inputs: |
| 1630 | + return _task_common_pb2.Inputs( |
| 1631 | + literals=[_task_common_pb2.NamedLiteral(name=name, value=lit) for name, lit in kwargs.items()] |
| 1632 | + ) |
| 1633 | + |
| 1634 | + |
| 1635 | +def test_content_inputs_hash_ignores_upload_uri(): |
| 1636 | + """Identical content re-uploaded to a fresh URI must produce the same key.""" |
| 1637 | + run1 = _named_inputs(aoi=_sd_literal("s3://bkt/run-1/abc/0", _CONTENT_HASH)) |
| 1638 | + run2 = _named_inputs(aoi=_sd_literal("s3://bkt/run-2/xyz/0", _CONTENT_HASH)) |
| 1639 | + |
| 1640 | + assert convert.generate_content_inputs_hash(run1, []) == convert.generate_content_inputs_hash(run2, []) |
| 1641 | + |
| 1642 | + |
| 1643 | +def test_content_inputs_hash_tracks_content(): |
| 1644 | + same_uri_other_content = _named_inputs(aoi=_sd_literal("s3://bkt/run-1/abc/0", "a-different-digest")) |
| 1645 | + baseline = _named_inputs(aoi=_sd_literal("s3://bkt/run-1/abc/0", _CONTENT_HASH)) |
| 1646 | + |
| 1647 | + assert convert.generate_content_inputs_hash(baseline, []) != convert.generate_content_inputs_hash( |
| 1648 | + same_uri_other_content, [] |
| 1649 | + ) |
| 1650 | + |
| 1651 | + |
| 1652 | +def test_content_inputs_hash_is_name_sensitive(): |
| 1653 | + """Same literal bound to a different parameter is a different call.""" |
| 1654 | + as_aoi = _named_inputs(aoi=_sd_literal("s3://bkt/1", _CONTENT_HASH)) |
| 1655 | + as_other = _named_inputs(other=_sd_literal("s3://bkt/1", _CONTENT_HASH)) |
| 1656 | + |
| 1657 | + assert convert.generate_content_inputs_hash(as_aoi, []) != convert.generate_content_inputs_hash(as_other, []) |
| 1658 | + |
| 1659 | + |
| 1660 | +@pytest.mark.parametrize( |
| 1661 | + "name,inputs", |
| 1662 | + [ |
| 1663 | + ("empty", _task_common_pb2.Inputs()), |
| 1664 | + ("plain scalar", _named_inputs(x=Literal(scalar=Scalar(primitive=Primitive(integer=5))))), |
| 1665 | + ("dataframe without a hash", _named_inputs(aoi=_sd_literal("s3://bkt/run-1/abc/0"))), |
| 1666 | + ], |
| 1667 | +) |
| 1668 | +def test_content_inputs_hash_defers_when_no_input_is_hashed(name, inputs): |
| 1669 | + """None means "leave the backend's value alone", which keeps already-written cache entries |
| 1670 | + reachable for the overwhelmingly common case of no content hashes at all.""" |
| 1671 | + assert convert.generate_content_inputs_hash(inputs, []) is None |
| 1672 | + |
| 1673 | + |
| 1674 | +def test_content_inputs_hash_equals_the_sub_action_hash(): |
| 1675 | + """The value must be exactly what the controller computes for a sub-action. |
| 1676 | +
|
| 1677 | + The backend folds this field into the cache key as |
| 1678 | + `sha256(inputsHash + taskName + interfaceHash + cacheVersion)` |
| 1679 | + (cloud `workflow/service/utils.go:generateCacheKeyFromInputsHash`), which is the same |
| 1680 | + formula as `generate_cache_key_hash`. Equal inputs hashes therefore mean equal cache keys, |
| 1681 | + so a root action and a sub-action of the same task share cache entries. |
| 1682 | + """ |
| 1683 | + inputs = _named_inputs(aoi=_sd_literal("s3://bkt/1", _CONTENT_HASH)) |
| 1684 | + |
| 1685 | + assert convert.generate_content_inputs_hash(inputs, []) == convert.generate_inputs_hash_from_proto(inputs) |
| 1686 | + |
| 1687 | + |
| 1688 | +def test_content_inputs_hash_excludes_cache_ignored_inputs(): |
| 1689 | + """Matches `filterInputsForHash` on the backend's upload path. |
| 1690 | +
|
| 1691 | + Without this, a task combining `Cache(ignored_inputs=...)` with a content-hashed input |
| 1692 | + would key on the very inputs the user asked to exclude. |
| 1693 | + """ |
| 1694 | + run1 = _named_inputs(aoi=_sd_literal("s3://bkt/1", _CONTENT_HASH), seed=_int_literal(1)) |
| 1695 | + run2 = _named_inputs(aoi=_sd_literal("s3://bkt/1", _CONTENT_HASH), seed=_int_literal(2)) |
| 1696 | + |
| 1697 | + assert convert.generate_content_inputs_hash(run1, ["seed"]) == convert.generate_content_inputs_hash(run2, ["seed"]) |
| 1698 | + # ...and without the ignore list, the differing input does move the key. |
| 1699 | + assert convert.generate_content_inputs_hash(run1, []) != convert.generate_content_inputs_hash(run2, []) |
| 1700 | + |
| 1701 | + |
| 1702 | +def test_content_inputs_hash_defers_when_only_ignored_inputs_are_hashed(): |
| 1703 | + """Nothing left to fix once the hashed input is filtered out — leave the backend's value.""" |
| 1704 | + inputs = _named_inputs(aoi=_sd_literal("s3://bkt/1", _CONTENT_HASH), seed=_int_literal(1)) |
| 1705 | + |
| 1706 | + assert convert.generate_content_inputs_hash(inputs, ["aoi"]) is None |
| 1707 | + |
| 1708 | + |
| 1709 | +@pytest.mark.parametrize( |
| 1710 | + "name,wrap", |
| 1711 | + [ |
| 1712 | + ("collection", lambda lit: Literal(collection=LiteralCollection(literals=[lit]))), |
| 1713 | + ("map", lambda lit: Literal(map=LiteralMap(literals={"k": lit}))), |
| 1714 | + ], |
| 1715 | +) |
| 1716 | +def test_content_inputs_hash_sees_nested_hashes(name, wrap): |
| 1717 | + """`Literal.hash` nested in a collection/map counts, matching what the repr substitutes.""" |
| 1718 | + run1 = _named_inputs(aoi=wrap(_sd_literal("s3://bkt/run-1/abc/0", _CONTENT_HASH))) |
| 1719 | + run2 = _named_inputs(aoi=wrap(_sd_literal("s3://bkt/run-2/xyz/0", _CONTENT_HASH))) |
| 1720 | + |
| 1721 | + assert convert.generate_content_inputs_hash(run1, []) is not None |
| 1722 | + assert convert.generate_content_inputs_hash(run1, []) == convert.generate_content_inputs_hash(run2, []) |
| 1723 | + |
| 1724 | + |
| 1725 | +def test_root_and_sub_action_agree_on_uri_independence(): |
| 1726 | + """The property the fix is really about: both paths now ignore a changed upload URI.""" |
| 1727 | + run1 = _named_inputs(aoi=_sd_literal("s3://bkt/run-1/abc/0", _CONTENT_HASH)) |
| 1728 | + run2 = _named_inputs(aoi=_sd_literal("s3://bkt/run-2/xyz/0", _CONTENT_HASH)) |
| 1729 | + |
| 1730 | + # sub-action path (controller-side), unchanged by this fix |
| 1731 | + assert convert.generate_inputs_hash_from_proto(run1) == convert.generate_inputs_hash_from_proto(run2) |
| 1732 | + # root-action path (client-side), previously URI-sensitive via the server's hash |
| 1733 | + assert convert.generate_content_inputs_hash(run1, []) == convert.generate_content_inputs_hash(run2, []) |
0 commit comments