(magnolify-beam) Map Instant to Beam's portable Timestamp logical type - #1392
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #1392 +/- ##
==========================================
+ Coverage 95.78% 95.82% +0.04%
==========================================
Files 58 60 +2
Lines 2183 2205 +22
Branches 173 181 +8
==========================================
+ Hits 2091 2113 +22
Misses 92 92 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
| "SqlTypes.TIMESTAMP is MicrosInstant, which throws on sub-microsecond instants. " + | ||
| "Use `date` plus one of millis/micros/nanos instead.", | ||
| "0.10.0" | ||
| ) |
There was a problem hiding this comment.
propose to remove it because it mixes 3 different precisions, and we already implement these types
clairemcginty
left a comment
There was a problem hiding this comment.
Makes sense for IcebergIO; my concern is that Row is the output type for many Beam transformations, for example beam sql and all the new managed IOs (which includes IcebergIO). Are Joda timestamp types used in any of those other IOs/will we break those use cases?
I slightly prefer the package name magnolify.beam.logical.joda over magnolify.beam.logical.legacy but it's not a blocking concern
|
@clairemcginty Beam's change is narrow - only changed what IcebergIO reads so But you are spot on about this PR's blast radius - if we introduce Beam's
But Iceberg is the only "live" case, because all other sinks are reachable through classic Scio IOs (that don't touchBeam Row, e.g. scio-jdbc). So I changed this, it's now opt-in:
Upgrading to 0.10 changes no schema; you get a deprecation warning asking you to pick a grouping |
| "encoding.", | ||
| "0.10.0" | ||
| ) | ||
| object millis extends MillisCompat |
There was a problem hiding this comment.
we don't break it anymore
Beam 2.76.0 changed how IcebergIO represents
timestamptz, which brokemagnolify.beam.logical.millis. Investigating that turned up two further problems that predate the Beam change. This PR addsTimestamp-based mappings underlogical.timestamp, groups the existing encodings underlogical.compat, deprecates the bare precision objects in favour of an explicit choice between the two, and deprecateslogical.sql.No schema changes on upgrade. The bare
logical.{millis,micros,nanos}objects keep their 0.9.7 behavior exactly; they are deprecated aliases ofcompat.*. Adopting the new encoding is opt-in, one import at a time. See Migration.What broke
Beam #39344 (in 2.76.0) changed Iceberg's
timestamptz→ Beam schema mapping fromFieldType.DATETIMEtoFieldType.logicalType(Timestamp.MICROS). From Beam'sCHANGES.md:FieldType.DATETIMEstoresorg.joda.time.Instant;Timestamp.MICROSstoresjava.time.Instant.logical.millis'sRowField[java.time.Instant]was built as a conversion layer overRowField[joda.Instant], so itsFromTwasjoda.Instant. Reading an IcebergIO Row on 2.76.0 therefore hit the unchecked cast inRowField#fromAnyand failed:Note the scope: the commit touches only
sdks/java/io/iceberg/**, and only the read direction. Iceberg writes still acceptDATETIME(IcebergUtils.java:77), and no other IO was changed. That bounds what actually had to move.Two pre-existing bugs found along the way
Both are independent of the Beam 2.76.0 change and are fixed/flagged here.
1.
logical.sql.rfSqlInstantthrows at write time.SqlTypes.TIMESTAMPis not a distinct type — it isnew MicrosInstant()(SqlTypes.java:43), whose own Javadoc says it "should never be used in a native Java context," and whosetoBaseTypethrowsAssertionErrorwhengetNano() % 1000 != 0.logical.microscarries a comment explaining it avoidsMicrosInstantfor precisely this reason;logical.sqlused it anyway. Now covered by a test that asserts the throw.2. The test suite could not observe precision handling.
shared'sarbInstantisGen.posNum[Long].map(Instant.ofEpochMilli), andGen.posNumis size-bounded rather than range-bounded (sized(n => c.choose(one, max(fromInt(n), one))), ScalaCheckGen.scala:1387-1391). With the default size of 100, that generates 100 values, all in the first 100 ms of 1970. EveryRowField.id-based instant mapping round-tripped vacuously, so neither bug above was detectable, and a naiveRowField.id[Instant]mapping ontoTimestamp.MILLIS/MICROSwould also have passed while throwing in production.Changes
logical.timestamp.{millis,micros,nanos}— newInstantmaps to Beam's portableTimestamplogical type at the named precision.Timestamp#toBaseTyperejects instants carrying finer precision than the type declares (checkState,Timestamp.java:123) rather than truncating, so these mappings truncate on write via a shared helper:Discarding excess precision matches what the non-instant mappings at each precision already do. Here
joda.Instantandjoda.DateTimederive from thejava.timebase, the reverse of thecompatdirection.Timestampis Beam's go-forward instant type: portable (beam:logical_type:timestamp:v1, which Python'sTimestampnow maps to), precision-parameterized, and lossless where the other options are not —FieldType.DATETIMEtruncates past millis and is joda-bound, andMicrosInstantthrows.logical.compat.{millis,micros,nanos}— new name for the existing encodingscompat(= 0.9.7)timestampmillisFieldType.DATETIME(joda.Instant)logicalType(Timestamp.MILLIS)microsINT64(micros since epoch)logicalType(Timestamp.MICROS)nanoslogicalType(NanosInstant)logicalType(Timestamp.NANOS)compatis not a deprecated holding pen — it is the magnolify-side counterpart to Beam's--updateCompatibilityVersionflag, and for several IOs the only thing that works at all. The flag gates only the read direction (icebergTypeToBeamFieldTypeand friends take anupdateCompatibilityVersion;beamFieldTypeToIcebergFieldTypedoes not), so a pipeline pinned below 2.76.0 getsFieldType.DATETIMEback from IcebergIO — which onlycompat.milliscan read. Pair the flag withcompat, or omit both; setting the flag while usingtimestamp.*is the one broken combination.It is also required for connectors that hardcode
FieldType.DATETIMEregardless of the flag. As of Beam 2.76.0, withinsdks/java/iothat is jdbc, google-cloud-platform, clickhouse, delta, hcatalog, iceberg, singlestore and amazon-web-services2;DATETIMEis additionally hardcoded by core and by the arrow, avro, sql and sql-datacatalog extensions.That list was built at the
v2.76.0tag restricted to/src/main/, counting only code that produces aDATETIMEfield. Connectors that merely accept one are excluded: CsvIO's hits are membership inVALID_FIELD_TYPE_SETplus a parse consumer, andProtoSchemaTranslator.java:92states that protobufTimestampcannot be translated toDATETIMEat all.One caveat on how to read it: it enumerates connectors that name
FieldType.DATETIME. Beam's schema inference also maps any jodaInstantfield toDATETIME(FieldTypeDescriptors.java:53), so a connector whose element type has joda fields produces it without naming it — KafkaIO'sKafkaSourceDescriptor(:35,59,69) is one.On the name.
compatrather thanjodabecause only one of the three is joda-backed:compat.microsis a rawINT64andcompat.nanosisNanosInstant, which isjava.time.Instant-backed. The three share no representation, only the fact that this is what 0.9.7 emitted, so the grouping is named for the compatibility it provides.timestampis named after the Beam type it produces.Bare
logical.{millis,micros,nanos}— deprecated, behavior unchangedEach extends the same
private[logical]trait as itscompatcounterpart, so they cannot drift:This is the part that matters most for safety. Repurposing these names would have been a silent change:
millis.rfInstantMilliskeeps its exact signature (RowField[java.time.Instant]), so downstream code would compile unchanged and write different bytes, with no tooling signal. Given that the original bug reached a worker as aClassCastExceptionrather than failing at compile or submit time, shipping a second silent behavior change seemed like the wrong trade. Now the only signal is a deprecation warning that names both alternatives.Three tests pin each bare object to its 0.9.7
FieldType, and a fourth asserts schema equality against the matchingcompatobject so a future edit to one grouping and not the other fails.The encoding you pick determines which Beam IOs you can write to
This is the real constraint in the PR, and the reason the new encodings are opt-in rather than default. All verified at the
v2.76.0tag. These are Beam's IOs consuming aPCollection<Row>— magnolify's ownavro,bigqueryandparquetmodules targetGenericRecord/TableRow/Parquet directly and never see a BeamSchema, so they are unaffected:IcebergIOBigQueryIOJSONAVROtimestamp.millis(p3)timestamp.micros(p6)timestamp.nanos(p9)compat.millis(DATETIME)† accepted at any precision, but Beam SQL round-trips timestamps through millis (
BeamCalcRel.java:463), silently dropping anything finer.IcebergUtils.java:227-234— not gated by any compatibility flag.BigQueryUtils.java:591-596,BeamRowToStorageApiProto.java:252-260,AvroUtils.java:1227-1232. KafkaAVROgoes through the same Avro check (KafkaWriteSchemaTransformProvider.java:201).Timestampbranch. Unknown logical types fall back to the base type (JdbcUtil.java:336-338), andTimestamp's base is aROW, which is not a writable JDBC type —RuntimeException("ROW in schema is not supported while writing").JSONfails late:RowJsonrecurses into theROWbase type, findsINT64/INT16, passes schema validation, then throwsClassCastExceptionper element (RowJson.java:176-180,:593).A note on weight: Iceberg is the only case anyone hits today. The JDBC and Kafka rows are reachable rather than exercised — a schema transform needs a schema-bearing
PCollection<Row>, and sinceRowCoderis aSchemaCoder(PCollection.java:318-319), the usualsetCoder(RowCoder.of(rowType.schema))idiom produces one, whichsaveAsManaged(sink, …)will hand to any managed sink. But nothing routes to those sinks that way in practice; Scio's own JDBC support never touches BeamRow. They are listed because they establish that noTimestampprecision is universal, which is the argument for keeping the new encodings opt-in.Two consequences: no
timestamp.*precision reaches every sink, andcompat.millisis the onlyInstantencoding every schema-aware Beam IO accepts. Records needing multiple destinations should usecompat.millisor per-destinationRowTypes.timestamp.nanosbeing Iceberg-rejected is not a regression —NanosInstantwas never writable there either (it is not aPassThroughLogicalTypeand is absent fromBEAM_LOGICAL_TYPES_TO_ICEBERG_TYPES, so it hitsthrow new RuntimeException("Unsupported Beam logical type …")atIcebergUtils.java:236).timestamp.millishas no write destination at all, and is kept for symmetry and for reading data that is already precision 3 — a BigQueryTIMESTAMP(12)column under--picosecondTimestampMapping=MILLIS(BigQueryUtils.java:474-493).Documented on the objects themselves and in
docs/beam.md.Reads tolerate a precision mismatch
Verified by running it, not by reading source — two
RowTypes over one case class, each reading the other's output:A
Timestampfield surfaces as ajava.time.Instantat any precision, and truncation lives only into, so a reader whose declared precision differs from the data silently returns whatever was stored. Only acompat.*reader againstTimestampdata fails loudly. Worth knowing because a green read proves less than it appears to.Reads are also where the groupings are least interchangeable, since most IOs still produce
DATETIME. Beam's BigQueryIO is the clearest case: precision 9 on write, butDATETIMEon read for an ordinaryTIMESTAMPcolumn. No single import round-trips it.NonInstantTypes.scalaandCompatTypes.scala— newLocalTime,LocalDateTime, andDurationare represented identically regardless of instant encoding, because Beam offers no joda schema type for them at all —SqlTypes.TIME/DATETIMEandNanosDurationare the only mappings and all returnjava.timetypes. Those mappings live in threeprivate[logical]traits inNonInstantTypes.scala, shared by all three groupings.CompatTypes.scalaholds the three 0.9.7 instant encodings, shared bycompat.*and the deprecated bare objects.Separate files are required:
tlFatalWarningsrejects traits declared inside package objects. This mirrorsparquet/logical/TimeTypes.scala.logical.sql— deprecatedThree of its four members (
DATE,TIME,DATETIME) are the sameLogicalTypeinstanceslogical.dateand the precision objects already use; the fourth throws. Deprecated rather than removed, per review preference — removal in a later release.Docs
docs/beam.md's "Time and dates" section described the pre-change model and recommendedlogical.sqlwithout qualification. Rewritten around the two groupings, with the per-IO matrix, acompatsection and an Iceberg section.docs/mapping.mdis unchanged:java.time.Instantandorg.joda.time.DateTimestill reachDATETIME,INT64andROW, since all three encodings remain available.Migration
logical.{millis,micros,nanos}._logical.millis._and want the warning gone, no behavior changelogical.compat.millis._logical.millis._reading IcebergIO on Beam ≥ 2.76logical.timestamp.micros._— this is theClassCastExceptionabovelogical.millis._writing to Iceberg, want microsecondslogical.timestamp.micros._logical.millis._with--updateCompatibilityVersionbelow 2.76.0logical.compat.millis._— the flag andcompatgo togetherlogical.micros._reading IcebergIO on Beam ≥ 2.76logical.timestamp.micros._logical.sql._logical.date._plus atimestamp.*orcompat.*objectSwitching from
compat.Xtotimestamp.Xchanges the wire format, so existing data needs rewriting or acompatreader.Binary compatibility
This targets 0.9.8, so
tlBaseVersionstays at0.9and MiMa runs for real in CI against every 0.9.x artifact — no manual baseline pinning, and the guarantee stays enforced for every future commit rather than resting on a one-off command:Keeping the bare objects as
objects that inherit fromprivate[logical]traits is what makes this work: members moved into an inherited trait keep their signatures and their owning class, so the 0.9 call sites still link.An earlier revision of this PR bumped
tlBaseVersionto0.10. That is no longer needed — the change is source- and binary-compatible, so early-semver puts it in the 0.9 line, and staying there is what keeps MiMa non-vacuous. Notebuild.sbt:185still carriestlVersionIntroduced := Map("3" -> "0.10.0")from #1258; that predates this PR and is a separate question for whoever cuts the release with Scala 3 support.Testing
beam55/55 on 2.13 and 2.12;avro40/40 andparquet216/216 unaffected; scalafmt clean;site/mdoccompiles all 17 docs files.26 new tests:
timestamp.*object; theFieldTypefor eachcompat.*object (previously untested, so representation changes could regress silently); the 0.9.7FieldTypefor each deprecated bare object plus schema equality againstcompat; and one assertinglogical.sqlthrowsAssertionErroron a sub-microsecond instant, documenting the deprecation rationale.timestamp.*andcompat.*for both java.time and joda records, over a generator described below.Closing the precision blind spot
Because
Gen.posNumis size-bounded (see above), the sharedarbInstantyields 100 values inside the first 100 ms of 1970 — and everything derived from it collapses too:arbLocalTimealways has hour, minute and second0;arbLocalDateTimeis always1970-01-01. Every pre-existing instant round-trip property therefore held vacuously: nothing was ever truncated and the epoch boundary was never crossed. That is why neither theTimestamp-throws behavior nor thelogical.sqlbug was detectable.Widening
arbInstantitself is out of scope here and cannot be done alone — it lives inshared, andTimeSpec.scala:37-39assertsjava → unit → joda → unit → java == identity, which is false for micros/nanos (the joda leg is millis-only) and passes only because of the weak generator. Fixing the generator requires fixing that expectation in the same commit, plusavroandparquet's round-trips. Scoped out inBEAM_TIMESTAMP_REMAINING_SCOPE.md. Instead this adds a beam-local generator spanning 1900–2100 at full nanosecond precision, and asserts the contract that actually holds:Note this is deliberately not
roundtrip(i) == i. Each precision discards excess, so the real invariant is truncation to the declared unit.Instant.truncatedTofloors, which is what makes pre-epoch instants correct againstTimestamp's non-negative-subseconds representation.All mappings satisfy it, including pre-epoch — so the negative-timestamp hazard called out in
Timestamp's Javadoc does not bite here, and there is now a regression test saying so.Both new assertion styles verified by mutation
getLogicalType.getArgument, notgetIdentifier.Timestamp.IDENTIFIERis one shared constant acrossMILLIS/MICROS/NANOS(Timestamp.java:61), so an identifier-only assertion passes for any precision. Pointingtimestamp.millisatTimestamp.NANOSwhile keepingChronoUnit.MILLISpasses an identifier check but fails the precision check.timestamp.millis's truncation unit toChronoUnit.SECONDSis falsified in one generated case (2065-06-17T23:01:44.265996624Z).Deliberate non-changes
logical.dateleft alone. FoldingLocalDateinto the precision objects would make the commonimport date._+import timestamp.millis._pairing an ambiguous-implicit error. Note that neitheravronorparquethas alogical.dateobject — both putLocalDatein base implicits (AvroType.scala:351,ParquetField.scala:658), so aligning with house style means moving it there, which is a separate change.Global
arbInstantnot widened to nanosecond precision. See "Closing the precision blind spot" above —beam's gap is closed with a module-local generator. Doing it globally means fixingshared/TimeSpec's java→joda→java identity expectation and auditingavroandparquet's precision expectations, all in one commit. The same vacuous-property problem exists in those modules today. Scoped out inBEAM_TIMESTAMP_REMAINING_SCOPE.md, along with theLong-nanos range limits in thenanosjoda paths, the missing non-instant precision coverage, and the open question of whethermicrosshould be built onMicrosInstant+ truncation for broader IO support.nanos.rfJodaLocalDateTimeMicroskeeps its misleading name. Misnamed since 0.9.7; renaming breaks source compatibility. Flagged with a comment.Joda support retained. It was added deliberately across six modules (#1234), and 10 Beam IO connectors still emit
FieldType.DATETIME, so a joda-backed reader remains necessary. Dropping it frombeamalone would make it the only module without joda support.Not validated against a running pipeline. Every read/write claim above comes from reading Beam source at
v2.76.0or from localRowTypeexperiments. No IcebergIO pipeline was run.BEAM_TIMESTAMP_REMAINING_SCOPE.md§6 lists what that leaves open, including a Beam-side gap inBeamRowWrapperthat would only show up on partitionedtimestamptzwrites.